mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 07:25:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca4c497300 | ||
|
|
84bed52d08 | ||
|
|
5d92dca135 | ||
|
|
41a6072b1b | ||
|
|
7d8fdea3c8 | ||
|
|
f6d6797d80 | ||
|
|
33d65b4129 | ||
|
|
48e86b6148 | ||
|
|
db7bc29d67 | ||
|
|
f17d20def3 | ||
|
|
2fc2747349 | ||
|
|
8ddde7ea8b | ||
|
|
11afe9652e | ||
|
|
9fe188d6b9 | ||
|
|
a5043dec2d | ||
|
|
2f8af04821 | ||
|
|
78083bd773 |
+2
-2
@@ -73,7 +73,7 @@ Private group members receive the group's name, roster, key epoch, and encrypted
|
|||||||
|
|
||||||
Internet-backed features are optional. When enabled or used:
|
Internet-backed features are optional. When enabled or used:
|
||||||
|
|
||||||
- Private fallback messages use encrypted NIP-17 gift wraps. Relays can observe event and network metadata but not the message plaintext.
|
- Private fallback messages use BitChat's app-specific encrypted envelopes. This format is not NIP-17, NIP-44, or NIP-59 compatible. Relays can observe the recipient public-key tag, event timing and size, and network metadata, but not the message plaintext or stable sender identity.
|
||||||
- Public location-channel messages, notes, notices, and presence include a geohash tag, event kind, timestamp, and a public key. A geohash reveals an approximate area; finer precision reveals a smaller area.
|
- Public location-channel messages, notes, notices, and presence include a geohash tag, event kind, timestamp, and a public key. A geohash reveals an approximate area; finer precision reveals a smaller area.
|
||||||
- The optional mesh bridge publishes bridge-enabled public mesh messages and presence to a neighborhood rendezvous cell. Those messages are public to participants and relays for that cell. A per-message “nearby only” choice prevents that message from crossing the bridge.
|
- The optional mesh bridge publishes bridge-enabled public mesh messages and presence to a neighborhood rendezvous cell. Those messages are public to participants and relays for that cell. A per-message “nearby only” choice prevents that message from crossing the bridge.
|
||||||
- Bridge courier drops contain opaque end-to-end encrypted envelopes and a rotating recipient tag. Relays still observe timing and network metadata.
|
- Bridge courier drops contain opaque end-to-end encrypted envelopes and a rotating recipient tag. Relays still observe timing and network metadata.
|
||||||
@@ -103,7 +103,7 @@ Private and public features use different protections:
|
|||||||
|
|
||||||
- Mesh private sessions use Noise XX with X25519, ChaCha20-Poly1305, and SHA-256.
|
- Mesh private sessions use Noise XX with X25519, ChaCha20-Poly1305, and SHA-256.
|
||||||
- Private group messages use ChaCha20-Poly1305; group state and relevant mesh packets use Ed25519 signatures.
|
- Private group messages use ChaCha20-Poly1305; group state and relevant mesh packets use Ed25519 signatures.
|
||||||
- Nostr events use secp256k1 Schnorr signatures. NIP-44 v2 private payloads use secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305.
|
- Nostr events use secp256k1 Schnorr signatures. BitChat private envelopes use secp256k1 key agreement, HKDF-SHA256 with a BitChat-specific domain separator, and XChaCha20-Poly1305. The envelope format is proprietary, only interoperates with BitChat clients, and does not provide forward secrecy against later compromise of the recipient's static Nostr private key.
|
||||||
- The persistent private-message outbox uses ChaCha20-Poly1305 with a key held in the keychain. Some other protected local identity state uses AES-GCM.
|
- The persistent private-message outbox uses ChaCha20-Poly1305 with a key held in the keychain. Some other protected local identity state uses AES-GCM.
|
||||||
- Public mesh, bridge, geohash, and board content is signed or authenticated as appropriate but is intentionally not confidential.
|
- Public mesh, bridge, geohash, and board content is signed or authenticated as appropriate but is intentionally not confidential.
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -63,7 +63,11 @@ let package = Package(
|
|||||||
// Only the vector fixture: declaring the whole "Noise"
|
// Only the vector fixture: declaring the whole "Noise"
|
||||||
// directory would claim its .swift test files as resources
|
// directory would claim its .swift test files as resources
|
||||||
// and silently drop them from compilation.
|
// and silently drop them from compilation.
|
||||||
.process("Noise/NoiseTestVectors.json")
|
.process("Noise/NoiseTestVectors.json"),
|
||||||
|
// Frozen output produced by the released 733098bb private-DM
|
||||||
|
// implementation; proves receive compatibility independently
|
||||||
|
// of the refactored legacy generator.
|
||||||
|
.process("Nostr/Fixtures")
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file
|
|||||||
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
|
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
|
||||||
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
|
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
|
||||||
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
|
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
|
||||||
- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, NIP-17 for Nostr
|
- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, BitChat private envelopes for Nostr fallback
|
||||||
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
|
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
|
||||||
- **Universal App**: Native support for iOS and macOS
|
- **Universal App**: Native support for iOS and macOS
|
||||||
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
||||||
@@ -44,9 +44,50 @@ BitChat uses a **hybrid messaging architecture** with two complementary transpor
|
|||||||
- **Global Reach**: Connect with users worldwide via internet relays
|
- **Global Reach**: Connect with users worldwide via internet relays
|
||||||
- **Location Channels**: Geographic chat rooms using geohash coordinates
|
- **Location Channels**: Geographic chat rooms using geohash coordinates
|
||||||
- **290+ Relay Network**: Distributed across the globe for reliability
|
- **290+ Relay Network**: Distributed across the globe for reliability
|
||||||
- **NIP-17 Encryption**: Gift-wrapped private messages for internet privacy
|
- **BitChat Private Envelopes**: App-specific encrypted private messages over Nostr relays
|
||||||
- **Ephemeral Keys**: Fresh cryptographic identity per geohash area
|
- **Ephemeral Keys**: Fresh cryptographic identity per geohash area
|
||||||
|
|
||||||
|
BitChat's private-envelope format is proprietary and is **not** NIP-17,
|
||||||
|
NIP-44, or NIP-59 compatible. It uses Nostr as a relay transport but only
|
||||||
|
interoperates with BitChat clients. New envelopes use provisional,
|
||||||
|
BitChat-specific public kind 1402 (not a formally reserved Nostr kind),
|
||||||
|
encrypted inner kinds 1403/1404, and the `bitchat-pm-v1:` content prefix.
|
||||||
|
For mixed-version delivery, clients publish both the primary kind-1402
|
||||||
|
envelope and a compatibility kind-1059 copy. There is no date-based cutoff:
|
||||||
|
kind 1059 must remain enabled until a coordinated iOS/Android release confirms
|
||||||
|
that supported older clients have migrated. Receivers subscribe to both kinds
|
||||||
|
and deduplicate the authenticated embedded BitChat payload.
|
||||||
|
|
||||||
|
Private-envelope migration compatibility:
|
||||||
|
|
||||||
|
| Sender | Receiver | Delivery path |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| New iOS | New iOS | Kind 1402 is primary; the kind-1059 twin is deduplicated |
|
||||||
|
| New iOS | Released iOS | Compatibility kind 1059 |
|
||||||
|
| New iOS | Current Android | Compatibility kind 1059 |
|
||||||
|
| Released iOS | New iOS | Kind 1059 with the released empty inner-tag shape |
|
||||||
|
| Current Android | New iOS | Kind 1059 with exactly the authenticated recipient `p` tag |
|
||||||
|
|
||||||
|
New kind-1402 envelopes require an empty inner tag list. The Android recipient
|
||||||
|
tag exception is intentionally confined to legacy kind 1059 and accepts only
|
||||||
|
the exact addressed recipient. Mailbox subscriptions cover the 24-hour
|
||||||
|
delivery window plus Android's full 48-hour timestamp randomization and 15
|
||||||
|
minutes of clock skew. Recovery uses
|
||||||
|
one independent 500-event relay filter per wire kind so either format cannot
|
||||||
|
consume the other's result budget.
|
||||||
|
|
||||||
|
The two outbound migration copies are admitted to the relay queue as one
|
||||||
|
protected batch. Queue pressure evicts ephemeral traffic first, never one copy
|
||||||
|
of a private pair; if protected capacity is exhausted, the entire new pair is
|
||||||
|
rejected as a whole. User-message rejection becomes a visible failed delivery;
|
||||||
|
acknowledgements and favorite notifications retain the exact pair in a
|
||||||
|
process-wide 256-entry bounded retry queue. A sustained outage beyond that
|
||||||
|
bound evicts the oldest whole control pair with an explicit warning, never half
|
||||||
|
a pair.
|
||||||
|
If either socket write fails, the same queued pair remains pending and both
|
||||||
|
copies are replayed on the replacement connection. A terminal relay target is
|
||||||
|
pruned after bounded retries so one dead relay cannot wedge healthy delivery.
|
||||||
|
|
||||||
### Channel Types
|
### Channel Types
|
||||||
|
|
||||||
#### `mesh #bluetooth`
|
#### `mesh #bluetooth`
|
||||||
@@ -80,7 +121,7 @@ Private messages use **intelligent transport selection**:
|
|||||||
2. **Nostr Fallback** (when Bluetooth unavailable)
|
2. **Nostr Fallback** (when Bluetooth unavailable)
|
||||||
|
|
||||||
- Uses recipient's Nostr public key
|
- Uses recipient's Nostr public key
|
||||||
- NIP-17 gift-wrapping for privacy
|
- BitChat's app-specific private-envelope encryption
|
||||||
- Routes through global relay network
|
- Routes through global relay network
|
||||||
|
|
||||||
3. **Smart Queuing** (when neither available)
|
3. **Smart Queuing** (when neither available)
|
||||||
|
|||||||
+10
-4
@@ -25,7 +25,7 @@ bitchat is a decentralized, peer-to-peer messaging application for secure, priva
|
|||||||
Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`:
|
Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`:
|
||||||
|
|
||||||
* **BLE mesh** — every device is simultaneously a GATT central and peripheral, relaying packets in a controlled flood. No infrastructure, pairing, or accounts.
|
* **BLE mesh** — every device is simultaneously a GATT central and peripheral, relaying packets in a controlled flood. No infrastructure, pairing, or accounts.
|
||||||
* **Nostr** — private messages to mutual favorites travel as NIP-17 gift-wrapped events over public relays (over Tor where enabled), bridging separate meshes through the internet.
|
* **Nostr** — private messages to mutual favorites travel in BitChat's app-specific encrypted envelopes over public relays (over Tor where enabled), bridging separate meshes through the internet.
|
||||||
|
|
||||||
The router prefers a live mesh link, falls back to Nostr, and engages the courier system when neither can deliver promptly.
|
The router prefers a live mesh link, falls back to Nostr, and engages the courier system when neither can deliver promptly.
|
||||||
|
|
||||||
@@ -78,7 +78,9 @@ Courier envelopes are sealed to the recipient's *static* key with the one-way No
|
|||||||
|
|
||||||
### 5.3 Nostr Path
|
### 5.3 Nostr Path
|
||||||
|
|
||||||
Private messages to mutual favorites are wrapped per NIP-17/NIP-59: a rumor (kind 14) sealed (kind 13) and gift-wrapped (kind 1059) under a throwaway ephemeral key, so relays learn neither sender nor content.
|
Private messages to mutual favorites use BitChat's proprietary private-envelope protocol. An unsigned inner message (kind 1404) is encrypted and placed in a sender-signed seal (kind 1403); that seal is encrypted again inside a public envelope (kind 1402) signed by a one-time key. Kind 1402 is a provisional BitChat-specific assignment, not a formally reserved Nostr kind. Each encrypted content field is `bitchat-pm-v1:` followed by base64url of a 24-byte nonce, XChaCha20-Poly1305 ciphertext, and its 16-byte tag. Keys come from secp256k1 ECDH and HKDF-SHA256 with a BitChat-specific domain separator.
|
||||||
|
|
||||||
|
This format is **not NIP-17, NIP-44, or NIP-59 compatible** and interoperates only with BitChat clients. The outer `p` tag exposes the recipient's Nostr public key to relays; the stable sender identity and plaintext remain inside authenticated ciphertext. New-format seal and envelope timestamps are randomized up to 15 minutes into the past, while the actual message timestamp is encrypted. Legacy Android envelopes can carry public-layer timestamps randomized across the preceding 48 hours. The protocol does not provide forward secrecy: compromise of the recipient's static Nostr private key can expose stored envelopes.
|
||||||
|
|
||||||
## 6. Store and Forward
|
## 6. Store and Forward
|
||||||
|
|
||||||
@@ -105,7 +107,11 @@ Public broadcast messages are cached (1000 packets) and reconciled between peers
|
|||||||
|
|
||||||
### 6.4 Nostr Mailboxes
|
### 6.4 Nostr Mailboxes
|
||||||
|
|
||||||
Gift-wrapped messages rest on Nostr relays; clients re-subscribe with a 24-hour lookback on reconnect, covering the both-devices-offline case for mutual favorites whenever either side touches the internet.
|
BitChat private envelopes rest on Nostr relays; clients re-subscribe across the 24-hour delivery window plus the full 48-hour timestamp randomization used by deployed Android clients and 15 minutes of clock skew (72 hours 15 minutes total). During the rolling format migration, clients subscribe to both the provisional BitChat-specific kind 1402 and historical kind 1059. Recovery places the kinds in separate filters within one REQ, each with an independent 500-event limit, so traffic in one format cannot starve the other. Each logical payload is published first in the primary kind-1402 format and then as a compatibility legacy copy for older iOS and current Android clients. There is no calendar cutoff: legacy publication and reception remain until a coordinated cross-platform release confirms supported clients have migrated. Bounded dedup of the authenticated embedded payload collapses the migration pair at receivers.
|
||||||
|
|
||||||
|
The relay send queue treats those two events as one protected batch. Capacity pressure removes ephemeral events before regular traffic and never evicts only one private-envelope copy. A queue containing only protected batches rejects a new pair as a whole: user messages surface a failed delivery, while acknowledgements and favorite notifications retain the exact pair in one process-wide 256-entry bounded-backoff retry queue shared by account and short-lived geohash transports. Sustained control-payload overflow evicts the oldest whole pair with an explicit warning rather than growing memory or splitting formats. After either socket write fails, the same pair remains pending and both copies are replayed on the replacement connection. Terminal relay targets are pruned after bounded connection retries; a batch that succeeded elsewhere retires normally, while an all-target failure returns to the transport's failure/retry policy. Receive-side dedup suppresses the replay if one copy had already reached the relay.
|
||||||
|
|
||||||
|
Released iOS legacy envelopes use an empty inner tag list. Current Android legacy envelopes use exactly one inner `p` tag naming the recipient. The kind-1059 decoder accepts only those two shapes after authenticating the outer recipient and sender-signed seal; alternate recipients, duplicate tags, and extra tags are rejected. Kind 1402 remains strict and permits no inner tags.
|
||||||
|
|
||||||
### 6.5 Delivery Metrics
|
### 6.5 Delivery Metrics
|
||||||
|
|
||||||
@@ -127,7 +133,7 @@ Bare local counters (deposits, handovers, sprays, opens, outbox flushes and drop
|
|||||||
* **Flooding abuse** is bounded by TTL clamps, deduplication, per-depositor quotas, connect-rate limits, and announce-rate limiting.
|
* **Flooding abuse** is bounded by TTL clamps, deduplication, per-depositor quotas, connect-rate limits, and announce-rate limiting.
|
||||||
* **Replay** of public broadcasts is bounded by the 6-hour acceptance window plus deduplication; private payloads are protected by Noise nonces.
|
* **Replay** of public broadcasts is bounded by the 6-hour acceptance window plus deduplication; private payloads are protected by Noise nonces.
|
||||||
* **Metadata.** BLE proximity is inherently observable; ephemeral IDs and daily-rotating courier tags limit long-term correlation. Nostr traffic can ride Tor.
|
* **Metadata.** BLE proximity is inherently observable; ephemeral IDs and daily-rotating courier tags limit long-term correlation. Nostr traffic can ride Tor.
|
||||||
* **No forward secrecy for sealed mail** (§5.2) is the main cryptographic trade-off of the offline path.
|
* **No forward secrecy for sealed mail or Nostr envelopes** (§5.2–5.3) means compromise of a recipient's static key can expose retained ciphertext addressed to that key.
|
||||||
|
|
||||||
## 9. Future Work
|
## 9. Future Work
|
||||||
|
|
||||||
|
|||||||
@@ -426,6 +426,40 @@ final class ConversationStore: ObservableObject {
|
|||||||
@discardableResult
|
@discardableResult
|
||||||
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||||
guard let ids = conversationIDsByMessageID[messageID] else { return false }
|
guard let ids = conversationIDsByMessageID[messageID] else { return false }
|
||||||
|
return applyDeliveryStatus(status, forMessageID: messageID, among: ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies an authenticated delivery/read receipt only to the supplied
|
||||||
|
/// direct-conversation aliases. A colliding message ID in another peer's
|
||||||
|
/// conversation (or a public timeline) must not inherit the receipt.
|
||||||
|
///
|
||||||
|
/// Stable and ephemeral aliases can temporarily hold the same message
|
||||||
|
/// instance during handoff. The shared helper republishes every targeted
|
||||||
|
/// alias even when the first mutation already changed that instance.
|
||||||
|
@discardableResult
|
||||||
|
func setDeliveryStatus(
|
||||||
|
_ status: DeliveryStatus,
|
||||||
|
forMessageID messageID: String,
|
||||||
|
inDirectPeerAliases peerIDs: Set<PeerID>
|
||||||
|
) -> Bool {
|
||||||
|
guard !peerIDs.isEmpty,
|
||||||
|
let indexedIDs = conversationIDsByMessageID[messageID] else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let allowedIDs = Set(peerIDs.map { ConversationID.directPeer($0) })
|
||||||
|
return applyDeliveryStatus(
|
||||||
|
status,
|
||||||
|
forMessageID: messageID,
|
||||||
|
among: indexedIDs.intersection(allowedIDs)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyDeliveryStatus(
|
||||||
|
_ status: DeliveryStatus,
|
||||||
|
forMessageID messageID: String,
|
||||||
|
among ids: Set<ConversationID>
|
||||||
|
) -> Bool {
|
||||||
|
guard !ids.isEmpty else { return false }
|
||||||
var applied = false
|
var applied = false
|
||||||
var skipped: [ConversationID] = []
|
var skipped: [ConversationID] = []
|
||||||
for id in ids {
|
for id in ids {
|
||||||
|
|||||||
@@ -33,15 +33,25 @@ final class NearbyNotesCounter: ObservableObject {
|
|||||||
private let locationManager: LocationChannelManager
|
private let locationManager: LocationChannelManager
|
||||||
private let managerFactory: @MainActor (String) -> LocationNotesManager
|
private let managerFactory: @MainActor (String) -> LocationNotesManager
|
||||||
private let releaseManager: @MainActor (LocationNotesManager?) -> Void
|
private let releaseManager: @MainActor (LocationNotesManager?) -> Void
|
||||||
|
private let locationNotesEnabled: @MainActor () -> Bool
|
||||||
|
private let locationNotesSettingsPublisher: AnyPublisher<Void, Never>
|
||||||
|
|
||||||
init(
|
init(
|
||||||
locationManager: LocationChannelManager = .shared,
|
locationManager: LocationChannelManager = .shared,
|
||||||
managerFactory: @escaping @MainActor (String) -> LocationNotesManager = { LocationNotesPool.shared.acquire($0) },
|
managerFactory: @escaping @MainActor (String) -> LocationNotesManager = { LocationNotesPool.shared.acquire($0) },
|
||||||
releaseManager: @escaping @MainActor (LocationNotesManager?) -> Void = { LocationNotesPool.shared.release($0) }
|
releaseManager: @escaping @MainActor (LocationNotesManager?) -> Void = { LocationNotesPool.shared.release($0) },
|
||||||
|
locationNotesEnabled: @escaping @MainActor () -> Bool = { LocationNotesSettings.enabled },
|
||||||
|
locationNotesSettings: AnyPublisher<Void, Never>? = nil
|
||||||
) {
|
) {
|
||||||
self.locationManager = locationManager
|
self.locationManager = locationManager
|
||||||
self.managerFactory = managerFactory
|
self.managerFactory = managerFactory
|
||||||
self.releaseManager = releaseManager
|
self.releaseManager = releaseManager
|
||||||
|
self.locationNotesEnabled = locationNotesEnabled
|
||||||
|
self.locationNotesSettingsPublisher = locationNotesSettings
|
||||||
|
?? NotificationCenter.default
|
||||||
|
.publisher(for: LocationNotesSettings.didChangeNotification)
|
||||||
|
.map { _ in () }
|
||||||
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the empty-timeline "check for notes" hint should render.
|
/// Whether the empty-timeline "check for notes" hint should render.
|
||||||
@@ -53,7 +63,7 @@ final class NearbyNotesCounter: ObservableObject {
|
|||||||
/// passes its own observed permission state so the hint re-renders when
|
/// passes its own observed permission state so the hint re-renders when
|
||||||
/// authorization changes.
|
/// authorization changes.
|
||||||
func offersRevealHint(permissionState: LocationChannelManager.PermissionState) -> Bool {
|
func offersRevealHint(permissionState: LocationChannelManager.PermissionState) -> Bool {
|
||||||
!revealed && LocationNotesSettings.enabled && permissionState == .authorized
|
!revealed && locationNotesEnabled() && permissionState == .authorized
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marks the one explicit act that lets the counter subscribe. Sticky for
|
/// Marks the one explicit act that lets the counter subscribe. Sticky for
|
||||||
@@ -83,8 +93,7 @@ final class NearbyNotesCounter: ObservableObject {
|
|||||||
.sink { [weak self] _ in self?.retarget() }
|
.sink { [weak self] _ in self?.retarget() }
|
||||||
// The app-info kill switch must take effect immediately, not on the
|
// The app-info kill switch must take effect immediately, not on the
|
||||||
// next location change or remount.
|
// next location change or remount.
|
||||||
settingCancellable = NotificationCenter.default
|
settingCancellable = locationNotesSettingsPublisher
|
||||||
.publisher(for: LocationNotesSettings.didChangeNotification)
|
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] _ in self?.retarget() }
|
.sink { [weak self] _ in self?.retarget() }
|
||||||
retarget()
|
retarget()
|
||||||
@@ -105,7 +114,7 @@ final class NearbyNotesCounter: ObservableObject {
|
|||||||
private func retarget() {
|
private func retarget() {
|
||||||
guard activeHolders > 0,
|
guard activeHolders > 0,
|
||||||
revealed,
|
revealed,
|
||||||
LocationNotesSettings.enabled,
|
locationNotesEnabled(),
|
||||||
locationManager.permissionState == .authorized,
|
locationManager.permissionState == .authorized,
|
||||||
let geohash = locationManager.availableChannels
|
let geohash = locationManager.availableChannels
|
||||||
.first(where: { $0.level == .building })?.geohash
|
.first(where: { $0.level == .building })?.geohash
|
||||||
|
|||||||
@@ -265,10 +265,10 @@ final class PrivateConversationModel: ObservableObject {
|
|||||||
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
||||||
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
||||||
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
||||||
// Geo DMs are always routed over Nostr (NIP-17); their nostr_ keys
|
// Geo DMs are always routed through BitChat private envelopes over
|
||||||
// never resolve to a reachable mesh peer, so resolveAvailability would
|
// Nostr; their nostr_ keys never resolve to a reachable mesh peer, so
|
||||||
// report .offline. Report .nostrAvailable so the header shows the
|
// resolveAvailability would report .offline. Report .nostrAvailable
|
||||||
// globe instead of a misleading "offline" tag.
|
// so the header shows the globe instead of a misleading "offline" tag.
|
||||||
let availability = conversationPeerID.isGeoDM
|
let availability = conversationPeerID.isGeoDM
|
||||||
? .nostrAvailable
|
? .nostrAvailable
|
||||||
: resolveAvailability(for: headerPeerID, peer: peer)
|
: resolveAvailability(for: headerPeerID, peer: peer)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import P256K
|
import P256K
|
||||||
|
|
||||||
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
|
/// Manages the secp256k1 identity used by BitChat's Nostr relay features,
|
||||||
|
/// including the proprietary private-envelope transport.
|
||||||
struct NostrIdentity: Codable {
|
struct NostrIdentity: Codable {
|
||||||
let privateKey: Data
|
let privateKey: Data
|
||||||
let publicKey: Data
|
let publicKey: Data
|
||||||
|
|||||||
+445
-231
@@ -1,4 +1,3 @@
|
|||||||
import BitLogger
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import P256K
|
import P256K
|
||||||
@@ -7,16 +6,31 @@ import Security
|
|||||||
// Note: This file depends on Data extension from BinaryEncodingUtils.swift
|
// Note: This file depends on Data extension from BinaryEncodingUtils.swift
|
||||||
// Make sure BinaryEncodingUtils.swift is included in the target
|
// Make sure BinaryEncodingUtils.swift is included in the target
|
||||||
|
|
||||||
/// NIP-17 Protocol Implementation for Private Direct Messages
|
/// BitChat's private-envelope protocol transported over Nostr relays.
|
||||||
|
///
|
||||||
|
/// This is deliberately BitChat-specific and is not NIP-17, NIP-44, or NIP-59.
|
||||||
|
/// It uses Nostr events and secp256k1 identities, but its XChaCha20-Poly1305
|
||||||
|
/// payload layout is proprietary and interoperates only with BitChat clients.
|
||||||
struct NostrProtocol {
|
struct NostrProtocol {
|
||||||
|
|
||||||
/// Nostr event kinds
|
/// Nostr event kinds
|
||||||
enum EventKind: Int {
|
enum EventKind: Int {
|
||||||
case metadata = 0
|
case metadata = 0
|
||||||
case textNote = 1
|
case textNote = 1
|
||||||
case dm = 14 // NIP-17 DM rumor kind
|
// Compatibility for BitChat releases that incorrectly emitted the
|
||||||
case seal = 13 // NIP-17 sealed event
|
// proprietary payload under standard NIP kinds. Kind 1059 continues
|
||||||
case giftWrap = 1059 // NIP-59 gift wrap
|
// to be published and read until a coordinated cross-platform release
|
||||||
|
// explicitly removes it; all three legacy layers remain readable.
|
||||||
|
case legacyNIP59Seal = 13
|
||||||
|
case legacyNIP17DirectMessage = 14
|
||||||
|
case legacyNIP59GiftWrap = 1059
|
||||||
|
// Provisional BitChat-specific regular event kinds. These are not
|
||||||
|
// formally reserved by the Nostr kind registry. Only
|
||||||
|
// `privateEnvelope` is published; message and seal exist solely
|
||||||
|
// inside ciphertext.
|
||||||
|
case privateEnvelope = 1402
|
||||||
|
case privateSeal = 1403
|
||||||
|
case privateMessage = 1404
|
||||||
case ephemeralEvent = 20000
|
case ephemeralEvent = 20000
|
||||||
case geohashPresence = 20001
|
case geohashPresence = 20001
|
||||||
case deletion = 5 // NIP-09 event deletion request
|
case deletion = 5 // NIP-09 event deletion request
|
||||||
@@ -25,145 +39,290 @@ struct NostrProtocol {
|
|||||||
/// its NIP-40 expiration — the whole point is store-and-forward.
|
/// its NIP-40 expiration — the whole point is store-and-forward.
|
||||||
case courierDrop = 1401
|
case courierDrop = 1401
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prefix for BitChat private-envelope ciphertext. The suffix is
|
||||||
|
/// base64url(nonce24 || ciphertext || poly1305Tag).
|
||||||
|
static let privateEnvelopeContentPrefix = "bitchat-pm-v1:"
|
||||||
|
|
||||||
|
/// Bound work before Base64 decoding either encrypted layer. Current
|
||||||
|
/// private messages are normally only a few KiB; 64 KiB leaves ample
|
||||||
|
/// migration headroom without allowing an addressed relay event to drive
|
||||||
|
/// unbounded allocation.
|
||||||
|
static let maximumPrivateEnvelopeCiphertextBytes = 64 * 1024
|
||||||
|
|
||||||
|
/// Bound the inner authenticated message JSON before allocation/parsing.
|
||||||
|
/// This is intentionally an envelope limit, not the generic composer
|
||||||
|
/// limit. Raising it alone would not make larger private-message packets
|
||||||
|
/// compatible with released readers; callers must surface a rejected
|
||||||
|
/// packet or envelope as a failed send.
|
||||||
|
static let maximumPrivateEnvelopePlaintextBytes = 32 * 1024
|
||||||
|
|
||||||
|
/// The outer authenticated seal JSON contains a Base64-encoded encrypted
|
||||||
|
/// copy of the inner JSON, so it needs expansion headroom of its own. Keep
|
||||||
|
/// the layer-specific cap below the public ciphertext ceiling.
|
||||||
|
private static let maximumPrivateEnvelopeSealPlaintextBytes = 48 * 1024
|
||||||
|
|
||||||
|
/// New clients subscribe to the provisional BitChat-specific kind and the
|
||||||
|
/// compatibility legacy kind so both sides of a rolling rollout can
|
||||||
|
/// recover stored messages. Do not remove kind 1059 here until all
|
||||||
|
/// supported iOS and Android releases have migrated.
|
||||||
|
static let acceptedPrivateEnvelopeKinds = [
|
||||||
|
EventKind.privateEnvelope.rawValue,
|
||||||
|
EventKind.legacyNIP59GiftWrap.rawValue
|
||||||
|
]
|
||||||
|
|
||||||
|
private enum PrivateEnvelopeWireFormat {
|
||||||
|
case bitchatV1
|
||||||
|
case legacyMislabelledV2
|
||||||
|
|
||||||
|
init?(outerKind: Int) {
|
||||||
|
switch outerKind {
|
||||||
|
case EventKind.privateEnvelope.rawValue:
|
||||||
|
self = .bitchatV1
|
||||||
|
case EventKind.legacyNIP59GiftWrap.rawValue:
|
||||||
|
self = .legacyMislabelledV2
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var messageKind: EventKind {
|
||||||
|
switch self {
|
||||||
|
case .bitchatV1: .privateMessage
|
||||||
|
case .legacyMislabelledV2: .legacyNIP17DirectMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sealKind: EventKind {
|
||||||
|
switch self {
|
||||||
|
case .bitchatV1: .privateSeal
|
||||||
|
case .legacyMislabelledV2: .legacyNIP59Seal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var envelopeKind: EventKind {
|
||||||
|
switch self {
|
||||||
|
case .bitchatV1: .privateEnvelope
|
||||||
|
case .legacyMislabelledV2: .legacyNIP59GiftWrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var contentPrefix: String {
|
||||||
|
switch self {
|
||||||
|
case .bitchatV1: NostrProtocol.privateEnvelopeContentPrefix
|
||||||
|
case .legacyMislabelledV2: "v2:"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hkdfSalt: Data {
|
||||||
|
switch self {
|
||||||
|
case .bitchatV1: Data("bitchat-private-envelope-v1".utf8)
|
||||||
|
case .legacyMislabelledV2: Data()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hkdfInfo: Data {
|
||||||
|
switch self {
|
||||||
|
case .bitchatV1: Data()
|
||||||
|
case .legacyMislabelledV2: Data("nip44-v2".utf8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a NIP-17 private message
|
/// Create a BitChat private envelope for relay transport.
|
||||||
static func createPrivateMessage(
|
static func createPrivateEnvelope(
|
||||||
content: String,
|
content: String,
|
||||||
recipientPubkey: String,
|
recipientPubkey: String,
|
||||||
senderIdentity: NostrIdentity
|
senderIdentity: NostrIdentity
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
|
try createPrivateEnvelope(
|
||||||
// Creating private message
|
content: content,
|
||||||
|
recipientPubkey: recipientPubkey,
|
||||||
// 1. Create the rumor (unsigned event)
|
senderIdentity: senderIdentity,
|
||||||
let rumor = NostrEvent(
|
format: .bitchatV1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Events to publish for one logical private payload. The primary
|
||||||
|
/// BitChat-specific format is always first and a legacy copy follows for
|
||||||
|
/// clients that still subscribe only to kind 1059. There is deliberately
|
||||||
|
/// no date-based cutoff: removal requires a coordinated iOS/Android
|
||||||
|
/// release after supported old clients have migrated. Both encrypt the
|
||||||
|
/// exact same embedded BitChat payload, so receive-side logical-payload
|
||||||
|
/// dedup collapses the pair.
|
||||||
|
static func createPrivateEnvelopePublicationBatch(
|
||||||
|
content: String,
|
||||||
|
recipientPubkey: String,
|
||||||
|
senderIdentity: NostrIdentity
|
||||||
|
) throws -> [NostrEvent] {
|
||||||
|
let primary = try createPrivateEnvelope(
|
||||||
|
content: content,
|
||||||
|
recipientPubkey: recipientPubkey,
|
||||||
|
senderIdentity: senderIdentity
|
||||||
|
)
|
||||||
|
let compatibilityCopy = try createPrivateEnvelope(
|
||||||
|
content: content,
|
||||||
|
recipientPubkey: recipientPubkey,
|
||||||
|
senderIdentity: senderIdentity,
|
||||||
|
format: .legacyMislabelledV2
|
||||||
|
)
|
||||||
|
return [primary, compatibilityCopy]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func createPrivateEnvelope(
|
||||||
|
content: String,
|
||||||
|
recipientPubkey: String,
|
||||||
|
senderIdentity: NostrIdentity,
|
||||||
|
format: PrivateEnvelopeWireFormat,
|
||||||
|
messageTags: [[String]] = []
|
||||||
|
) throws -> NostrEvent {
|
||||||
|
// 1. Create the unsigned inner BitChat message.
|
||||||
|
let message = NostrEvent(
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
kind: .dm, // NIP-17: DM rumor kind 14
|
kind: format.messageKind,
|
||||||
tags: [],
|
tags: messageTags,
|
||||||
content: content
|
content: content
|
||||||
)
|
)
|
||||||
|
|
||||||
// 2. Seal the rumor (encrypt to recipient) and sign it with the SENDER'S
|
// 2. Encrypt the message to the recipient and sign the private seal
|
||||||
// real identity key. NIP-17 requires the seal be signed by the sender
|
// with the sender's stable Nostr identity for sender authentication.
|
||||||
// so the recipient can authenticate who sent the message; signing with
|
|
||||||
// a throwaway key leaves DMs forgeable/impersonatable.
|
|
||||||
let senderKey = try senderIdentity.schnorrSigningKey()
|
let senderKey = try senderIdentity.schnorrSigningKey()
|
||||||
let sealedEvent = try createSeal(
|
let sealedEvent = try createPrivateSeal(
|
||||||
rumor: rumor,
|
message: message,
|
||||||
recipientPubkey: recipientPubkey,
|
recipientPubkey: recipientPubkey,
|
||||||
senderKey: senderKey
|
senderKey: senderKey,
|
||||||
|
format: format
|
||||||
)
|
)
|
||||||
|
|
||||||
// 3. Gift wrap the sealed event with a throwaway ephemeral key (the wrap
|
// 3. Encrypt the seal under a one-time key so the public envelope does
|
||||||
// layer hides the sender's identity from relays; createGiftWrap mints
|
// not reveal the stable sender identity.
|
||||||
// its own ephemeral key internally).
|
return try createPrivateEnvelopeEvent(
|
||||||
let giftWrap = try createGiftWrap(
|
|
||||||
seal: sealedEvent,
|
seal: sealedEvent,
|
||||||
recipientPubkey: recipientPubkey
|
recipientPubkey: recipientPubkey,
|
||||||
|
format: format
|
||||||
)
|
)
|
||||||
|
|
||||||
// Created gift wrap
|
|
||||||
|
|
||||||
return giftWrap
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt a received NIP-17 message
|
/// Decrypt a BitChat private envelope. Legacy proprietary envelopes that
|
||||||
/// Returns the content, sender pubkey, and the actual message timestamp (not the randomized gift wrap timestamp)
|
/// older BitChat releases placed under kinds 1059/13/14 are accepted only
|
||||||
static func decryptPrivateMessage(
|
/// through the format-isolated receive path.
|
||||||
giftWrap: NostrEvent,
|
static func decryptPrivateEnvelope(
|
||||||
|
envelope: NostrEvent,
|
||||||
recipientIdentity: NostrIdentity
|
recipientIdentity: NostrIdentity
|
||||||
) throws -> (content: String, senderPubkey: String, timestamp: Int) {
|
) throws -> (content: String, senderPubkey: String, timestamp: Int) {
|
||||||
|
let layers = try decodePrivateEnvelopeLayers(
|
||||||
// Starting decryption
|
envelope: envelope,
|
||||||
|
recipientIdentity: recipientIdentity
|
||||||
// 1. Unwrap the gift wrap
|
)
|
||||||
let seal: NostrEvent
|
return (
|
||||||
do {
|
content: layers.message.content,
|
||||||
seal = try unwrapGiftWrap(
|
senderPubkey: layers.seal.pubkey,
|
||||||
giftWrap: giftWrap,
|
timestamp: layers.message.created_at
|
||||||
recipientKey: recipientIdentity.schnorrSigningKey()
|
)
|
||||||
)
|
|
||||||
// Successfully unwrapped gift wrap
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("❌ Failed to unwrap gift wrap: \(error)", category: .session)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Authenticate the seal. The seal MUST be signed by the sender's real
|
|
||||||
// identity key (NIP-17); without this check a DM is forgeable by anyone
|
|
||||||
// who knows the recipient's npub. Verify the seal's own signature.
|
|
||||||
guard seal.isValidSignature() else {
|
|
||||||
SecureLogger.error("❌ Rejecting DM: seal signature is missing or invalid", category: .session)
|
|
||||||
throw NostrError.invalidEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Open the seal
|
|
||||||
let rumor: NostrEvent
|
|
||||||
do {
|
|
||||||
rumor = try openSeal(
|
|
||||||
seal: seal,
|
|
||||||
recipientKey: recipientIdentity.schnorrSigningKey()
|
|
||||||
)
|
|
||||||
// Successfully opened seal
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("❌ Failed to open seal: \(error)", category: .session)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. The sender claimed inside the rumor must match the key that actually
|
|
||||||
// signed the seal, otherwise the sender field is unauthenticated and
|
|
||||||
// spoofable.
|
|
||||||
guard seal.pubkey == rumor.pubkey else {
|
|
||||||
SecureLogger.error("❌ Rejecting DM: rumor pubkey does not match seal signer", category: .session)
|
|
||||||
throw NostrError.invalidEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the seal signer's pubkey as the authenticated sender.
|
|
||||||
return (content: rumor.content, senderPubkey: seal.pubkey, timestamp: rumor.created_at)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
static func createPrivateMessageWithInvalidSealSignatureForTesting(
|
static func createPrivateEnvelopeWithInvalidSealSignatureForTesting(
|
||||||
content: String,
|
content: String,
|
||||||
recipientPubkey: String,
|
recipientPubkey: String,
|
||||||
senderIdentity: NostrIdentity
|
senderIdentity: NostrIdentity
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
let rumor = NostrEvent(
|
let format = PrivateEnvelopeWireFormat.bitchatV1
|
||||||
|
let message = NostrEvent(
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
kind: .dm,
|
kind: format.messageKind,
|
||||||
tags: [],
|
tags: [],
|
||||||
content: content
|
content: content
|
||||||
)
|
)
|
||||||
var seal = try createSeal(
|
var seal = try createPrivateSeal(
|
||||||
rumor: rumor,
|
message: message,
|
||||||
recipientPubkey: recipientPubkey,
|
recipientPubkey: recipientPubkey,
|
||||||
senderKey: senderIdentity.schnorrSigningKey()
|
senderKey: senderIdentity.schnorrSigningKey(),
|
||||||
|
format: format
|
||||||
)
|
)
|
||||||
seal.sig = String(repeating: "0", count: 128)
|
seal.sig = String(repeating: "0", count: 128)
|
||||||
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
|
return try createPrivateEnvelopeEvent(
|
||||||
|
seal: seal,
|
||||||
|
recipientPubkey: recipientPubkey,
|
||||||
|
format: format
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func createPrivateMessageWithMismatchedSealRumorPubkeyForTesting(
|
static func createPrivateEnvelopeWithMismatchedSealMessagePubkeyForTesting(
|
||||||
content: String,
|
content: String,
|
||||||
recipientPubkey: String,
|
recipientPubkey: String,
|
||||||
rumorIdentity: NostrIdentity,
|
messageIdentity: NostrIdentity,
|
||||||
sealSignerIdentity: NostrIdentity
|
sealSignerIdentity: NostrIdentity
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
let rumor = NostrEvent(
|
let format = PrivateEnvelopeWireFormat.bitchatV1
|
||||||
pubkey: rumorIdentity.publicKeyHex,
|
let message = NostrEvent(
|
||||||
|
pubkey: messageIdentity.publicKeyHex,
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
kind: .dm,
|
kind: format.messageKind,
|
||||||
tags: [],
|
tags: [],
|
||||||
content: content
|
content: content
|
||||||
)
|
)
|
||||||
let seal = try createSeal(
|
let seal = try createPrivateSeal(
|
||||||
rumor: rumor,
|
message: message,
|
||||||
recipientPubkey: recipientPubkey,
|
recipientPubkey: recipientPubkey,
|
||||||
senderKey: sealSignerIdentity.schnorrSigningKey()
|
senderKey: sealSignerIdentity.schnorrSigningKey(),
|
||||||
|
format: format
|
||||||
)
|
)
|
||||||
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
|
return try createPrivateEnvelopeEvent(
|
||||||
|
seal: seal,
|
||||||
|
recipientPubkey: recipientPubkey,
|
||||||
|
format: format
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func createPrivateEnvelopeWithInnerTagsForTesting(
|
||||||
|
content: String,
|
||||||
|
recipientPubkey: String,
|
||||||
|
senderIdentity: NostrIdentity,
|
||||||
|
innerMessageTags: [[String]]
|
||||||
|
) throws -> NostrEvent {
|
||||||
|
try createPrivateEnvelope(
|
||||||
|
content: content,
|
||||||
|
recipientPubkey: recipientPubkey,
|
||||||
|
senderIdentity: senderIdentity,
|
||||||
|
format: .bitchatV1,
|
||||||
|
messageTags: innerMessageTags
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func createLegacyPrivateEnvelopeForTesting(
|
||||||
|
content: String,
|
||||||
|
recipientPubkey: String,
|
||||||
|
senderIdentity: NostrIdentity,
|
||||||
|
innerMessageTags: [[String]] = []
|
||||||
|
) throws -> NostrEvent {
|
||||||
|
// Current Android legacy envelopes use exactly one recipient `p` tag
|
||||||
|
// on the unsigned inner kind-14 event; released iOS envelopes use no
|
||||||
|
// inner tags. Tests pass the Android shape explicitly so this helper
|
||||||
|
// cannot silently make the production encoder depend on that quirk.
|
||||||
|
try createPrivateEnvelope(
|
||||||
|
content: content,
|
||||||
|
recipientPubkey: recipientPubkey,
|
||||||
|
senderIdentity: senderIdentity,
|
||||||
|
format: .legacyMislabelledV2,
|
||||||
|
messageTags: innerMessageTags
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decodePrivateEnvelopeLayersForTesting(
|
||||||
|
envelope: NostrEvent,
|
||||||
|
recipientIdentity: NostrIdentity
|
||||||
|
) throws -> (seal: NostrEvent, message: NostrEvent) {
|
||||||
|
try decodePrivateEnvelopeLayers(
|
||||||
|
envelope: envelope,
|
||||||
|
recipientIdentity: recipientIdentity
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decodePrivateEnvelopeEventJSONForTesting(_ json: String) throws -> NostrEvent {
|
||||||
|
try decodePrivateEnvelopeEventJSON(json)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -400,151 +559,207 @@ struct NostrProtocol {
|
|||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
private static func createSeal(
|
private static func createPrivateSeal(
|
||||||
rumor: NostrEvent,
|
message: NostrEvent,
|
||||||
recipientPubkey: String,
|
recipientPubkey: String,
|
||||||
senderKey: P256K.Schnorr.PrivateKey
|
senderKey: P256K.Schnorr.PrivateKey,
|
||||||
|
format: PrivateEnvelopeWireFormat
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
|
|
||||||
let rumorJSON = try rumor.jsonString()
|
|
||||||
let encrypted = try encrypt(
|
let encrypted = try encrypt(
|
||||||
plaintext: rumorJSON,
|
plaintext: message.jsonString(),
|
||||||
recipientPubkey: recipientPubkey,
|
recipientPubkey: recipientPubkey,
|
||||||
senderKey: senderKey
|
senderKey: senderKey,
|
||||||
|
format: format,
|
||||||
|
maximumPlaintextBytes: maximumPrivateEnvelopePlaintextBytes
|
||||||
)
|
)
|
||||||
|
|
||||||
let seal = NostrEvent(
|
let seal = NostrEvent(
|
||||||
pubkey: Data(senderKey.xonly.bytes).hexEncodedString(),
|
pubkey: Data(senderKey.xonly.bytes).hexEncodedString(),
|
||||||
createdAt: randomizedTimestamp(),
|
createdAt: randomizedPastTimestamp(),
|
||||||
kind: .seal,
|
kind: format.sealKind,
|
||||||
tags: [],
|
tags: [],
|
||||||
content: encrypted
|
content: encrypted
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sign the seal with the sender's Schnorr private key
|
|
||||||
return try seal.sign(with: senderKey)
|
return try seal.sign(with: senderKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func createGiftWrap(
|
|
||||||
seal: NostrEvent,
|
|
||||||
recipientPubkey: String
|
|
||||||
) throws -> NostrEvent {
|
|
||||||
|
|
||||||
let sealJSON = try seal.jsonString()
|
private static func createPrivateEnvelopeEvent(
|
||||||
|
seal: NostrEvent,
|
||||||
// Create new ephemeral key for gift wrap
|
recipientPubkey: String,
|
||||||
let wrapKey = try P256K.Schnorr.PrivateKey()
|
format: PrivateEnvelopeWireFormat
|
||||||
// Creating gift wrap with ephemeral key
|
) throws -> NostrEvent {
|
||||||
|
// A fresh signing/encryption key for every public envelope keeps the
|
||||||
// Encrypt the seal with the new ephemeral key (not the seal's key)
|
// stable sender identity inside ciphertext.
|
||||||
|
let envelopeKey = try P256K.Schnorr.PrivateKey()
|
||||||
let encrypted = try encrypt(
|
let encrypted = try encrypt(
|
||||||
plaintext: sealJSON,
|
plaintext: seal.jsonString(),
|
||||||
recipientPubkey: recipientPubkey,
|
recipientPubkey: recipientPubkey,
|
||||||
senderKey: wrapKey // Use the gift wrap ephemeral key
|
senderKey: envelopeKey,
|
||||||
|
format: format,
|
||||||
|
maximumPlaintextBytes: maximumPrivateEnvelopeSealPlaintextBytes
|
||||||
)
|
)
|
||||||
|
|
||||||
let giftWrap = NostrEvent(
|
let envelope = NostrEvent(
|
||||||
pubkey: Data(wrapKey.xonly.bytes).hexEncodedString(),
|
pubkey: Data(envelopeKey.xonly.bytes).hexEncodedString(),
|
||||||
createdAt: randomizedTimestamp(),
|
createdAt: randomizedPastTimestamp(),
|
||||||
kind: .giftWrap,
|
kind: format.envelopeKind,
|
||||||
tags: [["p", recipientPubkey]], // Tag recipient
|
tags: [["p", recipientPubkey]],
|
||||||
content: encrypted
|
content: encrypted
|
||||||
)
|
)
|
||||||
|
return try envelope.sign(with: envelopeKey)
|
||||||
// Sign the gift wrap with the wrap Schnorr private key
|
|
||||||
return try giftWrap.sign(with: wrapKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func unwrapGiftWrap(
|
private static func decodePrivateEnvelopeLayers(
|
||||||
giftWrap: NostrEvent,
|
envelope: NostrEvent,
|
||||||
recipientKey: P256K.Schnorr.PrivateKey
|
recipientIdentity: NostrIdentity
|
||||||
) throws -> NostrEvent {
|
) throws -> (seal: NostrEvent, message: NostrEvent) {
|
||||||
|
guard envelope.content.utf8.count <= maximumPrivateEnvelopeCiphertextBytes else {
|
||||||
// Unwrapping gift wrap
|
throw NostrError.invalidCiphertext
|
||||||
|
}
|
||||||
let decrypted = try decrypt(
|
guard let format = PrivateEnvelopeWireFormat(outerKind: envelope.kind),
|
||||||
ciphertext: giftWrap.content,
|
envelope.tags == [["p", recipientIdentity.publicKeyHex]],
|
||||||
senderPubkey: giftWrap.pubkey,
|
envelope.isValidSignature() else {
|
||||||
recipientKey: recipientKey
|
|
||||||
)
|
|
||||||
|
|
||||||
guard let data = decrypted.data(using: .utf8),
|
|
||||||
let sealDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
||||||
throw NostrError.invalidEvent
|
throw NostrError.invalidEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
let seal = try NostrEvent(from: sealDict)
|
let recipientKey = try recipientIdentity.schnorrSigningKey()
|
||||||
// Unwrapped seal
|
let sealJSON = try decrypt(
|
||||||
|
ciphertext: envelope.content,
|
||||||
return seal
|
senderPubkey: envelope.pubkey,
|
||||||
}
|
recipientKey: recipientKey,
|
||||||
|
format: format,
|
||||||
private static func openSeal(
|
maximumPlaintextBytes: maximumPrivateEnvelopeSealPlaintextBytes
|
||||||
seal: NostrEvent,
|
)
|
||||||
recipientKey: P256K.Schnorr.PrivateKey
|
let seal = try decodePrivateEnvelopeEventJSON(
|
||||||
) throws -> NostrEvent {
|
sealJSON,
|
||||||
|
maximumBytes: maximumPrivateEnvelopeSealPlaintextBytes
|
||||||
let decrypted = try decrypt(
|
)
|
||||||
|
guard seal.kind == format.sealKind.rawValue,
|
||||||
|
seal.tags.isEmpty,
|
||||||
|
seal.isValidSignature() else {
|
||||||
|
throw NostrError.invalidEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
let messageJSON = try decrypt(
|
||||||
ciphertext: seal.content,
|
ciphertext: seal.content,
|
||||||
senderPubkey: seal.pubkey,
|
senderPubkey: seal.pubkey,
|
||||||
recipientKey: recipientKey
|
recipientKey: recipientKey,
|
||||||
|
format: format,
|
||||||
|
maximumPlaintextBytes: maximumPrivateEnvelopePlaintextBytes
|
||||||
)
|
)
|
||||||
|
let message = try decodePrivateEnvelopeEventJSON(
|
||||||
guard let data = decrypted.data(using: .utf8),
|
messageJSON,
|
||||||
let rumorDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
maximumBytes: maximumPrivateEnvelopePlaintextBytes
|
||||||
|
)
|
||||||
|
|
||||||
|
// The inner message is intentionally unsigned; sender authentication
|
||||||
|
// comes from the seal. Bind its claimed sender and custom kind to that
|
||||||
|
// authenticated layer before exposing content.
|
||||||
|
guard message.kind == format.messageKind.rawValue,
|
||||||
|
validInnerMessageTags(
|
||||||
|
message.tags,
|
||||||
|
format: format,
|
||||||
|
recipientPubkey: recipientIdentity.publicKeyHex
|
||||||
|
),
|
||||||
|
message.sig == nil,
|
||||||
|
seal.pubkey == message.pubkey else {
|
||||||
throw NostrError.invalidEvent
|
throw NostrError.invalidEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
return try NostrEvent(from: rumorDict)
|
return (seal, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Encryption (NIP-44 v2)
|
/// Released iOS legacy envelopes used no inner tags, while current
|
||||||
|
/// Android legacy envelopes use exactly the authenticated recipient tag.
|
||||||
|
/// Accept only those two historical shapes for kind 1059. The new kind
|
||||||
|
/// 1402 format remains strict and rejects every inner tag.
|
||||||
|
private static func validInnerMessageTags(
|
||||||
|
_ tags: [[String]],
|
||||||
|
format: PrivateEnvelopeWireFormat,
|
||||||
|
recipientPubkey: String
|
||||||
|
) -> Bool {
|
||||||
|
switch format {
|
||||||
|
case .bitchatV1:
|
||||||
|
return tags.isEmpty
|
||||||
|
case .legacyMislabelledV2:
|
||||||
|
return tags.isEmpty || tags == [["p", recipientPubkey]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func decodePrivateEnvelopeEventJSON(
|
||||||
|
_ json: String,
|
||||||
|
maximumBytes: Int = maximumPrivateEnvelopePlaintextBytes
|
||||||
|
) throws -> NostrEvent {
|
||||||
|
// Check UTF-8 size before allocating Data or invoking the general JSON
|
||||||
|
// parser. `decrypt` enforces the same cap on authenticated bytes; this
|
||||||
|
// local guard keeps the parser boundary explicit and independently
|
||||||
|
// testable.
|
||||||
|
guard json.utf8.count <= maximumBytes else {
|
||||||
|
throw NostrError.invalidCiphertext
|
||||||
|
}
|
||||||
|
guard let data = json.data(using: .utf8),
|
||||||
|
let dictionary = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
|
throw NostrError.invalidEvent
|
||||||
|
}
|
||||||
|
return try NostrEvent(from: dictionary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - BitChat private-envelope encryption
|
||||||
|
|
||||||
private static func encrypt(
|
private static func encrypt(
|
||||||
plaintext: String,
|
plaintext: String,
|
||||||
recipientPubkey: String,
|
recipientPubkey: String,
|
||||||
senderKey: P256K.Schnorr.PrivateKey
|
senderKey: P256K.Schnorr.PrivateKey,
|
||||||
|
format: PrivateEnvelopeWireFormat,
|
||||||
|
maximumPlaintextBytes: Int
|
||||||
) throws -> String {
|
) throws -> String {
|
||||||
|
|
||||||
guard let recipientPubkeyData = Data(hexString: recipientPubkey) else {
|
guard let recipientPubkeyData = Data(hexString: recipientPubkey) else {
|
||||||
throw NostrError.invalidPublicKey
|
throw NostrError.invalidPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
|
|
||||||
|
|
||||||
// Derive shared secret
|
|
||||||
let sharedSecret = try deriveSharedSecret(
|
let sharedSecret = try deriveSharedSecret(
|
||||||
privateKey: senderKey,
|
privateKey: senderKey,
|
||||||
publicKey: recipientPubkeyData
|
publicKey: recipientPubkeyData
|
||||||
)
|
)
|
||||||
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info)
|
let key = derivePrivateEnvelopeKey(from: sharedSecret, format: format)
|
||||||
let key = try deriveNIP44V2Key(from: sharedSecret)
|
|
||||||
|
|
||||||
// 24-byte random nonce for XChaCha20-Poly1305
|
|
||||||
var nonce24 = Data(count: 24)
|
var nonce24 = Data(count: 24)
|
||||||
_ = nonce24.withUnsafeMutableBytes { ptr in
|
let randomStatus = nonce24.withUnsafeMutableBytes { ptr in
|
||||||
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
|
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
|
||||||
}
|
}
|
||||||
|
guard randomStatus == errSecSuccess else {
|
||||||
let pt = Data(plaintext.utf8)
|
throw NostrError.cryptographicFailure
|
||||||
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
|
}
|
||||||
|
|
||||||
// v2: base64url(nonce24 || ciphertext || tag)
|
let plaintextData = Data(plaintext.utf8)
|
||||||
|
guard plaintextData.count <= maximumPlaintextBytes else {
|
||||||
|
throw NostrError.invalidCiphertext
|
||||||
|
}
|
||||||
|
let sealed = try XChaCha20Poly1305Compat.seal(
|
||||||
|
plaintext: plaintextData,
|
||||||
|
key: key,
|
||||||
|
nonce24: nonce24
|
||||||
|
)
|
||||||
|
|
||||||
var combined = Data()
|
var combined = Data()
|
||||||
combined.append(nonce24)
|
combined.append(nonce24)
|
||||||
combined.append(sealed.ciphertext)
|
combined.append(sealed.ciphertext)
|
||||||
combined.append(sealed.tag)
|
combined.append(sealed.tag)
|
||||||
return "v2:" + Base64URLCoding.encode(combined)
|
return format.contentPrefix + Base64URLCoding.encode(combined)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func decrypt(
|
private static func decrypt(
|
||||||
ciphertext: String,
|
ciphertext: String,
|
||||||
senderPubkey: String,
|
senderPubkey: String,
|
||||||
recipientKey: P256K.Schnorr.PrivateKey
|
recipientKey: P256K.Schnorr.PrivateKey,
|
||||||
|
format: PrivateEnvelopeWireFormat,
|
||||||
|
maximumPlaintextBytes: Int
|
||||||
) throws -> String {
|
) throws -> String {
|
||||||
// Expect NIP-44 v2 format
|
guard ciphertext.utf8.count <= maximumPrivateEnvelopeCiphertextBytes,
|
||||||
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
|
ciphertext.hasPrefix(format.contentPrefix) else {
|
||||||
let encoded = String(ciphertext.dropFirst(3))
|
throw NostrError.invalidCiphertext
|
||||||
|
}
|
||||||
|
let encoded = String(ciphertext.dropFirst(format.contentPrefix.count))
|
||||||
guard let data = Base64URLCoding.decode(encoded),
|
guard let data = Base64URLCoding.decode(encoded),
|
||||||
data.count > (24 + 16),
|
data.count > (24 + 16),
|
||||||
let senderPubkeyData = Data(hexString: senderPubkey) else {
|
let senderPubkeyData = Data(hexString: senderPubkey) else {
|
||||||
@@ -554,33 +769,40 @@ struct NostrProtocol {
|
|||||||
let nonce24 = data.prefix(24)
|
let nonce24 = data.prefix(24)
|
||||||
let rest = data.dropFirst(24)
|
let rest = data.dropFirst(24)
|
||||||
let tag = rest.suffix(16)
|
let tag = rest.suffix(16)
|
||||||
let ct = rest.dropLast(16)
|
let ciphertextBytes = rest.dropLast(16)
|
||||||
|
|
||||||
// Try decryption with even-Y then odd-Y when sender pubkey is x-only
|
func attemptDecrypt(using publicKeyData: Data) throws -> Data {
|
||||||
func attemptDecrypt(using pubKeyData: Data) throws -> Data {
|
let sharedSecret = try deriveSharedSecret(
|
||||||
let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData)
|
privateKey: recipientKey,
|
||||||
let key = try deriveNIP44V2Key(from: ss)
|
publicKey: publicKeyData
|
||||||
|
)
|
||||||
|
let key = derivePrivateEnvelopeKey(from: sharedSecret, format: format)
|
||||||
return try XChaCha20Poly1305Compat.open(
|
return try XChaCha20Poly1305Compat.open(
|
||||||
ciphertext: Data(ct),
|
ciphertext: Data(ciphertextBytes),
|
||||||
tag: Data(tag),
|
tag: Data(tag),
|
||||||
key: key,
|
key: key,
|
||||||
nonce24: Data(nonce24)
|
nonce24: Data(nonce24)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If 32 bytes (x-only) try both parities, otherwise single try
|
let plaintext: Data
|
||||||
if senderPubkeyData.count == 32 {
|
if senderPubkeyData.count == 32 {
|
||||||
let even = Data([0x02]) + senderPubkeyData
|
let evenKey = Data([0x02]) + senderPubkeyData
|
||||||
if let pt = try? attemptDecrypt(using: even) {
|
if let opened = try? attemptDecrypt(using: evenKey) {
|
||||||
return String(data: pt, encoding: .utf8) ?? ""
|
plaintext = opened
|
||||||
|
} else {
|
||||||
|
let oddKey = Data([0x03]) + senderPubkeyData
|
||||||
|
plaintext = try attemptDecrypt(using: oddKey)
|
||||||
}
|
}
|
||||||
let odd = Data([0x03]) + senderPubkeyData
|
|
||||||
let pt = try attemptDecrypt(using: odd)
|
|
||||||
return String(data: pt, encoding: .utf8) ?? ""
|
|
||||||
} else {
|
} else {
|
||||||
let pt = try attemptDecrypt(using: senderPubkeyData)
|
plaintext = try attemptDecrypt(using: senderPubkeyData)
|
||||||
return String(data: pt, encoding: .utf8) ?? ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard plaintext.count <= maximumPlaintextBytes,
|
||||||
|
let decoded = String(data: plaintext, encoding: .utf8) else {
|
||||||
|
throw NostrError.invalidCiphertext
|
||||||
|
}
|
||||||
|
return decoded
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func deriveSharedSecret(
|
private static func deriveSharedSecret(
|
||||||
@@ -640,29 +862,17 @@ struct NostrProtocol {
|
|||||||
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
||||||
// ECDH shared secret derived
|
// ECDH shared secret derived
|
||||||
|
|
||||||
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
|
// Return raw ECDH shared secret; the wire-format-specific HKDF is
|
||||||
|
// applied by derivePrivateEnvelopeKey.
|
||||||
return sharedSecretData
|
return sharedSecretData
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func randomizedTimestamp() -> Date {
|
private static func randomizedPastTimestamp() -> Date {
|
||||||
// Add random offset to current time for privacy
|
// Keep public timestamps in the past: future-dated events are rejected
|
||||||
// This prevents timing correlation attacks while the actual message timestamp
|
// by some relays. The actual message timestamp remains encrypted.
|
||||||
// is preserved in the encrypted rumor
|
Date().addingTimeInterval(
|
||||||
let offset = TimeInterval.random(in: -900...900) // +/- 15 minutes
|
-TimeInterval.random(in: 0...TransportConfig.nostrPrivateEnvelopeTimestampFuzzSeconds)
|
||||||
let now = Date()
|
)
|
||||||
let randomized = now.addingTimeInterval(offset)
|
|
||||||
|
|
||||||
// Log with explicit UTC and local time for debugging
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
//
|
|
||||||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
|
||||||
formatter.timeZone = TimeZone(abbreviation: "UTC")
|
|
||||||
|
|
||||||
formatter.timeZone = TimeZone.current
|
|
||||||
|
|
||||||
// Timestamp randomized for privacy
|
|
||||||
|
|
||||||
return randomized
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -794,16 +1004,20 @@ enum NostrError: Error {
|
|||||||
case invalidPublicKey
|
case invalidPublicKey
|
||||||
case invalidEvent
|
case invalidEvent
|
||||||
case invalidCiphertext
|
case invalidCiphertext
|
||||||
|
case cryptographicFailure
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
|
// MARK: - BitChat private-envelope key derivation
|
||||||
|
|
||||||
private extension NostrProtocol {
|
private extension NostrProtocol {
|
||||||
static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {
|
private static func derivePrivateEnvelopeKey(
|
||||||
|
from sharedSecretData: Data,
|
||||||
|
format: PrivateEnvelopeWireFormat
|
||||||
|
) -> Data {
|
||||||
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
||||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
||||||
salt: Data(),
|
salt: format.hkdfSalt,
|
||||||
info: Data("nip44-v2".utf8),
|
info: format.hkdfInfo,
|
||||||
outputByteCount: 32
|
outputByteCount: 32
|
||||||
)
|
)
|
||||||
return derivedKey.withUnsafeBytes { Data($0) }
|
return derivedKey.withUnsafeBytes { Data($0) }
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -154,3 +154,90 @@ struct BitchatFilePacket {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wire-compatible identity for private media exchanged by clients using the
|
||||||
|
/// current iOS entropy-bearing filenames, without extending the deployed file
|
||||||
|
/// TLV. Android clients reject unknown file tags, so eligible senders and
|
||||||
|
/// receivers derive the receipt key from fields already on the wire.
|
||||||
|
///
|
||||||
|
/// Locally-created image and voice-note filenames contain a UUID or live-voice
|
||||||
|
/// burst ID. Including the normalized direction keeps a reused filename
|
||||||
|
/// distinct across chats while allowing short and full Noise-key peer IDs to
|
||||||
|
/// converge. Android and older-iOS timestamp-only names remain ineligible and
|
||||||
|
/// retain their legacy random local IDs (transfer-compatible, no receipts).
|
||||||
|
enum PrivateMediaMessageIdentity {
|
||||||
|
private static let domain = Data("bitchat-private-media-message-v1".utf8)
|
||||||
|
private static let idPrefix = "media-"
|
||||||
|
private static let digestHexLength = 32
|
||||||
|
|
||||||
|
static func isStableID(_ candidate: String) -> Bool {
|
||||||
|
guard candidate.hasPrefix(idPrefix) else { return false }
|
||||||
|
let digest = candidate.dropFirst(idPrefix.count)
|
||||||
|
guard digest.utf8.count == digestHexLength else { return false }
|
||||||
|
return digest.utf8.allSatisfy { byte in
|
||||||
|
(UInt8(ascii: "0")...UInt8(ascii: "9")).contains(byte)
|
||||||
|
|| (UInt8(ascii: "a")...UInt8(ascii: "f")).contains(byte)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func stableID(
|
||||||
|
senderPeerID: PeerID,
|
||||||
|
recipientPeerID: PeerID,
|
||||||
|
fileName: String?
|
||||||
|
) -> String? {
|
||||||
|
guard let fileName, !fileName.isEmpty else { return nil }
|
||||||
|
let leafName = (fileName as NSString).lastPathComponent
|
||||||
|
guard leafName == fileName else { return nil }
|
||||||
|
|
||||||
|
let path = leafName as NSString
|
||||||
|
let stem = path.deletingPathExtension
|
||||||
|
let fileExtension = path.pathExtension.lowercased()
|
||||||
|
switch true {
|
||||||
|
case stem.hasPrefix("img_"):
|
||||||
|
guard fileExtension == "jpg" || fileExtension == "jpeg" else { return nil }
|
||||||
|
case stem.hasPrefix("voice_"):
|
||||||
|
guard fileExtension == "m4a" else { return nil }
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let entropyToken = stem.split(separator: "_").last.map(String.init)
|
||||||
|
let hasUUIDEntropy = entropyToken.flatMap(UUID.init(uuidString:)) != nil
|
||||||
|
let voiceBurstID = stem.hasPrefix("voice_")
|
||||||
|
? String(stem.dropFirst("voice_".count))
|
||||||
|
: ""
|
||||||
|
let hasBurstEntropy = voiceBurstID.count == 16
|
||||||
|
&& voiceBurstID.allSatisfy(\.isHexDigit)
|
||||||
|
guard hasUUIDEntropy || hasBurstEntropy else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let fields = [
|
||||||
|
Data(senderPeerID.toShort().bare.utf8),
|
||||||
|
Data(recipientPeerID.toShort().bare.utf8),
|
||||||
|
Data(leafName.utf8)
|
||||||
|
]
|
||||||
|
var input = domain
|
||||||
|
for field in fields {
|
||||||
|
guard let length = UInt32(exactly: field.count) else { return nil }
|
||||||
|
var bigEndianLength = length.bigEndian
|
||||||
|
withUnsafeBytes(of: &bigEndianLength) {
|
||||||
|
input.append(contentsOf: $0)
|
||||||
|
}
|
||||||
|
input.append(field)
|
||||||
|
}
|
||||||
|
|
||||||
|
return "\(idPrefix)\(input.sha256Hex().prefix(digestHexLength))"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func stableID(
|
||||||
|
for packet: BitchatFilePacket,
|
||||||
|
senderPeerID: PeerID,
|
||||||
|
recipientPeerID: PeerID
|
||||||
|
) -> String? {
|
||||||
|
stableID(
|
||||||
|
senderPeerID: senderPeerID,
|
||||||
|
recipientPeerID: recipientPeerID,
|
||||||
|
fileName: packet.fileName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,5 +3,11 @@ import BitFoundation
|
|||||||
extension PeerCapabilities {
|
extension PeerCapabilities {
|
||||||
/// Capabilities this build advertises in its announce packets.
|
/// Capabilities this build advertises in its announce packets.
|
||||||
/// Each feature adds its bit here when it ships.
|
/// Each feature adds its bit here when it ships.
|
||||||
static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups, .privateMedia]
|
static let localSupported: PeerCapabilities = [
|
||||||
|
.vouch,
|
||||||
|
.prekeys,
|
||||||
|
.groups,
|
||||||
|
.privateMedia,
|
||||||
|
.privateMediaReceipts
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,10 +32,85 @@ struct BLEFileTransferHandlerEnvironment {
|
|||||||
_ fallbackExtension: String?,
|
_ fallbackExtension: String?,
|
||||||
_ defaultPrefix: String
|
_ defaultPrefix: String
|
||||||
) -> URL?
|
) -> URL?
|
||||||
|
/// Resolves the durable receiver decision for a stable private-media ID.
|
||||||
|
let privateMediaReceiptState: (
|
||||||
|
_ messageID: String
|
||||||
|
) -> BLEPrivateMediaReceiptState
|
||||||
|
/// Atomically records a stable private-media ID after the payload save.
|
||||||
|
let commitPrivateMediaFile: (_ messageID: String, _ storedURL: URL) -> Bool
|
||||||
|
/// Rolls back a saved payload when its durable receipt commit fails.
|
||||||
|
let removeIncomingFile: (_ storedURL: URL) -> Void
|
||||||
|
/// Releases the allocator's save-to-UI ownership guard after synchronous
|
||||||
|
/// conversation insertion has completed.
|
||||||
|
let finishIncomingFileDelivery: (_ storedURL: URL) -> Void
|
||||||
|
/// Checks the authenticated sender before any private-media disk work.
|
||||||
|
let isPrivateMediaSenderBlocked: (PeerID) -> Bool
|
||||||
/// Updates the registry last-seen timestamp for the peer (async barrier write).
|
/// Updates the registry last-seen timestamp for the peer (async barrier write).
|
||||||
let updatePeerLastSeen: (PeerID) -> Void
|
let updatePeerLastSeen: (PeerID) -> Void
|
||||||
/// Delivers `.messageReceived` to the UI as one main-actor hop.
|
/// Acknowledges stable private media only after its synchronous
|
||||||
let deliverMessage: (BitchatMessage) -> Void
|
/// conversation delivery has completed.
|
||||||
|
let acknowledgePrivateMedia: (_ messageID: String, _ peerID: PeerID) -> Void
|
||||||
|
/// Delivers `.messageReceived` as one main-actor hop while
|
||||||
|
/// `shouldDeliver` remains true before and after the synchronous sink.
|
||||||
|
/// The completion authorizes the stable-media ACK. Finalization runs after
|
||||||
|
/// every delivery attempt, including rejection, so allocator ownership
|
||||||
|
/// cannot leak indefinitely.
|
||||||
|
let deliverMessage: (
|
||||||
|
_ message: BitchatMessage,
|
||||||
|
_ shouldDeliver: @escaping () -> Bool,
|
||||||
|
_ completion: @escaping () -> Void,
|
||||||
|
_ finalization: @escaping (TransportEventDeliveryOutcome) -> Void
|
||||||
|
) -> Void
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process-lifetime reservation cache for stable private-media IDs.
|
||||||
|
///
|
||||||
|
/// The first arrival reserves its ID before quota enforcement. Concurrent
|
||||||
|
/// arrivals remain coalesced in memory, while accepted state is resolved from
|
||||||
|
/// the durable ID-to-file ledger so it survives relaunch and becomes retryable
|
||||||
|
/// if quota cleanup removed the file.
|
||||||
|
private final class PrivateMediaArrivalDeduplicator {
|
||||||
|
enum Reservation {
|
||||||
|
case reserved
|
||||||
|
case pending
|
||||||
|
case accepted(URL)
|
||||||
|
case tombstoned
|
||||||
|
case unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var pending: Set<String> = []
|
||||||
|
|
||||||
|
func reserve(
|
||||||
|
_ messageID: String,
|
||||||
|
receiptState: () -> BLEPrivateMediaReceiptState
|
||||||
|
) -> Reservation {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
if pending.contains(messageID) {
|
||||||
|
return .pending
|
||||||
|
}
|
||||||
|
|
||||||
|
switch receiptState() {
|
||||||
|
case .accepted(let existingURL):
|
||||||
|
return .accepted(existingURL)
|
||||||
|
case .tombstoned:
|
||||||
|
return .tombstoned
|
||||||
|
case .unavailable:
|
||||||
|
return .unavailable
|
||||||
|
case .absent:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
pending.insert(messageID)
|
||||||
|
return .reserved
|
||||||
|
}
|
||||||
|
|
||||||
|
func finish(_ messageID: String) {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
pending.remove(messageID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Orchestrates inbound file transfers: self-echo policy, sender display-name
|
/// Orchestrates inbound file transfers: self-echo policy, sender display-name
|
||||||
@@ -43,6 +118,7 @@ struct BLEFileTransferHandlerEnvironment {
|
|||||||
/// and UI delivery.
|
/// and UI delivery.
|
||||||
final class BLEFileTransferHandler {
|
final class BLEFileTransferHandler {
|
||||||
private let environment: BLEFileTransferHandlerEnvironment
|
private let environment: BLEFileTransferHandlerEnvironment
|
||||||
|
private let privateMediaArrivals = PrivateMediaArrivalDeduplicator()
|
||||||
|
|
||||||
init(environment: BLEFileTransferHandlerEnvironment) {
|
init(environment: BLEFileTransferHandlerEnvironment) {
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
@@ -86,6 +162,7 @@ final class BLEFileTransferHandler {
|
|||||||
senderNickname: senderNickname,
|
senderNickname: senderNickname,
|
||||||
timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000),
|
timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000),
|
||||||
isPrivate: deliveryPlan.isPrivateMessage,
|
isPrivate: deliveryPlan.isPrivateMessage,
|
||||||
|
usesDurableReceipts: false,
|
||||||
env: env
|
env: env
|
||||||
)
|
)
|
||||||
// Once authenticated, a local decode/quota/save failure is not proof
|
// Once authenticated, a local decode/quota/save failure is not proof
|
||||||
@@ -116,6 +193,11 @@ final class BLEFileTransferHandler {
|
|||||||
senderNickname: senderNickname,
|
senderNickname: senderNickname,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
|
// Every authenticated Noise private-file keeps the stable ID/ACK
|
||||||
|
// contract introduced with capability bit 8. Bit 9 advertises
|
||||||
|
// sender-side automatic retry support; it must not downgrade
|
||||||
|
// prior iOS clients to random IDs or single-check delivery.
|
||||||
|
usesDurableReceipts: true,
|
||||||
env: env
|
env: env
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -126,9 +208,11 @@ final class BLEFileTransferHandler {
|
|||||||
senderNickname: String,
|
senderNickname: String,
|
||||||
timestamp: Date,
|
timestamp: Date,
|
||||||
isPrivate: Bool,
|
isPrivate: Bool,
|
||||||
|
usesDurableReceipts: Bool,
|
||||||
env: BLEFileTransferHandlerEnvironment
|
env: BLEFileTransferHandlerEnvironment
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
|
|
||||||
|
let localPeerID = env.localPeerID()
|
||||||
let filePacket: BitchatFilePacket
|
let filePacket: BitchatFilePacket
|
||||||
let mime: MimeType
|
let mime: MimeType
|
||||||
switch BLEIncomingFileValidator.validate(payload: payload) {
|
switch BLEIncomingFileValidator.validate(payload: payload) {
|
||||||
@@ -149,6 +233,89 @@ final class BLEFileTransferHandler {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isPrivate, env.isPrivateMediaSenderBlocked(peerID) {
|
||||||
|
SecureLogger.debug(
|
||||||
|
"🚫 Dropping private media from blocked peer \(peerID.id.prefix(8))… before disk write",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let messageID = usesDurableReceipts
|
||||||
|
? PrivateMediaMessageIdentity.stableID(
|
||||||
|
for: filePacket,
|
||||||
|
senderPeerID: peerID,
|
||||||
|
recipientPeerID: localPeerID
|
||||||
|
)
|
||||||
|
: nil
|
||||||
|
if let messageID {
|
||||||
|
switch privateMediaArrivals.reserve(
|
||||||
|
messageID,
|
||||||
|
receiptState: { env.privateMediaReceiptState(messageID) }
|
||||||
|
) {
|
||||||
|
case .reserved:
|
||||||
|
break
|
||||||
|
case .pending:
|
||||||
|
// The first arrival has not reached durable storage yet.
|
||||||
|
// Coalesce this retry without ACKing so a failed first save
|
||||||
|
// remains retryable by the sender.
|
||||||
|
SecureLogger.debug(
|
||||||
|
"📁 Coalesced in-flight private media id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))…",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
case .accepted(let existingFile):
|
||||||
|
env.updatePeerLastSeen(peerID)
|
||||||
|
let message = incomingMessage(
|
||||||
|
messageID: messageID,
|
||||||
|
senderNickname: senderNickname,
|
||||||
|
timestamp: timestamp,
|
||||||
|
isPrivate: true,
|
||||||
|
peerID: peerID,
|
||||||
|
destination: existingFile,
|
||||||
|
category: storedMediaCategory(
|
||||||
|
for: existingFile,
|
||||||
|
fallback: mime.category
|
||||||
|
),
|
||||||
|
env: env
|
||||||
|
)
|
||||||
|
SecureLogger.debug(
|
||||||
|
"📁 Restored durable private media duplicate id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))… -> \(existingFile.lastPathComponent)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
deliverStableMessage(
|
||||||
|
message,
|
||||||
|
messageID: messageID,
|
||||||
|
peerID: peerID,
|
||||||
|
expectedURL: existingFile,
|
||||||
|
env: env
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
case .tombstoned:
|
||||||
|
// Explicit deletion is a durable terminal receiver decision.
|
||||||
|
env.updatePeerLastSeen(peerID)
|
||||||
|
env.acknowledgePrivateMedia(messageID, peerID)
|
||||||
|
SecureLogger.debug(
|
||||||
|
"📁 Dropped explicitly deleted private media id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))…",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
case .unavailable:
|
||||||
|
// Never turn an unreadable ledger into an empty ledger. The
|
||||||
|
// sender can retry after the transient storage failure clears.
|
||||||
|
SecureLogger.warning(
|
||||||
|
"📁 Withholding private media id=\(messageID.prefix(12))… while durable receipt state is unavailable",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer {
|
||||||
|
if let messageID {
|
||||||
|
privateMediaArrivals.finish(messageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// BCH-01-002: Enforce storage quota before saving
|
// BCH-01-002: Enforce storage quota before saving
|
||||||
env.enforceStorageQuota(filePacket.content.count)
|
env.enforceStorageQuota(filePacket.content.count)
|
||||||
|
|
||||||
@@ -162,13 +329,102 @@ final class BLEFileTransferHandler {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let messageID,
|
||||||
|
!env.commitPrivateMediaFile(messageID, destination) {
|
||||||
|
// A payload without its durable ID mapping cannot safely suppress
|
||||||
|
// a retry after relaunch. Roll it back and withhold UI/ACK.
|
||||||
|
env.removeIncomingFile(destination)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if isPrivate {
|
if isPrivate {
|
||||||
env.updatePeerLastSeen(peerID)
|
env.updatePeerLastSeen(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
let message = BitchatMessage(
|
let message = incomingMessage(
|
||||||
|
messageID: messageID,
|
||||||
|
senderNickname: senderNickname,
|
||||||
|
timestamp: timestamp,
|
||||||
|
isPrivate: isPrivate,
|
||||||
|
peerID: peerID,
|
||||||
|
destination: destination,
|
||||||
|
category: mime.category,
|
||||||
|
env: env
|
||||||
|
)
|
||||||
|
|
||||||
|
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
|
||||||
|
|
||||||
|
if let messageID {
|
||||||
|
deliverStableMessage(
|
||||||
|
message,
|
||||||
|
messageID: messageID,
|
||||||
|
peerID: peerID,
|
||||||
|
expectedURL: destination,
|
||||||
|
env: env
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
env.deliverMessage(
|
||||||
|
message,
|
||||||
|
{ true },
|
||||||
|
{},
|
||||||
|
{ outcome in
|
||||||
|
if outcome == .rejected {
|
||||||
|
// Raw media has no durable receipt that can redeliver
|
||||||
|
// it later. Do not leave a newly saved, UI-unowned file
|
||||||
|
// available for a stale fallback path to misidentify.
|
||||||
|
env.removeIncomingFile(destination)
|
||||||
|
} else {
|
||||||
|
// Plain delegates are invoked without synchronous
|
||||||
|
// insertion confirmation. Preserve the payload for
|
||||||
|
// that supported delivery path.
|
||||||
|
env.finishIncomingFileDelivery(destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deliverStableMessage(
|
||||||
|
_ message: BitchatMessage,
|
||||||
|
messageID: String,
|
||||||
|
peerID: PeerID,
|
||||||
|
expectedURL: URL,
|
||||||
|
env: BLEFileTransferHandlerEnvironment
|
||||||
|
) {
|
||||||
|
env.deliverMessage(
|
||||||
|
message,
|
||||||
|
{
|
||||||
|
guard case .accepted(let resolvedURL) =
|
||||||
|
env.privateMediaReceiptState(messageID) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return resolvedURL.standardizedFileURL
|
||||||
|
== expectedURL.standardizedFileURL
|
||||||
|
},
|
||||||
|
{
|
||||||
|
env.acknowledgePrivateMedia(messageID, peerID)
|
||||||
|
},
|
||||||
|
{ _ in
|
||||||
|
env.finishIncomingFileDelivery(expectedURL)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func incomingMessage(
|
||||||
|
messageID: String?,
|
||||||
|
senderNickname: String,
|
||||||
|
timestamp: Date,
|
||||||
|
isPrivate: Bool,
|
||||||
|
peerID: PeerID,
|
||||||
|
destination: URL,
|
||||||
|
category: MimeType.Category,
|
||||||
|
env: BLEFileTransferHandlerEnvironment
|
||||||
|
) -> BitchatMessage {
|
||||||
|
BitchatMessage(
|
||||||
|
id: messageID,
|
||||||
sender: senderNickname,
|
sender: senderNickname,
|
||||||
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
|
content: "\(category.messagePrefix)\(destination.lastPathComponent)",
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
isRelay: false,
|
isRelay: false,
|
||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
@@ -176,17 +432,35 @@ final class BLEFileTransferHandler {
|
|||||||
recipientNickname: nil,
|
recipientNickname: nil,
|
||||||
senderPeerID: peerID,
|
senderPeerID: peerID,
|
||||||
// Received messages need an explicit status: BitchatMessage
|
// Received messages need an explicit status: BitchatMessage
|
||||||
// defaults private messages to .sending, which the media views
|
// defaults private messages to .sending, which media views render
|
||||||
// render as an in-flight send (empty reveal mask, disabled tap).
|
// as an in-flight send.
|
||||||
deliveryStatus: isPrivate
|
deliveryStatus: isPrivate
|
||||||
? .delivered(to: env.localNickname(), at: timestamp)
|
? .delivered(to: env.localNickname(), at: timestamp)
|
||||||
: nil
|
: nil
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
|
/// The durable URL is authoritative during reconstruction. A sender that
|
||||||
|
/// reuses a stable filename with a different MIME type must not change how
|
||||||
env.deliverMessage(message)
|
/// the already-stored payload renders.
|
||||||
return true
|
private func storedMediaCategory(
|
||||||
|
for url: URL,
|
||||||
|
fallback: MimeType.Category
|
||||||
|
) -> MimeType.Category {
|
||||||
|
let mediaDirectory = url
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.lastPathComponent
|
||||||
|
switch mediaDirectory {
|
||||||
|
case MimeType.Category.audio.mediaDir:
|
||||||
|
return .audio
|
||||||
|
case MimeType.Category.image.mediaDir:
|
||||||
|
return .image
|
||||||
|
case MimeType.Category.file.mediaDir:
|
||||||
|
return .file
|
||||||
|
default:
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every remaining raw file transfer is signed, regardless of whether it
|
/// Every remaining raw file transfer is signed, regardless of whether it
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ struct PanicRecoveryOperations {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct BLEIncomingFileStore {
|
struct BLEIncomingFileStore: @unchecked Sendable {
|
||||||
enum PanicRecoveryError: Error {
|
enum PanicRecoveryError: Error {
|
||||||
case externalMarkerCommitFailed
|
case externalMarkerCommitFailed
|
||||||
case markerWriteFailed(Error)
|
case markerWriteFailed(Error)
|
||||||
@@ -103,7 +103,17 @@ struct BLEIncomingFileStore {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let quotaBytes: Int64 = 100 * 1024 * 1024
|
struct PrivateMediaDeletionReservation: Sendable {
|
||||||
|
fileprivate let id: UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class PayloadCoordination: @unchecked Sendable {
|
||||||
|
let lock = NSLock()
|
||||||
|
var pendingDeliveryPaths: Set<String> = []
|
||||||
|
var deletionReservations: [UUID: Set<String>] = [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let defaultQuotaBytes: Int64 = 100 * 1024 * 1024
|
||||||
/// Kept outside `files/` so deleting the media tree cannot erase the
|
/// Kept outside `files/` so deleting the media tree cannot erase the
|
||||||
/// fail-closed startup decision before the full panic has committed.
|
/// fail-closed startup decision before the full panic has committed.
|
||||||
private static let panicRecoveryPendingMarkerFileName =
|
private static let panicRecoveryPendingMarkerFileName =
|
||||||
@@ -120,7 +130,6 @@ struct BLEIncomingFileStore {
|
|||||||
"files/incoming",
|
"files/incoming",
|
||||||
"files/outgoing"
|
"files/outgoing"
|
||||||
]
|
]
|
||||||
|
|
||||||
/// Name prefix of in-flight live voice captures (progressively written by
|
/// Name prefix of in-flight live voice captures (progressively written by
|
||||||
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern —
|
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern —
|
||||||
/// deleting one mid-stream unlinks the inode under an open `FileHandle`
|
/// deleting one mid-stream unlinks the inode under an open `FileHandle`
|
||||||
@@ -134,6 +143,9 @@ struct BLEIncomingFileStore {
|
|||||||
private let baseDirectory: URL?
|
private let baseDirectory: URL?
|
||||||
private let dateProvider: () -> Date
|
private let dateProvider: () -> Date
|
||||||
private let panicMarkerWriter: (Data, URL) throws -> Void
|
private let panicMarkerWriter: (Data, URL) throws -> Void
|
||||||
|
private let quotaBytes: Int64
|
||||||
|
private let privateMediaReceipts: BLEPrivateMediaReceiptStore
|
||||||
|
private let payloadCoordination: PayloadCoordination
|
||||||
|
|
||||||
init(
|
init(
|
||||||
fileManager: FileManager = .default,
|
fileManager: FileManager = .default,
|
||||||
@@ -141,12 +153,20 @@ struct BLEIncomingFileStore {
|
|||||||
dateProvider: @escaping () -> Date = Date.init,
|
dateProvider: @escaping () -> Date = Date.init,
|
||||||
panicMarkerWriter: @escaping (Data, URL) throws -> Void = {
|
panicMarkerWriter: @escaping (Data, URL) throws -> Void = {
|
||||||
try $0.write(to: $1, options: .atomic)
|
try $0.write(to: $1, options: .atomic)
|
||||||
}
|
},
|
||||||
|
quotaBytes: Int64 = Self.defaultQuotaBytes
|
||||||
) {
|
) {
|
||||||
self.fileManager = fileManager
|
self.fileManager = fileManager
|
||||||
self.baseDirectory = baseDirectory
|
self.baseDirectory = baseDirectory
|
||||||
self.dateProvider = dateProvider
|
self.dateProvider = dateProvider
|
||||||
self.panicMarkerWriter = panicMarkerWriter
|
self.panicMarkerWriter = panicMarkerWriter
|
||||||
|
self.quotaBytes = max(0, quotaBytes)
|
||||||
|
self.privateMediaReceipts = BLEPrivateMediaReceiptStore(
|
||||||
|
fileManager: fileManager,
|
||||||
|
baseDirectory: baseDirectory,
|
||||||
|
now: dateProvider
|
||||||
|
)
|
||||||
|
self.payloadCoordination = PayloadCoordination()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Panic-wipe every managed incoming and outgoing media artifact before
|
/// Panic-wipe every managed incoming and outgoing media artifact before
|
||||||
@@ -159,6 +179,22 @@ struct BLEIncomingFileStore {
|
|||||||
func panicWipe(
|
func panicWipe(
|
||||||
hasDurablePendingMarker: Bool = false
|
hasDurablePendingMarker: Bool = false
|
||||||
) throws {
|
) throws {
|
||||||
|
// The receipt index caches tombstones as well as accepted payloads,
|
||||||
|
// while payload coordination retains save/delete reservations. Always
|
||||||
|
// invalidate both on return, including partial-failure paths, so no
|
||||||
|
// pre-panic receiver decision survives after identity reset.
|
||||||
|
defer {
|
||||||
|
privateMediaReceipts.resetForPanic()
|
||||||
|
payloadCoordination.lock.lock()
|
||||||
|
payloadCoordination.pendingDeliveryPaths.removeAll(
|
||||||
|
keepingCapacity: false
|
||||||
|
)
|
||||||
|
payloadCoordination.deletionReservations.removeAll(
|
||||||
|
keepingCapacity: false
|
||||||
|
)
|
||||||
|
payloadCoordination.lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
let markerError: Error?
|
let markerError: Error?
|
||||||
do {
|
do {
|
||||||
try markPanicRecoveryPending()
|
try markPanicRecoveryPending()
|
||||||
@@ -240,6 +276,9 @@ struct BLEIncomingFileStore {
|
|||||||
fallbackExtension: String?,
|
fallbackExtension: String?,
|
||||||
defaultPrefix: String
|
defaultPrefix: String
|
||||||
) -> URL? {
|
) -> URL? {
|
||||||
|
payloadCoordination.lock.lock()
|
||||||
|
defer { payloadCoordination.lock.unlock() }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let base = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
|
let base = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
|
||||||
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
|
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
|
||||||
@@ -248,8 +287,26 @@ struct BLEIncomingFileStore {
|
|||||||
defaultName: "\(defaultPrefix)_\(Self.timestampString(from: dateProvider()))",
|
defaultName: "\(defaultPrefix)_\(Self.timestampString(from: dateProvider()))",
|
||||||
fallbackExtension: fallbackExtension
|
fallbackExtension: fallbackExtension
|
||||||
)
|
)
|
||||||
let destination = uniqueFileURL(in: base, fileName: sanitized)
|
let reservedPaths = privateMediaReceipts.reservedPayloadPaths()
|
||||||
|
let deletionPaths = payloadCoordination
|
||||||
|
.deletionReservations.values.reduce(into: Set<String>()) {
|
||||||
|
$0.formUnion($1)
|
||||||
|
}
|
||||||
|
let allocationReservations = deletionPaths.union(
|
||||||
|
payloadCoordination.pendingDeliveryPaths
|
||||||
|
)
|
||||||
|
let destination = uniqueFileURL(
|
||||||
|
in: base,
|
||||||
|
fileName: sanitized,
|
||||||
|
reservedPaths: (reservedPaths ?? []).union(
|
||||||
|
allocationReservations
|
||||||
|
),
|
||||||
|
forceRandomizedName: reservedPaths == nil
|
||||||
|
)
|
||||||
try data.write(to: destination, options: .atomic)
|
try data.write(to: destination, options: .atomic)
|
||||||
|
payloadCoordination.pendingDeliveryPaths.insert(
|
||||||
|
destination.standardizedFileURL.path
|
||||||
|
)
|
||||||
return destination
|
return destination
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("❌ Failed to persist incoming media: \(error)", category: .session)
|
SecureLogger.error("❌ Failed to persist incoming media: \(error)", category: .session)
|
||||||
@@ -257,12 +314,111 @@ struct BLEIncomingFileStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func privateMediaReceiptState(
|
||||||
|
messageID: String
|
||||||
|
) -> BLEPrivateMediaReceiptState {
|
||||||
|
privateMediaReceipts.state(for: messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func commitPrivateMediaFile(
|
||||||
|
messageID: String,
|
||||||
|
storedURL: URL
|
||||||
|
) -> Bool {
|
||||||
|
privateMediaReceipts.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: storedURL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reserves every receipt/UI path before the asynchronous deletion
|
||||||
|
/// barrier. Allocation and reservation share one lock, so either an
|
||||||
|
/// in-flight raw arrival is observed and deletion fails closed, or the
|
||||||
|
/// arrival is forced onto a different filename.
|
||||||
|
func reservePrivateMediaDeletion(
|
||||||
|
messageIDs: [String],
|
||||||
|
payloadRelativePaths: [String: String]
|
||||||
|
) -> PrivateMediaDeletionReservation? {
|
||||||
|
payloadCoordination.lock.lock()
|
||||||
|
defer { payloadCoordination.lock.unlock() }
|
||||||
|
|
||||||
|
guard let paths = privateMediaReceipts
|
||||||
|
.prospectiveDeletionPayloadPaths(
|
||||||
|
messageIDs: messageIDs,
|
||||||
|
payloadRelativePaths: payloadRelativePaths
|
||||||
|
),
|
||||||
|
paths.isDisjoint(
|
||||||
|
with: payloadCoordination.pendingDeliveryPaths
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let reservation = PrivateMediaDeletionReservation(id: UUID())
|
||||||
|
payloadCoordination.deletionReservations[reservation.id] = paths
|
||||||
|
return reservation
|
||||||
|
}
|
||||||
|
|
||||||
|
func commitPrivateMediaDeletion(
|
||||||
|
reservation: PrivateMediaDeletionReservation,
|
||||||
|
messageIDs: [String],
|
||||||
|
payloadRelativePaths: [String: String],
|
||||||
|
protectedPayloadRelativePaths: Set<String>
|
||||||
|
) -> Bool {
|
||||||
|
payloadCoordination.lock.lock()
|
||||||
|
defer {
|
||||||
|
payloadCoordination.deletionReservations.removeValue(
|
||||||
|
forKey: reservation.id
|
||||||
|
)
|
||||||
|
payloadCoordination.lock.unlock()
|
||||||
|
}
|
||||||
|
guard payloadCoordination.deletionReservations[reservation.id] != nil
|
||||||
|
else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return privateMediaReceipts.recordDeleted(
|
||||||
|
messageIDs: messageIDs,
|
||||||
|
payloadRelativePaths: payloadRelativePaths,
|
||||||
|
protectedPayloadRelativePaths: protectedPayloadRelativePaths
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Releases the short window between disk save and synchronous
|
||||||
|
/// conversation insertion. Before this callback, a deletion transaction
|
||||||
|
/// may not infer ownership from a stale bubble that names the same path.
|
||||||
|
func finishIncomingFileDelivery(at storedURL: URL) {
|
||||||
|
payloadCoordination.lock.lock()
|
||||||
|
defer { payloadCoordination.lock.unlock() }
|
||||||
|
payloadCoordination.pendingDeliveryPaths.remove(
|
||||||
|
storedURL.standardizedFileURL.path
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort rollback for a payload whose durable receipt commit failed.
|
||||||
|
func removeIncomingFile(at storedURL: URL) {
|
||||||
|
payloadCoordination.lock.lock()
|
||||||
|
defer { payloadCoordination.lock.unlock() }
|
||||||
|
payloadCoordination.pendingDeliveryPaths.remove(
|
||||||
|
storedURL.standardizedFileURL.path
|
||||||
|
)
|
||||||
|
guard isURLInsideFilesDirectory(storedURL) else { return }
|
||||||
|
do {
|
||||||
|
try fileManager.removeItem(at: storedURL)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.warning(
|
||||||
|
"⚠️ Failed to roll back uncommitted incoming media: \(error)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Frees least-recently-modified incoming files until `reservingBytes`
|
/// Frees least-recently-modified incoming files until `reservingBytes`
|
||||||
/// fits under the quota. Files named `voice_live_*` (in-flight live
|
/// fits under the quota. Files named `voice_live_*` (in-flight live
|
||||||
/// captures) are never evicted regardless of who triggers enforcement —
|
/// captures) are never evicted regardless of who triggers enforcement —
|
||||||
/// a finalized transfer can arrive at quota while a burst is still
|
/// a finalized transfer can arrive at quota while a burst is still
|
||||||
/// streaming — but they still count toward usage.
|
/// streaming — but they still count toward usage.
|
||||||
func enforceQuota(reservingBytes: Int) {
|
func enforceQuota(reservingBytes: Int) {
|
||||||
|
payloadCoordination.lock.lock()
|
||||||
|
defer { payloadCoordination.lock.unlock() }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let base = try filesDirectory()
|
let base = try filesDirectory()
|
||||||
let incomingDirs = [
|
let incomingDirs = [
|
||||||
@@ -288,14 +444,26 @@ struct BLEIncomingFileStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let currentUsage = allFiles.reduce(0) { $0 + $1.size }
|
let currentUsage = allFiles.reduce(0) { $0 + $1.size }
|
||||||
let targetUsage = Self.quotaBytes - Int64(reservingBytes)
|
let targetUsage = quotaBytes - Int64(reservingBytes)
|
||||||
guard currentUsage > targetUsage else { return }
|
guard currentUsage > targetUsage else { return }
|
||||||
|
|
||||||
let needToFree = currentUsage - targetUsage
|
let needToFree = currentUsage - targetUsage
|
||||||
|
let activeDeletionPaths = payloadCoordination
|
||||||
|
.deletionReservations.values.reduce(into: Set<String>()) {
|
||||||
|
$0.formUnion($1)
|
||||||
|
}
|
||||||
|
let protectedPaths = activeDeletionPaths.union(
|
||||||
|
payloadCoordination.pendingDeliveryPaths
|
||||||
|
)
|
||||||
var freedSpace: Int64 = 0
|
var freedSpace: Int64 = 0
|
||||||
for file in allFiles.sorted(by: { $0.modified < $1.modified }) {
|
for file in allFiles.sorted(by: { $0.modified < $1.modified }) {
|
||||||
guard freedSpace < needToFree else { break }
|
guard freedSpace < needToFree else { break }
|
||||||
guard !file.url.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue }
|
guard !file.url.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue }
|
||||||
|
guard !protectedPaths.contains(
|
||||||
|
file.url.standardizedFileURL.path
|
||||||
|
) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
do {
|
do {
|
||||||
try fileManager.removeItem(at: file.url)
|
try fileManager.removeItem(at: file.url)
|
||||||
freedSpace += file.size
|
freedSpace += file.size
|
||||||
@@ -349,6 +517,13 @@ struct BLEIncomingFileStore {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func isURLInsideFilesDirectory(_ url: URL) -> Bool {
|
||||||
|
guard let filesDirectory = try? filesDirectory().standardizedFileURL else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return url.standardizedFileURL.path.hasPrefix(filesDirectory.path + "/")
|
||||||
|
}
|
||||||
|
|
||||||
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
||||||
var candidate = (name ?? "")
|
var candidate = (name ?? "")
|
||||||
.replacingOccurrences(of: "\0", with: "")
|
.replacingOccurrences(of: "\0", with: "")
|
||||||
@@ -376,11 +551,20 @@ struct BLEIncomingFileStore {
|
|||||||
return candidate.isEmpty ? defaultName : candidate
|
return candidate.isEmpty ? defaultName : candidate
|
||||||
}
|
}
|
||||||
|
|
||||||
private func uniqueFileURL(in directory: URL, fileName: String) -> URL {
|
private func uniqueFileURL(
|
||||||
|
in directory: URL,
|
||||||
|
fileName: String,
|
||||||
|
reservedPaths: Set<String>,
|
||||||
|
forceRandomizedName: Bool
|
||||||
|
) -> URL {
|
||||||
let directoryPath = directory.standardizedFileURL.path
|
let directoryPath = directory.standardizedFileURL.path
|
||||||
func isInsideDirectory(_ url: URL) -> Bool {
|
func isInsideDirectory(_ url: URL) -> Bool {
|
||||||
url.standardizedFileURL.path.hasPrefix(directoryPath + "/")
|
url.standardizedFileURL.path.hasPrefix(directoryPath + "/")
|
||||||
}
|
}
|
||||||
|
func isAvailable(_ url: URL) -> Bool {
|
||||||
|
!reservedPaths.contains(url.standardizedFileURL.path)
|
||||||
|
&& !fileManager.fileExists(atPath: url.path)
|
||||||
|
}
|
||||||
|
|
||||||
var candidate = directory.appendingPathComponent(fileName)
|
var candidate = directory.appendingPathComponent(fileName)
|
||||||
guard isInsideDirectory(candidate) else {
|
guard isInsideDirectory(candidate) else {
|
||||||
@@ -388,19 +572,27 @@ struct BLEIncomingFileStore {
|
|||||||
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !fileManager.fileExists(atPath: candidate.path) {
|
let baseName = (fileName as NSString).deletingPathExtension
|
||||||
|
let ext = (fileName as NSString).pathExtension
|
||||||
|
if forceRandomizedName {
|
||||||
|
let suffix = UUID().uuidString
|
||||||
|
let randomizedName = ext.isEmpty
|
||||||
|
? "\(baseName)_\(suffix)"
|
||||||
|
: "\(baseName)_\(suffix).\(ext)"
|
||||||
|
return directory.appendingPathComponent(randomizedName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isAvailable(candidate) {
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
|
|
||||||
let baseName = (fileName as NSString).deletingPathExtension
|
|
||||||
let ext = (fileName as NSString).pathExtension
|
|
||||||
for counter in 1..<100 {
|
for counter in 1..<100 {
|
||||||
let newName = ext.isEmpty ? "\(baseName) (\(counter))" : "\(baseName) (\(counter)).\(ext)"
|
let newName = ext.isEmpty ? "\(baseName) (\(counter))" : "\(baseName) (\(counter)).\(ext)"
|
||||||
candidate = directory.appendingPathComponent(newName)
|
candidate = directory.appendingPathComponent(newName)
|
||||||
guard isInsideDirectory(candidate) else {
|
guard isInsideDirectory(candidate) else {
|
||||||
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
||||||
}
|
}
|
||||||
if !fileManager.fileExists(atPath: candidate.path) {
|
if isAvailable(candidate) {
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1119,6 +1119,51 @@ final class BLEService: NSObject {
|
|||||||
collectionsQueue.sync { peerRegistry.capabilities(for: peerID) }
|
collectionsQueue.sync { peerRegistry.capabilities(for: peerID) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func authenticatedPrivateMediaReceiptSessionGeneration(
|
||||||
|
to peerID: PeerID
|
||||||
|
) -> UUID? {
|
||||||
|
let normalizedPeerID = peerID.toShort()
|
||||||
|
let currentNoiseGeneration =
|
||||||
|
noiseService.sessionGeneration(for: normalizedPeerID)
|
||||||
|
return collectionsQueue.sync {
|
||||||
|
guard let generation =
|
||||||
|
privateMediaSessionGenerations[normalizedPeerID],
|
||||||
|
generation == currentNoiseGeneration,
|
||||||
|
let authenticated =
|
||||||
|
authenticatedPeerStates[normalizedPeerID],
|
||||||
|
authenticated.sessionGeneration == generation,
|
||||||
|
authenticated.capabilities.contains(.privateMedia),
|
||||||
|
authenticated.capabilities.contains(
|
||||||
|
.privateMediaReceipts
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return generation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func privateMediaPolicyFingerprint(
|
||||||
|
for peerID: PeerID,
|
||||||
|
expectedSessionGeneration: UUID?
|
||||||
|
) -> String? {
|
||||||
|
let normalizedPeerID = peerID.toShort()
|
||||||
|
if let expectedSessionGeneration,
|
||||||
|
noiseService.sessionGeneration(for: normalizedPeerID)
|
||||||
|
== expectedSessionGeneration,
|
||||||
|
let fingerprint = noiseService.getPeerFingerprint(normalizedPeerID),
|
||||||
|
noiseService.sessionGeneration(for: normalizedPeerID)
|
||||||
|
== expectedSessionGeneration {
|
||||||
|
// The exact authenticated Noise static key is stronger than a
|
||||||
|
// registry entry populated by a public announce.
|
||||||
|
return fingerprint
|
||||||
|
}
|
||||||
|
return collectionsQueue.sync {
|
||||||
|
peerRegistry.info(for: normalizedPeerID)?
|
||||||
|
.noisePublicKey?
|
||||||
|
.sha256Fingerprint()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
|
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
|
||||||
let normalizedPeerID = peerID.toShort()
|
let normalizedPeerID = peerID.toShort()
|
||||||
let state: (
|
let state: (
|
||||||
@@ -1146,7 +1191,10 @@ final class BLEService: NSObject {
|
|||||||
return .awaitingCapabilityProof
|
return .awaitingCapabilityProof
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let fingerprint = state.fingerprint else {
|
guard let fingerprint = privateMediaPolicyFingerprint(
|
||||||
|
for: normalizedPeerID,
|
||||||
|
expectedSessionGeneration: state.sessionGeneration
|
||||||
|
) ?? state.fingerprint else {
|
||||||
// A raw fallback must be bound to the stable Noise key from a
|
// A raw fallback must be bound to the stable Noise key from a
|
||||||
// verified registry entry; a routing ID alone can rotate or be
|
// verified registry entry; a routing ID alone can rotate or be
|
||||||
// spoofed. Without that key neither proof nor safe migration state
|
// spoofed. Without that key neither proof nor safe migration state
|
||||||
@@ -1200,11 +1248,13 @@ final class BLEService: NSObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let fingerprint: String? = self.collectionsQueue.sync {
|
let generation = self.collectionsQueue.sync {
|
||||||
self.peerRegistry.info(for: normalizedPeerID)?
|
self.privateMediaSessionGenerations[normalizedPeerID]
|
||||||
.noisePublicKey?
|
|
||||||
.sha256Fingerprint()
|
|
||||||
}
|
}
|
||||||
|
let fingerprint = self.privateMediaPolicyFingerprint(
|
||||||
|
for: normalizedPeerID,
|
||||||
|
expectedSessionGeneration: generation
|
||||||
|
)
|
||||||
guard let fingerprint else {
|
guard let fingerprint else {
|
||||||
self.completePrivateMediaPolicyResolution([completion], with: .blockedDowngrade)
|
self.completePrivateMediaPolicyResolution([completion], with: .blockedDowngrade)
|
||||||
return
|
return
|
||||||
@@ -1576,6 +1626,36 @@ final class BLEService: NSObject {
|
|||||||
to peerID: PeerID,
|
to peerID: PeerID,
|
||||||
transferId: String,
|
transferId: String,
|
||||||
allowLegacyFallback: Bool
|
allowLegacyFallback: Bool
|
||||||
|
) {
|
||||||
|
sendFilePrivate(
|
||||||
|
filePacket,
|
||||||
|
to: peerID,
|
||||||
|
transferId: transferId,
|
||||||
|
allowLegacyFallback: allowLegacyFallback,
|
||||||
|
requiresAuthenticatedPrivateMediaReceipts: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendFilePrivateReceiptRetry(
|
||||||
|
_ filePacket: BitchatFilePacket,
|
||||||
|
to peerID: PeerID,
|
||||||
|
transferId: String
|
||||||
|
) {
|
||||||
|
sendFilePrivate(
|
||||||
|
filePacket,
|
||||||
|
to: peerID,
|
||||||
|
transferId: transferId,
|
||||||
|
allowLegacyFallback: false,
|
||||||
|
requiresAuthenticatedPrivateMediaReceipts: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendFilePrivate(
|
||||||
|
_ filePacket: BitchatFilePacket,
|
||||||
|
to peerID: PeerID,
|
||||||
|
transferId: String,
|
||||||
|
allowLegacyFallback: Bool,
|
||||||
|
requiresAuthenticatedPrivateMediaReceipts: Bool
|
||||||
) {
|
) {
|
||||||
// Register before enqueueing onto messageQueue. This closes the window
|
// Register before enqueueing onto messageQueue. This closes the window
|
||||||
// where cancel/delete could run first, observe no scheduler state, and
|
// where cancel/delete could run first, observe no scheduler state, and
|
||||||
@@ -1688,6 +1768,25 @@ final class BLEService: NSObject {
|
|||||||
self.privateMediaTransferAdmissions.finish(transferId)
|
self.privateMediaTransferAdmissions.finish(transferId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if requiresAuthenticatedPrivateMediaReceipts,
|
||||||
|
self.authenticatedPrivateMediaReceiptSessionGeneration(
|
||||||
|
to: targetID
|
||||||
|
) == nil {
|
||||||
|
SecureLogger.warning(
|
||||||
|
"Private media retry blocked without current authenticated receipt support for \(targetID.id.prefix(8))…",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
TransferProgressManager.shared.rejectBeforeStart(
|
||||||
|
id: transferId,
|
||||||
|
reason: String(
|
||||||
|
localized: "content.delivery.reason.private_media_capability_unresolved",
|
||||||
|
defaultValue: "Could not confirm encrypted media support",
|
||||||
|
comment: "Failure reason when private-media capability negotiation did not resolve"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.privateMediaTransferAdmissions.finish(transferId)
|
||||||
|
return
|
||||||
|
}
|
||||||
guard let typedPayload = BLENoisePayloadFactory.privateFile(filePacket) else {
|
guard let typedPayload = BLENoisePayloadFactory.privateFile(filePacket) else {
|
||||||
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
|
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
|
||||||
TransferProgressManager.shared.rejectBeforeStart(
|
TransferProgressManager.shared.rejectBeforeStart(
|
||||||
@@ -1698,6 +1797,21 @@ final class BLEService: NSObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard self.noiseService.hasEstablishedSession(with: targetID) else {
|
guard self.noiseService.hasEstablishedSession(with: targetID) else {
|
||||||
|
if requiresAuthenticatedPrivateMediaReceipts {
|
||||||
|
// A retry belongs to one exact authenticated generation.
|
||||||
|
// Never let it enter the ordinary pending queue where a
|
||||||
|
// bit-8-only replacement session could later flush it.
|
||||||
|
TransferProgressManager.shared.rejectBeforeStart(
|
||||||
|
id: transferId,
|
||||||
|
reason: String(
|
||||||
|
localized: "content.delivery.reason.private_media_capability_unresolved",
|
||||||
|
defaultValue: "Could not confirm encrypted media support",
|
||||||
|
comment: "Failure reason when private-media capability negotiation did not resolve"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.privateMediaTransferAdmissions.finish(transferId)
|
||||||
|
return
|
||||||
|
}
|
||||||
let queued = self.collectionsQueue.sync(flags: .barrier) {
|
let queued = self.collectionsQueue.sync(flags: .barrier) {
|
||||||
self.privateMediaTransferAdmissions.withActive(transferId) {
|
self.privateMediaTransferAdmissions.withActive(transferId) {
|
||||||
self.pendingNoiseSessionQueues.appendTypedPayload(
|
self.pendingNoiseSessionQueues.appendTypedPayload(
|
||||||
@@ -1729,7 +1843,12 @@ final class BLEService: NSObject {
|
|||||||
self.privateMediaTransferAdmissions.finish(transferId)
|
self.privateMediaTransferAdmissions.finish(transferId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let packet = try self.makeEncryptedNoisePacket(typedPayload, to: targetID)
|
let packet = try self.makeEncryptedNoisePacket(
|
||||||
|
typedPayload,
|
||||||
|
to: targetID,
|
||||||
|
requiresAuthenticatedPrivateMediaReceipts:
|
||||||
|
requiresAuthenticatedPrivateMediaReceipts
|
||||||
|
)
|
||||||
guard self.privateMediaTransferAdmissions.isActive(transferId) else {
|
guard self.privateMediaTransferAdmissions.isActive(transferId) else {
|
||||||
self.privateMediaTransferAdmissions.finish(transferId)
|
self.privateMediaTransferAdmissions.finish(transferId)
|
||||||
return
|
return
|
||||||
@@ -2527,12 +2646,63 @@ final class BLEService: NSObject {
|
|||||||
defaultPrefix: defaultPrefix
|
defaultPrefix: defaultPrefix
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
privateMediaReceiptState: { [weak self] messageID in
|
||||||
|
self?.incomingFileStore.privateMediaReceiptState(
|
||||||
|
messageID: messageID
|
||||||
|
) ?? .unavailable
|
||||||
|
},
|
||||||
|
commitPrivateMediaFile: { [weak self] messageID, storedURL in
|
||||||
|
self?.incomingFileStore.commitPrivateMediaFile(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: storedURL
|
||||||
|
) ?? false
|
||||||
|
},
|
||||||
|
removeIncomingFile: { [weak self] storedURL in
|
||||||
|
self?.incomingFileStore.removeIncomingFile(at: storedURL)
|
||||||
|
},
|
||||||
|
finishIncomingFileDelivery: { [weak self] storedURL in
|
||||||
|
// Serialize pending-owner release behind deletion barriers.
|
||||||
|
// If /clear snapshots before this UI insertion, its already
|
||||||
|
// queued barrier must still observe the path as pending. If
|
||||||
|
// insertion wins first, the next MainActor snapshot sees the
|
||||||
|
// new bubble and protects the path explicitly.
|
||||||
|
self?.messageQueue.async(flags: .barrier) {
|
||||||
|
self?.incomingFileStore.finishIncomingFileDelivery(
|
||||||
|
at: storedURL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isPrivateMediaSenderBlocked: { [weak self] peerID in
|
||||||
|
guard let self else { return false }
|
||||||
|
let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID)
|
||||||
|
?? self.collectionsQueue.sync {
|
||||||
|
self.peerRegistry.info(for: peerID)?.noisePublicKey
|
||||||
|
}
|
||||||
|
guard let senderStaticKey else { return false }
|
||||||
|
return self.identityManager.isBlocked(
|
||||||
|
fingerprint: senderStaticKey.sha256Fingerprint()
|
||||||
|
)
|
||||||
|
},
|
||||||
updatePeerLastSeen: { [weak self] peerID in
|
updatePeerLastSeen: { [weak self] peerID in
|
||||||
self?.updatePeerLastSeen(peerID)
|
self?.updatePeerLastSeen(peerID)
|
||||||
},
|
},
|
||||||
deliverMessage: { [weak self] message in
|
acknowledgePrivateMedia: { [weak self] messageID, peerID in
|
||||||
// Single main-actor hop delivering `.messageReceived`.
|
guard let self,
|
||||||
self?.emitTransportEvent(.messageReceived(message))
|
let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID),
|
||||||
|
!self.identityManager.isBlocked(
|
||||||
|
fingerprint: senderStaticKey.sha256Fingerprint()
|
||||||
|
) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.sendDeliveryAck(for: messageID, to: peerID)
|
||||||
|
},
|
||||||
|
deliverMessage: { [weak self] message, shouldDeliver, completion, finalization in
|
||||||
|
self?.emitTransportEvent(
|
||||||
|
.messageReceived(message),
|
||||||
|
shouldDeliver: shouldDeliver,
|
||||||
|
completion: completion,
|
||||||
|
finalization: finalization
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -3322,6 +3492,18 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _test_emitTransportEvent(
|
||||||
|
_ event: TransportEvent,
|
||||||
|
completion: @escaping () -> Void,
|
||||||
|
finalization: @escaping (TransportEventDeliveryOutcome) -> Void
|
||||||
|
) {
|
||||||
|
emitTransportEvent(
|
||||||
|
event,
|
||||||
|
completion: completion,
|
||||||
|
finalization: finalization
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true, signingPublicKey: Data? = nil) {
|
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true, signingPublicKey: Data? = nil) {
|
||||||
if preseedPeer {
|
if preseedPeer {
|
||||||
// Ensure the synthetic peer is known and marked verified for public-message tests
|
// Ensure the synthetic peer is known and marked verified for public-message tests
|
||||||
@@ -4227,8 +4409,87 @@ extension BLEService {
|
|||||||
// No alias rotation or advertising restarts required.
|
// No alias rotation or advertising restarts required.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Media Deletion
|
||||||
|
|
||||||
|
extension BLEService: PrivateMediaDeletionPersisting {
|
||||||
|
@MainActor
|
||||||
|
func persistDeletedPrivateMedia(
|
||||||
|
messageIDs: [String],
|
||||||
|
payloadRelativePaths: [String: String],
|
||||||
|
protectedPayloadRelativePaths: Set<String>,
|
||||||
|
completion: @escaping @MainActor (Bool) -> Void
|
||||||
|
) {
|
||||||
|
let fileStore = incomingFileStore
|
||||||
|
messageQueue.async(flags: .barrier) {
|
||||||
|
guard let reservation = fileStore
|
||||||
|
.reservePrivateMediaDeletion(
|
||||||
|
messageIDs: messageIDs,
|
||||||
|
payloadRelativePaths: payloadRelativePaths
|
||||||
|
) else {
|
||||||
|
Task { @MainActor in
|
||||||
|
completion(false)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let persisted = fileStore
|
||||||
|
.commitPrivateMediaDeletion(
|
||||||
|
reservation: reservation,
|
||||||
|
messageIDs: messageIDs,
|
||||||
|
payloadRelativePaths: payloadRelativePaths,
|
||||||
|
protectedPayloadRelativePaths:
|
||||||
|
protectedPayloadRelativePaths
|
||||||
|
)
|
||||||
|
Task { @MainActor in
|
||||||
|
completion(persisted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
enum TransportEventDeliveryOutcome: Equatable {
|
||||||
|
/// A synchronous sink inserted the message and revalidation succeeded.
|
||||||
|
case accepted
|
||||||
|
/// A supported plain delegate was invoked, but insertion cannot be
|
||||||
|
/// confirmed synchronously.
|
||||||
|
case invokedUnconfirmed
|
||||||
|
/// No sink accepted the event, or receipt revalidation rejected it.
|
||||||
|
case rejected
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TransportEventDeliveryGate {
|
||||||
|
/// Runs finalization exactly once for every attempted main-actor delivery,
|
||||||
|
/// including pre-insertion rejection, a missing/rejecting sink, and
|
||||||
|
/// post-insertion revalidation failure. Only a fully accepted delivery
|
||||||
|
/// runs `completion` (for example, a stable-media ACK).
|
||||||
|
@MainActor
|
||||||
|
static func attempt(
|
||||||
|
shouldDeliver: () -> Bool,
|
||||||
|
deliver: () -> TransportEventDeliveryOutcome,
|
||||||
|
completion: () -> Void,
|
||||||
|
finalization: (TransportEventDeliveryOutcome) -> Void
|
||||||
|
) {
|
||||||
|
var outcome = TransportEventDeliveryOutcome.rejected
|
||||||
|
defer { finalization(outcome) }
|
||||||
|
guard shouldDeliver() else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch deliver() {
|
||||||
|
case .rejected:
|
||||||
|
return
|
||||||
|
case .invokedUnconfirmed:
|
||||||
|
outcome = .invokedUnconfirmed
|
||||||
|
return
|
||||||
|
case .accepted:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
guard shouldDeliver() else { return }
|
||||||
|
outcome = .accepted
|
||||||
|
completion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension BLEService {
|
extension BLEService {
|
||||||
|
|
||||||
/// Notify UI on the MainActor to satisfy Swift concurrency isolation
|
/// Notify UI on the MainActor to satisfy Swift concurrency isolation
|
||||||
@@ -4249,42 +4510,74 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func emitTransportEvent(_ event: TransportEvent) {
|
private func emitTransportEvent(
|
||||||
notifyUI { [weak self] in
|
_ event: TransportEvent,
|
||||||
_ = self?.deliverTransportEvent(event)
|
shouldDeliver: (() -> Bool)? = nil,
|
||||||
|
completion: (() -> Void)? = nil,
|
||||||
|
finalization: ((TransportEventDeliveryOutcome) -> Void)? = nil
|
||||||
|
) {
|
||||||
|
guard let generation = capturePanicLifecycleGeneration() else {
|
||||||
|
Task { @MainActor in
|
||||||
|
finalization?(.rejected)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self,
|
||||||
|
self.isCurrentPanicLifecycleGeneration(generation) else {
|
||||||
|
finalization?(.rejected)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
TransportEventDeliveryGate.attempt(
|
||||||
|
shouldDeliver: {
|
||||||
|
self.isCurrentPanicLifecycleGeneration(generation)
|
||||||
|
&& (shouldDeliver?() ?? true)
|
||||||
|
},
|
||||||
|
deliver: {
|
||||||
|
return self.deliverTransportEvent(event)
|
||||||
|
},
|
||||||
|
completion: { completion?() },
|
||||||
|
finalization: { finalization?($0) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func deliverTransportEvent(_ event: TransportEvent) -> Bool {
|
private func deliverTransportEvent(
|
||||||
|
_ event: TransportEvent
|
||||||
|
) -> TransportEventDeliveryOutcome {
|
||||||
if case .messageReceived(let message) = event {
|
if case .messageReceived(let message) = event {
|
||||||
if let synchronousDelegate =
|
if let synchronousDelegate =
|
||||||
eventDelegate as? SynchronousMessageTransportEventDelegate {
|
eventDelegate as? SynchronousMessageTransportEventDelegate {
|
||||||
return synchronousDelegate
|
return synchronousDelegate
|
||||||
.didReceiveTransportMessageSynchronously(message)
|
.didReceiveTransportMessageSynchronously(message)
|
||||||
|
? .accepted
|
||||||
|
: .rejected
|
||||||
}
|
}
|
||||||
if let eventDelegate {
|
if let eventDelegate {
|
||||||
eventDelegate.didReceiveTransportEvent(event)
|
eventDelegate.didReceiveTransportEvent(event)
|
||||||
return false
|
return .invokedUnconfirmed
|
||||||
}
|
}
|
||||||
if let synchronousDelegate =
|
if let synchronousDelegate =
|
||||||
delegate as? SynchronousMessageTransportEventDelegate {
|
delegate as? SynchronousMessageTransportEventDelegate {
|
||||||
return synchronousDelegate
|
return synchronousDelegate
|
||||||
.didReceiveTransportMessageSynchronously(message)
|
.didReceiveTransportMessageSynchronously(message)
|
||||||
|
? .accepted
|
||||||
|
: .rejected
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let eventDelegate {
|
if let eventDelegate {
|
||||||
eventDelegate.didReceiveTransportEvent(event)
|
eventDelegate.didReceiveTransportEvent(event)
|
||||||
return true
|
return .accepted
|
||||||
} else {
|
} else {
|
||||||
guard let delegate else { return false }
|
guard let delegate else { return .rejected }
|
||||||
delegate.receiveTransportEvent(event)
|
delegate.receiveTransportEvent(event)
|
||||||
if case .messageReceived = event {
|
if case .messageReceived = event {
|
||||||
return false
|
return .invokedUnconfirmed
|
||||||
}
|
}
|
||||||
return true
|
return .accepted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5086,7 +5379,11 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeEncryptedNoisePacket(_ typedPayload: Data, to peerID: PeerID) throws -> BitchatPacket {
|
private func makeEncryptedNoisePacket(
|
||||||
|
_ typedPayload: Data,
|
||||||
|
to peerID: PeerID,
|
||||||
|
requiresAuthenticatedPrivateMediaReceipts: Bool = false
|
||||||
|
) throws -> BitchatPacket {
|
||||||
let encrypted: Data
|
let encrypted: Data
|
||||||
let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: typedPayload.first)
|
let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: typedPayload.first)
|
||||||
if isPrivateFile {
|
if isPrivateFile {
|
||||||
@@ -5096,6 +5393,13 @@ extension BLEService {
|
|||||||
let authenticated = authenticatedPeerStates[peerID],
|
let authenticated = authenticatedPeerStates[peerID],
|
||||||
authenticated.sessionGeneration == generation,
|
authenticated.sessionGeneration == generation,
|
||||||
authenticated.capabilities.contains(.privateMedia) else { return nil }
|
authenticated.capabilities.contains(.privateMedia) else { return nil }
|
||||||
|
if requiresAuthenticatedPrivateMediaReceipts {
|
||||||
|
guard authenticated.capabilities.contains(
|
||||||
|
.privateMediaReceipts
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
return generation
|
return generation
|
||||||
}
|
}
|
||||||
guard let provenGeneration else {
|
guard let provenGeneration else {
|
||||||
|
|||||||
@@ -322,7 +322,7 @@ final class CourierStore {
|
|||||||
|
|
||||||
/// Envelopes eligible to park on relays as bridge courier drops. Merely
|
/// Envelopes eligible to park on relays as bridge courier drops. Merely
|
||||||
/// offering one does not start its cooldown: the caller commits that only
|
/// offering one does not start its cooldown: the caller commits that only
|
||||||
/// after a relay explicitly accepts the event via NIP-20 OK.
|
/// after a relay explicitly accepts the event via NIP-01 `OK`.
|
||||||
func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] {
|
func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] {
|
||||||
let date = now()
|
let date = now()
|
||||||
return queue.sync {
|
return queue.sync {
|
||||||
|
|||||||
@@ -110,6 +110,10 @@ final class MessageOutboxStore {
|
|||||||
/// Delivery/read acknowledgments received before a deferred cold-load
|
/// Delivery/read acknowledgments received before a deferred cold-load
|
||||||
/// reveals the durable queue. Applied to every merge before persistence.
|
/// reveals the durable queue. Applied to every merge before persistence.
|
||||||
private var pendingRemovalMessageIDs = Set<String>()
|
private var pendingRemovalMessageIDs = Set<String>()
|
||||||
|
/// Peer-scoped acknowledgments received before a deferred cold-load
|
||||||
|
/// reveals the durable queue. Unlike the legacy global tombstones above,
|
||||||
|
/// these must not remove a colliding message ID queued for another peer.
|
||||||
|
private var pendingScopedRemovalMessageIDs: [PeerID: Set<String>] = [:]
|
||||||
private var recoveryHandler: (@MainActor (Snapshot) -> Void)?
|
private var recoveryHandler: (@MainActor (Snapshot) -> Void)?
|
||||||
/// Recovery loaded durable state that MessageRouter has not merged yet.
|
/// Recovery loaded durable state that MessageRouter has not merged yet.
|
||||||
/// While true, router saves must union with `cachedSnapshot` instead of
|
/// While true, router saves must union with `cachedSnapshot` instead of
|
||||||
@@ -195,7 +199,7 @@ final class MessageOutboxStore {
|
|||||||
? (pendingSnapshot ?? [:])
|
? (pendingSnapshot ?? [:])
|
||||||
: Self.merge(durable, pendingSnapshot ?? [:]))
|
: Self.merge(durable, pendingSnapshot ?? [:]))
|
||||||
diskState = .loaded
|
diskState = .loaded
|
||||||
if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty {
|
if pendingSnapshot != nil || hasPendingRemovalsLocked {
|
||||||
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
|
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
|
||||||
pendingSnapshot = nil
|
pendingSnapshot = nil
|
||||||
pendingSnapshotIsAuthoritative = false
|
pendingSnapshotIsAuthoritative = false
|
||||||
@@ -212,7 +216,7 @@ final class MessageOutboxStore {
|
|||||||
case .missing:
|
case .missing:
|
||||||
cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshot ?? [:])
|
cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshot ?? [:])
|
||||||
diskState = .loaded
|
diskState = .loaded
|
||||||
if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty {
|
if pendingSnapshot != nil || hasPendingRemovalsLocked {
|
||||||
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
|
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
|
||||||
pendingSnapshot = nil
|
pendingSnapshot = nil
|
||||||
pendingSnapshotIsAuthoritative = false
|
pendingSnapshotIsAuthoritative = false
|
||||||
@@ -235,7 +239,7 @@ final class MessageOutboxStore {
|
|||||||
diskState = .loaded
|
diskState = .loaded
|
||||||
cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshot ?? [:])
|
cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshot ?? [:])
|
||||||
SecureLogger.error("Failed to decode encrypted outbox: \(error)", category: .session)
|
SecureLogger.error("Failed to decode encrypted outbox: \(error)", category: .session)
|
||||||
if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty {
|
if pendingSnapshot != nil || hasPendingRemovalsLocked {
|
||||||
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
|
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
|
||||||
pendingSnapshot = nil
|
pendingSnapshot = nil
|
||||||
pendingSnapshotIsAuthoritative = false
|
pendingSnapshotIsAuthoritative = false
|
||||||
@@ -442,6 +446,25 @@ final class MessageOutboxStore {
|
|||||||
lock.unlock()
|
lock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Records an ack for only the supplied peer aliases. This preserves
|
||||||
|
/// another recipient's queued entry when message IDs happen to collide,
|
||||||
|
/// including while the durable snapshot is hidden by protected data.
|
||||||
|
func recordRemoval(messageID: String, for peerIDs: Set<PeerID>) {
|
||||||
|
guard !peerIDs.isEmpty else { return }
|
||||||
|
|
||||||
|
lock.lock()
|
||||||
|
for peerID in peerIDs {
|
||||||
|
pendingScopedRemovalMessageIDs[peerID, default: []].insert(messageID)
|
||||||
|
}
|
||||||
|
cachedSnapshot = Self.removing(pendingScopedRemovalMessageIDs, from: cachedSnapshot)
|
||||||
|
unseenRecoveredSnapshot = Self.removing(pendingScopedRemovalMessageIDs, from: unseenRecoveredSnapshot)
|
||||||
|
recoveryRouterSnapshot = Self.removing(pendingScopedRemovalMessageIDs, from: recoveryRouterSnapshot)
|
||||||
|
if let pendingSnapshot {
|
||||||
|
self.pendingSnapshot = Self.removing(pendingScopedRemovalMessageIDs, from: pendingSnapshot)
|
||||||
|
}
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
/// Retries a deferred protected-data load. The returned snapshot includes
|
/// Retries a deferred protected-data load. The returned snapshot includes
|
||||||
/// both durable messages and any messages queued during the locked wake.
|
/// both durable messages and any messages queued during the locked wake.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
@@ -478,7 +501,7 @@ final class MessageOutboxStore {
|
|||||||
: (pendingSnapshotIsAuthoritative ? known : Self.merge(durable, known)))
|
: (pendingSnapshotIsAuthoritative ? known : Self.merge(durable, known)))
|
||||||
cachedSnapshot = merged
|
cachedSnapshot = merged
|
||||||
diskState = .loaded
|
diskState = .loaded
|
||||||
if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) ||
|
if (pendingSnapshot == nil && !hasPendingRemovalsLocked) ||
|
||||||
persistSnapshotAndClearRemovalsLocked(merged) {
|
persistSnapshotAndClearRemovalsLocked(merged) {
|
||||||
pendingSnapshot = nil
|
pendingSnapshot = nil
|
||||||
pendingSnapshotIsAuthoritative = false
|
pendingSnapshotIsAuthoritative = false
|
||||||
@@ -507,7 +530,7 @@ final class MessageOutboxStore {
|
|||||||
: known)
|
: known)
|
||||||
cachedSnapshot = merged
|
cachedSnapshot = merged
|
||||||
diskState = .loaded
|
diskState = .loaded
|
||||||
if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) ||
|
if (pendingSnapshot == nil && !hasPendingRemovalsLocked) ||
|
||||||
persistSnapshotAndClearRemovalsLocked(merged) {
|
persistSnapshotAndClearRemovalsLocked(merged) {
|
||||||
pendingSnapshot = nil
|
pendingSnapshot = nil
|
||||||
pendingSnapshotIsAuthoritative = false
|
pendingSnapshotIsAuthoritative = false
|
||||||
@@ -539,7 +562,7 @@ final class MessageOutboxStore {
|
|||||||
: known)
|
: known)
|
||||||
cachedSnapshot = merged
|
cachedSnapshot = merged
|
||||||
diskState = .loaded
|
diskState = .loaded
|
||||||
if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) ||
|
if (pendingSnapshot == nil && !hasPendingRemovalsLocked) ||
|
||||||
persistSnapshotAndClearRemovalsLocked(merged) {
|
persistSnapshotAndClearRemovalsLocked(merged) {
|
||||||
pendingSnapshot = nil
|
pendingSnapshot = nil
|
||||||
pendingSnapshotIsAuthoritative = false
|
pendingSnapshotIsAuthoritative = false
|
||||||
@@ -578,6 +601,7 @@ final class MessageOutboxStore {
|
|||||||
pendingSnapshot = nil
|
pendingSnapshot = nil
|
||||||
pendingSnapshotIsAuthoritative = false
|
pendingSnapshotIsAuthoritative = false
|
||||||
pendingRemovalMessageIDs.removeAll()
|
pendingRemovalMessageIDs.removeAll()
|
||||||
|
pendingScopedRemovalMessageIDs.removeAll()
|
||||||
recoveryDeliveryPending = false
|
recoveryDeliveryPending = false
|
||||||
unseenRecoveryPendingPersistence = false
|
unseenRecoveryPendingPersistence = false
|
||||||
unseenRecoveredSnapshot = [:]
|
unseenRecoveredSnapshot = [:]
|
||||||
@@ -742,12 +766,21 @@ final class MessageOutboxStore {
|
|||||||
private func persistSnapshotAndClearRemovalsLocked(_ snapshot: Snapshot) -> Bool {
|
private func persistSnapshotAndClearRemovalsLocked(_ snapshot: Snapshot) -> Bool {
|
||||||
guard persistSnapshotLocked(snapshot) else { return false }
|
guard persistSnapshotLocked(snapshot) else { return false }
|
||||||
pendingRemovalMessageIDs.removeAll()
|
pendingRemovalMessageIDs.removeAll()
|
||||||
|
pendingScopedRemovalMessageIDs.removeAll()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Must be called with `lock` held.
|
/// Must be called with `lock` held.
|
||||||
private func applyingPendingRemovalsLocked(_ snapshot: Snapshot) -> Snapshot {
|
private func applyingPendingRemovalsLocked(_ snapshot: Snapshot) -> Snapshot {
|
||||||
Self.removing(pendingRemovalMessageIDs, from: snapshot)
|
Self.removing(
|
||||||
|
pendingScopedRemovalMessageIDs,
|
||||||
|
from: Self.removing(pendingRemovalMessageIDs, from: snapshot)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Must be read with `lock` held.
|
||||||
|
private var hasPendingRemovalsLocked: Bool {
|
||||||
|
!pendingRemovalMessageIDs.isEmpty || !pendingScopedRemovalMessageIDs.isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func removing(_ messageIDs: Set<String>, from snapshot: Snapshot) -> Snapshot {
|
private static func removing(_ messageIDs: Set<String>, from snapshot: Snapshot) -> Snapshot {
|
||||||
@@ -760,9 +793,28 @@ final class MessageOutboxStore {
|
|||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func removing(
|
||||||
|
_ messageIDsByPeer: [PeerID: Set<String>],
|
||||||
|
from snapshot: Snapshot
|
||||||
|
) -> Snapshot {
|
||||||
|
guard !messageIDsByPeer.isEmpty else { return snapshot }
|
||||||
|
var filtered = snapshot
|
||||||
|
for (peerID, messageIDs) in messageIDsByPeer {
|
||||||
|
guard !messageIDs.isEmpty, let queue = filtered[peerID] else { continue }
|
||||||
|
let remaining = queue.filter { !messageIDs.contains($0.messageID) }
|
||||||
|
filtered[peerID] = remaining.isEmpty ? nil : remaining
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
private static func excludingKnownMessages(from durable: Snapshot, known: Snapshot) -> Snapshot {
|
private static func excludingKnownMessages(from durable: Snapshot, known: Snapshot) -> Snapshot {
|
||||||
let knownIDs = Set(known.values.flatMap { $0.map(\.messageID) })
|
var unseen: Snapshot = [:]
|
||||||
return removing(knownIDs, from: durable)
|
for (peerID, durableQueue) in durable {
|
||||||
|
let knownIDs = Set(known[peerID]?.map(\.messageID) ?? [])
|
||||||
|
let remaining = durableQueue.filter { !knownIDs.contains($0.messageID) }
|
||||||
|
if !remaining.isEmpty { unseen[peerID] = remaining }
|
||||||
|
}
|
||||||
|
return unseen
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func merge(_ durable: Snapshot, _ pending: Snapshot) -> Snapshot {
|
private static func merge(_ durable: Snapshot, _ pending: Snapshot) -> Snapshot {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
var relaysConnected: (@MainActor () -> Bool)?
|
var relaysConnected: (@MainActor () -> Bool)?
|
||||||
/// Publishes a signed drop event directly to connected default (DM)
|
/// Publishes a signed drop event directly to connected default (DM)
|
||||||
/// relays. Completion is true only after at least one relay explicitly
|
/// relays. Completion is true only after at least one relay explicitly
|
||||||
/// accepts the event via NIP-20 OK; this must never mean "queued in RAM"
|
/// accepts the event via NIP-01 `OK`; this must never mean "queued in RAM"
|
||||||
/// or merely "written to a socket".
|
/// or merely "written to a socket".
|
||||||
var publishEvent: (@MainActor (NostrEvent, @escaping @MainActor (Bool) -> Void) -> Void)?
|
var publishEvent: (@MainActor (NostrEvent, @escaping @MainActor (Bool) -> Void) -> Void)?
|
||||||
/// (Re)opens the drop subscription for the given hex tags.
|
/// (Re)opens the drop subscription for the given hex tags.
|
||||||
@@ -106,13 +106,14 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
dedupKey: String?,
|
dedupKey: String?,
|
||||||
operationID: UUID?
|
operationID: UUID?
|
||||||
)] = []
|
)] = []
|
||||||
/// Message IDs already published as drops (sender-side dedup) and drop
|
/// Opaque recipient/message keys already published as drops (sender-side
|
||||||
/// event IDs already handled (multi-relay dedup). Both persist across
|
/// dedup) and drop event IDs already handled (multi-relay dedup). Both
|
||||||
/// relaunches: relays hold drops for the full 24h NIP-40 window and the
|
/// persist across relaunches: relays hold drops for the full 24h NIP-40
|
||||||
/// persisted outbox keeps re-depositing, so in-memory-only dedup meant
|
/// window and the persisted outbox keeps re-depositing, so in-memory-only
|
||||||
/// every relaunch republished the same message as a fresh drop and every
|
/// dedup meant every relaunch republished the same message as a fresh drop
|
||||||
/// gateway relaunch re-delivered the whole backlog (field-verified
|
/// and every gateway relaunch re-delivered the whole backlog
|
||||||
/// amplification storm). Entries age out with the 24h drop window.
|
/// (field-verified amplification storm). Entries age out with the 24h drop
|
||||||
|
/// window.
|
||||||
private var publishedDropKeys: ExpiringIDSet
|
private var publishedDropKeys: ExpiringIDSet
|
||||||
private var seenDropEventIDs: ExpiringIDSet
|
private var seenDropEventIDs: ExpiringIDSet
|
||||||
private var subscriptionOpen = false
|
private var subscriptionOpen = false
|
||||||
@@ -126,10 +127,10 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
}
|
}
|
||||||
/// Sender operations queued locally or awaiting relay confirmation.
|
/// Sender operations queued locally or awaiting relay confirmation.
|
||||||
/// The per-attempt ID prevents a stale pre-wipe callback from completing
|
/// The per-attempt ID prevents a stale pre-wipe callback from completing
|
||||||
/// a newer attempt for the same message.
|
/// a newer attempt for the same recipient/message pair.
|
||||||
private var activeDropOperations: [String: ActiveDropOperation] = [:]
|
private var activeDropOperations: [String: ActiveDropOperation] = [:]
|
||||||
/// Held-envelope publishes have no sender message ID, but still need an
|
/// Held-envelope publishes have no sender message ID, but still need an
|
||||||
/// in-flight identity: repeated refreshes inside the NIP-20 wait window
|
/// in-flight identity: repeated refreshes inside the relay-OK wait window
|
||||||
/// must not mint duplicate relay events for the same opaque envelope.
|
/// must not mint duplicate relay events for the same opaque envelope.
|
||||||
private var heldDropOperations: [Data: UUID] = [:]
|
private var heldDropOperations: [Data: UUID] = [:]
|
||||||
/// Deterministically invalid envelopes are suppressed for this process,
|
/// Deterministically invalid envelopes are suppressed for this process,
|
||||||
@@ -214,11 +215,46 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Sender role
|
// MARK: - Sender role
|
||||||
|
|
||||||
|
/// Stable, opaque sender-side dedup key. Recipient scope prevents one
|
||||||
|
/// conversation's colliding message ID from suppressing another's drop,
|
||||||
|
/// while hashing keeps recipient keys out of the persisted snapshot.
|
||||||
|
private static func senderDropKey(
|
||||||
|
messageID: String,
|
||||||
|
recipientNoiseKey: Data
|
||||||
|
) -> String {
|
||||||
|
var material = Data("bitchat-bridge-drop-dedup-v2".utf8)
|
||||||
|
appendLengthPrefixed(Data(messageID.utf8), to: &material)
|
||||||
|
appendLengthPrefixed(recipientNoiseKey, to: &material)
|
||||||
|
return "v2:\(material.sha256Hex())"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func appendLengthPrefixed(_ value: Data, to output: inout Data) {
|
||||||
|
let length = UInt32(value.count)
|
||||||
|
output.append(UInt8((length >> 24) & 0xFF))
|
||||||
|
output.append(UInt8((length >> 16) & 0xFF))
|
||||||
|
output.append(UInt8((length >> 8) & 0xFF))
|
||||||
|
output.append(UInt8(length & 0xFF))
|
||||||
|
output.append(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Previous releases persisted raw message IDs without recipient scope.
|
||||||
|
/// They remain conservative wildcards for their original 24-hour
|
||||||
|
/// lifetime: assigning one to a recipient would be guesswork and could
|
||||||
|
/// republish the original drop. New acceptances persist only v2 keys.
|
||||||
|
private func wasPublished(
|
||||||
|
legacyMessageID: String,
|
||||||
|
dedupKey: String,
|
||||||
|
now date: Date
|
||||||
|
) -> Bool {
|
||||||
|
publishedDropKeys.contains(dedupKey, now: date)
|
||||||
|
|| publishedDropKeys.contains(legacyMessageID, now: date)
|
||||||
|
}
|
||||||
|
|
||||||
/// Parallel-deposit a sealed copy of an outbound private message as a
|
/// Parallel-deposit a sealed copy of an outbound private message as a
|
||||||
/// relay drop. Called by the message router alongside physical courier
|
/// relay drop. Called by the message router alongside physical courier
|
||||||
/// deposits; idempotent per message ID. Completion becomes true only
|
/// deposits; idempotent per recipient/message pair. Completion becomes
|
||||||
/// after a real relay acceptance arrives, which is when the router may
|
/// true only after a real relay acceptance arrives, which is when the
|
||||||
/// show "carried".
|
/// router may show "carried".
|
||||||
func depositDrop(
|
func depositDrop(
|
||||||
content: String,
|
content: String,
|
||||||
messageID: String,
|
messageID: String,
|
||||||
@@ -229,9 +265,18 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
completion(false)
|
completion(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard !publishedDropKeys.contains(messageID, now: now()),
|
let date = now()
|
||||||
activeDropOperations[messageID] == nil,
|
let dedupKey = Self.senderDropKey(
|
||||||
!rejectedDropKeys.contains(messageID, now: now()) else {
|
messageID: messageID,
|
||||||
|
recipientNoiseKey: recipientNoiseKey
|
||||||
|
)
|
||||||
|
guard !wasPublished(
|
||||||
|
legacyMessageID: messageID,
|
||||||
|
dedupKey: dedupKey,
|
||||||
|
now: date
|
||||||
|
),
|
||||||
|
activeDropOperations[dedupKey] == nil,
|
||||||
|
!rejectedDropKeys.contains(dedupKey, now: date) else {
|
||||||
completion(false)
|
completion(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -244,13 +289,13 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
// of the sealing); suppress it in-memory so the retry sweep does not
|
// of the sealing); suppress it in-memory so the retry sweep does not
|
||||||
// churn, but never persist it as a published drop.
|
// churn, but never persist it as a published drop.
|
||||||
guard let encoded = envelope.encode(), encoded.count <= Limits.maxDropEnvelopeBytes else {
|
guard let encoded = envelope.encode(), encoded.count <= Limits.maxDropEnvelopeBytes else {
|
||||||
rejectedDropKeys.insert(messageID, now: now())
|
rejectedDropKeys.insert(dedupKey, now: date)
|
||||||
completion(false)
|
completion(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let operationID = UUID()
|
let operationID = UUID()
|
||||||
activeDropOperations[messageID] = ActiveDropOperation(id: operationID, completion: completion)
|
activeDropOperations[dedupKey] = ActiveDropOperation(id: operationID, completion: completion)
|
||||||
publishDrop(envelope, messageID: messageID, operationID: operationID)
|
publishDrop(envelope, dedupKey: dedupKey, operationID: operationID)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publishes held envelopes (mail we carry for others) as drops,
|
/// Publishes held envelopes (mail we carry for others) as drops,
|
||||||
@@ -272,13 +317,14 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publishes a drop, or queues it when relays are down. `messageID` is the
|
/// Publishes a drop, or queues it when relays are down. `dedupKey` is the
|
||||||
/// sender-side dedup key (nil for held/relayed envelopes we don't track);
|
/// opaque sender-side recipient/message key (nil for held/relayed
|
||||||
/// it rides the pending queue so an evicted or failed drop can release its
|
/// envelopes we don't track); it rides the pending queue so an evicted or
|
||||||
/// in-flight slot. Completion reports actual NIP-20 relay acceptance.
|
/// failed drop can release its in-flight slot. Completion reports actual
|
||||||
|
/// NIP-01 relay acceptance.
|
||||||
private func publishDrop(
|
private func publishDrop(
|
||||||
_ envelope: CourierEnvelope,
|
_ envelope: CourierEnvelope,
|
||||||
messageID: String? = nil,
|
dedupKey: String? = nil,
|
||||||
operationID: UUID? = nil,
|
operationID: UUID? = nil,
|
||||||
untrackedCompletion: (@MainActor (Bool) -> Void)? = nil
|
untrackedCompletion: (@MainActor (Bool) -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
@@ -286,7 +332,7 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
encoded.count <= Limits.maxDropEnvelopeBytes,
|
encoded.count <= Limits.maxDropEnvelopeBytes,
|
||||||
!envelope.isExpired else {
|
!envelope.isExpired else {
|
||||||
finishPublish(
|
finishPublish(
|
||||||
messageID: messageID,
|
dedupKey: dedupKey,
|
||||||
operationID: operationID,
|
operationID: operationID,
|
||||||
succeeded: false,
|
succeeded: false,
|
||||||
untrackedCompletion: untrackedCompletion
|
untrackedCompletion: untrackedCompletion
|
||||||
@@ -297,15 +343,15 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
// Held mail remains in CourierStore and has no sender operation to
|
// Held mail remains in CourierStore and has no sender operation to
|
||||||
// recover after an in-memory queue loss. Leave its cooldown unset
|
// recover after an in-memory queue loss. Leave its cooldown unset
|
||||||
// and let the next connected refresh offer it again.
|
// and let the next connected refresh offer it again.
|
||||||
guard messageID != nil else {
|
guard dedupKey != nil else {
|
||||||
untrackedCompletion?(false)
|
untrackedCompletion?(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pendingDrops.append((envelope, messageID, operationID))
|
pendingDrops.append((envelope, dedupKey, operationID))
|
||||||
while pendingDrops.count > Limits.maxPendingDrops {
|
while pendingDrops.count > Limits.maxPendingDrops {
|
||||||
let evicted = pendingDrops.removeFirst()
|
let evicted = pendingDrops.removeFirst()
|
||||||
finishPublish(
|
finishPublish(
|
||||||
messageID: evicted.dedupKey,
|
dedupKey: evicted.dedupKey,
|
||||||
operationID: evicted.operationID,
|
operationID: evicted.operationID,
|
||||||
succeeded: false
|
succeeded: false
|
||||||
)
|
)
|
||||||
@@ -321,7 +367,7 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
) else {
|
) else {
|
||||||
SecureLogger.error("📦🌉 Failed to compose courier drop", category: .encryption)
|
SecureLogger.error("📦🌉 Failed to compose courier drop", category: .encryption)
|
||||||
finishPublish(
|
finishPublish(
|
||||||
messageID: messageID,
|
dedupKey: dedupKey,
|
||||||
operationID: operationID,
|
operationID: operationID,
|
||||||
succeeded: false,
|
succeeded: false,
|
||||||
untrackedCompletion: untrackedCompletion
|
untrackedCompletion: untrackedCompletion
|
||||||
@@ -331,7 +377,7 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
guard let publishEvent else {
|
guard let publishEvent else {
|
||||||
SecureLogger.error("📦🌉 Courier drop publisher is not configured", category: .session)
|
SecureLogger.error("📦🌉 Courier drop publisher is not configured", category: .session)
|
||||||
finishPublish(
|
finishPublish(
|
||||||
messageID: messageID,
|
dedupKey: dedupKey,
|
||||||
operationID: operationID,
|
operationID: operationID,
|
||||||
succeeded: false,
|
succeeded: false,
|
||||||
untrackedCompletion: untrackedCompletion
|
untrackedCompletion: untrackedCompletion
|
||||||
@@ -341,7 +387,7 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
publishEvent(event) { [weak self] succeeded in
|
publishEvent(event) { [weak self] succeeded in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
guard self.finishPublish(
|
guard self.finishPublish(
|
||||||
messageID: messageID,
|
dedupKey: dedupKey,
|
||||||
operationID: operationID,
|
operationID: operationID,
|
||||||
succeeded: succeeded,
|
succeeded: succeeded,
|
||||||
untrackedCompletion: untrackedCompletion
|
untrackedCompletion: untrackedCompletion
|
||||||
@@ -356,23 +402,23 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func finishPublish(
|
private func finishPublish(
|
||||||
messageID: String?,
|
dedupKey: String?,
|
||||||
operationID: UUID?,
|
operationID: UUID?,
|
||||||
succeeded: Bool,
|
succeeded: Bool,
|
||||||
untrackedCompletion: (@MainActor (Bool) -> Void)? = nil
|
untrackedCompletion: (@MainActor (Bool) -> Void)? = nil
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
guard let messageID else {
|
guard let dedupKey else {
|
||||||
untrackedCompletion?(succeeded)
|
untrackedCompletion?(succeeded)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Missing/mismatched means this callback was duplicated, invalidated
|
// Missing/mismatched means this callback was duplicated, invalidated
|
||||||
// by panic wipe, or belongs to an older attempt for the same key.
|
// by panic wipe, or belongs to an older attempt for the same key.
|
||||||
guard let operationID,
|
guard let operationID,
|
||||||
let operation = activeDropOperations[messageID],
|
let operation = activeDropOperations[dedupKey],
|
||||||
operation.id == operationID else { return false }
|
operation.id == operationID else { return false }
|
||||||
activeDropOperations.removeValue(forKey: messageID)
|
activeDropOperations.removeValue(forKey: dedupKey)
|
||||||
if succeeded {
|
if succeeded {
|
||||||
publishedDropKeys.insert(messageID, now: now())
|
publishedDropKeys.insert(dedupKey, now: now())
|
||||||
persistDedup()
|
persistDedup()
|
||||||
}
|
}
|
||||||
operation.completion(succeeded)
|
operation.completion(succeeded)
|
||||||
@@ -387,7 +433,7 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
for item in queued {
|
for item in queued {
|
||||||
publishDrop(
|
publishDrop(
|
||||||
item.envelope,
|
item.envelope,
|
||||||
messageID: item.dedupKey,
|
dedupKey: item.dedupKey,
|
||||||
operationID: item.operationID
|
operationID: item.operationID
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,11 +64,11 @@ struct ExpiringIDSet {
|
|||||||
/// fresh drop (fresh throwaway seal, undeduplicatable downstream) and every
|
/// fresh drop (fresh throwaway seal, undeduplicatable downstream) and every
|
||||||
/// gateway relaunch re-delivered the whole backlog. Field-verified: ~20
|
/// gateway relaunch re-delivered the whole backlog. Field-verified: ~20
|
||||||
/// copies of one DM delivered in 40ms fed the storm behind a permanent
|
/// copies of one DM delivered in 40ms fed the storm behind a permanent
|
||||||
/// device freeze. Persisting both sides caps this at one drop per message
|
/// device freeze. Persisting both sides caps this at one drop per
|
||||||
/// ID per 24h regardless of relaunch count.
|
/// recipient/message pair per 24h regardless of relaunch count.
|
||||||
///
|
///
|
||||||
/// Contents are opaque IDs (message UUIDs, relay event IDs) — no plaintext,
|
/// Contents are opaque hashes and relay event IDs — no plaintext or peer
|
||||||
/// no peer identities — so until-first-unlock protection matches
|
/// identities — so until-first-unlock protection matches
|
||||||
/// `NostrProcessedEventStore`, and the file must load during a
|
/// `NostrProcessedEventStore`, and the file must load during a
|
||||||
/// locked-background restoration relaunch. Wiped on panic with the rest of
|
/// locked-background restoration relaunch. Wiped on panic with the rest of
|
||||||
/// the courier state.
|
/// the courier state.
|
||||||
|
|||||||
@@ -172,11 +172,11 @@ final class MessageDeduplicationService {
|
|||||||
/// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format)
|
/// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format)
|
||||||
private let nostrAckCache: LRUDeduplicationCache<Bool>
|
private let nostrAckCache: LRUDeduplicationCache<Bool>
|
||||||
|
|
||||||
/// Optional cross-launch persistence for the Nostr event cache. NIP-59
|
/// Optional cross-launch persistence for the Nostr event cache. BitChat
|
||||||
/// randomizes gift-wrap timestamps, so DM subscriptions look back 24h and
|
/// randomizes private-envelope timestamps, so DM subscriptions look back
|
||||||
/// relays redeliver the same events on every launch; without this record
|
/// 24h and relays redeliver the same events on every launch; without this
|
||||||
/// each relaunch reprocesses old PMs and acks. Nil (tests, macOS callers
|
/// record each relaunch reprocesses old PMs and acks. Nil (tests, macOS
|
||||||
/// that don't opt in) keeps the cache purely in-memory.
|
/// callers that don't opt in) keeps the cache purely in-memory.
|
||||||
private let nostrEventStore: NostrProcessedEventStore?
|
private let nostrEventStore: NostrProcessedEventStore?
|
||||||
private let nostrEventCapacity: Int
|
private let nostrEventCapacity: Int
|
||||||
private var persistScheduled = false
|
private var persistScheduled = false
|
||||||
@@ -314,7 +314,7 @@ final class MessageDeduplicationService {
|
|||||||
// MARK: - Clear
|
// MARK: - Clear
|
||||||
|
|
||||||
/// Clears all caches. This is the wipe/panic path: the persisted
|
/// Clears all caches. This is the wipe/panic path: the persisted
|
||||||
/// gift-wrap record goes with everything else.
|
/// private-envelope record goes with everything else.
|
||||||
func clearAll() {
|
func clearAll() {
|
||||||
contentCache.clear()
|
contentCache.clear()
|
||||||
nostrEventCache.clear()
|
nostrEventCache.clear()
|
||||||
@@ -325,7 +325,7 @@ final class MessageDeduplicationService {
|
|||||||
|
|
||||||
/// Clears only the in-memory Nostr caches (events and ACKs). Runs on
|
/// Clears only the in-memory Nostr caches (events and ACKs). Runs on
|
||||||
/// every geohash channel switch, so the disk record deliberately
|
/// every geohash channel switch, so the disk record deliberately
|
||||||
/// survives — wiping it here would forfeit cross-launch gift-wrap dedup
|
/// survives — wiping it here would forfeit cross-launch private-envelope dedup
|
||||||
/// each time the user changes channels (flagged by Codex on #1398).
|
/// each time the user changes channels (flagged by Codex on #1398).
|
||||||
func clearNostrCaches() {
|
func clearNostrCaches() {
|
||||||
nostrEventCache.clear()
|
nostrEventCache.clear()
|
||||||
|
|||||||
@@ -34,6 +34,20 @@ struct CourierDirectory {
|
|||||||
final class MessageRouter {
|
final class MessageRouter {
|
||||||
typealias QueuedMessage = MessageOutboxStore.QueuedMessage
|
typealias QueuedMessage = MessageOutboxStore.QueuedMessage
|
||||||
|
|
||||||
|
private struct PeerMessageKey: Hashable {
|
||||||
|
let peerID: PeerID
|
||||||
|
let messageID: String
|
||||||
|
|
||||||
|
static func == (lhs: PeerMessageKey, rhs: PeerMessageKey) -> Bool {
|
||||||
|
lhs.peerID == rhs.peerID && lhs.messageID == rhs.messageID
|
||||||
|
}
|
||||||
|
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(peerID)
|
||||||
|
hasher.combine(messageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private let transports: [Transport]
|
private let transports: [Transport]
|
||||||
private let now: () -> Date
|
private let now: () -> Date
|
||||||
private let courierDirectory: CourierDirectory
|
private let courierDirectory: CourierDirectory
|
||||||
@@ -104,15 +118,25 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var bridgeSweepTask: Task<Void, Never>?
|
private var bridgeSweepTask: Task<Void, Never>?
|
||||||
private var bridgeDepositsInFlight = Set<String>()
|
private var bridgeDepositsInFlight = Set<PeerMessageKey>()
|
||||||
|
|
||||||
private var outbox: [PeerID: [QueuedMessage]] = [:]
|
private var outbox: [PeerID: [QueuedMessage]] = [:]
|
||||||
|
/// Peer/message pairs whose latest router-owned transmission used an
|
||||||
|
/// already-established secure session and still await an ack. Peer scope
|
||||||
|
/// is required because message IDs are not globally unique across direct
|
||||||
|
/// conversations. This deliberately excludes messages handed to BLE while
|
||||||
|
/// a handshake is pending: BLE owns those sends and drains its queue after
|
||||||
|
/// authentication, so retrying them here would duplicate every normal
|
||||||
|
/// first-handshake DM.
|
||||||
|
private var secureTransmissions = Set<PeerMessageKey>()
|
||||||
|
|
||||||
// Outbox limits to prevent unbounded memory growth
|
// Outbox limits to prevent unbounded memory growth
|
||||||
private static let maxMessagesPerPeer = 100
|
private static let maxMessagesPerPeer = 100
|
||||||
private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours
|
private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours
|
||||||
// Bound resends of messages sent on a weak reachability signal that never
|
// Bound actual sends that never receive an ack, whether they used weak
|
||||||
// get a delivery ack (e.g. peer on an old client that doesn't ack).
|
// reachability or an apparently secure session that keeps being replaced.
|
||||||
|
// Connected pre-handshake sends are transport-owned and do not burn this
|
||||||
|
// cap because BLE queues/drains them itself.
|
||||||
private static let maxSendAttempts = 8
|
private static let maxSendAttempts = 8
|
||||||
// Redundant couriers improve delivery odds; receivers dedup by message ID.
|
// Redundant couriers improve delivery odds; receivers dedup by message ID.
|
||||||
private static let maxCouriersPerMessage = 3
|
private static let maxCouriersPerMessage = 3
|
||||||
@@ -158,6 +182,16 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wire one typed event sink to every route, including the queue-backed
|
||||||
|
/// Nostr transport. Keeping this at the router boundary prevents a relay
|
||||||
|
/// admission failure from disappearing merely because only the primary
|
||||||
|
/// mesh transport was assigned a UI delegate.
|
||||||
|
func setEventDelegate(_ delegate: TransportEventDelegate?) {
|
||||||
|
for transport in transports {
|
||||||
|
transport.eventDelegate = delegate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Transport Selection
|
// MARK: - Transport Selection
|
||||||
|
|
||||||
private func reachableTransport(for peerID: PeerID) -> Transport? {
|
private func reachableTransport(for peerID: PeerID) -> Transport? {
|
||||||
@@ -171,15 +205,28 @@ final class MessageRouter {
|
|||||||
// MARK: - Message Sending
|
// MARK: - Message Sending
|
||||||
|
|
||||||
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
|
let message = QueuedMessage(
|
||||||
|
content: content,
|
||||||
|
nickname: recipientNickname,
|
||||||
|
messageID: messageID,
|
||||||
|
timestamp: now(),
|
||||||
|
sendAttempts: 1
|
||||||
|
)
|
||||||
|
|
||||||
if let transport = connectedTransport(for: peerID), transport.canDeliverSecurely(to: peerID) {
|
if let transport = connectedTransport(for: peerID), transport.canDeliverSecurely(to: peerID) {
|
||||||
// A live link that can complete an encrypted delivery is a
|
// Even an established Noise session can be stale after the peer
|
||||||
// strong delivery signal; trust it outright.
|
// restarts or replaces its app. Persist before handing the packet
|
||||||
|
// to the transport so a fast ack cannot race ahead of retention,
|
||||||
|
// then keep the copy until a delivery/read ack clears it. A
|
||||||
|
// replacement handshake will retry this same message ID, which
|
||||||
|
// receivers deduplicate.
|
||||||
|
enqueue(message, for: peerID)
|
||||||
|
secureTransmissions.insert(PeerMessageKey(peerID: peerID, messageID: messageID))
|
||||||
SecureLogger.debug("Routing PM via \(type(of: transport)) (connected) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing PM via \(type(of: transport)) (connected) 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)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: now(), sendAttempts: 1)
|
|
||||||
if let transport = connectedTransport(for: peerID) {
|
if let transport = connectedTransport(for: peerID) {
|
||||||
// "Connected" without an established secure session is forgeable:
|
// "Connected" without an established secure session is forgeable:
|
||||||
// link bindings heal on signature-verified "direct" announces, but
|
// link bindings heal on signature-verified "direct" announces, but
|
||||||
@@ -197,8 +244,9 @@ final class MessageRouter {
|
|||||||
// deposit is cleared on ack. Don't "optimize" the courier call
|
// deposit is cleared on ack. Don't "optimize" the courier call
|
||||||
// away.
|
// away.
|
||||||
SecureLogger.debug("Routing PM via \(type(of: transport)) (connected, no secure session) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing PM via \(type(of: transport)) (connected, no secure session) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
|
||||||
enqueue(message, for: peerID)
|
enqueue(message, for: peerID)
|
||||||
|
secureTransmissions.remove(PeerMessageKey(peerID: peerID, messageID: messageID))
|
||||||
|
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
attemptCourierDeposit(messageID: messageID, for: peerID)
|
attemptCourierDeposit(messageID: messageID, for: peerID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -209,8 +257,8 @@ final class MessageRouter {
|
|||||||
// Send now, but retain a copy until a delivery/read ack clears it;
|
// Send now, but retain a copy until a delivery/read ack clears it;
|
||||||
// receivers dedup resends by message ID.
|
// receivers dedup resends by message ID.
|
||||||
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)
|
|
||||||
enqueue(message, for: peerID)
|
enqueue(message, for: peerID)
|
||||||
|
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
// "Reachable" without prompt delivery means the send only joined
|
// "Reachable" without prompt delivery means the send only joined
|
||||||
// a queue (Nostr with relays down): also hand a sealed copy to
|
// a queue (Nostr with relays down): also hand a sealed copy to
|
||||||
// any connected couriers rather than waiting for internet that
|
// any connected couriers rather than waiting for internet that
|
||||||
@@ -333,11 +381,12 @@ final class MessageRouter {
|
|||||||
for peerID: PeerID,
|
for peerID: PeerID,
|
||||||
recipientKey: Data
|
recipientKey: Data
|
||||||
) {
|
) {
|
||||||
|
let inFlightKey = PeerMessageKey(peerID: peerID, messageID: message.messageID)
|
||||||
guard let bridgeCourierDeposit,
|
guard let bridgeCourierDeposit,
|
||||||
bridgeDepositsInFlight.insert(message.messageID).inserted else { return }
|
bridgeDepositsInFlight.insert(inFlightKey).inserted else { return }
|
||||||
bridgeCourierDeposit(message.content, message.messageID, recipientKey) { [weak self] succeeded in
|
bridgeCourierDeposit(message.content, message.messageID, recipientKey) { [weak self] succeeded in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.bridgeDepositsInFlight.remove(message.messageID)
|
self.bridgeDepositsInFlight.remove(inFlightKey)
|
||||||
// A direct delivery may have cleared the outbox while the relay
|
// A direct delivery may have cleared the outbox while the relay
|
||||||
// relay confirmation was in flight; do not regress its UI state.
|
// relay confirmation was in flight; do not regress its UI state.
|
||||||
guard succeeded, self.queuedMessage(message.messageID, for: peerID) != nil else { return }
|
guard succeeded, self.queuedMessage(message.messageID, for: peerID) != nil else { return }
|
||||||
@@ -356,15 +405,40 @@ final class MessageRouter {
|
|||||||
|
|
||||||
// MARK: - Outbox Management
|
// MARK: - Outbox Management
|
||||||
|
|
||||||
/// A delivery or read ack confirms receipt; stop retaining the message.
|
/// A locally trusted delivery transition confirms receipt; stop retaining
|
||||||
|
/// every copy of the message. Authenticated remote receipts must use the
|
||||||
|
/// peer-bound overload below instead.
|
||||||
func markDelivered(_ messageID: String) {
|
func markDelivered(_ messageID: String) {
|
||||||
|
clearRetainedMessage(messageID, allowedPeerIDs: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops retaining a message only for the authenticated conversation
|
||||||
|
/// aliases that produced the accepted receipt. A peer that learns another
|
||||||
|
/// conversation's message ID cannot use it to clear that conversation's
|
||||||
|
/// retry state.
|
||||||
|
func markDelivered(_ messageID: String, from peerIDs: Set<PeerID>) {
|
||||||
|
guard !peerIDs.isEmpty else { return }
|
||||||
|
_ = markDelivered(messageID, for: Array(peerIDs))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearRetainedMessage(
|
||||||
|
_ messageID: String,
|
||||||
|
allowedPeerIDs: Set<PeerID>?
|
||||||
|
) {
|
||||||
var cleared = false
|
var cleared = false
|
||||||
for (peerID, queue) in outbox {
|
for (peerID, queue) in outbox {
|
||||||
|
if let allowedPeerIDs, !allowedPeerIDs.contains(peerID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
let filtered = queue.filter { $0.messageID != messageID }
|
let filtered = queue.filter { $0.messageID != messageID }
|
||||||
guard filtered.count != queue.count else { continue }
|
guard filtered.count != queue.count else { continue }
|
||||||
outbox[peerID] = filtered.isEmpty ? nil : filtered
|
outbox[peerID] = filtered.isEmpty ? nil : filtered
|
||||||
cleared = true
|
cleared = true
|
||||||
}
|
}
|
||||||
|
let matchingSecureTransmissions = secureTransmissions.filter {
|
||||||
|
$0.messageID == messageID
|
||||||
|
}
|
||||||
|
secureTransmissions.subtract(matchingSecureTransmissions)
|
||||||
// The durable snapshot may still be hidden by protected data. Record
|
// The durable snapshot may still be hidden by protected data. Record
|
||||||
// the ack even when this cold-load view cannot find the message, then
|
// the ack even when this cold-load view cannot find the message, then
|
||||||
// persist the current view so the store retains a removal tombstone.
|
// persist the current view so the store retains a removal tombstone.
|
||||||
@@ -375,6 +449,35 @@ final class MessageRouter {
|
|||||||
persistOutbox()
|
persistOutbox()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A delivery or read ack authenticated to one account confirms receipt
|
||||||
|
/// only for that account's transport aliases. A colliding message ID
|
||||||
|
/// queued for another peer must remain retained.
|
||||||
|
@discardableResult
|
||||||
|
func markDelivered(_ messageID: String, for peerAliases: [PeerID]) -> Bool {
|
||||||
|
let peerIDs = Set(peerAliases)
|
||||||
|
guard !peerIDs.isEmpty else { return false }
|
||||||
|
|
||||||
|
var cleared = false
|
||||||
|
for peerID in peerIDs {
|
||||||
|
guard let queue = outbox[peerID] else { continue }
|
||||||
|
let filtered = queue.filter { $0.messageID != messageID }
|
||||||
|
guard filtered.count != queue.count else { continue }
|
||||||
|
outbox[peerID] = filtered.isEmpty ? nil : filtered
|
||||||
|
cleared = true
|
||||||
|
}
|
||||||
|
for peerID in peerIDs {
|
||||||
|
secureTransmissions.remove(PeerMessageKey(peerID: peerID, messageID: messageID))
|
||||||
|
}
|
||||||
|
// Preserve the scoped ack even when protected data hides the durable
|
||||||
|
// queue during a cold launch.
|
||||||
|
outboxStore?.recordRemoval(messageID: messageID, for: peerIDs)
|
||||||
|
if cleared {
|
||||||
|
metrics?.record(.outboxDelivered)
|
||||||
|
}
|
||||||
|
persistOutbox()
|
||||||
|
return cleared
|
||||||
|
}
|
||||||
|
|
||||||
private func enqueue(_ message: QueuedMessage, for peerID: PeerID) {
|
private func enqueue(_ message: QueuedMessage, for peerID: PeerID) {
|
||||||
var message = message
|
var message = message
|
||||||
var queue = outbox[peerID] ?? []
|
var queue = outbox[peerID] ?? []
|
||||||
@@ -398,7 +501,34 @@ final class MessageRouter {
|
|||||||
persistOutbox()
|
persistOutbox()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func removeQueuedMessage(_ messageID: String, for peerID: PeerID) -> Bool {
|
||||||
|
guard let queue = outbox[peerID],
|
||||||
|
let index = queue.firstIndex(where: { $0.messageID == messageID }) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var updated = queue
|
||||||
|
updated.remove(at: index)
|
||||||
|
outbox[peerID] = updated.isEmpty ? nil : updated
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func incrementSendAttemptsIfQueued(_ messageID: String, for peerID: PeerID) -> Bool {
|
||||||
|
guard var queue = outbox[peerID],
|
||||||
|
let index = queue.firstIndex(where: { $0.messageID == messageID }) else {
|
||||||
|
// A synchronous delivery/read ack may have cleared the retained
|
||||||
|
// copy while `sendPrivateMessage` was on the stack. Never
|
||||||
|
// resurrect it from the flush snapshot.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
queue[index].sendAttempts += 1
|
||||||
|
outbox[peerID] = queue
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
private func dropMessage(_ messageID: String, for peerID: PeerID) {
|
private func dropMessage(_ messageID: String, for peerID: PeerID) {
|
||||||
|
secureTransmissions.remove(PeerMessageKey(peerID: peerID, messageID: messageID))
|
||||||
metrics?.record(.outboxDropped)
|
metrics?.record(.outboxDropped)
|
||||||
onMessageDropped?(messageID, peerID)
|
onMessageDropped?(messageID, peerID)
|
||||||
}
|
}
|
||||||
@@ -442,6 +572,7 @@ final class MessageRouter {
|
|||||||
/// Panic wipe: forget queued mail on disk and in memory.
|
/// Panic wipe: forget queued mail on disk and in memory.
|
||||||
func wipeOutbox() {
|
func wipeOutbox() {
|
||||||
outbox.removeAll()
|
outbox.removeAll()
|
||||||
|
secureTransmissions.removeAll()
|
||||||
outboxStore?.wipe()
|
outboxStore?.wipe()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,26 +600,160 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retries only messages that the router previously transmitted through
|
||||||
|
/// an already-established secure session and that still await an ack.
|
||||||
|
///
|
||||||
|
/// A peer restart can leave that local session looking usable until the
|
||||||
|
/// replacement handshake arrives; the first ciphertext is then
|
||||||
|
/// undecryptable remotely. Normal pre-handshake sends are intentionally
|
||||||
|
/// absent from `secureTransmissions` because BLE already queues
|
||||||
|
/// and drains them when authentication completes.
|
||||||
|
func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID]) {
|
||||||
|
typealias Candidate = (
|
||||||
|
peerID: PeerID,
|
||||||
|
message: QueuedMessage,
|
||||||
|
aliasOrder: Int,
|
||||||
|
queueOrder: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
var visitedPeerIDs = Set<PeerID>()
|
||||||
|
var retriedMessageIDs = Set<String>()
|
||||||
|
var outboxChanged = false
|
||||||
|
let currentDate = now()
|
||||||
|
var candidates: [Candidate] = []
|
||||||
|
|
||||||
|
for (aliasOrder, peerID) in peerIDAliases.enumerated() {
|
||||||
|
guard visitedPeerIDs.insert(peerID).inserted else { continue }
|
||||||
|
guard let queued = outbox[peerID], !queued.isEmpty,
|
||||||
|
let transport = connectedTransport(for: peerID),
|
||||||
|
transport.canDeliverSecurely(to: peerID) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (queueOrder, message) in queued.enumerated() {
|
||||||
|
let key = PeerMessageKey(peerID: peerID, messageID: message.messageID)
|
||||||
|
guard secureTransmissions.contains(key) else { continue }
|
||||||
|
candidates.append((
|
||||||
|
peerID: peerID,
|
||||||
|
message: message,
|
||||||
|
aliasOrder: aliasOrder,
|
||||||
|
queueOrder: queueOrder
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conversation migration can leave retained messages split across the
|
||||||
|
// ephemeral and stable outbox keys. Merge both queues into one
|
||||||
|
// chronological stream so callback alias order cannot send newer mail
|
||||||
|
// ahead of older mail.
|
||||||
|
candidates.sort { lhs, rhs in
|
||||||
|
if lhs.message.timestamp != rhs.message.timestamp {
|
||||||
|
return lhs.message.timestamp < rhs.message.timestamp
|
||||||
|
}
|
||||||
|
if lhs.aliasOrder != rhs.aliasOrder {
|
||||||
|
return lhs.aliasOrder < rhs.aliasOrder
|
||||||
|
}
|
||||||
|
if lhs.queueOrder != rhs.queueOrder {
|
||||||
|
return lhs.queueOrder < rhs.queueOrder
|
||||||
|
}
|
||||||
|
return lhs.message.messageID < rhs.message.messageID
|
||||||
|
}
|
||||||
|
|
||||||
|
for candidate in candidates {
|
||||||
|
let peerID = candidate.peerID
|
||||||
|
let message = candidate.message
|
||||||
|
let key = PeerMessageKey(peerID: peerID, messageID: message.messageID)
|
||||||
|
guard retriedMessageIDs.insert(message.messageID).inserted,
|
||||||
|
secureTransmissions.contains(key),
|
||||||
|
queuedMessage(message.messageID, for: peerID) != nil,
|
||||||
|
let transport = connectedTransport(for: peerID),
|
||||||
|
transport.canDeliverSecurely(to: peerID) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentDate.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
|
||||||
|
if removeQueuedMessage(message.messageID, for: peerID) {
|
||||||
|
dropMessage(message.messageID, for: peerID)
|
||||||
|
outboxChanged = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
guard message.sendAttempts < Self.maxSendAttempts else {
|
||||||
|
SecureLogger.warning(
|
||||||
|
"📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) secure attempts",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
if removeQueuedMessage(message.messageID, for: peerID) {
|
||||||
|
dropMessage(message.messageID, for: peerID)
|
||||||
|
outboxChanged = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
SecureLogger.debug(
|
||||||
|
"Auth retry -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
transport.sendPrivateMessage(
|
||||||
|
message.content,
|
||||||
|
to: peerID,
|
||||||
|
recipientNickname: message.nickname,
|
||||||
|
messageID: message.messageID
|
||||||
|
)
|
||||||
|
metrics?.record(.outboxResent)
|
||||||
|
outboxChanged = incrementSendAttemptsIfQueued(message.messageID, for: peerID) || outboxChanged
|
||||||
|
}
|
||||||
|
|
||||||
|
if outboxChanged {
|
||||||
|
persistOutbox()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func flushOutbox(for peerID: PeerID) {
|
func flushOutbox(for peerID: PeerID) {
|
||||||
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
||||||
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
|
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
|
||||||
|
|
||||||
let now = now()
|
let now = now()
|
||||||
var remaining: [QueuedMessage] = []
|
var outboxChanged = false
|
||||||
|
|
||||||
for message in queued {
|
for message in queued {
|
||||||
|
// A synchronous ack from an earlier send in this flush may have
|
||||||
|
// removed an entry from the live outbox. The snapshot is only an
|
||||||
|
// iteration order; never use it to recreate removed messages.
|
||||||
|
guard queuedMessage(message.messageID, for: peerID) != nil else { continue }
|
||||||
|
|
||||||
// Skip expired messages (TTL exceeded)
|
// Skip expired messages (TTL exceeded)
|
||||||
if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
|
if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
|
||||||
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session)
|
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session)
|
||||||
dropMessage(message.messageID, for: peerID)
|
if removeQueuedMessage(message.messageID, for: peerID) {
|
||||||
|
dropMessage(message.messageID, for: peerID)
|
||||||
|
outboxChanged = true
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if let transport = connectedTransport(for: peerID), transport.canDeliverSecurely(to: peerID) {
|
if let transport = connectedTransport(for: peerID), transport.canDeliverSecurely(to: peerID) {
|
||||||
// Live link with a secure session: send and stop retaining.
|
// A secure session is meaningful enough to retry, but not
|
||||||
|
// proof that this particular ciphertext reached the peer: the
|
||||||
|
// remote app may have restarted while our old session still
|
||||||
|
// looked established. Retain until an ack, while bounding
|
||||||
|
// actual secure transmissions for peers that never ack.
|
||||||
|
guard message.sendAttempts < Self.maxSendAttempts else {
|
||||||
|
SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session)
|
||||||
|
if removeQueuedMessage(message.messageID, for: peerID) {
|
||||||
|
dropMessage(message.messageID, for: peerID)
|
||||||
|
outboxChanged = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
SecureLogger.debug("Outbox -> \(type(of: transport)) (connected) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Outbox -> \(type(of: transport)) (connected) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
||||||
|
secureTransmissions.insert(
|
||||||
|
PeerMessageKey(peerID: peerID, messageID: message.messageID)
|
||||||
|
)
|
||||||
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
||||||
metrics?.record(.outboxResent)
|
metrics?.record(.outboxResent)
|
||||||
|
outboxChanged = incrementSendAttemptsIfQueued(message.messageID, for: peerID) || outboxChanged
|
||||||
} else if let transport = connectedTransport(for: peerID) {
|
} else if let transport = connectedTransport(for: peerID) {
|
||||||
// "Connected" without a secure session — possibly a stolen
|
// "Connected" without a secure session — possibly a stolen
|
||||||
// binding from a replayed announce: send (a genuine link
|
// binding from a replayed announce: send (a genuine link
|
||||||
@@ -501,9 +766,11 @@ final class MessageRouter {
|
|||||||
// preserve. Retention stays bounded by the 24h outbox TTL
|
// preserve. Retention stays bounded by the 24h outbox TTL
|
||||||
// and the per-peer FIFO cap.
|
// and the per-peer FIFO cap.
|
||||||
SecureLogger.debug("Outbox -> \(type(of: transport)) (connected, no secure session) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Outbox -> \(type(of: transport)) (connected, no secure session) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
||||||
|
secureTransmissions.remove(
|
||||||
|
PeerMessageKey(peerID: peerID, messageID: message.messageID)
|
||||||
|
)
|
||||||
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
||||||
metrics?.record(.outboxResent)
|
metrics?.record(.outboxResent)
|
||||||
remaining.append(message)
|
|
||||||
} else if let transport = reachableTransport(for: peerID) {
|
} else if let transport = reachableTransport(for: peerID) {
|
||||||
// Reachability without a connection is a freshness heuristic,
|
// Reachability without a connection is a freshness heuristic,
|
||||||
// so the send can silently go nowhere: send but keep retaining
|
// so the send can silently go nowhere: send but keep retaining
|
||||||
@@ -511,26 +778,22 @@ final class MessageRouter {
|
|||||||
// that never ack.
|
// that never ack.
|
||||||
guard message.sendAttempts < Self.maxSendAttempts else {
|
guard message.sendAttempts < Self.maxSendAttempts else {
|
||||||
SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session)
|
SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session)
|
||||||
dropMessage(message.messageID, for: peerID)
|
if removeQueuedMessage(message.messageID, for: peerID) {
|
||||||
|
dropMessage(message.messageID, for: peerID)
|
||||||
|
outboxChanged = true
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
SecureLogger.debug("Outbox -> \(type(of: transport)) (reachable) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Outbox -> \(type(of: transport)) (reachable) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
||||||
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
||||||
metrics?.record(.outboxResent)
|
metrics?.record(.outboxResent)
|
||||||
var retained = message
|
outboxChanged = incrementSendAttemptsIfQueued(message.messageID, for: peerID) || outboxChanged
|
||||||
retained.sendAttempts += 1
|
|
||||||
remaining.append(retained)
|
|
||||||
} else {
|
|
||||||
remaining.append(message)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if remaining.isEmpty {
|
if outboxChanged {
|
||||||
outbox.removeValue(forKey: peerID)
|
persistOutbox()
|
||||||
} else {
|
|
||||||
outbox[peerID] = remaining
|
|
||||||
}
|
}
|
||||||
persistOutbox()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func flushAllOutbox() {
|
func flushAllOutbox() {
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ enum EncryptionStatus: Equatable {
|
|||||||
case noiseHandshaking // Currently establishing
|
case noiseHandshaking // Currently establishing
|
||||||
case noiseSecured // Established but not verified
|
case noiseSecured // Established but not verified
|
||||||
case noiseVerified // Established and verified
|
case noiseVerified // Established and verified
|
||||||
|
|
||||||
var icon: String? { // Made optional to hide icon when no handshake
|
var icon: String? { // Made optional to hide icon when no handshake
|
||||||
switch self {
|
switch self {
|
||||||
case .none:
|
case .none:
|
||||||
@@ -660,7 +660,7 @@ final class NoiseEncryptionService {
|
|||||||
guard let packetData = packet.toBinaryDataForSigning() else {
|
guard let packetData = packet.toBinaryDataForSigning() else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sign with the noise private key (converted to Ed25519 for signing)
|
// Sign with the noise private key (converted to Ed25519 for signing)
|
||||||
guard let signature = signData(packetData) else {
|
guard let signature = signData(packetData) else {
|
||||||
return nil
|
return nil
|
||||||
@@ -919,7 +919,7 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
return try sessionManager.encrypt(data, for: peerID)
|
return try sessionManager.encrypt(data, for: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt data from a specific peer
|
/// Decrypt data from a specific peer
|
||||||
func decrypt(_ data: Data, from peerID: PeerID) throws -> Data {
|
func decrypt(_ data: Data, from peerID: PeerID) throws -> Data {
|
||||||
try decryptWithSessionGeneration(data, from: peerID).plaintext
|
try decryptWithSessionGeneration(data, from: peerID).plaintext
|
||||||
@@ -936,7 +936,7 @@ final class NoiseEncryptionService {
|
|||||||
let isStandardCiphertext = NoiseSecurityValidator.validateCiphertextSize(data)
|
let isStandardCiphertext = NoiseSecurityValidator.validateCiphertextSize(data)
|
||||||
let isAdmittedCiphertext = isStandardCiphertext
|
let isAdmittedCiphertext = isStandardCiphertext
|
||||||
|| NoiseSecurityValidator.validatePrivateFileCiphertextSize(data)
|
|| NoiseSecurityValidator.validatePrivateFileCiphertextSize(data)
|
||||||
|
|
||||||
// A quarantined transport is deliberately unavailable for outbound
|
// A quarantined transport is deliberately unavailable for outbound
|
||||||
// state, but remains receive-only until the responder proves identity
|
// state, but remains receive-only until the responder proves identity
|
||||||
// or the bounded rollback restores it.
|
// or the bounded rollback restores it.
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Disk persistence for processed gift-wrap event IDs. NIP-59 randomizes
|
/// Disk persistence for processed private-envelope event IDs. BitChat
|
||||||
/// gift-wrap timestamps, so DM subscriptions must look back generously (24h)
|
/// randomizes envelope timestamps, so DM subscriptions must look back
|
||||||
/// and relays redeliver the same events on every launch — without a
|
/// generously (24h) and relays redeliver the same events on every launch —
|
||||||
/// cross-launch record, each relaunch reprocesses old PMs and acks
|
/// without a cross-launch record, each relaunch reprocesses old PMs and acks
|
||||||
/// (re-sent DELIVERED bursts, "delivered ack for unknown mid" noise).
|
/// (re-sent DELIVERED bursts, "delivered ack for unknown mid" noise).
|
||||||
///
|
///
|
||||||
/// Contents are event IDs already visible to every relay, so
|
/// Contents are event IDs already visible to every relay, so
|
||||||
|
|||||||
@@ -11,8 +11,12 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
let favoriteStatusForNoiseKey: @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?
|
let favoriteStatusForNoiseKey: @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||||
let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
|
let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||||
let currentIdentity: @MainActor () throws -> NostrIdentity?
|
let currentIdentity: @MainActor () throws -> NostrIdentity?
|
||||||
let registerPendingGiftWrap: @MainActor (String) -> Void
|
let registerPendingPrivateEnvelope: @MainActor (String) -> Void
|
||||||
let sendEvent: @MainActor (NostrEvent) -> Void
|
let sendPrivateEnvelopeBatch: @MainActor (
|
||||||
|
[NostrEvent],
|
||||||
|
@escaping @MainActor () -> Void
|
||||||
|
) -> Bool
|
||||||
|
let envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue
|
||||||
/// Emits whether a relay that carries private messages is up
|
/// Emits whether a relay that carries private messages is up
|
||||||
/// (fail-closed behind Tor). A connected geohash/custom relay alone
|
/// (fail-closed behind Tor). A connected geohash/custom relay alone
|
||||||
/// doesn't count: DM sends target the default relay set and would
|
/// doesn't count: DM sends target the default relay set and would
|
||||||
@@ -22,25 +26,35 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
/// serialize behind each other; `live` passes the process-wide one.
|
/// serialize behind each other; `live` passes the process-wide one.
|
||||||
let ackPacer: AckPacer
|
let ackPacer: AckPacer
|
||||||
|
|
||||||
|
@MainActor
|
||||||
init(
|
init(
|
||||||
notificationCenter: NotificationCenter,
|
notificationCenter: NotificationCenter,
|
||||||
loadFavorites: @escaping @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship],
|
loadFavorites: @escaping @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship],
|
||||||
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?,
|
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?,
|
||||||
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?,
|
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?,
|
||||||
currentIdentity: @escaping @MainActor () throws -> NostrIdentity?,
|
currentIdentity: @escaping @MainActor () throws -> NostrIdentity?,
|
||||||
registerPendingGiftWrap: @escaping @MainActor (String) -> Void,
|
registerPendingPrivateEnvelope: @escaping @MainActor (String) -> Void,
|
||||||
sendEvent: @escaping @MainActor (NostrEvent) -> Void,
|
sendPrivateEnvelopeBatch: @escaping @MainActor (
|
||||||
|
[NostrEvent],
|
||||||
|
@escaping @MainActor () -> Void
|
||||||
|
) -> Bool,
|
||||||
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void,
|
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void,
|
||||||
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never>,
|
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never>,
|
||||||
ackPacer: AckPacer? = nil
|
ackPacer: AckPacer? = nil,
|
||||||
|
envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue? = nil
|
||||||
) {
|
) {
|
||||||
self.notificationCenter = notificationCenter
|
self.notificationCenter = notificationCenter
|
||||||
self.loadFavorites = loadFavorites
|
self.loadFavorites = loadFavorites
|
||||||
self.favoriteStatusForNoiseKey = favoriteStatusForNoiseKey
|
self.favoriteStatusForNoiseKey = favoriteStatusForNoiseKey
|
||||||
self.favoriteStatusForPeerID = favoriteStatusForPeerID
|
self.favoriteStatusForPeerID = favoriteStatusForPeerID
|
||||||
self.currentIdentity = currentIdentity
|
self.currentIdentity = currentIdentity
|
||||||
self.registerPendingGiftWrap = registerPendingGiftWrap
|
self.registerPendingPrivateEnvelope = registerPendingPrivateEnvelope
|
||||||
self.sendEvent = sendEvent
|
self.sendPrivateEnvelopeBatch = sendPrivateEnvelopeBatch
|
||||||
|
self.envelopeRetryQueue = envelopeRetryQueue ?? NostrPrivateEnvelopeRetryQueue(
|
||||||
|
sendPrivateEnvelopeBatch: sendPrivateEnvelopeBatch,
|
||||||
|
registerPendingPrivateEnvelope: registerPendingPrivateEnvelope,
|
||||||
|
scheduleAfter: scheduleAfter
|
||||||
|
)
|
||||||
self.relayConnectivity = relayConnectivity
|
self.relayConnectivity = relayConnectivity
|
||||||
// Default pacer drives its throttle through the same injected
|
// Default pacer drives its throttle through the same injected
|
||||||
// scheduler, so tests that step scheduleAfter manually keep
|
// scheduler, so tests that step scheduleAfter manually keep
|
||||||
@@ -56,13 +70,19 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
favoriteStatusForNoiseKey: { FavoritesPersistenceService.shared.getFavoriteStatus(for: $0) },
|
favoriteStatusForNoiseKey: { FavoritesPersistenceService.shared.getFavoriteStatus(for: $0) },
|
||||||
favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) },
|
favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) },
|
||||||
currentIdentity: { try idBridge.getCurrentNostrIdentity() },
|
currentIdentity: { try idBridge.getCurrentNostrIdentity() },
|
||||||
registerPendingGiftWrap: { NostrRelayManager.registerPendingGiftWrap(id: $0) },
|
registerPendingPrivateEnvelope: { NostrRelayManager.registerPendingPrivateEnvelope(id: $0) },
|
||||||
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
|
sendPrivateEnvelopeBatch: { events, terminalFailure in
|
||||||
|
NostrRelayManager.shared.sendPrivateEnvelopeBatch(
|
||||||
|
events,
|
||||||
|
terminalFailure: terminalFailure
|
||||||
|
)
|
||||||
|
},
|
||||||
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() },
|
relayConnectivity: { NostrRelayManager.shared.$isDMRelayConnected.eraseToAnyPublisher() },
|
||||||
ackPacer: NostrTransport.sharedAckPacer
|
ackPacer: NostrTransport.sharedAckPacer,
|
||||||
|
envelopeRetryQueue: NostrTransport.sharedEnvelopeRetryQueue
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,7 +148,37 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
static let sharedAckPacer = AckPacer()
|
static let sharedAckPacer = AckPacer()
|
||||||
|
// Geohash acknowledgements use short-lived NostrTransport instances, so
|
||||||
|
// the retry owner must be process-wide. A per-transport cap would still be
|
||||||
|
// globally unbounded under outage as throwaway instances accumulated.
|
||||||
|
@MainActor
|
||||||
|
private static let sharedEnvelopeRetryQueue = NostrPrivateEnvelopeRetryQueue(
|
||||||
|
sendPrivateEnvelopeBatch: { events, terminalFailure in
|
||||||
|
NostrRelayManager.shared.sendPrivateEnvelopeBatch(
|
||||||
|
events,
|
||||||
|
terminalFailure: terminalFailure
|
||||||
|
)
|
||||||
|
},
|
||||||
|
registerPendingPrivateEnvelope: {
|
||||||
|
NostrRelayManager.registerPendingPrivateEnvelope(id: $0)
|
||||||
|
},
|
||||||
|
scheduleAfter: { delay, action in
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
static func resetControlRetriesForPanicWipe() {
|
||||||
|
sharedEnvelopeRetryQueue.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum PrivateEnvelopeFailurePolicy {
|
||||||
|
case userMessage(messageID: String)
|
||||||
|
case retry(retryKey: String)
|
||||||
|
}
|
||||||
|
|
||||||
private let dependencies: Dependencies
|
private let dependencies: Dependencies
|
||||||
|
private let envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue
|
||||||
private var favoriteStatusObserver: NSObjectProtocol?
|
private var favoriteStatusObserver: NSObjectProtocol?
|
||||||
|
|
||||||
// Reachability Cache (thread-safe)
|
// Reachability Cache (thread-safe)
|
||||||
@@ -145,7 +195,9 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
idBridge: NostrIdentityBridge,
|
idBridge: NostrIdentityBridge,
|
||||||
dependencies: Dependencies? = nil
|
dependencies: Dependencies? = nil
|
||||||
) {
|
) {
|
||||||
self.dependencies = dependencies ?? .live(idBridge: idBridge)
|
let resolvedDependencies = dependencies ?? .live(idBridge: idBridge)
|
||||||
|
self.dependencies = resolvedDependencies
|
||||||
|
self.envelopeRetryQueue = resolvedDependencies.envelopeRetryQueue
|
||||||
|
|
||||||
setupObservers()
|
setupObservers()
|
||||||
|
|
||||||
@@ -172,6 +224,18 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
@MainActor
|
||||||
|
func debugEnqueueControlRetry(key: String, events: [NostrEvent]) {
|
||||||
|
envelopeRetryQueue.enqueue(key: key, events: events, registerPending: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
var debugControlRetryCount: Int {
|
||||||
|
envelopeRetryQueue.debugPendingCount
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
private func setupObservers() {
|
private func setupObservers() {
|
||||||
favoriteStatusObserver = dependencies.notificationCenter.addObserver(
|
favoriteStatusObserver = dependencies.notificationCenter.addObserver(
|
||||||
forName: .favoriteStatusChanged,
|
forName: .favoriteStatusChanged,
|
||||||
@@ -253,15 +317,49 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
|
|
||||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
let failurePolicy = PrivateEnvelopeFailurePolicy.userMessage(
|
||||||
let recipientHex = npubToHex(recipientNpub),
|
messageID: messageID
|
||||||
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
)
|
||||||
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else {
|
||||||
|
handlePrivateEnvelopeFailure(
|
||||||
|
events: [],
|
||||||
|
registerPending: false,
|
||||||
|
policy: failurePolicy
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let recipientHex = npubToHex(recipientNpub) else {
|
||||||
|
handlePrivateEnvelopeFailure(
|
||||||
|
events: [],
|
||||||
|
registerPending: false,
|
||||||
|
policy: failurePolicy
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let senderIdentity = try? dependencies.currentIdentity() else {
|
||||||
|
handlePrivateEnvelopeFailure(
|
||||||
|
events: [],
|
||||||
|
registerPending: false,
|
||||||
|
policy: failurePolicy
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
||||||
|
handlePrivateEnvelopeFailure(
|
||||||
|
events: [],
|
||||||
|
registerPending: false,
|
||||||
|
policy: failurePolicy
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
sendPrivateEnvelope(
|
||||||
|
content: embedded,
|
||||||
|
recipientHex: recipientHex,
|
||||||
|
senderIdentity: senderIdentity,
|
||||||
|
failurePolicy: failurePolicy
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +385,14 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
sendPrivateEnvelope(
|
||||||
|
content: embedded,
|
||||||
|
recipientHex: recipientHex,
|
||||||
|
senderIdentity: senderIdentity,
|
||||||
|
failurePolicy: .retry(
|
||||||
|
retryKey: privateEnvelopeRetryKey(content: embedded, recipientHex: recipientHex)
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,16 +416,48 @@ extension NostrTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Geohash DMs (per-geohash identity)
|
// MARK: Geohash DMs (per-geohash identity)
|
||||||
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
/// Returns true only when the complete migration pair entered the relay
|
||||||
Task { @MainActor in
|
/// delivery queue. GeoDM callers use this synchronous admission result
|
||||||
guard !recipientHex.isEmpty else { return }
|
/// before showing "sent"; deterministic packet/envelope failures must not
|
||||||
SecureLogger.debug("GeoDM: send PM mid=\(messageID.prefix(8))…", category: .session)
|
/// be hidden behind an unobserved MainActor task.
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
|
@MainActor
|
||||||
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
@discardableResult
|
||||||
return
|
func sendPrivateMessageGeohash(
|
||||||
}
|
content: String,
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
toRecipientHex recipientHex: String,
|
||||||
|
from identity: NostrIdentity,
|
||||||
|
messageID: String
|
||||||
|
) -> Bool {
|
||||||
|
let failurePolicy = PrivateEnvelopeFailurePolicy.userMessage(messageID: messageID)
|
||||||
|
guard !recipientHex.isEmpty else {
|
||||||
|
handlePrivateEnvelopeFailure(
|
||||||
|
events: [],
|
||||||
|
registerPending: false,
|
||||||
|
policy: failurePolicy
|
||||||
|
)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
SecureLogger.debug("GeoDM: send PM mid=\(messageID.prefix(8))…", category: .session)
|
||||||
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
|
||||||
|
content: content,
|
||||||
|
messageID: messageID,
|
||||||
|
senderPeerID: senderPeerID
|
||||||
|
) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
||||||
|
handlePrivateEnvelopeFailure(
|
||||||
|
events: [],
|
||||||
|
registerPending: false,
|
||||||
|
policy: failurePolicy
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return sendPrivateEnvelope(
|
||||||
|
content: embedded,
|
||||||
|
recipientHex: recipientHex,
|
||||||
|
senderIdentity: identity,
|
||||||
|
registerPending: true,
|
||||||
|
failurePolicy: failurePolicy
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,17 +477,112 @@ extension NostrTransport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates and sends a gift-wrapped private message event
|
/// Creates and sends a BitChat private-envelope event over Nostr.
|
||||||
@MainActor
|
@MainActor
|
||||||
private func sendWrappedMessage(content: String, recipientHex: String, senderIdentity: NostrIdentity, registerPending: Bool = false) {
|
@discardableResult
|
||||||
guard let event = try? NostrProtocol.createPrivateMessage(content: content, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
private func sendPrivateEnvelope(
|
||||||
SecureLogger.error("NostrTransport: failed to build Nostr event", category: .session)
|
content: String,
|
||||||
return
|
recipientHex: String,
|
||||||
|
senderIdentity: NostrIdentity,
|
||||||
|
registerPending: Bool = false,
|
||||||
|
failurePolicy: PrivateEnvelopeFailurePolicy
|
||||||
|
) -> Bool {
|
||||||
|
let events: [NostrEvent]
|
||||||
|
do {
|
||||||
|
events = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||||
|
content: content,
|
||||||
|
recipientPubkey: recipientHex,
|
||||||
|
senderIdentity: senderIdentity
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error(
|
||||||
|
"NostrTransport: failed to build Nostr private-envelope batch: \(error)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
// Construction failures are deterministic. User-authored messages
|
||||||
|
// must become visibly failed; control payloads have no valid event
|
||||||
|
// pair to retain and retry.
|
||||||
|
handlePrivateEnvelopeFailure(
|
||||||
|
events: [],
|
||||||
|
registerPending: false,
|
||||||
|
policy: failurePolicy
|
||||||
|
)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
if registerPending {
|
let accepted = dependencies.sendPrivateEnvelopeBatch(events) { [self] in
|
||||||
dependencies.registerPendingGiftWrap(event.id)
|
handlePrivateEnvelopeFailure(
|
||||||
|
events: events,
|
||||||
|
registerPending: registerPending,
|
||||||
|
policy: failurePolicy
|
||||||
|
)
|
||||||
|
}
|
||||||
|
guard accepted else {
|
||||||
|
SecureLogger.error(
|
||||||
|
"NostrTransport: private-envelope migration pair was not accepted for relay delivery",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
handlePrivateEnvelopeFailure(
|
||||||
|
events: events,
|
||||||
|
registerPending: registerPending,
|
||||||
|
policy: failurePolicy
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
registerPendingPrivateEnvelopesIfNeeded(events, registerPending: registerPending)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func handlePrivateEnvelopeFailure(
|
||||||
|
events: [NostrEvent],
|
||||||
|
registerPending: Bool,
|
||||||
|
policy: PrivateEnvelopeFailurePolicy
|
||||||
|
) {
|
||||||
|
switch policy {
|
||||||
|
case .userMessage(let messageID):
|
||||||
|
deliverTransportEvent(.messageDeliveryStatusUpdated(
|
||||||
|
messageID: messageID,
|
||||||
|
status: .failed(reason: String(
|
||||||
|
localized: "content.delivery.reason.not_delivered",
|
||||||
|
comment: "Failure reason shown when a private message could not enter the relay delivery queue"
|
||||||
|
))
|
||||||
|
))
|
||||||
|
case .retry(let retryKey):
|
||||||
|
// A deterministic packet/envelope construction failure has no
|
||||||
|
// events to retry. Only relay admission/delivery failures reach
|
||||||
|
// this branch with the complete atomic pair.
|
||||||
|
guard !events.isEmpty else { return }
|
||||||
|
envelopeRetryQueue.enqueue(
|
||||||
|
key: retryKey,
|
||||||
|
events: events,
|
||||||
|
registerPending: registerPending
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func registerPendingPrivateEnvelopesIfNeeded(
|
||||||
|
_ events: [NostrEvent],
|
||||||
|
registerPending: Bool
|
||||||
|
) {
|
||||||
|
guard registerPending else { return }
|
||||||
|
for event in events {
|
||||||
|
dependencies.registerPendingPrivateEnvelope(event.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func privateEnvelopeRetryKey(content: String, recipientHex: String) -> String {
|
||||||
|
"\(recipientHex.lowercased()):\(Data(content.utf8).sha256Fingerprint())"
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func deliverTransportEvent(_ event: TransportEvent) {
|
||||||
|
if let eventDelegate {
|
||||||
|
eventDelegate.didReceiveTransportEvent(event)
|
||||||
|
} else {
|
||||||
|
delegate?.receiveTransportEvent(event)
|
||||||
}
|
}
|
||||||
dependencies.sendEvent(event)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -367,7 +599,14 @@ extension NostrTransport {
|
|||||||
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
sendPrivateEnvelope(
|
||||||
|
content: ack,
|
||||||
|
recipientHex: recipientHex,
|
||||||
|
senderIdentity: senderIdentity,
|
||||||
|
failurePolicy: .retry(
|
||||||
|
retryKey: privateEnvelopeRetryKey(content: ack, recipientHex: recipientHex)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
case .deliveredDirect(let messageID, let peerID):
|
case .deliveredDirect(let messageID, let peerID):
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||||
@@ -378,17 +617,40 @@ extension NostrTransport {
|
|||||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
sendPrivateEnvelope(
|
||||||
|
content: ack,
|
||||||
|
recipientHex: recipientHex,
|
||||||
|
senderIdentity: senderIdentity,
|
||||||
|
failurePolicy: .retry(
|
||||||
|
retryKey: privateEnvelopeRetryKey(content: ack, recipientHex: recipientHex)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
case .deliveredGeohash(let messageID, let recipientHex, let identity):
|
case .deliveredGeohash(let messageID, let recipientHex, let identity):
|
||||||
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))…", category: .session)
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
sendPrivateEnvelope(
|
||||||
|
content: embedded,
|
||||||
|
recipientHex: recipientHex,
|
||||||
|
senderIdentity: identity,
|
||||||
|
registerPending: true,
|
||||||
|
failurePolicy: .retry(
|
||||||
|
retryKey: privateEnvelopeRetryKey(content: embedded, recipientHex: recipientHex)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
case .readGeohash(let messageID, let recipientHex, let identity):
|
case .readGeohash(let messageID, let recipientHex, let identity):
|
||||||
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))…", category: .session)
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
sendPrivateEnvelope(
|
||||||
|
content: embedded,
|
||||||
|
recipientHex: recipientHex,
|
||||||
|
senderIdentity: identity,
|
||||||
|
registerPending: true,
|
||||||
|
failurePolicy: .retry(
|
||||||
|
retryKey: privateEnvelopeRetryKey(content: embedded, recipientHex: recipientHex)
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -408,3 +670,122 @@ extension NostrTransport {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bounded retry owner for non-user private control payloads. It is separate
|
||||||
|
/// from `NostrTransport` so a scheduled retry from a short-lived geohash
|
||||||
|
/// transport remains valid after that transport deinitializes. Scheduler
|
||||||
|
/// callbacks retain this queue, never the transport; an evicted key simply
|
||||||
|
/// becomes a harmless no-op when its already-scheduled callback fires.
|
||||||
|
@MainActor
|
||||||
|
final class NostrPrivateEnvelopeRetryQueue {
|
||||||
|
private struct PendingRetry {
|
||||||
|
let events: [NostrEvent]
|
||||||
|
let registerPending: Bool
|
||||||
|
var attempt: Int
|
||||||
|
var isScheduled: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
private let sendPrivateEnvelopeBatch: @MainActor (
|
||||||
|
[NostrEvent],
|
||||||
|
@escaping @MainActor () -> Void
|
||||||
|
) -> Bool
|
||||||
|
private let registerPendingPrivateEnvelope: @MainActor (String) -> Void
|
||||||
|
private let scheduleAfter: @Sendable (
|
||||||
|
TimeInterval,
|
||||||
|
@escaping @Sendable () -> Void
|
||||||
|
) -> Void
|
||||||
|
private var pending: [String: PendingRetry] = [:]
|
||||||
|
private var insertionOrder: [String] = []
|
||||||
|
|
||||||
|
init(
|
||||||
|
sendPrivateEnvelopeBatch: @escaping @MainActor (
|
||||||
|
[NostrEvent],
|
||||||
|
@escaping @MainActor () -> Void
|
||||||
|
) -> Bool,
|
||||||
|
registerPendingPrivateEnvelope: @escaping @MainActor (String) -> Void,
|
||||||
|
scheduleAfter: @escaping @Sendable (
|
||||||
|
TimeInterval,
|
||||||
|
@escaping @Sendable () -> Void
|
||||||
|
) -> Void
|
||||||
|
) {
|
||||||
|
self.sendPrivateEnvelopeBatch = sendPrivateEnvelopeBatch
|
||||||
|
self.registerPendingPrivateEnvelope = registerPendingPrivateEnvelope
|
||||||
|
self.scheduleAfter = scheduleAfter
|
||||||
|
}
|
||||||
|
|
||||||
|
func enqueue(key: String, events: [NostrEvent], registerPending: Bool) {
|
||||||
|
guard pending[key] == nil else { return }
|
||||||
|
if pending.count >= TransportConfig.nostrPrivateEnvelopeRetryQueueCap,
|
||||||
|
let evictedKey = insertionOrder.first {
|
||||||
|
insertionOrder.removeFirst()
|
||||||
|
pending.removeValue(forKey: evictedKey)
|
||||||
|
// These are control payloads, never user-authored messages. Keep
|
||||||
|
// the bounded-loss decision explicit rather than silently growing
|
||||||
|
// memory during a prolonged outage.
|
||||||
|
SecureLogger.warning(
|
||||||
|
"📮 Private control retry queue full — evicted oldest whole migration pair",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
pending[key] = PendingRetry(
|
||||||
|
events: events,
|
||||||
|
registerPending: registerPending,
|
||||||
|
attempt: 0,
|
||||||
|
isScheduled: false
|
||||||
|
)
|
||||||
|
insertionOrder.append(key)
|
||||||
|
schedule(key: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func schedule(key: String) {
|
||||||
|
guard var item = pending[key], !item.isScheduled else { return }
|
||||||
|
item.isScheduled = true
|
||||||
|
pending[key] = item
|
||||||
|
let exponent = min(item.attempt, 5)
|
||||||
|
let delay = min(2.0 * pow(2.0, Double(exponent)), 60.0)
|
||||||
|
scheduleAfter(delay) { [self] in
|
||||||
|
Task { @MainActor [self] in
|
||||||
|
self.retry(key: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func retry(key: String) {
|
||||||
|
guard var item = pending[key] else { return }
|
||||||
|
item.isScheduled = false
|
||||||
|
pending[key] = item
|
||||||
|
|
||||||
|
let accepted = sendPrivateEnvelopeBatch(item.events) { [self] in
|
||||||
|
self.enqueue(
|
||||||
|
key: key,
|
||||||
|
events: item.events,
|
||||||
|
registerPending: item.registerPending
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if accepted {
|
||||||
|
remove(key: key)
|
||||||
|
if item.registerPending {
|
||||||
|
for event in item.events {
|
||||||
|
registerPendingPrivateEnvelope(event.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
item.attempt += 1
|
||||||
|
pending[key] = item
|
||||||
|
schedule(key: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func remove(key: String) {
|
||||||
|
pending.removeValue(forKey: key)
|
||||||
|
insertionOrder.removeAll { $0 == key }
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeAll() {
|
||||||
|
pending.removeAll()
|
||||||
|
insertionOrder.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
var debugPendingCount: Int { pending.count }
|
||||||
|
func debugContains(key: String) -> Bool { pending[key] != nil }
|
||||||
|
}
|
||||||
|
|||||||
@@ -97,6 +97,19 @@ enum PrivateMediaSendPolicy: Equatable {
|
|||||||
case blockedDowngrade
|
case blockedDowngrade
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Receiver-only persistence surface for explicit private-media deletion.
|
||||||
|
/// Kept separate from `Transport` so sender retry branches can rebase without
|
||||||
|
/// inheriting or implementing receiver storage concerns.
|
||||||
|
protocol PrivateMediaDeletionPersisting: AnyObject {
|
||||||
|
@MainActor
|
||||||
|
func persistDeletedPrivateMedia(
|
||||||
|
messageIDs: [String],
|
||||||
|
payloadRelativePaths: [String: String],
|
||||||
|
protectedPayloadRelativePaths: Set<String>,
|
||||||
|
completion: @escaping @MainActor (Bool) -> Void
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
protocol TransportEventDelegate: AnyObject {
|
protocol TransportEventDelegate: AnyObject {
|
||||||
@MainActor func didReceiveTransportEvent(_ event: TransportEvent)
|
@MainActor func didReceiveTransportEvent(_ event: TransportEvent)
|
||||||
}
|
}
|
||||||
@@ -190,6 +203,14 @@ protocol Transport: AnyObject {
|
|||||||
transferId: String,
|
transferId: String,
|
||||||
allowLegacyFallback: Bool
|
allowLegacyFallback: Bool
|
||||||
)
|
)
|
||||||
|
/// Automatic whole-file retry is admitted only while this exact Noise
|
||||||
|
/// generation authenticates bit 9. It must never queue across a session
|
||||||
|
/// replacement or enter the signed raw legacy path.
|
||||||
|
func sendFilePrivateReceiptRetry(
|
||||||
|
_ packet: BitchatFilePacket,
|
||||||
|
to peerID: PeerID,
|
||||||
|
transferId: String
|
||||||
|
)
|
||||||
func cancelTransfer(_ transferId: String)
|
func cancelTransfer(_ transferId: String)
|
||||||
|
|
||||||
// Live voice / push-to-talk (mesh transports only): one encoded
|
// Live voice / push-to-talk (mesh transports only): one encoded
|
||||||
@@ -236,6 +257,11 @@ protocol Transport: AnyObject {
|
|||||||
/// empty for peers that predate the capabilities TLV.
|
/// empty for peers that predate the capabilities TLV.
|
||||||
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities
|
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities
|
||||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
|
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
|
||||||
|
/// The exact current Noise generation that authenticated both encrypted
|
||||||
|
/// private media (bit 8) and durable receipts/retry (bit 9).
|
||||||
|
func authenticatedPrivateMediaReceiptSessionGeneration(
|
||||||
|
to peerID: PeerID
|
||||||
|
) -> UUID?
|
||||||
func resolvePrivateMediaSendPolicy(
|
func resolvePrivateMediaSendPolicy(
|
||||||
to peerID: PeerID,
|
to peerID: PeerID,
|
||||||
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||||
@@ -311,6 +337,11 @@ extension Transport {
|
|||||||
func broadcastGroupMessage(_ envelope: Data) {}
|
func broadcastGroupMessage(_ envelope: Data) {}
|
||||||
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
|
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
|
||||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { .blockedDowngrade }
|
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { .blockedDowngrade }
|
||||||
|
func authenticatedPrivateMediaReceiptSessionGeneration(
|
||||||
|
to peerID: PeerID
|
||||||
|
) -> UUID? {
|
||||||
|
nil
|
||||||
|
}
|
||||||
func resolvePrivateMediaSendPolicy(
|
func resolvePrivateMediaSendPolicy(
|
||||||
to peerID: PeerID,
|
to peerID: PeerID,
|
||||||
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||||
@@ -345,6 +376,11 @@ extension Transport {
|
|||||||
guard !allowLegacyFallback else { return }
|
guard !allowLegacyFallback else { return }
|
||||||
sendFilePrivate(packet, to: peerID, transferId: transferId)
|
sendFilePrivate(packet, to: peerID, transferId: transferId)
|
||||||
}
|
}
|
||||||
|
func sendFilePrivateReceiptRetry(
|
||||||
|
_ packet: BitchatFilePacket,
|
||||||
|
to peerID: PeerID,
|
||||||
|
transferId: String
|
||||||
|
) {}
|
||||||
func cancelTransfer(_ transferId: String) {}
|
func cancelTransfer(_ transferId: String) {}
|
||||||
|
|
||||||
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ enum TransportConfig {
|
|||||||
static let privateMediaCapabilityProofTimeoutSeconds: TimeInterval = 5
|
static let privateMediaCapabilityProofTimeoutSeconds: TimeInterval = 5
|
||||||
static let privateMediaCapabilityProofPendingPeerCap: Int = 64
|
static let privateMediaCapabilityProofPendingPeerCap: Int = 64
|
||||||
static let privateMediaCapabilityProofWaitersPerPeerCap: Int = 16
|
static let privateMediaCapabilityProofWaitersPerPeerCap: Int = 16
|
||||||
|
/// Accepted private-media receipts and explicit-deletion tombstones each
|
||||||
|
/// receive this independent capacity.
|
||||||
|
static let privateMediaReceivedLedgerCapacity: Int = 4_096
|
||||||
|
/// A bounded retry horizon prevents stable receipt state from growing into
|
||||||
|
/// permanent application history.
|
||||||
|
static let privateMediaReceivedLedgerTTLSeconds: TimeInterval =
|
||||||
|
7 * 24 * 60 * 60
|
||||||
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
|
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
|
||||||
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
|
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
|
||||||
// Fragment relay TTL in sparse graphs; matches messageTTLDefault so media
|
// Fragment relay TTL in sparse graphs; matches messageTTLDefault so media
|
||||||
@@ -188,7 +195,22 @@ enum TransportConfig {
|
|||||||
static let nostrGeoRelayCount: Int = 5
|
static let nostrGeoRelayCount: Int = 5
|
||||||
static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300
|
static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300
|
||||||
static let nostrGeohashSampleLimit: Int = 100
|
static let nostrGeohashSampleLimit: Int = 100
|
||||||
static let nostrDMSubscribeLookbackSeconds: TimeInterval = 86400
|
/// New iOS public-envelope timestamps are deliberately shifted into the
|
||||||
|
/// past for privacy and relay compatibility.
|
||||||
|
static let nostrPrivateEnvelopeTimestampFuzzSeconds: TimeInterval = 15 * 60
|
||||||
|
/// Deployed Android clients can shift legacy kind-1059 timestamps by the
|
||||||
|
/// full preceding 48 hours.
|
||||||
|
static let nostrLegacyAndroidTimestampFuzzSeconds: TimeInterval = 48 * 60 * 60
|
||||||
|
/// Private mail remains eligible for delivery for the same 24-hour window
|
||||||
|
/// as the persistent sender outbox. The relay query must add timestamp
|
||||||
|
/// randomization to that window rather than replacing it: an Android event
|
||||||
|
/// sent at t0 can legitimately be stamped t0-48h and fetched at t0+24h.
|
||||||
|
static let nostrPrivateEnvelopeDeliveryWindowSeconds: TimeInterval = 24 * 60 * 60
|
||||||
|
static let nostrDMSubscribeClockSkewSeconds: TimeInterval = 15 * 60
|
||||||
|
static let nostrDMSubscribeLookbackSeconds: TimeInterval =
|
||||||
|
nostrPrivateEnvelopeDeliveryWindowSeconds
|
||||||
|
+ nostrLegacyAndroidTimestampFuzzSeconds
|
||||||
|
+ nostrDMSubscribeClockSkewSeconds
|
||||||
// A sampled chat message this recent means "a conversation is happening
|
// A sampled chat message this recent means "a conversation is happening
|
||||||
// there" for the empty-timeline nearby-activity hint.
|
// there" for the empty-timeline nearby-activity hint.
|
||||||
static let uiGeohashChatActivityWindowSeconds: TimeInterval = 900
|
static let uiGeohashChatActivityWindowSeconds: TimeInterval = 900
|
||||||
@@ -216,11 +238,20 @@ enum TransportConfig {
|
|||||||
// Reconnect delays get ±20% random jitter so relays that dropped together
|
// Reconnect delays get ±20% random jitter so relays that dropped together
|
||||||
// (e.g. a network blip) don't thundering-herd the same reconnect instant.
|
// (e.g. a network blip) don't thundering-herd the same reconnect instant.
|
||||||
static let nostrRelayBackoffJitterRatio: Double = 0.2
|
static let nostrRelayBackoffJitterRatio: Double = 0.2
|
||||||
static let nostrRelayDefaultFetchLimit: Int = 100
|
/// Migration recovery uses one independent relay filter per wire kind so
|
||||||
|
/// primary traffic cannot consume the legacy result budget (or vice
|
||||||
|
/// versa). Five hundred per kind bounds startup work while preserving a
|
||||||
|
/// materially deeper offline mailbox than the generic feed default.
|
||||||
|
static let nostrPrivateEnvelopeFetchLimitPerKind: Int = 500
|
||||||
// How many consecutive Tor-readiness waits (each bounded by TorManager's
|
// How many consecutive Tor-readiness waits (each bounded by TorManager's
|
||||||
// bootstrap deadline) to attempt before unblocking pending EOSE callers.
|
// bootstrap deadline) to attempt before unblocking pending EOSE callers.
|
||||||
static let nostrTorReadyMaxWaitAttempts: Int = 3
|
static let nostrTorReadyMaxWaitAttempts: Int = 3
|
||||||
static let nostrPendingSendQueueCap: Int = 200
|
static let nostrPendingSendQueueCap: Int = 200
|
||||||
|
/// Control-payload pairs (delivery/read acknowledgements and favorite
|
||||||
|
/// notifications) that could not enter the relay queue. User messages do
|
||||||
|
/// not use this queue: they fail visibly, while direct messages also
|
||||||
|
/// remain in the router outbox.
|
||||||
|
static let nostrPrivateEnvelopeRetryQueueCap: Int = 256
|
||||||
// Sample interval for the send-queue overflow warning (first + every Nth
|
// Sample interval for the send-queue overflow warning (first + every Nth
|
||||||
// dropped event). Drops are ephemeral presence/geo traffic — log-only.
|
// dropped event). Drops are ephemeral presence/geo traffic — log-only.
|
||||||
static let nostrPendingSendDropLogInterval: Int = 10
|
static let nostrPendingSendDropLogInterval: Int = 10
|
||||||
@@ -232,7 +263,7 @@ enum TransportConfig {
|
|||||||
// Fallback deadline for treating a subscription's initial fetch as complete
|
// Fallback deadline for treating a subscription's initial fetch as complete
|
||||||
// when a relay never sends EOSE (generous to cover Tor circuit setup).
|
// when a relay never sends EOSE (generous to cover Tor circuit setup).
|
||||||
static let nostrSubscriptionEOSEFallbackSeconds: TimeInterval = 10.0
|
static let nostrSubscriptionEOSEFallbackSeconds: TimeInterval = 10.0
|
||||||
// A bridge drop is durable only after NIP-20 OK. Relays that omit OK must
|
// A bridge drop is durable only after NIP-01 `OK`. Relays that omit `OK` must
|
||||||
// not pin the router's in-flight state indefinitely.
|
// not pin the router's in-flight state indefinitely.
|
||||||
static let nostrConfirmedSendAckTimeoutSeconds: TimeInterval = 10.0
|
static let nostrConfirmedSendAckTimeoutSeconds: TimeInterval = 10.0
|
||||||
// After this long, a relay marked permanently failed gets another chance.
|
// After this long, a relay marked permanently failed gets another chance.
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ protocol ChatDeliveryContext: AnyObject {
|
|||||||
/// message is unknown or no copy changed.
|
/// message is unknown or no copy changed.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool
|
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool
|
||||||
|
/// Applies an authenticated receipt only to the direct conversations
|
||||||
|
/// represented by the supplied peer aliases.
|
||||||
|
@discardableResult
|
||||||
|
func setDeliveryStatus(
|
||||||
|
_ status: DeliveryStatus,
|
||||||
|
forMessageID messageID: String,
|
||||||
|
inDirectPeerAliases peerIDs: Set<PeerID>
|
||||||
|
) -> Bool
|
||||||
/// Current delivery status of the message in whichever conversation holds it.
|
/// Current delivery status of the message in whichever conversation holds it.
|
||||||
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus?
|
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus?
|
||||||
/// Message IDs across all direct conversations (read-receipt pruning).
|
/// Message IDs across all direct conversations (read-receipt pruning).
|
||||||
@@ -33,6 +41,12 @@ protocol ChatDeliveryContext: AnyObject {
|
|||||||
func notifyUIChanged()
|
func notifyUIChanged()
|
||||||
/// Confirms receipt so the message router stops retaining the message for resend.
|
/// Confirms receipt so the message router stops retaining the message for resend.
|
||||||
func markMessageDelivered(_ messageID: String)
|
func markMessageDelivered(_ messageID: String)
|
||||||
|
/// Peer-bound form for authenticated remote receipts. Only the supplied
|
||||||
|
/// conversation aliases may have retained state terminalized.
|
||||||
|
func markMessageDelivered(_ messageID: String, from peerIDs: Set<PeerID>)
|
||||||
|
/// Returns true only when `messageID` is one of our outgoing messages in
|
||||||
|
/// at least one of the authenticated peer's direct-conversation aliases.
|
||||||
|
func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set<PeerID>) -> Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ChatViewModel: ChatDeliveryContext {
|
extension ChatViewModel: ChatDeliveryContext {
|
||||||
@@ -41,6 +55,19 @@ extension ChatViewModel: ChatDeliveryContext {
|
|||||||
conversations.setDeliveryStatus(status, forMessageID: messageID)
|
conversations.setDeliveryStatus(status, forMessageID: messageID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func setDeliveryStatus(
|
||||||
|
_ status: DeliveryStatus,
|
||||||
|
forMessageID messageID: String,
|
||||||
|
inDirectPeerAliases peerIDs: Set<PeerID>
|
||||||
|
) -> Bool {
|
||||||
|
conversations.setDeliveryStatus(
|
||||||
|
status,
|
||||||
|
forMessageID: messageID,
|
||||||
|
inDirectPeerAliases: peerIDs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
|
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
|
||||||
conversations.deliveryStatus(forMessageID: messageID)
|
conversations.deliveryStatus(forMessageID: messageID)
|
||||||
}
|
}
|
||||||
@@ -55,6 +82,27 @@ extension ChatViewModel: ChatDeliveryContext {
|
|||||||
|
|
||||||
func markMessageDelivered(_ messageID: String) {
|
func markMessageDelivered(_ messageID: String) {
|
||||||
messageRouter.markDelivered(messageID)
|
messageRouter.markDelivered(messageID)
|
||||||
|
mediaTransferCoordinator.confirmPrivateMediaDelivery(
|
||||||
|
messageID: messageID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func markMessageDelivered(_ messageID: String, from peerIDs: Set<PeerID>) {
|
||||||
|
messageRouter.markDelivered(messageID, from: peerIDs)
|
||||||
|
// The caller has already bound this receipt to one of our outgoing
|
||||||
|
// conversations. Release the matching media retry only after that
|
||||||
|
// peer-scoped validation and accepted delivery-status transition.
|
||||||
|
mediaTransferCoordinator.confirmPrivateMediaDelivery(
|
||||||
|
messageID: messageID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set<PeerID>) -> Bool {
|
||||||
|
peerIDs.contains { peerID in
|
||||||
|
privateMessages(for: peerID).contains { message in
|
||||||
|
message.id == messageID && message.senderPeerID == myPeerID
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,9 +131,10 @@ final class ChatDeliveryCoordinator {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func didReceiveReadReceipt(_ receipt: ReadReceipt) {
|
func didReceiveReadReceipt(_ receipt: ReadReceipt) {
|
||||||
updateMessageDeliveryStatus(
|
updateAcknowledgedMessageDeliveryStatus(
|
||||||
receipt.originalMessageID,
|
receipt.originalMessageID,
|
||||||
status: .read(by: receipt.readerNickname, at: receipt.timestamp)
|
status: .read(by: receipt.readerNickname, at: receipt.timestamp),
|
||||||
|
from: [receipt.readerID]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,17 +151,47 @@ final class ChatDeliveryCoordinator {
|
|||||||
@MainActor
|
@MainActor
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool {
|
func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool {
|
||||||
|
guard context.setDeliveryStatus(status, forMessageID: messageID) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
switch status {
|
switch status {
|
||||||
case .delivered, .read:
|
case .delivered, .read:
|
||||||
// Confirmed receipt — stop retaining the message for resend.
|
// Terminalize only after the store accepted the transition.
|
||||||
context.markMessageDelivered(messageID)
|
context.markMessageDelivered(messageID)
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
context.notifyUIChanged()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
guard context.setDeliveryStatus(status, forMessageID: messageID) else {
|
/// Applies an authenticated remote delivery/read receipt only when it
|
||||||
|
/// belongs to one of our outgoing messages in that peer's conversation.
|
||||||
|
/// Retry state is cleared after, never before, the status transition is
|
||||||
|
/// accepted by the store.
|
||||||
|
@MainActor
|
||||||
|
@discardableResult
|
||||||
|
func updateAcknowledgedMessageDeliveryStatus(
|
||||||
|
_ messageID: String,
|
||||||
|
status: DeliveryStatus,
|
||||||
|
from peerIDAliases: Set<PeerID>
|
||||||
|
) -> Bool {
|
||||||
|
switch status {
|
||||||
|
case .delivered, .read:
|
||||||
|
break
|
||||||
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
guard !peerIDAliases.isEmpty,
|
||||||
|
context.isOutgoingPrivateMessage(messageID, toAny: peerIDAliases),
|
||||||
|
context.setDeliveryStatus(
|
||||||
|
status,
|
||||||
|
forMessageID: messageID,
|
||||||
|
inDirectPeerAliases: peerIDAliases
|
||||||
|
) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
context.markMessageDelivered(messageID, from: peerIDAliases)
|
||||||
context.notifyUIChanged()
|
context.notifyUIChanged()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Foundation
|
|||||||
@MainActor
|
@MainActor
|
||||||
protocol ChatLiveVoiceContext: AnyObject {
|
protocol ChatLiveVoiceContext: AnyObject {
|
||||||
var nickname: String { get }
|
var nickname: String { get }
|
||||||
|
var myPeerID: PeerID { get }
|
||||||
var selectedPrivateChatPeer: PeerID? { get }
|
var selectedPrivateChatPeer: PeerID? { get }
|
||||||
/// Whether the public mesh timeline is what's on screen (autoplay gate
|
/// Whether the public mesh timeline is what's on screen (autoplay gate
|
||||||
/// for public bursts).
|
/// for public bursts).
|
||||||
@@ -30,6 +31,12 @@ protocol ChatLiveVoiceContext: AnyObject {
|
|||||||
func upsertPublicMeshMessage(_ message: BitchatMessage)
|
func upsertPublicMeshMessage(_ message: BitchatMessage)
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func removePrivateMessage(withID messageID: String) -> BitchatMessage?
|
func removePrivateMessage(withID messageID: String) -> BitchatMessage?
|
||||||
|
/// Records and sends the finalized note's read receipt after a live
|
||||||
|
/// bubble adopts its wire-derivable message ID.
|
||||||
|
func hasSentReadReceipt(_ messageID: String) -> Bool
|
||||||
|
@discardableResult
|
||||||
|
func markReadReceiptSent(_ messageID: String) -> Bool
|
||||||
|
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
||||||
/// Removes a message from whichever conversation holds it.
|
/// Removes a message from whichever conversation holds it.
|
||||||
func removeMessage(withID messageID: String, cleanupFile: Bool)
|
func removeMessage(withID messageID: String, cleanupFile: Bool)
|
||||||
/// Publishes who is currently talking live in the public mesh channel
|
/// Publishes who is currently talking live in the public mesh channel
|
||||||
@@ -272,8 +279,16 @@ final class ChatLiveVoiceCoordinator {
|
|||||||
guard let entry = finishedBursts.first(where: { matches($0.key) }) else { return false }
|
guard let entry = finishedBursts.first(where: { matches($0.key) }) else { return false }
|
||||||
let finished = entry.value
|
let finished = entry.value
|
||||||
|
|
||||||
|
// A DM live bubble starts before the finalized file exists and
|
||||||
|
// therefore has a receiver-local random ID. Adopt the finalized
|
||||||
|
// message's deterministic ID so delivery/read ACKs address the same
|
||||||
|
// row as the sender's media placeholder. Public notes retain their
|
||||||
|
// live-bubble ID because public transfers have no private receipts.
|
||||||
|
let replacementID = finished.scope == .directMessage
|
||||||
|
? message.id
|
||||||
|
: finished.messageID
|
||||||
let replacement = BitchatMessage(
|
let replacement = BitchatMessage(
|
||||||
id: finished.messageID,
|
id: replacementID,
|
||||||
sender: message.sender,
|
sender: message.sender,
|
||||||
content: message.content,
|
content: message.content,
|
||||||
timestamp: finished.messageTimestamp,
|
timestamp: finished.messageTimestamp,
|
||||||
@@ -287,7 +302,31 @@ final class ChatLiveVoiceCoordinator {
|
|||||||
)
|
)
|
||||||
switch finished.scope {
|
switch finished.scope {
|
||||||
case .directMessage:
|
case .directMessage:
|
||||||
|
// Capture read state before rekeying. The user may have read the
|
||||||
|
// live bubble and navigated away before the finalized .m4a lands.
|
||||||
|
let shouldSendAdoptedReadReceipt =
|
||||||
|
context.hasSentReadReceipt(finished.messageID)
|
||||||
|
|| context.selectedPrivateChatPeer == finished.peerID
|
||||||
|
|
||||||
|
// Insert first so replacing the only row in a DM never
|
||||||
|
// transiently deletes its conversation, unread state, or current
|
||||||
|
// selection. Then remove the receiver-local live-bubble alias.
|
||||||
context.upsertPrivateMessage(replacement, in: finished.peerID)
|
context.upsertPrivateMessage(replacement, in: finished.peerID)
|
||||||
|
if replacementID != finished.messageID {
|
||||||
|
context.removePrivateMessage(withID: finished.messageID)
|
||||||
|
}
|
||||||
|
// The live bubble may already have emitted a receiver-local READ
|
||||||
|
// before the sender created its finalized media row. Re-emit once
|
||||||
|
// for the adopted stable ID now that the file has arrived.
|
||||||
|
if shouldSendAdoptedReadReceipt,
|
||||||
|
context.markReadReceiptSent(replacementID) {
|
||||||
|
let receipt = ReadReceipt(
|
||||||
|
originalMessageID: replacementID,
|
||||||
|
readerID: context.myPeerID,
|
||||||
|
readerNickname: context.nickname
|
||||||
|
)
|
||||||
|
context.sendMeshReadReceipt(receipt, to: finished.peerID)
|
||||||
|
}
|
||||||
case .publicMesh:
|
case .publicMesh:
|
||||||
context.upsertPublicMeshMessage(replacement)
|
context.upsertPublicMeshMessage(replacement)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -85,8 +85,19 @@ protocol ChatPrivateConversationContext: AnyObject {
|
|||||||
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
|
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool
|
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool
|
||||||
|
/// Confirms an authenticated delivery/read acknowledgement so the router
|
||||||
|
/// stops retaining the original private message for resend. The peer
|
||||||
|
/// aliases scope the removal to the authenticated sender.
|
||||||
|
@discardableResult
|
||||||
|
func markMessageDelivered(_ messageID: String, for peerIDs: [PeerID]) -> Bool
|
||||||
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
||||||
func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String)
|
@discardableResult
|
||||||
|
func sendGeohashPrivateMessage(
|
||||||
|
_ content: String,
|
||||||
|
toRecipientHex recipientHex: String,
|
||||||
|
from identity: NostrIdentity,
|
||||||
|
messageID: String
|
||||||
|
) -> Bool
|
||||||
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)
|
||||||
|
|
||||||
@@ -166,6 +177,11 @@ extension ChatViewModel: ChatPrivateConversationContext {
|
|||||||
messageRouter.sendReadReceipt(receipt, to: peerID)
|
messageRouter.sendReadReceipt(receipt, to: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func markMessageDelivered(_ messageID: String, for peerIDs: [PeerID]) -> Bool {
|
||||||
|
messageRouter.markDelivered(messageID, for: peerIDs)
|
||||||
|
}
|
||||||
|
|
||||||
func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||||
messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||||
}
|
}
|
||||||
@@ -174,7 +190,13 @@ extension ChatViewModel: ChatPrivateConversationContext {
|
|||||||
meshService.sendReadReceipt(receipt, to: peerID)
|
meshService.sendReadReceipt(receipt, to: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
@discardableResult
|
||||||
|
func sendGeohashPrivateMessage(
|
||||||
|
_ content: String,
|
||||||
|
toRecipientHex recipientHex: String,
|
||||||
|
from identity: NostrIdentity,
|
||||||
|
messageID: String
|
||||||
|
) -> Bool {
|
||||||
makeGeohashNostrTransport().sendPrivateMessageGeohash(
|
makeGeohashNostrTransport().sendPrivateMessageGeohash(
|
||||||
content: content,
|
content: content,
|
||||||
toRecipientHex: recipientHex,
|
toRecipientHex: recipientHex,
|
||||||
@@ -216,9 +238,16 @@ extension ChatViewModel: ChatPrivateConversationContext {
|
|||||||
NotificationService.shared.sendPrivateMessageNotification(from: senderName, message: message, peerID: peerID)
|
NotificationService.shared.sendPrivateMessageNotification(from: senderName, message: message, peerID: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeGeohashNostrTransport() -> NostrTransport {
|
func makeGeohashNostrTransport(
|
||||||
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
|
dependencies: NostrTransport.Dependencies? = nil
|
||||||
|
) -> NostrTransport {
|
||||||
|
let transport = NostrTransport(
|
||||||
|
keychain: keychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
dependencies: dependencies
|
||||||
|
)
|
||||||
transport.senderPeerID = meshService.myPeerID
|
transport.senderPeerID = meshService.myPeerID
|
||||||
|
transport.eventDelegate = self
|
||||||
return transport
|
return transport
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,7 +256,7 @@ extension ChatViewModel: ChatPrivateConversationContext {
|
|||||||
final class ChatPrivateConversationCoordinator {
|
final class ChatPrivateConversationCoordinator {
|
||||||
private unowned let context: any ChatPrivateConversationContext
|
private unowned let context: any ChatPrivateConversationContext
|
||||||
|
|
||||||
// Outbox retries re-wrap the same message in fresh gift-wrap events, so
|
// Outbox retries re-envelope the same message in fresh private events, so
|
||||||
// relay-level event-ID dedup can't catch them; track inbound GeoDM
|
// relay-level event-ID dedup can't catch them; track inbound GeoDM
|
||||||
// message IDs so each copy past the first costs one (already-deduped)
|
// message IDs so each copy past the first costs one (already-deduped)
|
||||||
// ack check and nothing else.
|
// ack check and nothing else.
|
||||||
@@ -250,6 +279,60 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Account DMs can arrive under the authenticated peer's full Noise key
|
||||||
|
/// while an existing mesh conversation is keyed by its derived short ID.
|
||||||
|
/// These are the only aliases we may safely join: the short ID is derived
|
||||||
|
/// directly from the authenticated key, rather than guessed from a
|
||||||
|
/// nickname or found by scanning unrelated chats.
|
||||||
|
private func accountConversationAliases(for peerID: PeerID) -> [PeerID] {
|
||||||
|
guard peerID.noiseKey != nil else { return [peerID] }
|
||||||
|
let shortPeerID = peerID.toShort()
|
||||||
|
return shortPeerID == peerID ? [peerID] : [peerID, shortPeerID]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps a connected account DM on its short routing ID and an offline DM
|
||||||
|
/// on its stable Noise-key ID, folding the other authenticated alias into
|
||||||
|
/// it and handing an open sheet across without closing it.
|
||||||
|
private func consolidateAccountConversationAliases(for peerID: PeerID) -> PeerID {
|
||||||
|
let aliases = accountConversationAliases(for: peerID)
|
||||||
|
guard aliases.count > 1 else { return peerID }
|
||||||
|
|
||||||
|
let shortPeerID = peerID.toShort()
|
||||||
|
let targetPeerID = context.isPeerConnected(shortPeerID) ? shortPeerID : peerID
|
||||||
|
let sourcePeerIDs = aliases.filter { $0 != targetPeerID }
|
||||||
|
|
||||||
|
for sourcePeerID in sourcePeerIDs where !context.privateMessages(for: sourcePeerID).isEmpty {
|
||||||
|
context.migratePrivateChat(from: sourcePeerID, to: targetPeerID)
|
||||||
|
// ConversationStore deliberately preserves message values during a
|
||||||
|
// generic migration, including its destination-wins rule for
|
||||||
|
// duplicate IDs. Rewrite the resulting canonical copies so later
|
||||||
|
// read-receipt scans compare against the new routing key without
|
||||||
|
// replacing a newer destination value with the source snapshot.
|
||||||
|
let canonicalMessages = context.privateMessages(for: targetPeerID)
|
||||||
|
for message in canonicalMessages where message.senderPeerID == sourcePeerID {
|
||||||
|
context.upsertPrivateMessage(
|
||||||
|
BitchatMessage(
|
||||||
|
id: message.id,
|
||||||
|
sender: message.sender,
|
||||||
|
content: message.content,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
isRelay: message.isRelay,
|
||||||
|
originalSender: message.originalSender,
|
||||||
|
isPrivate: message.isPrivate,
|
||||||
|
recipientNickname: message.recipientNickname,
|
||||||
|
senderPeerID: targetPeerID,
|
||||||
|
mentions: message.mentions,
|
||||||
|
deliveryStatus: message.deliveryStatus,
|
||||||
|
isBridged: message.isBridged
|
||||||
|
),
|
||||||
|
in: targetPeerID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.handOffSelectedPrivateChat(from: sourcePeerIDs, to: targetPeerID)
|
||||||
|
return targetPeerID
|
||||||
|
}
|
||||||
|
|
||||||
func sendPrivateMessage(_ content: String, to peerID: PeerID) {
|
func sendPrivateMessage(_ content: String, to peerID: PeerID) {
|
||||||
guard !content.isEmpty else { return }
|
guard !content.isEmpty else { return }
|
||||||
|
|
||||||
@@ -399,13 +482,21 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
"GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)",
|
"GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)",
|
||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
context.sendGeohashPrivateMessage(
|
let accepted = context.sendGeohashPrivateMessage(
|
||||||
content,
|
content,
|
||||||
toRecipientHex: recipientHex,
|
toRecipientHex: recipientHex,
|
||||||
from: identity,
|
from: identity,
|
||||||
messageID: messageID
|
messageID: messageID
|
||||||
)
|
)
|
||||||
context.setPrivateDeliveryStatus(.sent, forMessageID: messageID, peerID: peerID)
|
let status: DeliveryStatus = accepted
|
||||||
|
? .sent
|
||||||
|
: .failed(
|
||||||
|
reason: String(
|
||||||
|
localized: "content.delivery.reason.not_delivered",
|
||||||
|
comment: "Failure reason shown when a private message could not enter the relay delivery queue"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
context.setPrivateDeliveryStatus(status, forMessageID: messageID, peerID: peerID)
|
||||||
} catch {
|
} catch {
|
||||||
context.setPrivateDeliveryStatus(
|
context.setPrivateDeliveryStatus(
|
||||||
.failed(
|
.failed(
|
||||||
@@ -464,6 +555,8 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let conversationPeerID = consolidateAccountConversationAliases(for: convKey)
|
||||||
|
|
||||||
if context.privateChatsContainMessage(withID: messageId) { return }
|
if context.privateChatsContainMessage(withID: messageId) { return }
|
||||||
|
|
||||||
let message = BitchatMessage(
|
let message = BitchatMessage(
|
||||||
@@ -474,18 +567,18 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
isRelay: false,
|
isRelay: false,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
recipientNickname: context.nickname,
|
recipientNickname: context.nickname,
|
||||||
senderPeerID: convKey,
|
senderPeerID: conversationPeerID,
|
||||||
deliveryStatus: .delivered(to: context.nickname, at: Date())
|
deliveryStatus: .delivered(to: context.nickname, at: Date())
|
||||||
)
|
)
|
||||||
|
|
||||||
context.appendPrivateMessage(message, to: convKey)
|
context.appendPrivateMessage(message, to: conversationPeerID)
|
||||||
|
|
||||||
let isViewing = context.selectedPrivateChatPeer == convKey
|
let isViewing = context.selectedPrivateChatPeer == conversationPeerID
|
||||||
let wasReadBefore = context.sentReadReceipts.contains(messageId)
|
let wasReadBefore = context.sentReadReceipts.contains(messageId)
|
||||||
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
|
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
|
||||||
let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage
|
let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage
|
||||||
if shouldMarkUnread {
|
if shouldMarkUnread {
|
||||||
context.markPrivateChatUnread(convKey)
|
context.markPrivateChatUnread(conversationPeerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if isViewing {
|
if isViewing {
|
||||||
@@ -493,7 +586,7 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !isViewing && shouldMarkUnread {
|
if !isViewing && shouldMarkUnread {
|
||||||
context.notifyPrivateMessage(from: senderName, message: pm.content, peerID: convKey)
|
context.notifyPrivateMessage(from: senderName, message: pm.content, peerID: conversationPeerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
context.notifyUIChanged()
|
context.notifyUIChanged()
|
||||||
@@ -502,17 +595,32 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
|
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
|
||||||
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
|
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
|
||||||
|
|
||||||
if context.privateChat(convKey, containsMessageWithID: messageID) {
|
let aliases = accountConversationAliases(for: convKey)
|
||||||
context.setPrivateDeliveryStatus(
|
let clearedRetainedMessage = convKey.noiseKey != nil
|
||||||
|
? context.markMessageDelivered(messageID, for: aliases)
|
||||||
|
: false
|
||||||
|
let hasConversationMessage = aliases.contains {
|
||||||
|
context.privateChat($0, containsMessageWithID: messageID)
|
||||||
|
}
|
||||||
|
if hasConversationMessage {
|
||||||
|
let conversationPeerID = consolidateAccountConversationAliases(for: convKey)
|
||||||
|
let didChange = context.setPrivateDeliveryStatus(
|
||||||
.delivered(to: context.displayNameForNostrPubkey(senderPubkey), at: Date()),
|
.delivered(to: context.displayNameForNostrPubkey(senderPubkey), at: Date()),
|
||||||
forMessageID: messageID,
|
forMessageID: messageID,
|
||||||
peerID: convKey
|
peerID: conversationPeerID
|
||||||
)
|
)
|
||||||
context.notifyUIChanged()
|
if didChange {
|
||||||
|
context.notifyUIChanged()
|
||||||
|
}
|
||||||
SecureLogger.info(
|
SecureLogger.info(
|
||||||
"GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…",
|
"GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…",
|
||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
|
} else if clearedRetainedMessage {
|
||||||
|
SecureLogger.debug(
|
||||||
|
"GeoDM: recv DELIVERED for cleared mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
// A stale ack for a message this device no longer tracks (dropped
|
// A stale ack for a message this device no longer tracks (dropped
|
||||||
// outbox entry, cleared chat, or a peer re-acking after losing our
|
// outbox entry, cleared chat, or a peer re-acking after losing our
|
||||||
@@ -524,14 +632,29 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
|
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
|
||||||
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
|
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
|
||||||
|
|
||||||
if context.privateChat(convKey, containsMessageWithID: messageID) {
|
let aliases = accountConversationAliases(for: convKey)
|
||||||
context.setPrivateDeliveryStatus(
|
let clearedRetainedMessage = convKey.noiseKey != nil
|
||||||
|
? context.markMessageDelivered(messageID, for: aliases)
|
||||||
|
: false
|
||||||
|
let hasConversationMessage = aliases.contains {
|
||||||
|
context.privateChat($0, containsMessageWithID: messageID)
|
||||||
|
}
|
||||||
|
if hasConversationMessage {
|
||||||
|
let conversationPeerID = consolidateAccountConversationAliases(for: convKey)
|
||||||
|
let didChange = context.setPrivateDeliveryStatus(
|
||||||
.read(by: context.displayNameForNostrPubkey(senderPubkey), at: Date()),
|
.read(by: context.displayNameForNostrPubkey(senderPubkey), at: Date()),
|
||||||
forMessageID: messageID,
|
forMessageID: messageID,
|
||||||
peerID: convKey
|
peerID: conversationPeerID
|
||||||
)
|
)
|
||||||
context.notifyUIChanged()
|
if didChange {
|
||||||
|
context.notifyUIChanged()
|
||||||
|
}
|
||||||
SecureLogger.info("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session)
|
SecureLogger.info("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session)
|
||||||
|
} else if clearedRetainedMessage {
|
||||||
|
SecureLogger.debug(
|
||||||
|
"GeoDM: recv READ for cleared mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
SecureLogger.warning("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
|
SecureLogger.warning("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,10 +62,15 @@ protocol ChatTransportEventContext: AnyObject {
|
|||||||
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID)
|
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID)
|
||||||
|
|
||||||
// MARK: Delivery status
|
// MARK: Delivery status
|
||||||
/// Applies the status to every known location of the message.
|
/// Applies an authenticated receipt to the message only when it belongs
|
||||||
/// Returns `false` when no message with that ID was updated.
|
/// to the supplied peer conversation aliases. Returns `false` for an
|
||||||
|
/// unknown ID, wrong peer, or rejected status transition.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool
|
func applyAcknowledgedMessageDeliveryStatus(
|
||||||
|
_ messageID: String,
|
||||||
|
status: DeliveryStatus,
|
||||||
|
from peerIDAliases: Set<PeerID>
|
||||||
|
) -> Bool
|
||||||
func deliveryStatus(for messageID: String) -> DeliveryStatus?
|
func deliveryStatus(for messageID: String) -> DeliveryStatus?
|
||||||
|
|
||||||
// MARK: Verification payloads
|
// MARK: Verification payloads
|
||||||
@@ -122,8 +127,16 @@ extension ChatViewModel: ChatTransportEventContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool {
|
func applyAcknowledgedMessageDeliveryStatus(
|
||||||
deliveryCoordinator.updateMessageDeliveryStatus(messageID, status: status)
|
_ messageID: String,
|
||||||
|
status: DeliveryStatus,
|
||||||
|
from peerIDAliases: Set<PeerID>
|
||||||
|
) -> Bool {
|
||||||
|
deliveryCoordinator.updateAcknowledgedMessageDeliveryStatus(
|
||||||
|
messageID,
|
||||||
|
status: status,
|
||||||
|
from: peerIDAliases
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func deliveryStatus(for messageID: String) -> DeliveryStatus? {
|
func deliveryStatus(for messageID: String) -> DeliveryStatus? {
|
||||||
@@ -446,9 +459,10 @@ private extension ChatTransportEventCoordinator {
|
|||||||
guard let messageID = String(data: payload, encoding: .utf8) else { return }
|
guard let messageID = String(data: payload, encoding: .utf8) else { return }
|
||||||
|
|
||||||
let name = deliveryStatusName(for: peerID, in: context)
|
let name = deliveryStatusName(for: peerID, in: context)
|
||||||
let didUpdate = context.applyMessageDeliveryStatus(
|
let didUpdate = context.applyAcknowledgedMessageDeliveryStatus(
|
||||||
messageID,
|
messageID,
|
||||||
status: .delivered(to: name, at: Date())
|
status: .delivered(to: name, at: Date()),
|
||||||
|
from: receiptPeerAliases(for: peerID, in: context)
|
||||||
)
|
)
|
||||||
|
|
||||||
if !didUpdate {
|
if !didUpdate {
|
||||||
@@ -463,9 +477,10 @@ private extension ChatTransportEventCoordinator {
|
|||||||
guard let messageID = String(data: payload, encoding: .utf8) else { return }
|
guard let messageID = String(data: payload, encoding: .utf8) else { return }
|
||||||
|
|
||||||
let name = deliveryStatusName(for: peerID, in: context)
|
let name = deliveryStatusName(for: peerID, in: context)
|
||||||
let didUpdate = context.applyMessageDeliveryStatus(
|
let didUpdate = context.applyAcknowledgedMessageDeliveryStatus(
|
||||||
messageID,
|
messageID,
|
||||||
status: .read(by: name, at: Date())
|
status: .read(by: name, at: Date()),
|
||||||
|
from: receiptPeerAliases(for: peerID, in: context)
|
||||||
)
|
)
|
||||||
|
|
||||||
if !didUpdate {
|
if !didUpdate {
|
||||||
@@ -503,4 +518,21 @@ private extension ChatTransportEventCoordinator {
|
|||||||
func deliveryStatusName(for peerID: PeerID, in context: any ChatTransportEventContext) -> String {
|
func deliveryStatusName(for peerID: PeerID, in context: any ChatTransportEventContext) -> String {
|
||||||
context.unifiedPeer(for: peerID)?.nickname ?? context.resolveNickname(for: peerID)
|
context.unifiedPeer(for: peerID)?.nickname ?? context.resolveNickname(for: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func receiptPeerAliases(
|
||||||
|
for peerID: PeerID,
|
||||||
|
in context: any ChatTransportEventContext
|
||||||
|
) -> Set<PeerID> {
|
||||||
|
var aliases: Set<PeerID> = [peerID]
|
||||||
|
// The active authenticated Noise key is authoritative. A cached
|
||||||
|
// ephemeral→stable mapping can predate an identity replacement, so
|
||||||
|
// use it only when the live session cannot provide its static key.
|
||||||
|
if let keyData = context.noiseSessionPublicKeyData(for: peerID) {
|
||||||
|
aliases.insert(PeerID(hexData: keyData))
|
||||||
|
} else if let stablePeerID = context.cachedStablePeerID(for: peerID) {
|
||||||
|
aliases.insert(stablePeerID)
|
||||||
|
}
|
||||||
|
return aliases
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ protocol ChatVerificationContext: AnyObject {
|
|||||||
func noiseStaticPublicKeyData() -> Data
|
func noiseStaticPublicKeyData() -> Data
|
||||||
func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool
|
func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool
|
||||||
func triggerHandshake(with peerID: PeerID)
|
func triggerHandshake(with peerID: PeerID)
|
||||||
|
func privateMediaPeerDidAuthenticate(_ peerID: PeerID)
|
||||||
|
/// Retries only private messages previously transmitted through a secure
|
||||||
|
/// session and still pending an ack. Both ephemeral and stable aliases
|
||||||
|
/// are supplied because either can own the outbox entry.
|
||||||
|
func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID])
|
||||||
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)
|
||||||
|
|
||||||
@@ -116,6 +121,14 @@ extension ChatViewModel: ChatVerificationContext {
|
|||||||
meshService.noiseStaticPublicKeyData()
|
meshService.noiseStaticPublicKeyData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func privateMediaPeerDidAuthenticate(_ peerID: PeerID) {
|
||||||
|
mediaTransferCoordinator.peerDidAuthenticate(peerID.toShort())
|
||||||
|
}
|
||||||
|
|
||||||
|
func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID]) {
|
||||||
|
messageRouter.retrySecurePrivateMessagesAfterAuthentication(for: peerIDAliases)
|
||||||
|
}
|
||||||
|
|
||||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||||||
meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA)
|
meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA)
|
||||||
}
|
}
|
||||||
@@ -129,6 +142,10 @@ extension ChatViewModel: ChatVerificationContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension ChatVerificationContext {
|
||||||
|
func privateMediaPeerDidAuthenticate(_ peerID: PeerID) {}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class ChatVerificationCoordinator {
|
final class ChatVerificationCoordinator {
|
||||||
struct PendingVerification {
|
struct PendingVerification {
|
||||||
@@ -197,6 +214,7 @@ final class ChatVerificationCoordinator {
|
|||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
|
||||||
SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security)
|
SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security)
|
||||||
|
self.context.privateMediaPeerDidAuthenticate(peerID)
|
||||||
|
|
||||||
if self.context.isVerifiedFingerprint(fingerprint) {
|
if self.context.isVerifiedFingerprint(fingerprint) {
|
||||||
self.context.setEncryptionStatus(.noiseVerified, for: peerID)
|
self.context.setEncryptionStatus(.noiseVerified, for: peerID)
|
||||||
@@ -206,16 +224,37 @@ final class ChatVerificationCoordinator {
|
|||||||
|
|
||||||
self.context.invalidateEncryptionCache(for: peerID)
|
self.context.invalidateEncryptionCache(for: peerID)
|
||||||
|
|
||||||
if self.context.cachedStablePeerID(for: peerID) == nil,
|
var authenticatedStablePeerID: PeerID?
|
||||||
let keyData = self.context.noiseSessionPublicKeyData(for: peerID) {
|
if let keyData = self.context.noiseSessionPublicKeyData(for: peerID) {
|
||||||
let stablePeerID = PeerID(hexData: keyData)
|
let stablePeerID = PeerID(hexData: keyData)
|
||||||
self.context.cacheStablePeerID(stablePeerID, for: peerID)
|
authenticatedStablePeerID = stablePeerID
|
||||||
|
if self.context.cachedStablePeerID(for: peerID) != stablePeerID {
|
||||||
|
// The freshly authenticated Noise key outranks a
|
||||||
|
// stale announce-derived alias.
|
||||||
|
self.context.cacheStablePeerID(stablePeerID, for: peerID)
|
||||||
|
}
|
||||||
SecureLogger.debug(
|
SecureLogger.debug(
|
||||||
"🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stablePeerID.id.prefix(8))…",
|
"🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stablePeerID.id.prefix(8))…",
|
||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A locally established session may have belonged to the
|
||||||
|
// peer's previous app process. The first ciphertext sent
|
||||||
|
// into that stale session is retained by MessageRouter;
|
||||||
|
// retry it now that this newly authenticated/replacement
|
||||||
|
// session can actually decrypt it.
|
||||||
|
var peerIDAliases = [peerID]
|
||||||
|
if let stablePeerID = authenticatedStablePeerID
|
||||||
|
?? self.context.cachedStablePeerID(for: peerID),
|
||||||
|
stablePeerID != peerID {
|
||||||
|
// Conversations can migrate from the ephemeral BLE ID
|
||||||
|
// to the authenticated Noise-key ID. Retry both aliases
|
||||||
|
// because either may own the retained outbox entry.
|
||||||
|
peerIDAliases.append(stablePeerID)
|
||||||
|
}
|
||||||
|
self.context.retrySecurePrivateMessagesAfterAuthentication(for: peerIDAliases)
|
||||||
|
|
||||||
if var pending = self.pendingQRVerifications[peerID], pending.sent == false {
|
if var pending = self.pendingQRVerifications[peerID], pending.sent == false {
|
||||||
self.context.sendVerifyChallenge(
|
self.context.sendVerifyChallenge(
|
||||||
to: peerID,
|
to: peerID,
|
||||||
|
|||||||
@@ -109,6 +109,16 @@ struct PanicNetworkLifecycle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct PendingPrivateChatClear {
|
||||||
|
let peerID: PeerID
|
||||||
|
let sourceConversationID: ConversationID
|
||||||
|
let messages: [BitchatMessage]
|
||||||
|
let otherMessageIDs: Set<String>
|
||||||
|
let localPeerID: PeerID
|
||||||
|
let nickname: String
|
||||||
|
let outgoingMedia: [BitchatMessage]
|
||||||
|
}
|
||||||
|
|
||||||
/// Manages the application state and business logic for BitChat.
|
/// Manages the application state and business logic for BitChat.
|
||||||
/// Acts as the primary coordinator between UI components and backend services,
|
/// Acts as the primary coordinator between UI components and backend services,
|
||||||
/// implementing the BitchatDelegate protocol to handle network events.
|
/// implementing the BitchatDelegate protocol to handle network events.
|
||||||
@@ -376,6 +386,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
|||||||
@Published var bluetoothAlertMessage = ""
|
@Published var bluetoothAlertMessage = ""
|
||||||
@Published var bluetoothState: CBManagerState = .unknown
|
@Published var bluetoothState: CBManagerState = .unknown
|
||||||
@Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest?
|
@Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest?
|
||||||
|
@MainActor private var queuedPrivateChatClears: [
|
||||||
|
PendingPrivateChatClear
|
||||||
|
] = []
|
||||||
|
@MainActor private var privateChatClearInFlight = false
|
||||||
|
@MainActor private var privateChatClearGeneration: UInt64 = 0
|
||||||
private var pendingLegacyPrivateMediaConsents: [PendingLegacyPrivateMediaConsent] = []
|
private var pendingLegacyPrivateMediaConsents: [PendingLegacyPrivateMediaConsent] = []
|
||||||
|
|
||||||
private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) {
|
private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) {
|
||||||
@@ -495,6 +510,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a read receipt has already been recorded for `messageID`.
|
||||||
|
@MainActor
|
||||||
|
func hasSentReadReceipt(_ messageID: String) -> Bool {
|
||||||
|
sentReadReceipts.contains(messageID)
|
||||||
|
}
|
||||||
|
|
||||||
/// Records that a read receipt is being sent for `messageID`.
|
/// Records that a read receipt is being sent for `messageID`.
|
||||||
/// Returns `false` when one was already recorded — the caller must skip sending.
|
/// Returns `false` when one was already recorded — the caller must skip sending.
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -637,7 +658,271 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
|||||||
/// Empties the peer's chat but keeps the conversation alive (`/clear`).
|
/// Empties the peer's chat but keeps the conversation alive (`/clear`).
|
||||||
@MainActor
|
@MainActor
|
||||||
func clearPrivateChat(_ peerID: PeerID) {
|
func clearPrivateChat(_ peerID: PeerID) {
|
||||||
conversations.clear(.directPeer(peerID))
|
let sourceConversationID = ConversationID.directPeer(peerID)
|
||||||
|
// An active live-voice row owns an open FileHandle and may be
|
||||||
|
// republished as frames/final media arrive. Treat it like an in-flight
|
||||||
|
// arrival rather than unlinking its capture or removing its bubble.
|
||||||
|
let messages = privateMessages(for: peerID).filter {
|
||||||
|
!liveVoiceCoordinator.isLiveVoiceMessage($0)
|
||||||
|
}
|
||||||
|
let localPeerID = meshService.myPeerID.toShort()
|
||||||
|
let currentNickname = nickname
|
||||||
|
let mediaPrefixes = [
|
||||||
|
MimeType.Category.audio.messagePrefix,
|
||||||
|
MimeType.Category.image.messagePrefix,
|
||||||
|
MimeType.Category.file.messagePrefix
|
||||||
|
]
|
||||||
|
let outgoingMedia = messages.filter { message in
|
||||||
|
guard mediaPrefixes.contains(where: {
|
||||||
|
message.content.hasPrefix($0)
|
||||||
|
}) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if let senderPeerID = message.senderPeerID {
|
||||||
|
return senderPeerID.toShort() == localPeerID
|
||||||
|
}
|
||||||
|
return message.sender == currentNickname
|
||||||
|
|| message.sender.hasPrefix(currentNickname + "#")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send ownership is canceled at command time even when another clear
|
||||||
|
// transaction is ahead in the queue. UI and files remain untouched
|
||||||
|
// until this request's receiver journal commit succeeds.
|
||||||
|
for message in outgoingMedia {
|
||||||
|
mediaTransferCoordinator
|
||||||
|
.cancelMediaTransferForConversationClear(
|
||||||
|
messageID: message.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
queuedPrivateChatClears.append(PendingPrivateChatClear(
|
||||||
|
peerID: peerID,
|
||||||
|
sourceConversationID: sourceConversationID,
|
||||||
|
messages: messages,
|
||||||
|
otherMessageIDs: Set(
|
||||||
|
privateChats
|
||||||
|
.filter { $0.key != peerID }
|
||||||
|
.flatMap { $0.value.map(\.id) }
|
||||||
|
),
|
||||||
|
localPeerID: localPeerID,
|
||||||
|
nickname: currentNickname,
|
||||||
|
outgoingMedia: outgoingMedia
|
||||||
|
))
|
||||||
|
startNextPrivateChatClearIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func startNextPrivateChatClearIfNeeded() {
|
||||||
|
guard !privateChatClearInFlight,
|
||||||
|
!queuedPrivateChatClears.isEmpty else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
privateChatClearInFlight = true
|
||||||
|
let request = queuedPrivateChatClears.removeFirst()
|
||||||
|
let generation = privateChatClearGeneration
|
||||||
|
performPrivateChatClear(
|
||||||
|
request,
|
||||||
|
generation: generation
|
||||||
|
) { [weak self] in
|
||||||
|
guard let self,
|
||||||
|
self.privateChatClearGeneration == generation else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.privateChatClearInFlight = false
|
||||||
|
self.startNextPrivateChatClearIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func performPrivateChatClear(
|
||||||
|
_ request: PendingPrivateChatClear,
|
||||||
|
generation: UInt64,
|
||||||
|
completion: @escaping @MainActor () -> Void
|
||||||
|
) {
|
||||||
|
guard privateChatClearGeneration == generation else {
|
||||||
|
completion()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let peerID = request.peerID
|
||||||
|
let selectedConversationID = request.sourceConversationID
|
||||||
|
let messagesToClear = request.messages
|
||||||
|
guard !messagesToClear.isEmpty else {
|
||||||
|
completion()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture the transaction's exact UI set before any off-main receipt
|
||||||
|
// I/O. Messages arriving while the journal is written are not part of
|
||||||
|
// this command and must remain visible.
|
||||||
|
let capturedMessageIDs = Set(messagesToClear.map(\.id))
|
||||||
|
let survivingMessageIDs = request.otherMessageIDs
|
||||||
|
let mediaPrefixes = [
|
||||||
|
MimeType.Category.audio.messagePrefix,
|
||||||
|
MimeType.Category.image.messagePrefix,
|
||||||
|
MimeType.Category.file.messagePrefix
|
||||||
|
]
|
||||||
|
let localPeerID = request.localPeerID
|
||||||
|
let isMedia: (BitchatMessage) -> Bool = { message in
|
||||||
|
mediaPrefixes.contains(where: message.content.hasPrefix)
|
||||||
|
}
|
||||||
|
let isFromMe: (BitchatMessage) -> Bool = { [nickname = request.nickname] message in
|
||||||
|
if let senderPeerID = message.senderPeerID {
|
||||||
|
return senderPeerID.toShort() == localPeerID
|
||||||
|
}
|
||||||
|
return message.sender == nickname
|
||||||
|
|| message.sender.hasPrefix(nickname + "#")
|
||||||
|
}
|
||||||
|
|
||||||
|
let outgoingMedia = request.outgoingMedia
|
||||||
|
let outgoingMediaIDs = Set(outgoingMedia.map(\.id))
|
||||||
|
|
||||||
|
let capturedExclusiveIDs =
|
||||||
|
capturedMessageIDs.subtracting(survivingMessageIDs)
|
||||||
|
let capturedIncomingMedia = messagesToClear.filter {
|
||||||
|
isMedia($0) && !isFromMe($0)
|
||||||
|
}
|
||||||
|
let capturedStableMediaIDs = Set(
|
||||||
|
capturedIncomingMedia.compactMap { message in
|
||||||
|
PrivateMediaMessageIdentity.isStableID(message.id)
|
||||||
|
? message.id
|
||||||
|
: nil
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func currentRemovalPlan() -> [ConversationID: Set<String>] {
|
||||||
|
// Identity handoff removes the source conversation and inserts its
|
||||||
|
// rows elsewhere. The old source may then be recreated by a new
|
||||||
|
// arrival before journal I/O finishes, so always scan all direct
|
||||||
|
// conversations. Only IDs exclusive at command time may follow a
|
||||||
|
// migration; shared aliases remain outside the source.
|
||||||
|
var plan: [ConversationID: Set<String>] = [:]
|
||||||
|
for (conversationID, conversation) in
|
||||||
|
conversations.conversationsByID {
|
||||||
|
guard case .direct = conversationID else { continue }
|
||||||
|
let eligibleIDs = conversationID == selectedConversationID
|
||||||
|
? capturedMessageIDs
|
||||||
|
: capturedExclusiveIDs
|
||||||
|
let matchingIDs = Set(conversation.messages.map(\.id))
|
||||||
|
.intersection(eligibleIDs)
|
||||||
|
if !matchingIDs.isEmpty {
|
||||||
|
plan[conversationID] = matchingIDs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return plan
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasRemainingCopy(
|
||||||
|
of messageID: String,
|
||||||
|
after plan: [ConversationID: Set<String>]
|
||||||
|
) -> Bool {
|
||||||
|
conversations.conversationsByID.contains { conversationID, conversation in
|
||||||
|
guard case .direct = conversationID else { return false }
|
||||||
|
return conversation.messages.contains { message in
|
||||||
|
message.id == messageID
|
||||||
|
&& plan[conversationID]?.contains(messageID) != true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func continueClear(
|
||||||
|
persisted: Bool,
|
||||||
|
durableStableIDs: Set<String>
|
||||||
|
) {
|
||||||
|
guard privateChatClearGeneration == generation else {
|
||||||
|
completion()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard persisted else {
|
||||||
|
SecureLogger.error(
|
||||||
|
"Refusing to clear private chat without durable media tombstones peer=\(peerID.id.prefix(8))…",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
completion()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let plan = currentRemovalPlan()
|
||||||
|
let newlyLastStableIDs = Set(
|
||||||
|
capturedStableMediaIDs.filter {
|
||||||
|
!durableStableIDs.contains($0)
|
||||||
|
&& !hasRemainingCopy(of: $0, after: plan)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if !newlyLastStableIDs.isEmpty {
|
||||||
|
persistDeletedPrivateMedia(
|
||||||
|
messageIDs: Array(newlyLastStableIDs).sorted()
|
||||||
|
) { persisted in
|
||||||
|
continueClear(
|
||||||
|
persisted: persisted,
|
||||||
|
durableStableIDs:
|
||||||
|
durableStableIDs.union(newlyLastStableIDs)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stable receiver tombstone is global for that message ID.
|
||||||
|
// Remove any alias that arrived while journal I/O was in flight.
|
||||||
|
if !durableStableIDs.isEmpty {
|
||||||
|
let directConversationIDs = conversations
|
||||||
|
.conversationsByID.keys.filter {
|
||||||
|
if case .direct = $0 { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for conversationID in directConversationIDs {
|
||||||
|
conversations.removeMessages(from: conversationID) {
|
||||||
|
durableStableIDs.contains($0.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for message in outgoingMedia {
|
||||||
|
mediaTransferCoordinator.cleanupOutgoingLocalFile(
|
||||||
|
forMessage: message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if !outgoingMediaIDs.isEmpty {
|
||||||
|
let directConversationIDs = conversations
|
||||||
|
.conversationsByID.keys.filter {
|
||||||
|
if case .direct = $0 { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for conversationID in directConversationIDs {
|
||||||
|
conversations.removeMessages(from: conversationID) {
|
||||||
|
outgoingMediaIDs.contains($0.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable payload cleanup belongs entirely to the durable receiver
|
||||||
|
// journal. Legacy/raw incoming payloads have no durable identity,
|
||||||
|
// so their basenames may already belong to a pending new arrival;
|
||||||
|
// leave those files for bounded quota cleanup.
|
||||||
|
let finalPlan = currentRemovalPlan()
|
||||||
|
|
||||||
|
for (conversationID, messageIDs) in finalPlan {
|
||||||
|
conversations.removeMessages(from: conversationID) {
|
||||||
|
messageIDs.contains($0.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
completion()
|
||||||
|
}
|
||||||
|
|
||||||
|
let initialPlan = currentRemovalPlan()
|
||||||
|
let initialStableIDs = Set(
|
||||||
|
capturedStableMediaIDs.filter {
|
||||||
|
!hasRemainingCopy(of: $0, after: initialPlan)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
persistDeletedPrivateMedia(
|
||||||
|
messageIDs: Array(initialStableIDs).sorted()
|
||||||
|
) { persisted in
|
||||||
|
continueClear(
|
||||||
|
persisted: persisted,
|
||||||
|
durableStableIDs: initialStableIDs
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes the peer's chat entirely, including unread state.
|
/// Removes the peer's chat entirely, including unread state.
|
||||||
@@ -1269,6 +1554,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
|||||||
// handles before clearing state or removing the media directory.
|
// handles before clearing state or removing the media directory.
|
||||||
mediaTransferCoordinator.resetForPanic()
|
mediaTransferCoordinator.resetForPanic()
|
||||||
liveVoiceCoordinator.resetForPanic()
|
liveVoiceCoordinator.resetForPanic()
|
||||||
|
privateChatClearGeneration &+= 1
|
||||||
|
queuedPrivateChatClears.removeAll(keepingCapacity: false)
|
||||||
|
privateChatClearInFlight = false
|
||||||
|
|
||||||
// Deny and release any clear-media confirmations before identities,
|
// Deny and release any clear-media confirmations before identities,
|
||||||
// message state, and local files are wiped.
|
// message state, and local files are wiped.
|
||||||
@@ -1366,6 +1654,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
|||||||
// Drop relay subscriptions, handlers, pending sends, and replay state.
|
// Drop relay subscriptions, handlers, pending sends, and replay state.
|
||||||
// Geohash DM handlers can capture pre-wipe Nostr identities, so a plain
|
// Geohash DM handlers can capture pre-wipe Nostr identities, so a plain
|
||||||
// disconnect is not enough here.
|
// disconnect is not enough here.
|
||||||
|
NostrTransport.resetControlRetriesForPanicWipe()
|
||||||
NostrRelayManager.shared.resetForPanicWipe()
|
NostrRelayManager.shared.resetForPanicWipe()
|
||||||
nostrRelayManager = nil
|
nostrRelayManager = nil
|
||||||
|
|
||||||
@@ -1749,6 +2038,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
|||||||
|
|
||||||
case .peerConnected(let peerID):
|
case .peerConnected(let peerID):
|
||||||
transportEventCoordinator.didConnectToPeerSynchronously(peerID)
|
transportEventCoordinator.didConnectToPeerSynchronously(peerID)
|
||||||
|
mediaTransferCoordinator.peerDidReconnect(peerID)
|
||||||
|
|
||||||
case .peerDisconnected(let peerID):
|
case .peerDisconnected(let peerID):
|
||||||
transportEventCoordinator.didDisconnectFromPeerSynchronously(peerID)
|
transportEventCoordinator.didDisconnectFromPeerSynchronously(peerID)
|
||||||
@@ -1838,6 +2128,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
|||||||
|
|
||||||
func didConnectToPeer(_ peerID: PeerID) {
|
func didConnectToPeer(_ peerID: PeerID) {
|
||||||
transportEventCoordinator.didConnectToPeer(peerID)
|
transportEventCoordinator.didConnectToPeer(peerID)
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.mediaTransferCoordinator.peerDidReconnect(peerID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func didDisconnectFromPeer(_ peerID: PeerID) {
|
func didDisconnectFromPeer(_ peerID: PeerID) {
|
||||||
|
|||||||
@@ -41,10 +41,11 @@ struct ChatViewModelServiceBundle {
|
|||||||
self.privateChatManager = privateChatManager
|
self.privateChatManager = privateChatManager
|
||||||
self.unifiedPeerService = unifiedPeerService
|
self.unifiedPeerService = unifiedPeerService
|
||||||
self.autocompleteService = AutocompleteService()
|
self.autocompleteService = AutocompleteService()
|
||||||
// Persist processed gift-wrap event IDs: NIP-59 randomizes their
|
// Persist processed private-envelope event IDs: legacy Android can
|
||||||
// timestamps, so the 24h-lookback DM subscriptions redeliver the same
|
// randomize timestamps across the full 72h15m mailbox lookback, so DM
|
||||||
// events on every launch and only a cross-launch record stops the
|
// subscriptions redeliver the same events on every launch and only a
|
||||||
// reprocessing (re-sent DELIVERED bursts, phantom-ack noise).
|
// cross-launch record stops the reprocessing (re-sent DELIVERED
|
||||||
|
// bursts, phantom-ack noise).
|
||||||
self.deduplicationService = MessageDeduplicationService(nostrEventStore: NostrProcessedEventStore())
|
self.deduplicationService = MessageDeduplicationService(nostrEventStore: NostrProcessedEventStore())
|
||||||
self.publicMessagePipeline = PublicMessagePipeline()
|
self.publicMessagePipeline = PublicMessagePipeline()
|
||||||
}
|
}
|
||||||
@@ -176,7 +177,7 @@ private extension ChatViewModelBootstrapper {
|
|||||||
|
|
||||||
func configureTransport() {
|
func configureTransport() {
|
||||||
viewModel.meshService.delegate = viewModel
|
viewModel.meshService.delegate = viewModel
|
||||||
viewModel.meshService.eventDelegate = viewModel
|
viewModel.messageRouter.setEventDelegate(viewModel)
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiStartupInitialDelaySeconds) { [weak viewModel] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiStartupInitialDelaySeconds) { [weak viewModel] in
|
||||||
guard let viewModel else { return }
|
guard let viewModel else { return }
|
||||||
@@ -558,7 +559,7 @@ private extension ChatViewModelBootstrapper {
|
|||||||
// Default (DM) relays: drops need the standing global relay set,
|
// Default (DM) relays: drops need the standing global relay set,
|
||||||
// not geo relays — sender and recipient share no cell.
|
// not geo relays — sender and recipient share no cell.
|
||||||
// This confirmed path never falls back to the volatile relay
|
// This confirmed path never falls back to the volatile relay
|
||||||
// queue; bridge dedup is committed only after NIP-20 OK.
|
// queue; bridge dedup is committed only after NIP-01 `OK`.
|
||||||
NostrRelayManager.shared.sendEventImmediately(event, completion: completion)
|
NostrRelayManager.shared.sendEventImmediately(event, completion: completion)
|
||||||
}
|
}
|
||||||
courier.openSubscription = { tagsHex in
|
courier.openSubscription = { tagsHex in
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ extension ChatViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
func subscribePrivateEnvelope(_ envelope: NostrEvent, id: NostrIdentity) {
|
||||||
nostrCoordinator.inbound.subscribeGiftWrap(giftWrap, id: id)
|
nostrCoordinator.inbound.subscribePrivateEnvelope(envelope, id: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -36,8 +36,8 @@ extension ChatViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
func handlePrivateEnvelope(_ envelope: NostrEvent, id: NostrIdentity) {
|
||||||
nostrCoordinator.inbound.handleGiftWrap(giftWrap, id: id)
|
nostrCoordinator.inbound.handlePrivateEnvelope(envelope, id: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ extension ChatViewModel: GeohashSubscriptionContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Owns subscription IDs and relay lifecycle for geohash channels, geohash
|
/// Owns subscription IDs and relay lifecycle for geohash channels, geohash
|
||||||
/// DMs, the account gift-wrap mailbox, and background geohash sampling. The
|
/// DMs, the account private-envelope mailbox, and background geohash sampling. The
|
||||||
/// only component that talks to `NostrRelayManager`; inbound events are
|
/// only component that talks to `NostrRelayManager`; inbound events are
|
||||||
/// forwarded to `NostrInboundPipeline` / `GeoPresenceTracker`.
|
/// forwarded to `NostrInboundPipeline` / `GeoPresenceTracker`.
|
||||||
final class GeohashSubscriptionManager {
|
final class GeohashSubscriptionManager {
|
||||||
@@ -162,13 +162,13 @@ final class GeohashSubscriptionManager {
|
|||||||
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
|
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
|
||||||
let dmSub = "geo-dm-\(channel.geohash)"
|
let dmSub = "geo-dm-\(channel.geohash)"
|
||||||
context.setGeoDmSubscriptionID(dmSub)
|
context.setGeoDmSubscriptionID(dmSub)
|
||||||
let dmFilter = NostrFilter.giftWrapsFor(
|
let dmFilters = NostrFilter.privateEnvelopeFiltersFor(
|
||||||
pubkey: identity.publicKeyHex,
|
pubkey: identity.publicKeyHex,
|
||||||
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
||||||
)
|
)
|
||||||
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
|
NostrRelayManager.shared.subscribe(filters: dmFilters, id: dmSub) { [weak self] envelope in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
self?.inbound.subscribeGiftWrap(giftWrap, id: identity)
|
self?.inbound.subscribePrivateEnvelope(envelope, id: identity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,13 +260,13 @@ final class GeohashSubscriptionManager {
|
|||||||
if TorManager.shared.isReady {
|
if TorManager.shared.isReady {
|
||||||
SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
|
SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
|
||||||
}
|
}
|
||||||
let dmFilter = NostrFilter.giftWrapsFor(
|
let dmFilters = NostrFilter.privateEnvelopeFiltersFor(
|
||||||
pubkey: identity.publicKeyHex,
|
pubkey: identity.publicKeyHex,
|
||||||
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
||||||
)
|
)
|
||||||
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
|
NostrRelayManager.shared.subscribe(filters: dmFilters, id: dmSub) { [weak self] envelope in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
self?.inbound.handleGiftWrap(giftWrap, id: identity)
|
self?.inbound.handlePrivateEnvelope(envelope, id: identity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -388,14 +388,14 @@ final class GeohashSubscriptionManager {
|
|||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
|
|
||||||
let filter = NostrFilter.giftWrapsFor(
|
let filters = NostrFilter.privateEnvelopeFiltersFor(
|
||||||
pubkey: currentIdentity.publicKeyHex,
|
pubkey: currentIdentity.publicKeyHex,
|
||||||
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
||||||
)
|
)
|
||||||
|
|
||||||
context.nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in
|
context.nostrRelayManager?.subscribe(filters: filters, id: "chat-messages") { [weak self] event in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
self?.inbound.handleNostrMessage(event)
|
self?.inbound.handleAccountPrivateEnvelope(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,13 +84,21 @@ extension ChatViewModel: NostrInboundPipelineContext {
|
|||||||
/// Ordering is deliberate and performance-critical: cheap rejects (kind,
|
/// Ordering is deliberate and performance-critical: cheap rejects (kind,
|
||||||
/// dedup lookup) run BEFORE Schnorr signature verification because duplicates
|
/// dedup lookup) run BEFORE Schnorr signature verification because duplicates
|
||||||
/// dominate real relay traffic; events are recorded only AFTER verification so
|
/// dominate real relay traffic; events are recorded only AFTER verification so
|
||||||
/// a forged-signature copy can never poison the dedup set; gift-wrap
|
/// a forged-signature copy can never poison the dedup set; private-envelope
|
||||||
/// verification for the account mailbox runs off-main with an atomic
|
/// verification for the account mailbox runs off-main with an atomic
|
||||||
/// main-actor check-and-record.
|
/// main-actor check-and-record.
|
||||||
final class NostrInboundPipeline {
|
final class NostrInboundPipeline {
|
||||||
private weak var context: (any NostrInboundPipelineContext)?
|
private weak var context: (any NostrInboundPipelineContext)?
|
||||||
private let presence: GeoPresenceTracker
|
private let presence: GeoPresenceTracker
|
||||||
private var geoEventLogCount = 0
|
private var geoEventLogCount = 0
|
||||||
|
// During the coordinated wire-format migration, one logical private
|
||||||
|
// payload is published under both primary and compatibility formats. Outer
|
||||||
|
// event IDs differ, so collapse the authenticated embedded payload before
|
||||||
|
// invoking message/ack side effects. Keep this bounded like the outer-ID
|
||||||
|
// caches; the recipient and authenticated sender are part of the key.
|
||||||
|
private var recentPrivatePayloadFormats: [String: UInt8] = [:]
|
||||||
|
private var recentPrivatePayloadKeyOrder: [String] = []
|
||||||
|
private static let privatePayloadDedupCapacity = 2_048
|
||||||
|
|
||||||
init(context: any NostrInboundPipelineContext, presence: GeoPresenceTracker) {
|
init(context: any NostrInboundPipelineContext, presence: GeoPresenceTracker) {
|
||||||
self.context = context
|
self.context = context
|
||||||
@@ -271,15 +279,16 @@ final class NostrInboundPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
func subscribePrivateEnvelope(_ envelope: NostrEvent, id: NostrIdentity) {
|
||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
// Dedup lookup before Schnorr verification; record only after it passes.
|
// Dedup lookup before Schnorr verification; record only after it passes.
|
||||||
guard !context.hasProcessedNostrEvent(giftWrap.id) else { return }
|
guard !context.hasProcessedNostrEvent(envelope.id) else { return }
|
||||||
guard giftWrap.isValidSignature() else { return }
|
guard envelope.content.utf8.count <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes else { return }
|
||||||
context.recordProcessedNostrEvent(giftWrap.id)
|
guard envelope.isValidSignature() else { return }
|
||||||
|
context.recordProcessedNostrEvent(envelope.id)
|
||||||
|
|
||||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
|
guard let (content, senderPubkey, messageTs) = try? NostrProtocol.decryptPrivateEnvelope(
|
||||||
giftWrap: giftWrap,
|
envelope: envelope,
|
||||||
recipientIdentity: id
|
recipientIdentity: id
|
||||||
),
|
),
|
||||||
let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
||||||
@@ -288,8 +297,14 @@ final class NostrInboundPipeline {
|
|||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
guard shouldProcessPrivatePayload(
|
||||||
|
noisePayload,
|
||||||
|
senderPubkey: senderPubkey,
|
||||||
|
recipientPubkey: id.publicKeyHex,
|
||||||
|
envelopeKind: envelope.kind
|
||||||
|
) else { return }
|
||||||
|
|
||||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(messageTs))
|
||||||
let convKey = PeerID(nostr_: senderPubkey)
|
let convKey = PeerID(nostr_: senderPubkey)
|
||||||
context.registerNostrKeyMapping(senderPubkey, for: convKey)
|
context.registerNostrKeyMapping(senderPubkey, for: convKey)
|
||||||
|
|
||||||
@@ -316,25 +331,26 @@ final class NostrInboundPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
func handlePrivateEnvelope(_ envelope: NostrEvent, id: NostrIdentity) {
|
||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
// Dedup lookup before Schnorr verification; record only after it passes.
|
// Dedup lookup before Schnorr verification; record only after it passes.
|
||||||
if context.hasProcessedNostrEvent(giftWrap.id) {
|
if context.hasProcessedNostrEvent(envelope.id) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard giftWrap.isValidSignature() else { return }
|
guard envelope.content.utf8.count <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes else { return }
|
||||||
context.recordProcessedNostrEvent(giftWrap.id)
|
guard envelope.isValidSignature() else { return }
|
||||||
|
context.recordProcessedNostrEvent(envelope.id)
|
||||||
|
|
||||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
|
guard let (content, senderPubkey, messageTs) = try? NostrProtocol.decryptPrivateEnvelope(
|
||||||
giftWrap: giftWrap,
|
envelope: envelope,
|
||||||
recipientIdentity: id
|
recipientIdentity: id
|
||||||
) else {
|
) else {
|
||||||
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…", category: .session)
|
SecureLogger.warning("GeoDM: failed decrypt private envelope id=\(envelope.id.prefix(8))…", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.debug(
|
SecureLogger.debug(
|
||||||
"GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
|
"GeoDM: decrypted private envelope id=\(envelope.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
|
||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -344,13 +360,19 @@ final class NostrInboundPipeline {
|
|||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
guard shouldProcessPrivatePayload(
|
||||||
|
payload,
|
||||||
|
senderPubkey: senderPubkey,
|
||||||
|
recipientPubkey: id.publicKeyHex,
|
||||||
|
envelopeKind: envelope.kind
|
||||||
|
) else { return }
|
||||||
|
|
||||||
let convKey = PeerID(nostr_: senderPubkey)
|
let convKey = PeerID(nostr_: senderPubkey)
|
||||||
context.registerNostrKeyMapping(senderPubkey, for: convKey)
|
context.registerNostrKeyMapping(senderPubkey, for: convKey)
|
||||||
|
|
||||||
switch payload.type {
|
switch payload.type {
|
||||||
case .privateMessage:
|
case .privateMessage:
|
||||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(messageTs))
|
||||||
context.handlePrivateMessage(
|
context.handlePrivateMessage(
|
||||||
payload,
|
payload,
|
||||||
senderPubkey: senderPubkey,
|
senderPubkey: senderPubkey,
|
||||||
@@ -372,28 +394,29 @@ final class NostrInboundPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func handleNostrMessage(_ giftWrap: NostrEvent) {
|
func handleAccountPrivateEnvelope(_ envelope: NostrEvent) {
|
||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
// Cheap dedup pre-check only; Schnorr verification runs off-main in
|
// Cheap dedup pre-check only; Schnorr verification runs off-main in
|
||||||
// processNostrMessage, which then does the authoritative
|
// processAccountPrivateEnvelope, which then does the authoritative
|
||||||
// check-and-record. Recording stays after verification so a
|
// check-and-record. Recording stays after verification so a
|
||||||
// forged-signature copy can never poison the dedup set and suppress
|
// forged-signature copy can never poison the dedup set and suppress
|
||||||
// the genuine event.
|
// the genuine event.
|
||||||
if context.hasProcessedNostrEvent(giftWrap.id) { return }
|
if context.hasProcessedNostrEvent(envelope.id) { return }
|
||||||
|
|
||||||
Task.detached(priority: .userInitiated) { [weak self] in
|
Task.detached(priority: .userInitiated) { [weak self] in
|
||||||
await self?.processNostrMessage(giftWrap)
|
await self?.processAccountPrivateEnvelope(envelope)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func processNostrMessage(_ giftWrap: NostrEvent) async {
|
func processAccountPrivateEnvelope(_ envelope: NostrEvent) async {
|
||||||
guard giftWrap.isValidSignature() else { return }
|
guard envelope.content.utf8.count <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes else { return }
|
||||||
|
guard envelope.isValidSignature() else { return }
|
||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
// Authoritative check-and-record, atomic on the main actor so two
|
// Authoritative check-and-record, atomic on the main actor so two
|
||||||
// concurrent detached tasks can't both process the same event.
|
// concurrent detached tasks can't both process the same event.
|
||||||
let alreadyProcessed: Bool = await MainActor.run {
|
let alreadyProcessed: Bool = await MainActor.run {
|
||||||
if context.hasProcessedNostrEvent(giftWrap.id) { return true }
|
if context.hasProcessedNostrEvent(envelope.id) { return true }
|
||||||
context.recordProcessedNostrEvent(giftWrap.id)
|
context.recordProcessedNostrEvent(envelope.id)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if alreadyProcessed { return }
|
if alreadyProcessed { return }
|
||||||
@@ -403,8 +426,8 @@ final class NostrInboundPipeline {
|
|||||||
guard let currentIdentity else { return }
|
guard let currentIdentity else { return }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage(
|
let (content, senderPubkey, messageTimestampSeconds) = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
giftWrap: giftWrap,
|
envelope: envelope,
|
||||||
recipientIdentity: currentIdentity
|
recipientIdentity: currentIdentity
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -428,8 +451,14 @@ final class NostrInboundPipeline {
|
|||||||
|
|
||||||
if packet.type == MessageType.noiseEncrypted.rawValue,
|
if packet.type == MessageType.noiseEncrypted.rawValue,
|
||||||
let payload = NoisePayload.decode(packet.payload) {
|
let payload = NoisePayload.decode(packet.payload) {
|
||||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(messageTimestampSeconds))
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
guard self.shouldProcessPrivatePayload(
|
||||||
|
payload,
|
||||||
|
senderPubkey: senderPubkey,
|
||||||
|
recipientPubkey: currentIdentity.publicKeyHex,
|
||||||
|
envelopeKind: envelope.kind
|
||||||
|
) else { return }
|
||||||
context.registerNostrKeyMapping(senderPubkey, for: targetPeerID)
|
context.registerNostrKeyMapping(senderPubkey, for: targetPeerID)
|
||||||
|
|
||||||
switch payload.type {
|
switch payload.type {
|
||||||
@@ -503,6 +532,47 @@ final class NostrInboundPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extension NostrInboundPipeline {
|
private extension NostrInboundPipeline {
|
||||||
|
@MainActor
|
||||||
|
func shouldProcessPrivatePayload(
|
||||||
|
_ payload: NoisePayload,
|
||||||
|
senderPubkey: String,
|
||||||
|
recipientPubkey: String,
|
||||||
|
envelopeKind: Int
|
||||||
|
) -> Bool {
|
||||||
|
let digest = payload.encode().sha256Fingerprint()
|
||||||
|
let key = "\(recipientPubkey.lowercased()):\(senderPubkey.lowercased()):\(digest)"
|
||||||
|
let formatBit: UInt8
|
||||||
|
switch envelopeKind {
|
||||||
|
case NostrProtocol.EventKind.privateEnvelope.rawValue:
|
||||||
|
formatBit = 1 << 0
|
||||||
|
case NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue:
|
||||||
|
formatBit = 1 << 1
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if let observedFormats = recentPrivatePayloadFormats[key] {
|
||||||
|
if observedFormats & formatBit != 0 {
|
||||||
|
// A same-format re-envelope is a delivery retry. Let it reach
|
||||||
|
// the coordinator so a lost DELIVERED acknowledgement can be
|
||||||
|
// sent again; downstream message-ID dedup prevents rerendering.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// The same authenticated payload under the other migration format
|
||||||
|
// is the compatibility twin, not a new message or acknowledgement.
|
||||||
|
recentPrivatePayloadFormats[key] = observedFormats | formatBit
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
recentPrivatePayloadFormats[key] = formatBit
|
||||||
|
recentPrivatePayloadKeyOrder.append(key)
|
||||||
|
if recentPrivatePayloadKeyOrder.count > Self.privatePayloadDedupCapacity {
|
||||||
|
let evicted = recentPrivatePayloadKeyOrder.removeFirst()
|
||||||
|
recentPrivatePayloadFormats.removeValue(forKey: evicted)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {
|
static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {
|
||||||
guard content.hasPrefix("bitchat1:") else { return nil }
|
guard content.hasPrefix("bitchat1:") else { return nil }
|
||||||
|
|||||||
@@ -6,11 +6,26 @@ import UIKit
|
|||||||
import AppKit
|
import AppKit
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
struct ContentPeopleSheetModalPresentationState {
|
||||||
|
var isImagePreviewPresented = false
|
||||||
|
var isVerificationSheetPresented = false
|
||||||
|
var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest? = nil
|
||||||
|
var isMediaPickerPresented = false
|
||||||
|
|
||||||
|
var hasPresentation: Bool {
|
||||||
|
isImagePreviewPresented
|
||||||
|
|| isVerificationSheetPresented
|
||||||
|
|| legacyPrivateMediaConsentRequest != nil
|
||||||
|
|| isMediaPickerPresented
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ContentPeopleSheetView: View {
|
struct ContentPeopleSheetView: View {
|
||||||
@EnvironmentObject private var appChromeModel: AppChromeModel
|
@EnvironmentObject private var appChromeModel: AppChromeModel
|
||||||
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
|
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
|
||||||
@EnvironmentObject private var verificationModel: VerificationModel
|
@EnvironmentObject private var verificationModel: VerificationModel
|
||||||
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
@Binding var showSidebar: Bool
|
@Binding var showSidebar: Bool
|
||||||
@Binding var messageText: String
|
@Binding var messageText: String
|
||||||
@@ -23,6 +38,7 @@ struct ContentPeopleSheetView: View {
|
|||||||
var isTextFieldFocused: FocusState<Bool>.Binding
|
var isTextFieldFocused: FocusState<Bool>.Binding
|
||||||
@ObservedObject var voiceRecordingVM: VoiceRecordingViewModel
|
@ObservedObject var voiceRecordingVM: VoiceRecordingViewModel
|
||||||
@Binding var autocompleteDebounceTimer: Timer?
|
@Binding var autocompleteDebounceTimer: Timer?
|
||||||
|
@State private var showVerifySheet = false
|
||||||
@ThemedPalette private var palette
|
@ThemedPalette private var palette
|
||||||
|
|
||||||
let headerHeight: CGFloat
|
let headerHeight: CGFloat
|
||||||
@@ -35,6 +51,40 @@ struct ContentPeopleSheetView: View {
|
|||||||
@Binding var showMacImagePicker: Bool
|
@Binding var showMacImagePicker: Bool
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
private var hasModalPresentation: Bool {
|
||||||
|
#if os(iOS)
|
||||||
|
let isMediaPickerPresented = showImagePicker
|
||||||
|
#else
|
||||||
|
let isMediaPickerPresented = showMacImagePicker
|
||||||
|
#endif
|
||||||
|
|
||||||
|
return ContentPeopleSheetModalPresentationState(
|
||||||
|
isImagePreviewPresented: imagePreviewURL != nil,
|
||||||
|
isVerificationSheetPresented: showVerifySheet,
|
||||||
|
legacyPrivateMediaConsentRequest:
|
||||||
|
conversationUIModel.legacyPrivateMediaConsentRequest,
|
||||||
|
isMediaPickerPresented: isMediaPickerPresented
|
||||||
|
).hasPresentation
|
||||||
|
}
|
||||||
|
|
||||||
|
private var bluetoothAlertBinding: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: {
|
||||||
|
scenePhase == .active
|
||||||
|
&& appChromeModel.showBluetoothAlert
|
||||||
|
&& !hasModalPresentation
|
||||||
|
},
|
||||||
|
set: { isPresented in
|
||||||
|
guard !isPresented,
|
||||||
|
scenePhase == .active,
|
||||||
|
!hasModalPresentation else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
appChromeModel.showBluetoothAlert = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let legacyConsentRequest = conversationUIModel.legacyPrivateMediaConsentRequest
|
let legacyConsentRequest = conversationUIModel.legacyPrivateMediaConsentRequest
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -78,7 +128,8 @@ struct ContentPeopleSheetView: View {
|
|||||||
#endif
|
#endif
|
||||||
} else {
|
} else {
|
||||||
ContentPeopleListView(
|
ContentPeopleListView(
|
||||||
showSidebar: $showSidebar
|
showSidebar: $showSidebar,
|
||||||
|
showVerifySheet: $showVerifySheet
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,6 +233,17 @@ struct ContentPeopleSheetView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
.alert(
|
||||||
|
"content.alert.bluetooth_required.title",
|
||||||
|
isPresented: bluetoothAlertBinding
|
||||||
|
) {
|
||||||
|
Button("content.alert.bluetooth_required.settings") {
|
||||||
|
SystemSettings.bluetooth.open()
|
||||||
|
}
|
||||||
|
Button("common.ok", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text(appChromeModel.bluetoothAlertMessage)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,8 +258,7 @@ private struct ContentPeopleListView: View {
|
|||||||
@ThemedPalette private var palette
|
@ThemedPalette private var palette
|
||||||
|
|
||||||
@Binding var showSidebar: Bool
|
@Binding var showSidebar: Bool
|
||||||
|
@Binding var showVerifySheet: Bool
|
||||||
@State private var showVerifySheet = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -504,7 +565,8 @@ private struct ContentPrivateChatSheetView: View {
|
|||||||
if privateConversationModel.selectedPeerID?.isGroup == true {
|
if privateConversationModel.selectedPeerID?.isGroup == true {
|
||||||
return String(localized: "content.private.caption_group", comment: "Caption above the group chat composer noting messages are encrypted to group members")
|
return String(localized: "content.private.caption_group", comment: "Caption above the group chat composer noting messages are encrypted to group members")
|
||||||
}
|
}
|
||||||
// Geohash DMs are NIP-17 gift-wrapped — always end-to-end encrypted,
|
// Geohash DMs use BitChat's private-envelope transport over Nostr —
|
||||||
|
// always end-to-end encrypted,
|
||||||
// even though they carry no Noise session status. Mesh DMs earn the
|
// even though they carry no Noise session status. Mesh DMs earn the
|
||||||
// "encrypted" claim only once the Noise handshake has secured.
|
// "encrypted" claim only once the Noise handshake has secured.
|
||||||
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
||||||
|
|||||||
@@ -14,6 +14,61 @@ import AppKit
|
|||||||
#endif
|
#endif
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
|
||||||
|
struct ContentRootModalPresentationState {
|
||||||
|
var isPeopleSheetPresented = false
|
||||||
|
var isAppInfoPresented = false
|
||||||
|
var isFingerprintPresented = false
|
||||||
|
var isLocationChannelsSheetPresented = false
|
||||||
|
var isNoticesSheetPresented = false
|
||||||
|
var isImagePreviewPresented = false
|
||||||
|
var isVerificationSheetPresented = false
|
||||||
|
var isVoiceAlertPresented = false
|
||||||
|
var isScreenshotPrivacyAlertPresented = false
|
||||||
|
var isMediaPickerPresented = false
|
||||||
|
|
||||||
|
var hasPresentation: Bool {
|
||||||
|
isPeopleSheetPresented
|
||||||
|
|| isAppInfoPresented
|
||||||
|
|| isFingerprintPresented
|
||||||
|
|| isLocationChannelsSheetPresented
|
||||||
|
|| isNoticesSheetPresented
|
||||||
|
|| isImagePreviewPresented
|
||||||
|
|| isVerificationSheetPresented
|
||||||
|
|| isVoiceAlertPresented
|
||||||
|
|| isScreenshotPrivacyAlertPresented
|
||||||
|
|| isMediaPickerPresented
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ContentRootModalPresentationState {
|
||||||
|
@MainActor
|
||||||
|
init(
|
||||||
|
appChromeModel: AppChromeModel,
|
||||||
|
isPeopleSheetPresented: Bool = false,
|
||||||
|
isImagePreviewPresented: Bool = false,
|
||||||
|
isVerificationSheetPresented: Bool = false,
|
||||||
|
isVoiceAlertPresented: Bool = false,
|
||||||
|
isMediaPickerPresented: Bool = false
|
||||||
|
) {
|
||||||
|
self.init(
|
||||||
|
isPeopleSheetPresented: isPeopleSheetPresented,
|
||||||
|
isAppInfoPresented: appChromeModel.isAppInfoPresented,
|
||||||
|
isFingerprintPresented:
|
||||||
|
appChromeModel.showingFingerprintFor != nil,
|
||||||
|
isLocationChannelsSheetPresented:
|
||||||
|
appChromeModel.isLocationChannelsSheetPresented,
|
||||||
|
isNoticesSheetPresented:
|
||||||
|
appChromeModel.isNoticesSheetPresented,
|
||||||
|
isImagePreviewPresented: isImagePreviewPresented,
|
||||||
|
isVerificationSheetPresented: isVerificationSheetPresented,
|
||||||
|
isVoiceAlertPresented: isVoiceAlertPresented,
|
||||||
|
isScreenshotPrivacyAlertPresented:
|
||||||
|
appChromeModel.showScreenshotPrivacyWarning,
|
||||||
|
isMediaPickerPresented: isMediaPickerPresented
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// On macOS 14+, disables the default system focus ring on TextFields.
|
/// On macOS 14+, disables the default system focus ring on TextFields.
|
||||||
/// On earlier macOS versions and on iOS this is a no-op.
|
/// On earlier macOS versions and on iOS this is a no-op.
|
||||||
struct FocusEffectDisabledModifier: ViewModifier {
|
struct FocusEffectDisabledModifier: ViewModifier {
|
||||||
@@ -42,6 +97,7 @@ struct ContentView: View {
|
|||||||
@FocusState private var isTextFieldFocused: Bool
|
@FocusState private var isTextFieldFocused: Bool
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@Environment(\.appTheme) private var appTheme
|
@Environment(\.appTheme) private var appTheme
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@State private var showSidebar = false
|
@State private var showSidebar = false
|
||||||
@State private var selectedMessageSender: String?
|
@State private var selectedMessageSender: String?
|
||||||
@State private var selectedMessageSenderID: PeerID?
|
@State private var selectedMessageSenderID: PeerID?
|
||||||
@@ -71,6 +127,45 @@ struct ContentView: View {
|
|||||||
|
|
||||||
private var usesGlassLayout: Bool { appTheme.usesGlassChrome }
|
private var usesGlassLayout: Bool { appTheme.usesGlassChrome }
|
||||||
|
|
||||||
|
private var isPeopleSheetPresented: Bool {
|
||||||
|
showSidebar || selectedPrivatePeerID != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasRootModalPresentation: Bool {
|
||||||
|
#if os(iOS)
|
||||||
|
let isMediaPickerPresented = showImagePicker
|
||||||
|
#else
|
||||||
|
let isMediaPickerPresented = showMacImagePicker
|
||||||
|
#endif
|
||||||
|
|
||||||
|
return ContentRootModalPresentationState(
|
||||||
|
appChromeModel: appChromeModel,
|
||||||
|
isPeopleSheetPresented: isPeopleSheetPresented,
|
||||||
|
isImagePreviewPresented: imagePreviewURL != nil,
|
||||||
|
isVerificationSheetPresented: showVerifySheet,
|
||||||
|
isVoiceAlertPresented: voiceRecordingVM.showAlert,
|
||||||
|
isMediaPickerPresented: isMediaPickerPresented
|
||||||
|
).hasPresentation
|
||||||
|
}
|
||||||
|
|
||||||
|
private var rootBluetoothAlertBinding: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: {
|
||||||
|
scenePhase == .active
|
||||||
|
&& appChromeModel.showBluetoothAlert
|
||||||
|
&& !hasRootModalPresentation
|
||||||
|
},
|
||||||
|
set: { isPresented in
|
||||||
|
guard !isPresented,
|
||||||
|
scenePhase == .active,
|
||||||
|
!hasRootModalPresentation else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
appChromeModel.showBluetoothAlert = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
mainContent
|
mainContent
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@@ -107,11 +202,18 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.sheet(
|
.sheet(
|
||||||
isPresented: Binding(
|
isPresented: Binding(
|
||||||
get: { showSidebar || selectedPrivatePeerID != nil },
|
get: { isPeopleSheetPresented },
|
||||||
set: { isPresented in
|
set: { isPresented in
|
||||||
if !isPresented {
|
if !isPresented {
|
||||||
showSidebar = false
|
showSidebar = false
|
||||||
privateConversationModel.endConversation()
|
// Scene/background and Bluetooth-alert presentation
|
||||||
|
// reconciliation are not user requests to leave the
|
||||||
|
// conversation. Keep the selected DM so the sheet
|
||||||
|
// remains live when the app returns from Settings.
|
||||||
|
if scenePhase == .active,
|
||||||
|
!appChromeModel.showBluetoothAlert {
|
||||||
|
privateConversationModel.endConversation()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -222,7 +324,7 @@ struct ContentView: View {
|
|||||||
}, message: {
|
}, message: {
|
||||||
Text(voiceRecordingVM.state.alertMessage)
|
Text(voiceRecordingVM.state.alertMessage)
|
||||||
})
|
})
|
||||||
.alert("content.alert.bluetooth_required.title", isPresented: $appChromeModel.showBluetoothAlert) {
|
.alert("content.alert.bluetooth_required.title", isPresented: rootBluetoothAlertBinding) {
|
||||||
Button("content.alert.bluetooth_required.settings") {
|
Button("content.alert.bluetooth_required.settings") {
|
||||||
SystemSettings.bluetooth.open()
|
SystemSettings.bluetooth.open()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1030,6 +1030,44 @@ struct BLEServiceCoreTests {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func panicSuspension_finalizesStaleTransportEventsAsRejected() async {
|
||||||
|
let ble = makeService()
|
||||||
|
let delegate = TransportEventCaptureDelegate()
|
||||||
|
ble.eventDelegate = delegate
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: "pre-panic-finalization",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "must be rejected",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: "Me",
|
||||||
|
senderPeerID: PeerID(str: "1122334455667788")
|
||||||
|
)
|
||||||
|
var completions = 0
|
||||||
|
var outcomes: [TransportEventDeliveryOutcome] = []
|
||||||
|
|
||||||
|
ble._test_emitTransportEvent(
|
||||||
|
.messageReceived(message),
|
||||||
|
completion: { completions += 1 },
|
||||||
|
finalization: { outcomes.append($0) }
|
||||||
|
)
|
||||||
|
ble.suspendForPanicReset()
|
||||||
|
ble._test_emitTransportEvent(
|
||||||
|
.messageReceived(message),
|
||||||
|
completion: { completions += 1 },
|
||||||
|
finalization: { outcomes.append($0) }
|
||||||
|
)
|
||||||
|
for _ in 0..<4 {
|
||||||
|
await Task.yield()
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(delegate.messageIDs.isEmpty)
|
||||||
|
#expect(completions == 0)
|
||||||
|
#expect(outcomes == [.rejected, .rejected])
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws {
|
func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws {
|
||||||
let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")
|
let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import BitFoundation
|
|||||||
@MainActor
|
@MainActor
|
||||||
private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
|
private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
|
||||||
var nickname = "me"
|
var nickname = "me"
|
||||||
|
var myPeerID = PeerID(str: "0102030405060708")
|
||||||
var selectedPrivateChatPeer: PeerID?
|
var selectedPrivateChatPeer: PeerID?
|
||||||
var isViewingPublicMeshTimeline = false
|
var isViewingPublicMeshTimeline = false
|
||||||
var blockedPeers: Set<PeerID> = []
|
var blockedPeers: Set<PeerID> = []
|
||||||
@@ -23,7 +24,10 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
|
|||||||
private(set) var upsertedMessages: [(message: BitchatMessage, peerID: PeerID)] = []
|
private(set) var upsertedMessages: [(message: BitchatMessage, peerID: PeerID)] = []
|
||||||
private(set) var upsertedPublicMessages: [BitchatMessage] = []
|
private(set) var upsertedPublicMessages: [BitchatMessage] = []
|
||||||
private(set) var removedMessageIDs: [String] = []
|
private(set) var removedMessageIDs: [String] = []
|
||||||
|
private(set) var sentReadReceipts: [(receipt: ReadReceipt, peerID: PeerID)] = []
|
||||||
private(set) var talkerUpdates: [String?] = []
|
private(set) var talkerUpdates: [String?] = []
|
||||||
|
private(set) var privateMutationLog: [String] = []
|
||||||
|
private var readReceiptMessageIDs: Set<String> = []
|
||||||
|
|
||||||
func isPeerBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) }
|
func isPeerBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) }
|
||||||
func resolveNickname(for peerID: PeerID) -> String { "alice" }
|
func resolveNickname(for peerID: PeerID) -> String { "alice" }
|
||||||
@@ -31,6 +35,7 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
|
|||||||
func appendPublicMeshMessage(_ message: BitchatMessage) { appendedPublicMessages.append(message) }
|
func appendPublicMeshMessage(_ message: BitchatMessage) { appendedPublicMessages.append(message) }
|
||||||
func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) {
|
func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) {
|
||||||
upsertedMessages.append((message, peerID))
|
upsertedMessages.append((message, peerID))
|
||||||
|
privateMutationLog.append("upsert:\(message.id)")
|
||||||
}
|
}
|
||||||
func upsertPublicMeshMessage(_ message: BitchatMessage) {
|
func upsertPublicMeshMessage(_ message: BitchatMessage) {
|
||||||
upsertedPublicMessages.append(message)
|
upsertedPublicMessages.append(message)
|
||||||
@@ -38,8 +43,18 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
|
|||||||
@discardableResult
|
@discardableResult
|
||||||
func removePrivateMessage(withID messageID: String) -> BitchatMessage? {
|
func removePrivateMessage(withID messageID: String) -> BitchatMessage? {
|
||||||
removedMessageIDs.append(messageID)
|
removedMessageIDs.append(messageID)
|
||||||
|
privateMutationLog.append("remove:\(messageID)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
func hasSentReadReceipt(_ messageID: String) -> Bool {
|
||||||
|
readReceiptMessageIDs.contains(messageID)
|
||||||
|
}
|
||||||
|
func markReadReceiptSent(_ messageID: String) -> Bool {
|
||||||
|
readReceiptMessageIDs.insert(messageID).inserted
|
||||||
|
}
|
||||||
|
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||||
|
sentReadReceipts.append((receipt, peerID))
|
||||||
|
}
|
||||||
func removeMessage(withID messageID: String, cleanupFile: Bool) {
|
func removeMessage(withID messageID: String, cleanupFile: Bool) {
|
||||||
removedMessageIDs.append(messageID)
|
removedMessageIDs.append(messageID)
|
||||||
}
|
}
|
||||||
@@ -151,17 +166,29 @@ struct ChatLiveVoiceCoordinatorTests {
|
|||||||
|
|
||||||
@Test func absorbsFinalizedNoteIntoLiveBubble() throws {
|
@Test func absorbsFinalizedNoteIntoLiveBubble() throws {
|
||||||
let context = MockChatLiveVoiceContext()
|
let context = MockChatLiveVoiceContext()
|
||||||
|
context.selectedPrivateChatPeer = peer
|
||||||
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
|
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
|
||||||
let burstID = makeBurstID(0xB2)
|
let burstID = makeBurstID(0xB2)
|
||||||
let hex = burstID.hexEncodedString()
|
let hex = burstID.hexEncodedString()
|
||||||
|
let fileName = "voice_\(hex).m4a"
|
||||||
|
let stableMessageID = try #require(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: peer,
|
||||||
|
recipientPeerID: context.myPeerID,
|
||||||
|
fileName: fileName
|
||||||
|
))
|
||||||
|
|
||||||
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 7, count: 50)]))), to: coordinator, from: peer)
|
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 7, count: 50)]))), to: coordinator, from: peer)
|
||||||
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer)
|
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer)
|
||||||
let bubble = try #require(context.handledPrivateMessages.first)
|
let bubble = try #require(context.handledPrivateMessages.first)
|
||||||
|
// The user read the live bubble, then left before the finalized file
|
||||||
|
// arrived. Stable-ID adoption must preserve that read state.
|
||||||
|
#expect(context.markReadReceiptSent(bubble.id))
|
||||||
|
context.selectedPrivateChatPeer = nil
|
||||||
|
|
||||||
let note = BitchatMessage(
|
let note = BitchatMessage(
|
||||||
|
id: stableMessageID,
|
||||||
sender: "alice",
|
sender: "alice",
|
||||||
content: "[voice] voice_\(hex).m4a",
|
content: "[voice] \(fileName)",
|
||||||
timestamp: Date(),
|
timestamp: Date(),
|
||||||
isRelay: false,
|
isRelay: false,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
@@ -170,12 +197,21 @@ struct ChatLiveVoiceCoordinatorTests {
|
|||||||
)
|
)
|
||||||
#expect(coordinator.absorbFinalizedVoiceNote(note))
|
#expect(coordinator.absorbFinalizedVoiceNote(note))
|
||||||
|
|
||||||
// The note replaced the live bubble in place: same message ID, new
|
// The finalized note adopts the sender-correlatable ID, removes the
|
||||||
// content, partial capture deleted.
|
// receiver-local live ID, and emits a fresh READ now that the sender
|
||||||
|
// has created its finalized media row.
|
||||||
let replacement = try #require(context.upsertedMessages.last)
|
let replacement = try #require(context.upsertedMessages.last)
|
||||||
#expect(replacement.message.id == bubble.id)
|
#expect(replacement.message.id == stableMessageID)
|
||||||
#expect(replacement.message.content == note.content)
|
#expect(replacement.message.content == note.content)
|
||||||
#expect(replacement.peerID == peer)
|
#expect(replacement.peerID == peer)
|
||||||
|
#expect(context.removedMessageIDs.contains(bubble.id))
|
||||||
|
#expect(Array(context.privateMutationLog.suffix(2)) == [
|
||||||
|
"upsert:\(stableMessageID)",
|
||||||
|
"remove:\(bubble.id)"
|
||||||
|
])
|
||||||
|
#expect(context.sentReadReceipts.count == 1)
|
||||||
|
#expect(context.sentReadReceipts.first?.receipt.originalMessageID == stableMessageID)
|
||||||
|
#expect(context.sentReadReceipts.first?.peerID == peer)
|
||||||
// The promoted partial capture is deleted in favor of the note.
|
// The promoted partial capture is deleted in favor of the note.
|
||||||
let url = try #require(fallbackFileURL(burstID: burstID, peerID: peer))
|
let url = try #require(fallbackFileURL(burstID: burstID, peerID: peer))
|
||||||
#expect(!FileManager.default.fileExists(atPath: url.path))
|
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||||
@@ -449,7 +485,8 @@ struct ChatLiveVoiceCoordinatorTests {
|
|||||||
isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer
|
isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer
|
||||||
)
|
)
|
||||||
#expect(coordinator.absorbFinalizedVoiceNote(dmNote))
|
#expect(coordinator.absorbFinalizedVoiceNote(dmNote))
|
||||||
#expect(try #require(context.upsertedMessages.last).message.id == dmBubble.id)
|
#expect(try #require(context.upsertedMessages.last).message.id == dmNote.id)
|
||||||
|
#expect(context.removedMessageIDs.contains(dmBubble.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func finalizedNoteBindsToItsAuthenticatedSender() throws {
|
@Test func finalizedNoteBindsToItsAuthenticatedSender() throws {
|
||||||
@@ -479,8 +516,9 @@ struct ChatLiveVoiceCoordinatorTests {
|
|||||||
)
|
)
|
||||||
#expect(coordinator.absorbFinalizedVoiceNote(note))
|
#expect(coordinator.absorbFinalizedVoiceNote(note))
|
||||||
let replacement = try #require(context.upsertedMessages.last)
|
let replacement = try #require(context.upsertedMessages.last)
|
||||||
#expect(replacement.message.id == victimBubble.id)
|
#expect(replacement.message.id == note.id)
|
||||||
#expect(replacement.peerID == peer)
|
#expect(replacement.peerID == peer)
|
||||||
|
#expect(context.removedMessageIDs.contains(victimBubble.id))
|
||||||
|
|
||||||
// The attacker's note can only ever claim the attacker's own bubble.
|
// The attacker's note can only ever claim the attacker's own bubble.
|
||||||
let attackerNote = BitchatMessage(
|
let attackerNote = BitchatMessage(
|
||||||
@@ -489,8 +527,9 @@ struct ChatLiveVoiceCoordinatorTests {
|
|||||||
)
|
)
|
||||||
#expect(coordinator.absorbFinalizedVoiceNote(attackerNote))
|
#expect(coordinator.absorbFinalizedVoiceNote(attackerNote))
|
||||||
let attackerReplacement = try #require(context.upsertedMessages.last)
|
let attackerReplacement = try #require(context.upsertedMessages.last)
|
||||||
#expect(attackerReplacement.message.id == attackerBubble.id)
|
#expect(attackerReplacement.message.id == attackerNote.id)
|
||||||
#expect(attackerReplacement.peerID == attacker)
|
#expect(attackerReplacement.peerID == attacker)
|
||||||
|
#expect(context.removedMessageIDs.contains(attackerBubble.id))
|
||||||
|
|
||||||
// Both registry entries are consumed — nothing left to hijack.
|
// Both registry entries are consumed — nothing left to hijack.
|
||||||
#expect(!coordinator.absorbFinalizedVoiceNote(note))
|
#expect(!coordinator.absorbFinalizedVoiceNote(note))
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -243,7 +243,7 @@ private func drainMainQueue() async {
|
|||||||
|
|
||||||
/// Exercises `ChatNostrCoordinator` against `MockChatNostrContext` with no
|
/// Exercises `ChatNostrCoordinator` against `MockChatNostrContext` with no
|
||||||
/// `ChatViewModel`. Scoped to the inbound event pipeline (dedup, presence,
|
/// `ChatViewModel`. Scoped to the inbound event pipeline (dedup, presence,
|
||||||
/// public-message ingest), gift-wrap DM ingest, key mapping, channel-switch
|
/// public-message ingest), private-envelope ingest, key mapping, channel-switch
|
||||||
/// teardown, embedded ack flows, and — now that favorites and notifications
|
/// teardown, embedded ack flows, and — now that favorites and notifications
|
||||||
/// are injected through the context — the favorite-notification ingest and
|
/// are injected through the context — the favorite-notification ingest and
|
||||||
/// the sampled-geohash notification cooldown. Flows that hit live singletons
|
/// the sampled-geohash notification cooldown. Flows that hit live singletons
|
||||||
@@ -335,7 +335,7 @@ struct ChatNostrCoordinatorContextTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func handleGiftWrap_routesEmbeddedPrivateMessageAndDeduplicates() async throws {
|
func handlePrivateEnvelope_routesEmbeddedPrivateMessageAndDeduplicates() async throws {
|
||||||
let context = MockChatNostrContext()
|
let context = MockChatNostrContext()
|
||||||
let coordinator = ChatNostrCoordinator(context: context)
|
let coordinator = ChatNostrCoordinator(context: context)
|
||||||
|
|
||||||
@@ -346,17 +346,21 @@ struct ChatNostrCoordinatorContextTests {
|
|||||||
messageID: "gm-1",
|
messageID: "gm-1",
|
||||||
senderPeerID: PeerID(str: "aabbccddeeff0011")
|
senderPeerID: PeerID(str: "aabbccddeeff0011")
|
||||||
))
|
))
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
let envelopes = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||||
content: embedded,
|
content: embedded,
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
|
for envelope in envelopes {
|
||||||
|
coordinator.inbound.handlePrivateEnvelope(envelope, id: recipient)
|
||||||
|
}
|
||||||
|
|
||||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
#expect(context.recordedNostrEventIDs == envelopes.map(\.id))
|
||||||
#expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex)
|
#expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex)
|
||||||
|
// The primary and compatibility envelopes carry the same authenticated
|
||||||
|
// embedded payload and must invoke message side effects only once.
|
||||||
#expect(context.handledPrivateMessages.count == 1)
|
#expect(context.handledPrivateMessages.count == 1)
|
||||||
#expect(context.handledPrivateMessages.first?.senderPubkey == sender.publicKeyHex)
|
#expect(context.handledPrivateMessages.first?.senderPubkey == sender.publicKeyHex)
|
||||||
#expect(context.handledPrivateMessages.first?.convKey == convKey)
|
#expect(context.handledPrivateMessages.first?.convKey == convKey)
|
||||||
@@ -368,34 +372,84 @@ struct ChatNostrCoordinatorContextTests {
|
|||||||
#expect(pm.messageID == "gm-1")
|
#expect(pm.messageID == "gm-1")
|
||||||
#expect(pm.content == "psst")
|
#expect(pm.content == "psst")
|
||||||
|
|
||||||
// The same gift wrap is dropped on replay.
|
// The same private envelope is dropped on replay.
|
||||||
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
|
coordinator.inbound.handlePrivateEnvelope(envelopes[0], id: recipient)
|
||||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
#expect(context.recordedNostrEventIDs == envelopes.map(\.id))
|
||||||
#expect(context.handledPrivateMessages.count == 1)
|
#expect(context.handledPrivateMessages.count == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func processNostrMessage_invalidSignatureDoesNotPoisonDedup() async throws {
|
func migrationEnvelopePairs_processEachMessageAndAckOnlyOnce() throws {
|
||||||
|
let context = MockChatNostrContext()
|
||||||
|
let coordinator = ChatNostrCoordinator(context: context)
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let messageContent = try #require(NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
|
||||||
|
content: "migration message",
|
||||||
|
messageID: "migration-message-id",
|
||||||
|
senderPeerID: PeerID(str: "aabbccddeeff0011")
|
||||||
|
))
|
||||||
|
let deliveredContent = try #require(NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(
|
||||||
|
type: .delivered,
|
||||||
|
messageID: "migration-ack-id",
|
||||||
|
senderPeerID: PeerID(str: "aabbccddeeff0011")
|
||||||
|
))
|
||||||
|
let readContent = try #require(NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(
|
||||||
|
type: .readReceipt,
|
||||||
|
messageID: "migration-ack-id",
|
||||||
|
senderPeerID: PeerID(str: "aabbccddeeff0011")
|
||||||
|
))
|
||||||
|
|
||||||
|
for content in [messageContent, deliveredContent, readContent] {
|
||||||
|
let envelopes = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||||
|
content: content,
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
senderIdentity: sender
|
||||||
|
)
|
||||||
|
#expect(envelopes.count == 2)
|
||||||
|
for envelope in envelopes {
|
||||||
|
coordinator.inbound.handlePrivateEnvelope(envelope, id: recipient)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(context.handledPrivateMessages.count == 1)
|
||||||
|
#expect(context.handledDelivered.count == 1)
|
||||||
|
#expect(context.handledReadReceipts.count == 1)
|
||||||
|
|
||||||
|
// A later same-format re-envelope is a delivery retry, not the
|
||||||
|
// migration twin, so it must still reach downstream message-ID dedup
|
||||||
|
// and acknowledgement resend logic.
|
||||||
|
let primaryRetry = try NostrProtocol.createPrivateEnvelope(
|
||||||
|
content: messageContent,
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
senderIdentity: sender
|
||||||
|
)
|
||||||
|
coordinator.inbound.handlePrivateEnvelope(primaryRetry, id: recipient)
|
||||||
|
#expect(context.handledPrivateMessages.count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func processAccountPrivateEnvelope_invalidSignatureDoesNotPoisonDedup() async throws {
|
||||||
let context = MockChatNostrContext()
|
let context = MockChatNostrContext()
|
||||||
let coordinator = ChatNostrCoordinator(context: context)
|
let coordinator = ChatNostrCoordinator(context: context)
|
||||||
|
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||||
content: "verify:noop",
|
content: "verify:noop",
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
var invalidGiftWrap = giftWrap
|
var invalidEnvelope = envelope
|
||||||
invalidGiftWrap.sig = String(repeating: "0", count: 128)
|
invalidEnvelope.sig = String(repeating: "0", count: 128)
|
||||||
|
|
||||||
// A forged-signature copy is rejected WITHOUT entering the dedup set...
|
// A forged-signature copy is rejected WITHOUT entering the dedup set...
|
||||||
await coordinator.inbound.processNostrMessage(invalidGiftWrap)
|
await coordinator.inbound.processAccountPrivateEnvelope(invalidEnvelope)
|
||||||
#expect(context.recordedNostrEventIDs.isEmpty)
|
#expect(context.recordedNostrEventIDs.isEmpty)
|
||||||
|
|
||||||
// ...so the genuine event with the same ID still processes and records.
|
// ...so the genuine event with the same ID still processes and records.
|
||||||
await coordinator.inbound.processNostrMessage(giftWrap)
|
await coordinator.inbound.processAccountPrivateEnvelope(envelope)
|
||||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
#expect(context.recordedNostrEventIDs == [envelope.id])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
|
|||||||
@@ -176,6 +176,10 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
|
|||||||
private(set) var geoPrivateMessages: [(content: String, recipientHex: String, messageID: String)] = []
|
private(set) var geoPrivateMessages: [(content: String, recipientHex: String, messageID: String)] = []
|
||||||
private(set) var geoDeliveryAcks: [(messageID: String, recipientHex: String)] = []
|
private(set) var geoDeliveryAcks: [(messageID: String, recipientHex: String)] = []
|
||||||
private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = []
|
private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = []
|
||||||
|
var geohashPrivateMessageAccepted = true
|
||||||
|
var queuedMessageIDsByPeerID: [PeerID: Set<String>] = [:]
|
||||||
|
private(set) var deliveryAckAttempts: [(messageID: String, peerIDs: [PeerID])] = []
|
||||||
|
private(set) var deliveredMessageIDs: [String] = []
|
||||||
|
|
||||||
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
routedPrivateMessages.append((content, peerID, messageID))
|
routedPrivateMessages.append((content, peerID, messageID))
|
||||||
@@ -187,12 +191,34 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
|
|||||||
return routeReadReceiptResult
|
return routeReadReceiptResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func markMessageDelivered(_ messageID: String, for peerIDs: [PeerID]) -> Bool {
|
||||||
|
deliveryAckAttempts.append((messageID, peerIDs))
|
||||||
|
var cleared = false
|
||||||
|
for peerID in Set(peerIDs) {
|
||||||
|
guard var queued = queuedMessageIDsByPeerID[peerID],
|
||||||
|
queued.remove(messageID) != nil else { continue }
|
||||||
|
queuedMessageIDsByPeerID[peerID] = queued.isEmpty ? nil : queued
|
||||||
|
cleared = true
|
||||||
|
}
|
||||||
|
if cleared {
|
||||||
|
deliveredMessageIDs.append(messageID)
|
||||||
|
}
|
||||||
|
return cleared
|
||||||
|
}
|
||||||
|
|
||||||
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||||
meshReadReceipts.append((receipt.originalMessageID, peerID))
|
meshReadReceipts.append((receipt.originalMessageID, peerID))
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
func sendGeohashPrivateMessage(
|
||||||
|
_ content: String,
|
||||||
|
toRecipientHex recipientHex: String,
|
||||||
|
from identity: NostrIdentity,
|
||||||
|
messageID: String
|
||||||
|
) -> Bool {
|
||||||
geoPrivateMessages.append((content, recipientHex, messageID))
|
geoPrivateMessages.append((content, recipientHex, messageID))
|
||||||
|
return geohashPrivateMessageAccepted
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||||
@@ -279,6 +305,11 @@ private func isRead(_ status: DeliveryStatus?, by expected: String) -> Bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func isFailed(_ status: DeliveryStatus?) -> Bool {
|
||||||
|
if case .failed = status { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
private func makeFavoriteRelationship(
|
private func makeFavoriteRelationship(
|
||||||
noiseKey: Data,
|
noiseKey: Data,
|
||||||
nostrPublicKey: String? = nil,
|
nostrPublicKey: String? = nil,
|
||||||
@@ -370,6 +401,66 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
|||||||
convKey: convKey
|
convKey: convKey
|
||||||
)
|
)
|
||||||
#expect(context.notifyUIChangedCount == 2)
|
#expect(context.notifyUIChangedCount == 2)
|
||||||
|
#expect(context.deliveryAckAttempts.isEmpty)
|
||||||
|
#expect(context.deliveredMessageIDs.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func accountDMAcks_findShortIDMessageAndHandConversationToStable() async {
|
||||||
|
let context = MockChatPrivateConversationContext()
|
||||||
|
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||||
|
let noiseKey = Data(repeating: 0xD5, count: 32)
|
||||||
|
let stablePeerID = PeerID(hexData: noiseKey)
|
||||||
|
let shortPeerID = stablePeerID.toShort()
|
||||||
|
let senderPubkey = "feedface00112233"
|
||||||
|
context.displayNamesByPubkey[senderPubkey] = "alice"
|
||||||
|
context.selectedPrivateChatPeer = shortPeerID
|
||||||
|
context.privateChats[shortPeerID] = [
|
||||||
|
makeIncomingMessage(id: "mine-short-1", sender: "me"),
|
||||||
|
makeIncomingMessage(id: "mine-short-2", sender: "me")
|
||||||
|
]
|
||||||
|
context.queuedMessageIDsByPeerID[shortPeerID] = ["mine-short-1", "mine-short-2"]
|
||||||
|
|
||||||
|
coordinator.handleDelivered(
|
||||||
|
NoisePayload(type: .delivered, data: Data("mine-short-1".utf8)),
|
||||||
|
senderPubkey: senderPubkey,
|
||||||
|
convKey: stablePeerID
|
||||||
|
)
|
||||||
|
coordinator.handleReadReceipt(
|
||||||
|
NoisePayload(type: .readReceipt, data: Data("mine-short-2".utf8)),
|
||||||
|
senderPubkey: senderPubkey,
|
||||||
|
convKey: stablePeerID
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(context.privateChats[shortPeerID] == nil)
|
||||||
|
#expect(isDelivered(context.privateChats[stablePeerID]?.first?.deliveryStatus, to: "alice"))
|
||||||
|
#expect(isRead(context.privateChats[stablePeerID]?.last?.deliveryStatus, by: "alice"))
|
||||||
|
#expect(context.selectedPrivateChatPeer == stablePeerID)
|
||||||
|
#expect(context.deliveredMessageIDs == ["mine-short-1", "mine-short-2"])
|
||||||
|
#expect(context.notifyUIChangedCount == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func accountDMAck_doesNotTouchAnUnrelatedConversationWithTheSameMessageID() async {
|
||||||
|
let context = MockChatPrivateConversationContext()
|
||||||
|
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||||
|
let stablePeerID = PeerID(hexData: Data(repeating: 0xC1, count: 32))
|
||||||
|
let unrelatedPeerID = PeerID(str: "1111222233334444")
|
||||||
|
context.privateChats[unrelatedPeerID] = [
|
||||||
|
makeIncomingMessage(id: "collision", sender: "me")
|
||||||
|
]
|
||||||
|
context.queuedMessageIDsByPeerID[unrelatedPeerID] = ["collision"]
|
||||||
|
|
||||||
|
coordinator.handleDelivered(
|
||||||
|
NoisePayload(type: .delivered, data: Data("collision".utf8)),
|
||||||
|
senderPubkey: "feedface00112233",
|
||||||
|
convKey: stablePeerID
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(isDelivered(context.privateChats[unrelatedPeerID]?.first?.deliveryStatus, to: "me"))
|
||||||
|
#expect(context.deliveredMessageIDs.isEmpty)
|
||||||
|
#expect(context.queuedMessageIDsByPeerID[unrelatedPeerID] == ["collision"])
|
||||||
|
#expect(context.notifyUIChangedCount == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
@@ -412,6 +503,199 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
|||||||
#expect(context.privateChats[convKey]?.count == 1)
|
#expect(context.privateChats[convKey]?.count == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func sendGeohashDM_rejectedAdmissionNeverBecomesSent() async {
|
||||||
|
let context = MockChatPrivateConversationContext()
|
||||||
|
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||||
|
let recipientHex = String(repeating: "33", count: 32)
|
||||||
|
let peerID = PeerID(nostr_: recipientHex)
|
||||||
|
context.activeChannel = .location(
|
||||||
|
GeohashChannel(level: .city, geohash: "u4pruy")
|
||||||
|
)
|
||||||
|
context.nostrKeyMapping[peerID] = recipientHex
|
||||||
|
context.geohashPrivateMessageAccepted = false
|
||||||
|
|
||||||
|
coordinator.sendGeohashDM("rejected", to: peerID)
|
||||||
|
|
||||||
|
#expect(context.geoPrivateMessages.map(\.content) == ["rejected"])
|
||||||
|
#expect(context.privateChats[peerID]?.count == 1)
|
||||||
|
#expect(isFailed(context.privateChats[peerID]?.first?.deliveryStatus))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func accountDM_handsOpenShortIDConversationToStableWhenOffline() async {
|
||||||
|
let context = MockChatPrivateConversationContext()
|
||||||
|
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||||
|
let noiseKey = Data(repeating: 0xA7, count: 32)
|
||||||
|
let stablePeerID = PeerID(hexData: noiseKey)
|
||||||
|
let shortPeerID = stablePeerID.toShort()
|
||||||
|
let senderPubkey = "feedface00112233"
|
||||||
|
let now = Date()
|
||||||
|
context.displayNamesByPubkey[senderPubkey] = "bob"
|
||||||
|
context.selectedPrivateChatPeer = shortPeerID
|
||||||
|
context.privateChats[shortPeerID] = [
|
||||||
|
makeIncomingMessage(
|
||||||
|
id: "short-history",
|
||||||
|
sender: "me",
|
||||||
|
timestamp: now.addingTimeInterval(-30),
|
||||||
|
senderPeerID: context.myPeerID
|
||||||
|
),
|
||||||
|
makeIncomingMessage(
|
||||||
|
id: "short-inbound",
|
||||||
|
sender: "bob",
|
||||||
|
timestamp: now.addingTimeInterval(-25),
|
||||||
|
senderPeerID: shortPeerID
|
||||||
|
)
|
||||||
|
]
|
||||||
|
context.privateChats[stablePeerID] = [
|
||||||
|
makeIncomingMessage(
|
||||||
|
id: "stable-history",
|
||||||
|
sender: "bob",
|
||||||
|
timestamp: now.addingTimeInterval(-20),
|
||||||
|
senderPeerID: stablePeerID
|
||||||
|
)
|
||||||
|
]
|
||||||
|
let payloadData = PrivateMessagePacket(messageID: "account-live-1", content: "live reply").encode()!
|
||||||
|
|
||||||
|
coordinator.handlePrivateMessage(
|
||||||
|
NoisePayload(type: .privateMessage, data: payloadData),
|
||||||
|
senderPubkey: senderPubkey,
|
||||||
|
convKey: stablePeerID,
|
||||||
|
id: MockChatPrivateConversationContext.dummyIdentity,
|
||||||
|
messageTimestamp: now
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(context.privateChats[shortPeerID] == nil)
|
||||||
|
#expect(context.privateChats[stablePeerID]?.map(\.id) == [
|
||||||
|
"short-history",
|
||||||
|
"short-inbound",
|
||||||
|
"stable-history",
|
||||||
|
"account-live-1"
|
||||||
|
])
|
||||||
|
#expect(context.privateChats[stablePeerID]?[1].senderPeerID == stablePeerID)
|
||||||
|
#expect(context.privateChats[stablePeerID]?.last?.senderPeerID == stablePeerID)
|
||||||
|
#expect(context.migratedChats.contains(where: { $0.from == shortPeerID && $0.to == stablePeerID }))
|
||||||
|
#expect(context.selectedPrivateChatPeer == stablePeerID)
|
||||||
|
#expect(context.geoReadReceipts.map(\.messageID) == ["account-live-1"])
|
||||||
|
#expect(context.unreadPrivateMessages.isEmpty)
|
||||||
|
#expect(context.notifyUIChangedCount == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func accountDM_aliasMergePreservesTheCanonicalDestinationCopy() async {
|
||||||
|
let context = MockChatPrivateConversationContext()
|
||||||
|
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||||
|
let noiseKey = Data(repeating: 0xA8, count: 32)
|
||||||
|
let stablePeerID = PeerID(hexData: noiseKey)
|
||||||
|
let shortPeerID = stablePeerID.toShort()
|
||||||
|
let senderPubkey = "feedface00112233"
|
||||||
|
let olderSource = makeIncomingMessage(
|
||||||
|
id: "duplicate-history",
|
||||||
|
sender: "bob",
|
||||||
|
content: "older source copy",
|
||||||
|
senderPeerID: shortPeerID
|
||||||
|
)
|
||||||
|
olderSource.deliveryStatus = .delivered(to: "me", at: Date(timeIntervalSince1970: 10))
|
||||||
|
let newerDestination = makeIncomingMessage(
|
||||||
|
id: "duplicate-history",
|
||||||
|
sender: "bob",
|
||||||
|
content: "newer destination copy",
|
||||||
|
senderPeerID: shortPeerID
|
||||||
|
)
|
||||||
|
newerDestination.deliveryStatus = .read(by: "me", at: Date(timeIntervalSince1970: 20))
|
||||||
|
context.privateChats[shortPeerID] = [olderSource]
|
||||||
|
context.privateChats[stablePeerID] = [newerDestination]
|
||||||
|
context.displayNamesByPubkey[senderPubkey] = "bob"
|
||||||
|
let payloadData = PrivateMessagePacket(messageID: "after-merge", content: "new reply").encode()!
|
||||||
|
|
||||||
|
coordinator.handlePrivateMessage(
|
||||||
|
NoisePayload(type: .privateMessage, data: payloadData),
|
||||||
|
senderPubkey: senderPubkey,
|
||||||
|
convKey: stablePeerID,
|
||||||
|
id: MockChatPrivateConversationContext.dummyIdentity,
|
||||||
|
messageTimestamp: Date()
|
||||||
|
)
|
||||||
|
|
||||||
|
let merged = context.privateChats[stablePeerID]?.first
|
||||||
|
#expect(context.privateChats[shortPeerID] == nil)
|
||||||
|
#expect(merged?.content == "newer destination copy")
|
||||||
|
#expect(isRead(merged?.deliveryStatus, by: "me"))
|
||||||
|
#expect(merged?.senderPeerID == stablePeerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func accountDM_keepsTheConnectedShortIDConversationCanonical() async {
|
||||||
|
let context = MockChatPrivateConversationContext()
|
||||||
|
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||||
|
let noiseKey = Data(repeating: 0xB8, count: 32)
|
||||||
|
let stablePeerID = PeerID(hexData: noiseKey)
|
||||||
|
let shortPeerID = stablePeerID.toShort()
|
||||||
|
let senderPubkey = "feedface00112233"
|
||||||
|
context.connectedPeers = [shortPeerID]
|
||||||
|
context.selectedPrivateChatPeer = stablePeerID
|
||||||
|
context.displayNamesByPubkey[senderPubkey] = "bob"
|
||||||
|
context.privateChats[stablePeerID] = [
|
||||||
|
makeIncomingMessage(id: "stable-history", senderPeerID: stablePeerID)
|
||||||
|
]
|
||||||
|
let payloadData = PrivateMessagePacket(messageID: "connected-live-1", content: "still nearby").encode()!
|
||||||
|
|
||||||
|
coordinator.handlePrivateMessage(
|
||||||
|
NoisePayload(type: .privateMessage, data: payloadData),
|
||||||
|
senderPubkey: senderPubkey,
|
||||||
|
convKey: stablePeerID,
|
||||||
|
id: MockChatPrivateConversationContext.dummyIdentity,
|
||||||
|
messageTimestamp: Date()
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(context.privateChats[stablePeerID] == nil)
|
||||||
|
#expect(context.privateChats[shortPeerID]?.map(\.id) == ["stable-history", "connected-live-1"])
|
||||||
|
#expect(context.privateChats[shortPeerID]?.first?.senderPeerID == shortPeerID)
|
||||||
|
#expect(context.privateChats[shortPeerID]?.last?.senderPeerID == shortPeerID)
|
||||||
|
#expect(context.selectedPrivateChatPeer == shortPeerID)
|
||||||
|
#expect(context.geoReadReceipts.map(\.messageID) == ["connected-live-1"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func accountDM_duplicateDowngradeAckStillClearsTheRetainedOutbox() async {
|
||||||
|
let context = MockChatPrivateConversationContext()
|
||||||
|
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||||
|
let stablePeerID = PeerID(hexData: Data(repeating: 0xE2, count: 32))
|
||||||
|
let message = makeIncomingMessage(id: "already-read", sender: "me")
|
||||||
|
message.deliveryStatus = .read(by: "bob", at: Date())
|
||||||
|
context.privateChats[stablePeerID] = [message]
|
||||||
|
context.queuedMessageIDsByPeerID[stablePeerID] = ["already-read"]
|
||||||
|
|
||||||
|
coordinator.handleDelivered(
|
||||||
|
NoisePayload(type: .delivered, data: Data("already-read".utf8)),
|
||||||
|
senderPubkey: "feedface00112233",
|
||||||
|
convKey: stablePeerID
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(isRead(context.privateChats[stablePeerID]?.first?.deliveryStatus, by: "bob"))
|
||||||
|
#expect(context.deliveredMessageIDs == ["already-read"])
|
||||||
|
#expect(context.notifyUIChangedCount == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func accountDMAck_clearsRetainedMessageAfterConversationWasRemoved() async {
|
||||||
|
let context = MockChatPrivateConversationContext()
|
||||||
|
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||||
|
let stablePeerID = PeerID(hexData: Data(repeating: 0xE3, count: 32))
|
||||||
|
let shortPeerID = stablePeerID.toShort()
|
||||||
|
context.queuedMessageIDsByPeerID[shortPeerID] = ["cleared-bubble"]
|
||||||
|
|
||||||
|
coordinator.handleDelivered(
|
||||||
|
NoisePayload(type: .delivered, data: Data("cleared-bubble".utf8)),
|
||||||
|
senderPubkey: "feedface00112233",
|
||||||
|
convKey: stablePeerID
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(context.privateChats.isEmpty)
|
||||||
|
#expect(context.queuedMessageIDsByPeerID[shortPeerID] == nil)
|
||||||
|
#expect(context.deliveredMessageIDs == ["cleared-bubble"])
|
||||||
|
#expect(context.notifyUIChangedCount == 0)
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func handleViewingThisChat_clearsUnreadAndSendsRoutedReadReceiptOnce() async {
|
func handleViewingThisChat_clearsUnreadAndSendsRoutedReadReceiptOnce() async {
|
||||||
let context = MockChatPrivateConversationContext()
|
let context = MockChatPrivateConversationContext()
|
||||||
|
|||||||
@@ -133,11 +133,17 @@ private final class MockChatTransportEventContext: ChatTransportEventContext {
|
|||||||
// Delivery status
|
// Delivery status
|
||||||
var applyMessageDeliveryStatusResult = true
|
var applyMessageDeliveryStatusResult = true
|
||||||
var deliveryStatusesByMessageID: [String: DeliveryStatus] = [:]
|
var deliveryStatusesByMessageID: [String: DeliveryStatus] = [:]
|
||||||
private(set) var appliedDeliveryStatuses: [(messageID: String, status: DeliveryStatus)] = []
|
private(set) var appliedDeliveryStatuses: [
|
||||||
|
(messageID: String, status: DeliveryStatus, peerIDAliases: Set<PeerID>)
|
||||||
|
] = []
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool {
|
func applyAcknowledgedMessageDeliveryStatus(
|
||||||
appliedDeliveryStatuses.append((messageID, status))
|
_ messageID: String,
|
||||||
|
status: DeliveryStatus,
|
||||||
|
from peerIDAliases: Set<PeerID>
|
||||||
|
) -> Bool {
|
||||||
|
appliedDeliveryStatuses.append((messageID, status, peerIDAliases))
|
||||||
return applyMessageDeliveryStatusResult
|
return applyMessageDeliveryStatusResult
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,26 +226,61 @@ struct ChatTransportEventCoordinatorContextTests {
|
|||||||
func didReceiveMessage_routesPrivateAndPublic_skipsBlockedAndEmpty() async {
|
func didReceiveMessage_routesPrivateAndPublic_skipsBlockedAndEmpty() async {
|
||||||
let context = MockChatTransportEventContext()
|
let context = MockChatTransportEventContext()
|
||||||
let coordinator = ChatTransportEventCoordinator(context: context)
|
let coordinator = ChatTransportEventCoordinator(context: context)
|
||||||
|
let peerID = PeerID(str: "1122334455667788")
|
||||||
|
|
||||||
// Blocked messages are dropped before any handling.
|
// Blocked messages are dropped before any handling.
|
||||||
context.blockedMessageIDs = ["blocked"]
|
context.blockedMessageIDs = ["blocked", "blocked-private"]
|
||||||
coordinator.didReceiveMessage(makeMessage(id: "blocked"))
|
coordinator.didReceiveMessage(makeMessage(id: "blocked"))
|
||||||
|
coordinator.didReceiveMessage(makeMessage(
|
||||||
|
id: "blocked-private",
|
||||||
|
isPrivate: true,
|
||||||
|
senderPeerID: peerID
|
||||||
|
))
|
||||||
// Empty public content is dropped too.
|
// Empty public content is dropped too.
|
||||||
coordinator.didReceiveMessage(makeMessage(id: "empty", content: " "))
|
coordinator.didReceiveMessage(makeMessage(id: "empty", content: " "))
|
||||||
await drainMainActorTasks()
|
await drainMainActorTasks()
|
||||||
#expect(context.handledPublicMessages.isEmpty)
|
#expect(context.handledPublicMessages.isEmpty)
|
||||||
#expect(context.handledPrivateMessages.isEmpty)
|
#expect(context.handledPrivateMessages.isEmpty)
|
||||||
#expect(context.mentionCheckedMessageIDs.isEmpty)
|
#expect(context.mentionCheckedMessageIDs.isEmpty)
|
||||||
|
#expect(context.meshDeliveryAcks.isEmpty)
|
||||||
|
|
||||||
// Private goes to the private handler, public to the public handler;
|
// Private goes to the private handler, public to the public handler;
|
||||||
// both get mention checks and haptics.
|
// both get mention checks and haptics. Stable-media ACK authorization
|
||||||
coordinator.didReceiveMessage(makeMessage(id: "pm", isPrivate: true))
|
// belongs to BLEFileTransferHandler after its durable commit and this
|
||||||
|
// synchronous acceptance result, not to the generic UI coordinator.
|
||||||
|
let stableMediaID = "media-\(String(repeating: "a", count: 32))"
|
||||||
|
coordinator.didReceiveMessage(makeMessage(
|
||||||
|
id: stableMediaID,
|
||||||
|
isPrivate: true,
|
||||||
|
senderPeerID: peerID
|
||||||
|
))
|
||||||
|
coordinator.didReceiveMessage(makeMessage(
|
||||||
|
id: "legacy-media",
|
||||||
|
isPrivate: true,
|
||||||
|
senderPeerID: peerID
|
||||||
|
))
|
||||||
|
coordinator.didReceiveMessage(makeMessage(id: "pm-missing-sender", isPrivate: true))
|
||||||
coordinator.didReceiveMessage(makeMessage(id: "pub"))
|
coordinator.didReceiveMessage(makeMessage(id: "pub"))
|
||||||
await drainMainActorTasks()
|
await drainMainActorTasks()
|
||||||
#expect(context.handledPrivateMessages.map(\.id) == ["pm"])
|
#expect(context.handledPrivateMessages.map(\.id) == [
|
||||||
|
stableMediaID,
|
||||||
|
"legacy-media",
|
||||||
|
"pm-missing-sender"
|
||||||
|
])
|
||||||
#expect(context.handledPublicMessages.map(\.id) == ["pub"])
|
#expect(context.handledPublicMessages.map(\.id) == ["pub"])
|
||||||
#expect(context.mentionCheckedMessageIDs == ["pm", "pub"])
|
#expect(context.mentionCheckedMessageIDs == [
|
||||||
#expect(context.hapticMessageIDs == ["pm", "pub"])
|
stableMediaID,
|
||||||
|
"legacy-media",
|
||||||
|
"pm-missing-sender",
|
||||||
|
"pub"
|
||||||
|
])
|
||||||
|
#expect(context.hapticMessageIDs == [
|
||||||
|
stableMediaID,
|
||||||
|
"legacy-media",
|
||||||
|
"pm-missing-sender",
|
||||||
|
"pub"
|
||||||
|
])
|
||||||
|
#expect(context.meshDeliveryAcks.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
@@ -382,6 +423,10 @@ struct ChatTransportEventCoordinatorContextTests {
|
|||||||
let peerID = PeerID(str: "99aabbccddeeff00")
|
let peerID = PeerID(str: "99aabbccddeeff00")
|
||||||
let noiseKey = Data(repeating: 0x44, count: 32)
|
let noiseKey = Data(repeating: 0x44, count: 32)
|
||||||
context.peersByID[peerID] = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice")
|
context.peersByID[peerID] = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice")
|
||||||
|
let stablePeerID = PeerID(hexData: noiseKey)
|
||||||
|
let staleStablePeerID = PeerID(hexData: Data(repeating: 0x55, count: 32))
|
||||||
|
context.cacheStablePeerID(staleStablePeerID, for: peerID)
|
||||||
|
context.noiseSessionKeysByPeerID[peerID] = noiseKey
|
||||||
|
|
||||||
// Inbound private message: decoded, handled, and delivery-acked.
|
// Inbound private message: decoded, handled, and delivery-acked.
|
||||||
let packet = PrivateMessagePacket(messageID: "pm-1", content: "hi there")
|
let packet = PrivateMessagePacket(messageID: "pm-1", content: "hi there")
|
||||||
@@ -403,6 +448,8 @@ struct ChatTransportEventCoordinatorContextTests {
|
|||||||
await drainMainActorTasks()
|
await drainMainActorTasks()
|
||||||
#expect(context.appliedDeliveryStatuses.count == 2)
|
#expect(context.appliedDeliveryStatuses.count == 2)
|
||||||
#expect(context.appliedDeliveryStatuses[0].messageID == "m-1")
|
#expect(context.appliedDeliveryStatuses[0].messageID == "m-1")
|
||||||
|
#expect(context.appliedDeliveryStatuses[0].peerIDAliases == [peerID, stablePeerID])
|
||||||
|
#expect(!context.appliedDeliveryStatuses[0].peerIDAliases.contains(staleStablePeerID))
|
||||||
if case .delivered(let to, _) = context.appliedDeliveryStatuses[0].status {
|
if case .delivered(let to, _) = context.appliedDeliveryStatuses[0].status {
|
||||||
#expect(to == "alice")
|
#expect(to == "alice")
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ private final class MockChatVerificationContext: ChatVerificationContext {
|
|||||||
var noiseSessionKeysByPeerID: [PeerID: Data] = [:]
|
var noiseSessionKeysByPeerID: [PeerID: Data] = [:]
|
||||||
private(set) var installedCallbacks: (onPeerAuthenticated: (PeerID, String) -> Void, onHandshakeRequired: (PeerID) -> Void)?
|
private(set) var installedCallbacks: (onPeerAuthenticated: (PeerID, String) -> Void, onHandshakeRequired: (PeerID) -> Void)?
|
||||||
private(set) var triggeredHandshakes: [PeerID] = []
|
private(set) var triggeredHandshakes: [PeerID] = []
|
||||||
|
private(set) var privateMediaAuthenticatedPeers: [PeerID] = []
|
||||||
|
private(set) var securePrivateMessageRetryAliases: [[PeerID]] = []
|
||||||
private(set) var sentChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
private(set) var sentChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||||
private(set) var sentResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
private(set) var sentResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||||
|
|
||||||
@@ -113,6 +115,13 @@ private final class MockChatVerificationContext: ChatVerificationContext {
|
|||||||
establishedNoiseSessions.contains(peerID)
|
establishedNoiseSessions.contains(peerID)
|
||||||
}
|
}
|
||||||
func triggerHandshake(with peerID: PeerID) { triggeredHandshakes.append(peerID) }
|
func triggerHandshake(with peerID: PeerID) { triggeredHandshakes.append(peerID) }
|
||||||
|
func privateMediaPeerDidAuthenticate(_ peerID: PeerID) {
|
||||||
|
privateMediaAuthenticatedPeers.append(peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID]) {
|
||||||
|
securePrivateMessageRetryAliases.append(peerIDAliases)
|
||||||
|
}
|
||||||
|
|
||||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||||||
sentChallenges.append((peerID, noiseKeyHex, nonceA))
|
sentChallenges.append((peerID, noiseKeyHex, nonceA))
|
||||||
@@ -265,6 +274,10 @@ struct ChatVerificationCoordinatorContextTests {
|
|||||||
let peerID = PeerID(str: "1122334455667788")
|
let peerID = PeerID(str: "1122334455667788")
|
||||||
let noiseKey = Data(repeating: 0x33, count: 32)
|
let noiseKey = Data(repeating: 0x33, count: 32)
|
||||||
context.noiseSessionKeysByPeerID[peerID] = noiseKey
|
context.noiseSessionKeysByPeerID[peerID] = noiseKey
|
||||||
|
context.cacheStablePeerID(
|
||||||
|
PeerID(hexData: Data(repeating: 0x44, count: 32)),
|
||||||
|
for: peerID
|
||||||
|
)
|
||||||
context.verifiedFingerprints = ["fp-verified"]
|
context.verifiedFingerprints = ["fp-verified"]
|
||||||
|
|
||||||
coordinator.setupNoiseCallbacks()
|
coordinator.setupNoiseCallbacks()
|
||||||
@@ -275,8 +288,11 @@ struct ChatVerificationCoordinatorContextTests {
|
|||||||
callbacks?.onPeerAuthenticated(peerID, "fp-verified")
|
callbacks?.onPeerAuthenticated(peerID, "fp-verified")
|
||||||
await waitForMainQueue()
|
await waitForMainQueue()
|
||||||
#expect(context.encryptionStatuses[peerID] == .noiseVerified)
|
#expect(context.encryptionStatuses[peerID] == .noiseVerified)
|
||||||
#expect(context.stablePeerIDCache[peerID] == PeerID(hexData: noiseKey))
|
let stablePeerID = PeerID(hexData: noiseKey)
|
||||||
|
#expect(context.stablePeerIDCache[peerID] == stablePeerID)
|
||||||
#expect(context.invalidatedEncryptionCachePeers.contains(peerID))
|
#expect(context.invalidatedEncryptionCachePeers.contains(peerID))
|
||||||
|
#expect(context.privateMediaAuthenticatedPeers == [peerID])
|
||||||
|
#expect(context.securePrivateMessageRetryAliases == [[peerID, stablePeerID]])
|
||||||
|
|
||||||
// Handshake required -> handshaking status.
|
// Handshake required -> handshaking status.
|
||||||
callbacks?.onHandshakeRequired(peerID)
|
callbacks?.onHandshakeRequired(peerID)
|
||||||
|
|||||||
@@ -298,6 +298,137 @@ struct ChatViewModelDeliveryStatusTests {
|
|||||||
}())
|
}())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func authenticatedNoiseAckCannotClearAnotherPeersRetryState() async {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
let intendedPeer = PeerID(str: "0102030405060708")
|
||||||
|
let otherPeer = PeerID(str: "1112131415161718")
|
||||||
|
let messageID = "noise-peer-bound-ack"
|
||||||
|
|
||||||
|
viewModel.seedPrivateChat(
|
||||||
|
[
|
||||||
|
BitchatMessage(
|
||||||
|
id: messageID,
|
||||||
|
sender: viewModel.nickname,
|
||||||
|
content: "Keep retrying",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: "Intended",
|
||||||
|
senderPeerID: viewModel.myPeerID,
|
||||||
|
deliveryStatus: .sent
|
||||||
|
)
|
||||||
|
],
|
||||||
|
for: intendedPeer
|
||||||
|
)
|
||||||
|
transport.reachablePeers.insert(intendedPeer)
|
||||||
|
viewModel.messageRouter.sendPrivate(
|
||||||
|
"Keep retrying",
|
||||||
|
to: intendedPeer,
|
||||||
|
recipientNickname: "Intended",
|
||||||
|
messageID: messageID
|
||||||
|
)
|
||||||
|
#expect(transport.sentPrivateMessages.count == 1)
|
||||||
|
|
||||||
|
// This models a decrypted Noise receipt: the transport-authenticated
|
||||||
|
// peer is authoritative, not the attacker-controlled message ID.
|
||||||
|
viewModel.didReceiveNoisePayload(
|
||||||
|
from: otherPeer,
|
||||||
|
type: .delivered,
|
||||||
|
payload: Data(messageID.utf8),
|
||||||
|
timestamp: Date()
|
||||||
|
)
|
||||||
|
for _ in 0..<10 { await Task.yield() }
|
||||||
|
|
||||||
|
#expect(isSent(viewModel.conversations.deliveryStatus(forMessageID: messageID)))
|
||||||
|
viewModel.messageRouter.flushOutbox(for: intendedPeer)
|
||||||
|
#expect(transport.sentPrivateMessages.count == 2)
|
||||||
|
|
||||||
|
viewModel.didReceiveNoisePayload(
|
||||||
|
from: intendedPeer,
|
||||||
|
type: .delivered,
|
||||||
|
payload: Data(messageID.utf8),
|
||||||
|
timestamp: Date()
|
||||||
|
)
|
||||||
|
for _ in 0..<10 { await Task.yield() }
|
||||||
|
|
||||||
|
#expect(isDelivered(viewModel.conversations.deliveryStatus(forMessageID: messageID)))
|
||||||
|
viewModel.messageRouter.flushOutbox(for: intendedPeer)
|
||||||
|
#expect(transport.sentPrivateMessages.count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func authenticatedNoiseAckClearsOnlyIntendedPeersPrivateMediaRetry() async throws {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
let intendedPeer = PeerID(str: "0102030405060708")
|
||||||
|
let otherPeer = PeerID(str: "1112131415161718")
|
||||||
|
let fileName = "voice_0011223344556677.m4a"
|
||||||
|
let content = Data("voice".utf8)
|
||||||
|
|
||||||
|
transport.privateMediaPolicies[intendedPeer] = .encrypted
|
||||||
|
transport.privateMediaReceiptSessionGenerations[intendedPeer] = UUID()
|
||||||
|
viewModel.selectedPrivateChatPeer = intendedPeer
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(
|
||||||
|
context: viewModel,
|
||||||
|
prepareVoiceNotePacket: { _ in
|
||||||
|
BitchatFilePacket(
|
||||||
|
fileName: fileName,
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "audio/mp4",
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
},
|
||||||
|
transferIDFactory: { "\($0)-receipt-ack" }
|
||||||
|
)
|
||||||
|
viewModel.mediaTransferCoordinator = coordinator
|
||||||
|
|
||||||
|
let directory = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(
|
||||||
|
"scoped-media-ack-\(UUID().uuidString)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: directory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let sourceURL = directory.appendingPathComponent(fileName)
|
||||||
|
try content.write(to: sourceURL)
|
||||||
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
|
|
||||||
|
coordinator.sendVoiceNote(at: sourceURL)
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{
|
||||||
|
transport.sentPrivateFiles.count == 1
|
||||||
|
&& coordinator.retainedReconnectRetryCount == 1
|
||||||
|
},
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
let messageID = try #require(
|
||||||
|
viewModel.privateChats[intendedPeer]?.first?.id
|
||||||
|
)
|
||||||
|
let transferID = try #require(
|
||||||
|
transport.sentPrivateFiles.first?.transferID
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(!viewModel.deliveryCoordinator
|
||||||
|
.updateAcknowledgedMessageDeliveryStatus(
|
||||||
|
messageID,
|
||||||
|
status: .delivered(to: "Other", at: Date()),
|
||||||
|
from: [otherPeer]
|
||||||
|
))
|
||||||
|
#expect(coordinator.retainedReconnectRetryCount == 1)
|
||||||
|
#expect(transport.cancelledTransfers.isEmpty)
|
||||||
|
|
||||||
|
#expect(viewModel.deliveryCoordinator
|
||||||
|
.updateAcknowledgedMessageDeliveryStatus(
|
||||||
|
messageID,
|
||||||
|
status: .delivered(to: "Intended", at: Date()),
|
||||||
|
from: [intendedPeer]
|
||||||
|
))
|
||||||
|
#expect(coordinator.retainedReconnectRetryCount == 0)
|
||||||
|
#expect(transport.cancelledTransfers == [transferID])
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func cleanupOldReadReceipts_removesReceiptIDsWithoutMessages() async {
|
func cleanupOldReadReceipts_removesReceiptIDsWithoutMessages() async {
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
@@ -577,12 +708,26 @@ private final class MockChatDeliveryContext: ChatDeliveryContext {
|
|||||||
var isStartupPhase = false
|
var isStartupPhase = false
|
||||||
private(set) var notifyUIChangedCount = 0
|
private(set) var notifyUIChangedCount = 0
|
||||||
private(set) var markedDeliveredMessageIDs: [String] = []
|
private(set) var markedDeliveredMessageIDs: [String] = []
|
||||||
|
private(set) var peerBoundDeliveredMessages: [(messageID: String, peerIDs: Set<PeerID>)] = []
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||||
store.setDeliveryStatus(status, forMessageID: messageID)
|
store.setDeliveryStatus(status, forMessageID: messageID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func setDeliveryStatus(
|
||||||
|
_ status: DeliveryStatus,
|
||||||
|
forMessageID messageID: String,
|
||||||
|
inDirectPeerAliases peerIDs: Set<PeerID>
|
||||||
|
) -> Bool {
|
||||||
|
store.setDeliveryStatus(
|
||||||
|
status,
|
||||||
|
forMessageID: messageID,
|
||||||
|
inDirectPeerAliases: peerIDs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
|
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
|
||||||
store.deliveryStatus(forMessageID: messageID)
|
store.deliveryStatus(forMessageID: messageID)
|
||||||
}
|
}
|
||||||
@@ -604,6 +749,24 @@ private final class MockChatDeliveryContext: ChatDeliveryContext {
|
|||||||
func markMessageDelivered(_ messageID: String) {
|
func markMessageDelivered(_ messageID: String) {
|
||||||
markedDeliveredMessageIDs.append(messageID)
|
markedDeliveredMessageIDs.append(messageID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func markMessageDelivered(_ messageID: String, from peerIDs: Set<PeerID>) {
|
||||||
|
peerBoundDeliveredMessages.append((messageID, peerIDs))
|
||||||
|
}
|
||||||
|
|
||||||
|
func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set<PeerID>) -> Bool {
|
||||||
|
peerIDs.contains { peerID in
|
||||||
|
contextMessages(for: peerID).contains { message in
|
||||||
|
message.id == messageID && message.senderPeerID == localPeerID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let localPeerID = PeerID(str: "aabbccddeeff0011")
|
||||||
|
|
||||||
|
private func contextMessages(for peerID: PeerID) -> [BitchatMessage] {
|
||||||
|
store.conversationsByID[.directPeer(peerID)]?.messages ?? []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -685,7 +848,151 @@ struct ChatDeliveryCoordinatorContextTests {
|
|||||||
|
|
||||||
#expect(isRead(coordinator.deliveryStatus(for: messageID)))
|
#expect(isRead(coordinator.deliveryStatus(for: messageID)))
|
||||||
#expect(context.notifyUIChangedCount == 1)
|
#expect(context.notifyUIChangedCount == 1)
|
||||||
#expect(context.markedDeliveredMessageIDs == [messageID])
|
#expect(context.markedDeliveredMessageIDs.isEmpty)
|
||||||
|
#expect(context.peerBoundDeliveredMessages.count == 1)
|
||||||
|
#expect(context.peerBoundDeliveredMessages[0].messageID == messageID)
|
||||||
|
#expect(context.peerBoundDeliveredMessages[0].peerIDs == [peerID])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func authenticatedReceiptWithCollidingIDUpdatesOnlyAuthenticatedAliases() async {
|
||||||
|
let context = MockChatDeliveryContext()
|
||||||
|
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||||
|
let ephemeralPeerID = PeerID(str: "0102030405060708")
|
||||||
|
let stablePeerID = PeerID(hexData: Data(repeating: 0x08, count: 32))
|
||||||
|
let otherPeerID = PeerID(str: "1112131415161718")
|
||||||
|
let messageID = "authenticated-receipt-collision"
|
||||||
|
let mirroredMessage = makePrivateMessage(id: messageID, status: .sent)
|
||||||
|
|
||||||
|
context.store.append(mirroredMessage, to: .directPeer(ephemeralPeerID))
|
||||||
|
context.store.append(mirroredMessage, to: .directPeer(stablePeerID))
|
||||||
|
context.store.append(
|
||||||
|
makePrivateMessage(id: messageID, status: .sent),
|
||||||
|
to: .directPeer(otherPeerID)
|
||||||
|
)
|
||||||
|
context.store.append(makePublicMessage(id: messageID, status: .sent), to: .mesh)
|
||||||
|
|
||||||
|
let aliases: Set<PeerID> = [ephemeralPeerID, stablePeerID]
|
||||||
|
let didUpdate = coordinator.updateAcknowledgedMessageDeliveryStatus(
|
||||||
|
messageID,
|
||||||
|
status: .delivered(to: "Peer", at: Date()),
|
||||||
|
from: aliases
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(didUpdate)
|
||||||
|
#expect(isDelivered(
|
||||||
|
context.store.conversation(for: .directPeer(ephemeralPeerID))
|
||||||
|
.message(withID: messageID)?.deliveryStatus
|
||||||
|
))
|
||||||
|
#expect(isDelivered(
|
||||||
|
context.store.conversation(for: .directPeer(stablePeerID))
|
||||||
|
.message(withID: messageID)?.deliveryStatus
|
||||||
|
))
|
||||||
|
#expect(isSent(
|
||||||
|
context.store.conversation(for: .directPeer(otherPeerID))
|
||||||
|
.message(withID: messageID)?.deliveryStatus
|
||||||
|
))
|
||||||
|
#expect(isSent(
|
||||||
|
context.store.conversation(for: .mesh)
|
||||||
|
.message(withID: messageID)?.deliveryStatus
|
||||||
|
))
|
||||||
|
#expect(context.peerBoundDeliveredMessages.count == 1)
|
||||||
|
#expect(context.peerBoundDeliveredMessages[0].messageID == messageID)
|
||||||
|
#expect(context.peerBoundDeliveredMessages[0].peerIDs == aliases)
|
||||||
|
#expect(context.notifyUIChangedCount == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func authenticatedReceiptRemainsScopedAfterPeerAliasMigration() async {
|
||||||
|
let context = MockChatDeliveryContext()
|
||||||
|
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||||
|
let ephemeralPeerID = PeerID(str: "2122232425262728")
|
||||||
|
let stablePeerID = PeerID(hexData: Data(repeating: 0x28, count: 32))
|
||||||
|
let otherPeerID = PeerID(str: "3132333435363738")
|
||||||
|
let messageID = "authenticated-receipt-after-migration"
|
||||||
|
|
||||||
|
context.store.append(
|
||||||
|
makePrivateMessage(id: messageID, status: .sent),
|
||||||
|
to: .directPeer(ephemeralPeerID)
|
||||||
|
)
|
||||||
|
context.store.append(
|
||||||
|
makePrivateMessage(id: messageID, status: .sent),
|
||||||
|
to: .directPeer(otherPeerID)
|
||||||
|
)
|
||||||
|
context.store.migrateConversation(
|
||||||
|
from: .directPeer(ephemeralPeerID),
|
||||||
|
to: .directPeer(stablePeerID)
|
||||||
|
)
|
||||||
|
|
||||||
|
let didUpdate = coordinator.updateAcknowledgedMessageDeliveryStatus(
|
||||||
|
messageID,
|
||||||
|
status: .read(by: "Peer", at: Date()),
|
||||||
|
from: [ephemeralPeerID, stablePeerID]
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(didUpdate)
|
||||||
|
#expect(context.store.conversationsByID[.directPeer(ephemeralPeerID)] == nil)
|
||||||
|
#expect(isRead(
|
||||||
|
context.store.conversation(for: .directPeer(stablePeerID))
|
||||||
|
.message(withID: messageID)?.deliveryStatus
|
||||||
|
))
|
||||||
|
#expect(isSent(
|
||||||
|
context.store.conversation(for: .directPeer(otherPeerID))
|
||||||
|
.message(withID: messageID)?.deliveryStatus
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func receiptFromWrongPeerDoesNotUpdateOrTerminalizeOutgoingMessage() async {
|
||||||
|
let context = MockChatDeliveryContext()
|
||||||
|
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||||
|
let intendedPeer = PeerID(str: "0102030405060708")
|
||||||
|
let otherPeer = PeerID(str: "1112131415161718")
|
||||||
|
let messageID = "wrong-peer-receipt"
|
||||||
|
context.store.append(
|
||||||
|
makePrivateMessage(id: messageID, status: .sent),
|
||||||
|
to: .directPeer(intendedPeer)
|
||||||
|
)
|
||||||
|
|
||||||
|
coordinator.didReceiveReadReceipt(
|
||||||
|
ReadReceipt(
|
||||||
|
originalMessageID: messageID,
|
||||||
|
readerID: otherPeer,
|
||||||
|
readerNickname: "Other"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(isSent(coordinator.deliveryStatus(for: messageID)))
|
||||||
|
#expect(context.peerBoundDeliveredMessages.isEmpty)
|
||||||
|
#expect(context.markedDeliveredMessageIDs.isEmpty)
|
||||||
|
#expect(context.notifyUIChangedCount == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func rejectedStaleReceiptDoesNotTerminalizeRetryState() async {
|
||||||
|
let context = MockChatDeliveryContext()
|
||||||
|
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||||
|
let peerID = PeerID(str: "0102030405060708")
|
||||||
|
let messageID = "stale-receipt"
|
||||||
|
context.store.append(
|
||||||
|
makePrivateMessage(
|
||||||
|
id: messageID,
|
||||||
|
status: .read(by: "Peer", at: Date())
|
||||||
|
),
|
||||||
|
to: .directPeer(peerID)
|
||||||
|
)
|
||||||
|
|
||||||
|
let didUpdate = coordinator.updateAcknowledgedMessageDeliveryStatus(
|
||||||
|
messageID,
|
||||||
|
status: .delivered(to: "Peer", at: Date()),
|
||||||
|
from: [peerID]
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(!didUpdate)
|
||||||
|
#expect(isRead(coordinator.deliveryStatus(for: messageID)))
|
||||||
|
#expect(context.peerBoundDeliveredMessages.isEmpty)
|
||||||
|
#expect(context.markedDeliveredMessageIDs.isEmpty)
|
||||||
|
#expect(context.notifyUIChangedCount == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
|
|||||||
@@ -393,20 +393,25 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func subscribeGiftWrap_rejectsOversizedEmbeddedPacket() async throws {
|
func privateEnvelope_rejectsOversizedEmbeddedPacketBeforePublication() async throws {
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
|
|
||||||
let oversized = Data(repeating: 0x41, count: FileTransferLimits.maxFramedFileBytes + 1)
|
let oversized = Data(repeating: 0x41, count: FileTransferLimits.maxFramedFileBytes + 1)
|
||||||
let content = "bitchat1:" + base64URLEncode(oversized)
|
let content = "bitchat1:" + base64URLEncode(oversized)
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
do {
|
||||||
content: content,
|
_ = try NostrProtocol.createPrivateEnvelope(
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
content: content,
|
||||||
senderIdentity: sender
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
)
|
senderIdentity: sender
|
||||||
|
)
|
||||||
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
|
Issue.record("Expected oversized private-envelope plaintext to be rejected")
|
||||||
|
} catch NostrError.invalidCiphertext {
|
||||||
|
// Rejected before encryption/publication, as intended.
|
||||||
|
} catch {
|
||||||
|
Issue.record("Expected NostrError.invalidCiphertext, got \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||||
#expect(viewModel.privateChats.isEmpty)
|
#expect(viewModel.privateChats.isEmpty)
|
||||||
@@ -451,7 +456,7 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func subscribeGiftWrap_deliveredAckUpdatesExistingMessage() async throws {
|
func subscribePrivateEnvelope_deliveredAckUpdatesExistingMessage() async throws {
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
@@ -473,13 +478,13 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
], for: convKey)
|
], for: convKey)
|
||||||
|
|
||||||
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||||
content: content,
|
content: content,
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
|
viewModel.subscribePrivateEnvelope(envelope, id: recipient)
|
||||||
|
|
||||||
let didUpdate = await TestHelpers.waitUntil(
|
let didUpdate = await TestHelpers.waitUntil(
|
||||||
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||||
@@ -489,7 +494,7 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func subscribeGiftWrap_readAckUpdatesExistingMessage() async throws {
|
func subscribePrivateEnvelope_readAckUpdatesExistingMessage() async throws {
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
@@ -511,13 +516,13 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
], for: convKey)
|
], for: convKey)
|
||||||
|
|
||||||
let content = try ackContent(type: .readReceipt, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
let content = try ackContent(type: .readReceipt, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||||
content: content,
|
content: content,
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
|
viewModel.subscribePrivateEnvelope(envelope, id: recipient)
|
||||||
|
|
||||||
let didUpdate = await TestHelpers.waitUntil(
|
let didUpdate = await TestHelpers.waitUntil(
|
||||||
{ isRead(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
{ isRead(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||||
@@ -527,28 +532,28 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func handleGiftWrap_privateMessageStoresConversationAndMapping() async throws {
|
func handlePrivateEnvelope_privateMessageStoresConversationAndMapping() async throws {
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
let messageID = "gift-private"
|
let messageID = "envelope-private"
|
||||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||||
|
|
||||||
let content = try privateMessageContent(
|
let content = try privateMessageContent(
|
||||||
text: "Hello from gift wrap",
|
text: "Hello from private envelope",
|
||||||
messageID: messageID,
|
messageID: messageID,
|
||||||
senderPeerID: PeerID(str: "0123456789abcdef")
|
senderPeerID: PeerID(str: "0123456789abcdef")
|
||||||
)
|
)
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||||
content: content,
|
content: content,
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
viewModel.handleGiftWrap(giftWrap, id: recipient)
|
viewModel.handlePrivateEnvelope(envelope, id: recipient)
|
||||||
|
|
||||||
let didStore = await TestHelpers.waitUntil(
|
let didStore = await TestHelpers.waitUntil(
|
||||||
{ viewModel.privateChats[convKey]?.first?.content == "Hello from gift wrap" },
|
{ viewModel.privateChats[convKey]?.first?.content == "Hello from private envelope" },
|
||||||
timeout: 5.0
|
timeout: 5.0
|
||||||
)
|
)
|
||||||
#expect(didStore)
|
#expect(didStore)
|
||||||
@@ -557,11 +562,11 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func handleGiftWrap_blockedSenderSkipsMessageStorage() async throws {
|
func handlePrivateEnvelope_blockedSenderSkipsMessageStorage() async throws {
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
let messageID = "gift-blocked"
|
let messageID = "envelope-blocked"
|
||||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||||
|
|
||||||
viewModel.identityManager.setNostrBlocked(sender.publicKeyHex, isBlocked: true)
|
viewModel.identityManager.setNostrBlocked(sender.publicKeyHex, isBlocked: true)
|
||||||
@@ -571,13 +576,13 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
messageID: messageID,
|
messageID: messageID,
|
||||||
senderPeerID: PeerID(str: "0123456789abcdef")
|
senderPeerID: PeerID(str: "0123456789abcdef")
|
||||||
)
|
)
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||||
content: content,
|
content: content,
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
viewModel.handleGiftWrap(giftWrap, id: recipient)
|
viewModel.handlePrivateEnvelope(envelope, id: recipient)
|
||||||
|
|
||||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||||
#expect(viewModel.privateChats[convKey] == nil)
|
#expect(viewModel.privateChats[convKey] == nil)
|
||||||
@@ -585,12 +590,12 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func handleGiftWrap_deliveredAckUpdatesExistingMessage() async throws {
|
func handlePrivateEnvelope_deliveredAckUpdatesExistingMessage() async throws {
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||||
let messageID = "gift-delivered"
|
let messageID = "envelope-delivered"
|
||||||
|
|
||||||
viewModel.seedPrivateChat([
|
viewModel.seedPrivateChat([
|
||||||
BitchatMessage(
|
BitchatMessage(
|
||||||
@@ -607,13 +612,13 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
], for: convKey)
|
], for: convKey)
|
||||||
|
|
||||||
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||||
content: content,
|
content: content,
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
viewModel.handleGiftWrap(giftWrap, id: recipient)
|
viewModel.handlePrivateEnvelope(envelope, id: recipient)
|
||||||
|
|
||||||
let didUpdate = await TestHelpers.waitUntil(
|
let didUpdate = await TestHelpers.waitUntil(
|
||||||
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||||
@@ -781,6 +786,63 @@ struct ChatViewModelGeoDMTests {
|
|||||||
#expect(isFailed(status: viewModel.privateChats[convKey]?.last?.deliveryStatus))
|
#expect(isFailed(status: viewModel.privateChats[convKey]?.last?.deliveryStatus))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func geohashTerminalRelayFailure_transitionsSentMessageToFailed() async throws {
|
||||||
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let messageID = "geo-terminal-failure"
|
||||||
|
let convKey = PeerID(nostr_: recipient.publicKeyHex)
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: messageID,
|
||||||
|
sender: viewModel.nickname,
|
||||||
|
content: "queued geohash message",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: "recipient",
|
||||||
|
senderPeerID: viewModel.myPeerID,
|
||||||
|
deliveryStatus: .sent
|
||||||
|
)
|
||||||
|
viewModel.seedPrivateChat([message], for: convKey)
|
||||||
|
|
||||||
|
var terminalFailure: (@MainActor () -> Void)?
|
||||||
|
let dependencies = NostrTransport.Dependencies(
|
||||||
|
notificationCenter: NotificationCenter(),
|
||||||
|
loadFavorites: { [:] },
|
||||||
|
favoriteStatusForNoiseKey: { _ in nil },
|
||||||
|
favoriteStatusForPeerID: { _ in nil },
|
||||||
|
currentIdentity: { sender },
|
||||||
|
registerPendingPrivateEnvelope: { _ in },
|
||||||
|
sendPrivateEnvelopeBatch: { _, failure in
|
||||||
|
terminalFailure = failure
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
scheduleAfter: { _, _ in },
|
||||||
|
relayConnectivity: {
|
||||||
|
Just(false).eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let transport = viewModel.makeGeohashNostrTransport(
|
||||||
|
dependencies: dependencies
|
||||||
|
)
|
||||||
|
|
||||||
|
let accepted = transport.sendPrivateMessageGeohash(
|
||||||
|
content: message.content,
|
||||||
|
toRecipientHex: recipient.publicKeyHex,
|
||||||
|
from: sender,
|
||||||
|
messageID: messageID
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(accepted)
|
||||||
|
#expect(viewModel.privateChats[convKey]?.first?.deliveryStatus == .sent)
|
||||||
|
let fail = try #require(terminalFailure)
|
||||||
|
fail()
|
||||||
|
#expect(isFailed(
|
||||||
|
status: viewModel.privateChats[convKey]?.first?.deliveryStatus
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// The blocked notice belongs in the DM thread the person is typing in,
|
/// The blocked notice belongs in the DM thread the person is typing in,
|
||||||
/// not on the active location-channel timeline.
|
/// not on the active location-channel timeline.
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
|
|||||||
@@ -1194,6 +1194,759 @@ struct ChatViewModelBluetoothTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Media Deletion Tests
|
||||||
|
|
||||||
|
struct ChatViewModelPrivateMediaDeletionTests {
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func deleteMediaMessageTombstonesIncomingButNotOutgoingStableMedia() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
let peerID = PeerID(str: String(repeating: "8", count: 64))
|
||||||
|
let incomingID = "media-\(String(repeating: "e", count: 32))"
|
||||||
|
let outgoingID = "media-\(String(repeating: "f", count: 32))"
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: incomingID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: peerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "incoming.jpg"
|
||||||
|
),
|
||||||
|
privateMediaMessage(
|
||||||
|
id: outgoingID,
|
||||||
|
sender: viewModel.nickname,
|
||||||
|
senderPeerID: transport.myPeerID,
|
||||||
|
recipient: "Peer",
|
||||||
|
filename: "outgoing.jpg"
|
||||||
|
)
|
||||||
|
], for: peerID)
|
||||||
|
|
||||||
|
viewModel.deleteMediaMessage(messageID: outgoingID)
|
||||||
|
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
|
||||||
|
#expect(viewModel.privateChats[peerID]?.map(\.id) == [incomingID])
|
||||||
|
|
||||||
|
viewModel.deleteMediaMessage(messageID: incomingID)
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaMessageIDBatches == [[incomingID]]
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaRelativePaths
|
||||||
|
== [[incomingID: "images/incoming/incoming.jpg"]]
|
||||||
|
)
|
||||||
|
#expect((viewModel.privateChats[peerID] ?? []).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func stableDeleteProtectsPathSharedWithLegacyBubble() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
transport.persistDeletedPrivateMediaResult = false
|
||||||
|
let peerID = PeerID(str: String(repeating: "6", count: 64))
|
||||||
|
let stableID = "media-\(String(repeating: "5", count: 32))"
|
||||||
|
let legacyID = UUID().uuidString
|
||||||
|
let filename = "shared-migration.jpg"
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: stableID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: peerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: filename
|
||||||
|
),
|
||||||
|
privateMediaMessage(
|
||||||
|
id: legacyID,
|
||||||
|
sender: "Old client",
|
||||||
|
senderPeerID: peerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: filename
|
||||||
|
)
|
||||||
|
], for: peerID)
|
||||||
|
|
||||||
|
viewModel.deleteMediaMessage(messageID: stableID)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaMessageIDBatches == [[stableID]]
|
||||||
|
)
|
||||||
|
#expect(transport.deletedPrivateMediaRelativePaths == [[:]])
|
||||||
|
#expect(
|
||||||
|
transport.protectedPrivateMediaRelativePaths == [[
|
||||||
|
"images/incoming/\(filename)"
|
||||||
|
]]
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
viewModel.privateChats[peerID]?.map(\.id)
|
||||||
|
== [stableID, legacyID]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func legacyIncomingDeleteLeavesAmbiguousPayloadForQuota() throws {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
let peerID = PeerID(str: String(repeating: "3", count: 64))
|
||||||
|
let incoming = try FileManager.default.url(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
)
|
||||||
|
.appendingPathComponent(
|
||||||
|
"files/images/incoming",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: incoming,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let payload = incoming.appendingPathComponent(
|
||||||
|
"legacy-delete-\(UUID().uuidString).jpg"
|
||||||
|
)
|
||||||
|
try Data([0x01]).write(to: payload)
|
||||||
|
defer { try? FileManager.default.removeItem(at: payload) }
|
||||||
|
let legacyID = UUID().uuidString
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: legacyID,
|
||||||
|
sender: "Old client",
|
||||||
|
senderPeerID: peerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: payload.lastPathComponent
|
||||||
|
)
|
||||||
|
], for: peerID)
|
||||||
|
|
||||||
|
viewModel.deleteMediaMessage(messageID: legacyID)
|
||||||
|
|
||||||
|
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
|
||||||
|
#expect((viewModel.privateChats[peerID] ?? []).isEmpty)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func clearPrivateChatTombstonesIncomingAndCancelsOutgoingBeforeClear() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
let peerID = PeerID(str: String(repeating: "1", count: 64))
|
||||||
|
let incomingID = "media-\(String(repeating: "a", count: 32))"
|
||||||
|
let outgoingID = "media-\(String(repeating: "b", count: 32))"
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: incomingID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: peerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "incoming.jpg"
|
||||||
|
),
|
||||||
|
privateMediaMessage(
|
||||||
|
id: outgoingID,
|
||||||
|
sender: viewModel.nickname,
|
||||||
|
senderPeerID: transport.myPeerID,
|
||||||
|
recipient: "Peer",
|
||||||
|
filename: "outgoing.jpg"
|
||||||
|
),
|
||||||
|
BitchatMessage(
|
||||||
|
id: "ordinary-message",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "hello",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: peerID
|
||||||
|
)
|
||||||
|
], for: peerID)
|
||||||
|
viewModel.registerTransfer(
|
||||||
|
transferId: "outgoing-clear-transfer",
|
||||||
|
messageID: outgoingID
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(peerID)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaMessageIDBatches == [[incomingID]]
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaRelativePaths
|
||||||
|
== [[incomingID: "images/incoming/incoming.jpg"]]
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
transport.cancelledTransfers == ["outgoing-clear-transfer"]
|
||||||
|
)
|
||||||
|
#expect(viewModel.messageIDToTransferId[outgoingID] == nil)
|
||||||
|
#expect(viewModel.privateChats[peerID]?.isEmpty == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func clearPrivateChatPreservesCapturedMessagesWhenTombstoneFails() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
transport.persistDeletedPrivateMediaResult = false
|
||||||
|
let peerID = PeerID(str: String(repeating: "2", count: 64))
|
||||||
|
let incomingID = "media-\(String(repeating: "c", count: 32))"
|
||||||
|
let outgoingID = "media-\(String(repeating: "7", count: 32))"
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: incomingID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: peerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "incoming.jpg"
|
||||||
|
),
|
||||||
|
privateMediaMessage(
|
||||||
|
id: outgoingID,
|
||||||
|
sender: viewModel.nickname,
|
||||||
|
senderPeerID: transport.myPeerID,
|
||||||
|
recipient: "Peer",
|
||||||
|
filename: "outgoing.jpg"
|
||||||
|
),
|
||||||
|
BitchatMessage(
|
||||||
|
id: "ordinary-message",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "keep me on failure",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: peerID
|
||||||
|
)
|
||||||
|
], for: peerID)
|
||||||
|
viewModel.registerTransfer(
|
||||||
|
transferId: "failed-clear-outgoing",
|
||||||
|
messageID: outgoingID
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(peerID)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaMessageIDBatches == [[incomingID]]
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
viewModel.privateChats[peerID]?.map(\.id)
|
||||||
|
== [incomingID, outgoingID, "ordinary-message"]
|
||||||
|
)
|
||||||
|
#expect(transport.cancelledTransfers == ["failed-clear-outgoing"])
|
||||||
|
#expect(viewModel.messageIDToTransferId[outgoingID] == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func clearFailurePreservesSameNameIncomingPayload() throws {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
transport.persistDeletedPrivateMediaResult = false
|
||||||
|
let peerID = PeerID(str: String(repeating: "7", count: 64))
|
||||||
|
let incomingID = "media-\(String(repeating: "8", count: 32))"
|
||||||
|
let outgoingID = "media-\(String(repeating: "9", count: 32))"
|
||||||
|
let filename = "clear-collision-\(UUID().uuidString).jpg"
|
||||||
|
let filesDirectory = try FileManager.default.url(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
)
|
||||||
|
.appendingPathComponent("files/images", isDirectory: true)
|
||||||
|
let incomingDirectory = filesDirectory.appendingPathComponent(
|
||||||
|
"incoming",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
let outgoingDirectory = filesDirectory.appendingPathComponent(
|
||||||
|
"outgoing",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: incomingDirectory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: outgoingDirectory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let incomingURL = incomingDirectory.appendingPathComponent(filename)
|
||||||
|
let outgoingURL = outgoingDirectory.appendingPathComponent(filename)
|
||||||
|
try Data("incoming".utf8).write(to: incomingURL, options: .atomic)
|
||||||
|
try Data("outgoing".utf8).write(to: outgoingURL, options: .atomic)
|
||||||
|
defer {
|
||||||
|
try? FileManager.default.removeItem(at: incomingURL)
|
||||||
|
try? FileManager.default.removeItem(at: outgoingURL)
|
||||||
|
}
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: incomingID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: peerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: filename
|
||||||
|
),
|
||||||
|
privateMediaMessage(
|
||||||
|
id: outgoingID,
|
||||||
|
sender: viewModel.nickname,
|
||||||
|
senderPeerID: transport.myPeerID,
|
||||||
|
recipient: "Peer",
|
||||||
|
filename: filename
|
||||||
|
)
|
||||||
|
], for: peerID)
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(peerID)
|
||||||
|
|
||||||
|
#expect(FileManager.default.fileExists(atPath: incomingURL.path))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: outgoingURL.path))
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaRelativePaths
|
||||||
|
== [[incomingID: "images/incoming/\(filename)"]]
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
viewModel.privateChats[peerID]?.map(\.id)
|
||||||
|
== [incomingID, outgoingID]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func clearPrivateChatPreservesArrivalDuringTombstoneIO() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
transport.deferDeletedPrivateMediaPersistence = true
|
||||||
|
let peerID = PeerID(str: String(repeating: "3", count: 64))
|
||||||
|
let incomingID = "media-\(String(repeating: "d", count: 32))"
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: incomingID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: peerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "incoming.jpg"
|
||||||
|
),
|
||||||
|
BitchatMessage(
|
||||||
|
id: "captured-text",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "old",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: peerID
|
||||||
|
)
|
||||||
|
], for: peerID)
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(peerID)
|
||||||
|
let arrival = BitchatMessage(
|
||||||
|
id: "concurrent-arrival",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "new",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: peerID
|
||||||
|
)
|
||||||
|
#expect(viewModel.appendPrivateMessage(arrival, to: peerID))
|
||||||
|
|
||||||
|
transport.resolveNextDeletedPrivateMediaPersistence(true)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
viewModel.privateChats[peerID]?.map(\.id)
|
||||||
|
== ["concurrent-arrival"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func clearPrivateChatPreservesActiveLiveVoiceAssembly() throws {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
let peerID = PeerID(str: String(repeating: "7", count: 64))
|
||||||
|
viewModel.selectedPrivateChatPeer = peerID
|
||||||
|
let burstID = Data(
|
||||||
|
repeating: 0xE1,
|
||||||
|
count: VoiceBurstPacket.burstIDSize
|
||||||
|
)
|
||||||
|
let start = try #require(VoiceBurstPacket(
|
||||||
|
burstID: burstID,
|
||||||
|
seq: 0,
|
||||||
|
kind: .start(codec: .aacLC16kMono)
|
||||||
|
))
|
||||||
|
let cancel = try #require(VoiceBurstPacket(
|
||||||
|
burstID: burstID,
|
||||||
|
seq: 1,
|
||||||
|
kind: .canceled
|
||||||
|
))
|
||||||
|
let coordinator = viewModel.liveVoiceCoordinator
|
||||||
|
defer {
|
||||||
|
coordinator.handleVoiceFramePayload(
|
||||||
|
from: peerID,
|
||||||
|
payload: cancel.encode(),
|
||||||
|
timestamp: Date()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
coordinator.handleVoiceFramePayload(
|
||||||
|
from: peerID,
|
||||||
|
payload: start.encode(),
|
||||||
|
timestamp: Date()
|
||||||
|
)
|
||||||
|
let liveMessage = try #require(
|
||||||
|
viewModel.privateChats[peerID]?.first
|
||||||
|
)
|
||||||
|
#expect(coordinator.isLiveVoiceMessage(liveMessage))
|
||||||
|
let ordinary = BitchatMessage(
|
||||||
|
id: "clear-around-live-voice",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "old text",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: peerID
|
||||||
|
)
|
||||||
|
#expect(viewModel.appendPrivateMessage(ordinary, to: peerID))
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(peerID)
|
||||||
|
|
||||||
|
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
|
||||||
|
#expect(
|
||||||
|
viewModel.privateChats[peerID]?.map(\.id)
|
||||||
|
== [liveMessage.id]
|
||||||
|
)
|
||||||
|
#expect(coordinator.isLiveVoiceMessage(liveMessage))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func overlappingClearsTombstoneTheLastMirroredStableAlias() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
transport.deferDeletedPrivateMediaPersistence = true
|
||||||
|
let firstPeerID = PeerID(str: String(repeating: "b", count: 64))
|
||||||
|
let secondPeerID = PeerID(str: String(repeating: "c", count: 64))
|
||||||
|
let sharedID = "media-\(String(repeating: "1", count: 32))"
|
||||||
|
let firstUniqueID = "media-\(String(repeating: "2", count: 32))"
|
||||||
|
let secondUniqueID = "media-\(String(repeating: "3", count: 32))"
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: sharedID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: firstPeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "shared.jpg"
|
||||||
|
),
|
||||||
|
privateMediaMessage(
|
||||||
|
id: firstUniqueID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: firstPeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "first.jpg"
|
||||||
|
)
|
||||||
|
], for: firstPeerID)
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: sharedID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: secondPeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "shared.jpg"
|
||||||
|
),
|
||||||
|
privateMediaMessage(
|
||||||
|
id: secondUniqueID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: secondPeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "second.jpg"
|
||||||
|
)
|
||||||
|
], for: secondPeerID)
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(firstPeerID)
|
||||||
|
viewModel.clearPrivateChat(secondPeerID)
|
||||||
|
let queuedArrival = BitchatMessage(
|
||||||
|
id: "arrival-after-queued-clear",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "new",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: secondPeerID
|
||||||
|
)
|
||||||
|
#expect(viewModel.appendPrivateMessage(
|
||||||
|
queuedArrival,
|
||||||
|
to: secondPeerID
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaMessageIDBatches
|
||||||
|
== [[firstUniqueID]]
|
||||||
|
)
|
||||||
|
|
||||||
|
transport.resolveNextDeletedPrivateMediaPersistence(true)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaMessageIDBatches == [
|
||||||
|
[firstUniqueID],
|
||||||
|
[secondUniqueID, sharedID].sorted()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
transport.resolveNextDeletedPrivateMediaPersistence(true)
|
||||||
|
|
||||||
|
#expect((viewModel.privateChats[firstPeerID] ?? []).isEmpty)
|
||||||
|
#expect(
|
||||||
|
viewModel.privateChats[secondPeerID]?.map(\.id)
|
||||||
|
== ["arrival-after-queued-clear"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func clearFollowsCapturedRowsAcrossPeerIdentityMigration() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
transport.deferDeletedPrivateMediaPersistence = true
|
||||||
|
let sourcePeerID = PeerID(str: String(repeating: "d", count: 64))
|
||||||
|
let destinationPeerID = PeerID(str: String(repeating: "e", count: 64))
|
||||||
|
let thirdPeerID = PeerID(str: String(repeating: "f", count: 64))
|
||||||
|
let stableID = "media-\(String(repeating: "4", count: 32))"
|
||||||
|
viewModel.selectedPrivateChatPeer = sourcePeerID
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: stableID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: sourcePeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "migrated.jpg"
|
||||||
|
),
|
||||||
|
BitchatMessage(
|
||||||
|
id: "captured-before-migration",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "old",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: sourcePeerID
|
||||||
|
)
|
||||||
|
], for: sourcePeerID)
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(sourcePeerID)
|
||||||
|
viewModel.migratePrivateChat(
|
||||||
|
from: sourcePeerID,
|
||||||
|
to: destinationPeerID
|
||||||
|
)
|
||||||
|
viewModel.selectedPrivateChatPeer = thirdPeerID
|
||||||
|
let arrival = BitchatMessage(
|
||||||
|
id: "arrival-after-migration",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "new",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: destinationPeerID
|
||||||
|
)
|
||||||
|
#expect(viewModel.appendPrivateMessage(
|
||||||
|
arrival,
|
||||||
|
to: destinationPeerID
|
||||||
|
))
|
||||||
|
|
||||||
|
transport.resolveNextDeletedPrivateMediaPersistence(true)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
viewModel.privateChats[destinationPeerID]?.map(\.id)
|
||||||
|
== ["arrival-after-migration"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func clearFollowsMigrationWhenOldSourceIsRecreated() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
transport.deferDeletedPrivateMediaPersistence = true
|
||||||
|
let sourcePeerID = PeerID(str: String(repeating: "1", count: 64))
|
||||||
|
let destinationPeerID = PeerID(str: String(repeating: "2", count: 64))
|
||||||
|
let stableID = "media-\(String(repeating: "6", count: 32))"
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: stableID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: sourcePeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "migrated-recreated.jpg"
|
||||||
|
),
|
||||||
|
BitchatMessage(
|
||||||
|
id: "captured-before-recreation",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "old",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: sourcePeerID
|
||||||
|
)
|
||||||
|
], for: sourcePeerID)
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(sourcePeerID)
|
||||||
|
viewModel.migratePrivateChat(
|
||||||
|
from: sourcePeerID,
|
||||||
|
to: destinationPeerID
|
||||||
|
)
|
||||||
|
let recreatedArrival = BitchatMessage(
|
||||||
|
id: "arrival-recreating-source",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "new",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: sourcePeerID
|
||||||
|
)
|
||||||
|
#expect(viewModel.appendPrivateMessage(
|
||||||
|
recreatedArrival,
|
||||||
|
to: sourcePeerID
|
||||||
|
))
|
||||||
|
|
||||||
|
transport.resolveNextDeletedPrivateMediaPersistence(true)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
(viewModel.privateChats[destinationPeerID] ?? []).isEmpty
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
viewModel.privateChats[sourcePeerID]?.map(\.id)
|
||||||
|
== ["arrival-recreating-source"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func clearPrivateChatKeepsMediaReferencedByAnotherConversation() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
let firstPeerID = PeerID(str: String(repeating: "4", count: 64))
|
||||||
|
let aliasPeerID = PeerID(str: String(repeating: "5", count: 64))
|
||||||
|
let messageID = "media-\(String(repeating: "6", count: 32))"
|
||||||
|
let message = privateMediaMessage(
|
||||||
|
id: messageID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: firstPeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "mirrored.jpg"
|
||||||
|
)
|
||||||
|
viewModel.seedPrivateChat([message], for: firstPeerID)
|
||||||
|
viewModel.seedPrivateChat([message], for: aliasPeerID)
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(firstPeerID)
|
||||||
|
|
||||||
|
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
|
||||||
|
#expect(viewModel.privateChats[firstPeerID]?.isEmpty == true)
|
||||||
|
#expect(
|
||||||
|
viewModel.privateChats[aliasPeerID]?.map(\.id) == [messageID]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func clearPrivateChatLeavesAmbiguousLegacyFileForQuotaCleanup()
|
||||||
|
throws {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
let firstPeerID = PeerID(str: String(repeating: "9", count: 64))
|
||||||
|
let aliasPeerID = PeerID(str: String(repeating: "a", count: 64))
|
||||||
|
let incomingDirectory = try FileManager.default.url(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
)
|
||||||
|
.appendingPathComponent("files/images/incoming", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: incomingDirectory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let fileURL = incomingDirectory.appendingPathComponent(
|
||||||
|
"legacy-clear-\(UUID().uuidString).jpg"
|
||||||
|
)
|
||||||
|
try Data("legacy-image".utf8).write(to: fileURL, options: .atomic)
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
let message = privateMediaMessage(
|
||||||
|
id: UUID().uuidString,
|
||||||
|
sender: "Old client",
|
||||||
|
senderPeerID: firstPeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: fileURL.lastPathComponent
|
||||||
|
)
|
||||||
|
viewModel.seedPrivateChat([message], for: firstPeerID)
|
||||||
|
viewModel.seedPrivateChat([message], for: aliasPeerID)
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(firstPeerID)
|
||||||
|
|
||||||
|
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: fileURL.path))
|
||||||
|
#expect(viewModel.privateChats[aliasPeerID]?.map(\.id) == [message.id])
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(aliasPeerID)
|
||||||
|
|
||||||
|
#expect(FileManager.default.fileExists(atPath: fileURL.path))
|
||||||
|
#expect((viewModel.privateChats[aliasPeerID] ?? []).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func panicInvalidatesActiveAndQueuedPrivateChatClears() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
transport.deferDeletedPrivateMediaPersistence = true
|
||||||
|
let firstPeerID = PeerID(str: String(repeating: "4", count: 64))
|
||||||
|
let secondPeerID = PeerID(str: String(repeating: "5", count: 64))
|
||||||
|
let firstID = "media-\(String(repeating: "6", count: 32))"
|
||||||
|
let secondID = "media-\(String(repeating: "7", count: 32))"
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: firstID,
|
||||||
|
sender: "First",
|
||||||
|
senderPeerID: firstPeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "first-pre-panic.jpg"
|
||||||
|
)
|
||||||
|
], for: firstPeerID)
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: secondID,
|
||||||
|
sender: "Second",
|
||||||
|
senderPeerID: secondPeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "second-pre-panic.jpg"
|
||||||
|
)
|
||||||
|
], for: secondPeerID)
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(firstPeerID)
|
||||||
|
viewModel.clearPrivateChat(secondPeerID)
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaMessageIDBatches == [[firstID]]
|
||||||
|
)
|
||||||
|
|
||||||
|
_ = viewModel.panicClearAllData(restartServices: false)
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: firstID,
|
||||||
|
sender: "First",
|
||||||
|
senderPeerID: firstPeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "first-post-panic.jpg"
|
||||||
|
)
|
||||||
|
], for: firstPeerID)
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: secondID,
|
||||||
|
sender: "Second",
|
||||||
|
senderPeerID: secondPeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "second-post-panic.jpg"
|
||||||
|
)
|
||||||
|
], for: secondPeerID)
|
||||||
|
|
||||||
|
transport.resolveNextDeletedPrivateMediaPersistence(true)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaMessageIDBatches == [[firstID]]
|
||||||
|
)
|
||||||
|
#expect(viewModel.privateChats[firstPeerID]?.map(\.id) == [firstID])
|
||||||
|
#expect(viewModel.privateChats[secondPeerID]?.map(\.id) == [secondID])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func privateMediaMessage(
|
||||||
|
id: String,
|
||||||
|
sender: String,
|
||||||
|
senderPeerID: PeerID,
|
||||||
|
recipient: String,
|
||||||
|
filename: String
|
||||||
|
) -> BitchatMessage {
|
||||||
|
BitchatMessage(
|
||||||
|
id: id,
|
||||||
|
sender: sender,
|
||||||
|
content: "\(MimeType.Category.image.messagePrefix)\(filename)",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: recipient,
|
||||||
|
senderPeerID: senderPeerID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Panic Clear Tests
|
// MARK: - Panic Clear Tests
|
||||||
|
|
||||||
struct ChatViewModelPanicTests {
|
struct ChatViewModelPanicTests {
|
||||||
|
|||||||
@@ -755,6 +755,71 @@ struct ConversationStoreTests {
|
|||||||
#expect(statusChangedIDs.isEmpty)
|
#expect(statusChangedIDs.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("peer-scoped receipt updates only authenticated direct aliases")
|
||||||
|
@MainActor
|
||||||
|
func peerScopedReceiptUpdatesOnlyAuthenticatedDirectAliases() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
let ephemeralPeer = PeerID(str: "0102030405060708")
|
||||||
|
let stablePeer = PeerID(hexData: Data(repeating: 0x08, count: 32))
|
||||||
|
let otherPeer = PeerID(str: "1112131415161718")
|
||||||
|
let ephemeral = ConversationID.directPeer(ephemeralPeer)
|
||||||
|
let stable = ConversationID.directPeer(stablePeer)
|
||||||
|
let other = ConversationID.directPeer(otherPeer)
|
||||||
|
let messageID = "scoped-receipt"
|
||||||
|
let mirrored = makeMessage(
|
||||||
|
id: messageID,
|
||||||
|
timestamp: 1,
|
||||||
|
isPrivate: true,
|
||||||
|
deliveryStatus: .sent
|
||||||
|
)
|
||||||
|
store.upsertByID(mirrored, in: ephemeral)
|
||||||
|
store.upsertByID(mirrored, in: stable)
|
||||||
|
store.upsertByID(
|
||||||
|
makeMessage(
|
||||||
|
id: messageID,
|
||||||
|
timestamp: 1,
|
||||||
|
isPrivate: true,
|
||||||
|
deliveryStatus: .sent
|
||||||
|
),
|
||||||
|
in: other
|
||||||
|
)
|
||||||
|
store.upsertByID(
|
||||||
|
makeMessage(id: messageID, timestamp: 1, deliveryStatus: .sent),
|
||||||
|
in: .mesh
|
||||||
|
)
|
||||||
|
|
||||||
|
var cancellables = Set<AnyCancellable>()
|
||||||
|
var publishedIDs: [ConversationID] = []
|
||||||
|
for id in [ephemeral, stable, other, .mesh] {
|
||||||
|
store.conversation(for: id).objectWillChange
|
||||||
|
.sink { publishedIDs.append(id) }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
var statusChangedIDs: [ConversationID] = []
|
||||||
|
store.changes
|
||||||
|
.sink { change in
|
||||||
|
if case .statusChanged(let id, messageID, _) = change,
|
||||||
|
messageID == "scoped-receipt" {
|
||||||
|
statusChangedIDs.append(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
let delivered = DeliveryStatus.delivered(to: "bob", at: Date())
|
||||||
|
#expect(store.setDeliveryStatus(
|
||||||
|
delivered,
|
||||||
|
forMessageID: messageID,
|
||||||
|
inDirectPeerAliases: [ephemeralPeer, stablePeer]
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(Set(publishedIDs) == Set([ephemeral, stable]))
|
||||||
|
#expect(Set(statusChangedIDs) == Set([ephemeral, stable]))
|
||||||
|
#expect(store.conversation(for: ephemeral).message(withID: messageID)?.deliveryStatus == delivered)
|
||||||
|
#expect(store.conversation(for: stable).message(withID: messageID)?.deliveryStatus == delivered)
|
||||||
|
#expect(store.conversation(for: other).message(withID: messageID)?.deliveryStatus == .sent)
|
||||||
|
#expect(store.conversation(for: .mesh).message(withID: messageID)?.deliveryStatus == .sent)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Invariant audit (field observability)
|
// MARK: - Invariant audit (field observability)
|
||||||
|
|
||||||
/// A store exercised through every intent family: public + geohash +
|
/// A store exercised through every intent family: public + geohash +
|
||||||
|
|||||||
@@ -364,6 +364,217 @@ struct PrivateMediaEndToEndTests {
|
|||||||
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent)
|
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func privateMediaRetryRequiresExactAuthenticatedBit9Proof() async throws {
|
||||||
|
let root = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(
|
||||||
|
"private-media-receipt-proof-\(UUID().uuidString)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let alice = makeService(
|
||||||
|
baseDirectory: root.appendingPathComponent(
|
||||||
|
"alice",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let bob = makeService(
|
||||||
|
baseDirectory: root.appendingPathComponent(
|
||||||
|
"bob",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let bothCapabilities: PeerCapabilities = [
|
||||||
|
.privateMedia,
|
||||||
|
.privateMediaReceipts
|
||||||
|
]
|
||||||
|
|
||||||
|
// A public bit-9 announce is discovery only.
|
||||||
|
alice._test_seedConnectedPeer(
|
||||||
|
bob.myPeerID,
|
||||||
|
nickname: "Bob",
|
||||||
|
capabilities: bothCapabilities,
|
||||||
|
noisePublicKey: bob.noiseStaticPublicKeyData()
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
alice.authenticatedPrivateMediaReceiptSessionGeneration(
|
||||||
|
to: bob.myPeerID
|
||||||
|
) == nil
|
||||||
|
)
|
||||||
|
|
||||||
|
let proofs = try await establishSessionCapturingPeerState(
|
||||||
|
alice: alice,
|
||||||
|
bob: bob
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
alice.authenticatedPrivateMediaReceiptSessionGeneration(
|
||||||
|
to: bob.myPeerID
|
||||||
|
) == nil
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bit 8 alone preserves encrypted transfer compatibility but cannot
|
||||||
|
// authorize automatic resend.
|
||||||
|
let privateMediaOnly = try authenticatedPeerStatePacket(
|
||||||
|
from: bob,
|
||||||
|
to: alice,
|
||||||
|
capabilities: .privateMedia
|
||||||
|
)
|
||||||
|
alice._test_handlePacket(
|
||||||
|
privateMediaOnly,
|
||||||
|
fromPeerID: bob.myPeerID
|
||||||
|
)
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{
|
||||||
|
alice.privateMediaSendPolicy(to: bob.myPeerID)
|
||||||
|
== .encrypted
|
||||||
|
},
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
#expect(
|
||||||
|
alice.authenticatedPrivateMediaReceiptSessionGeneration(
|
||||||
|
to: bob.myPeerID
|
||||||
|
) == nil
|
||||||
|
)
|
||||||
|
|
||||||
|
let receiptCapable = try authenticatedPeerStatePacket(
|
||||||
|
from: bob,
|
||||||
|
to: alice,
|
||||||
|
capabilities: bothCapabilities
|
||||||
|
)
|
||||||
|
alice._test_handlePacket(
|
||||||
|
receiptCapable,
|
||||||
|
fromPeerID: bob.myPeerID
|
||||||
|
)
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{
|
||||||
|
alice.authenticatedPrivateMediaReceiptSessionGeneration(
|
||||||
|
to: bob.myPeerID
|
||||||
|
) != nil
|
||||||
|
},
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
|
||||||
|
bob._test_handlePacket(
|
||||||
|
proofs.alice,
|
||||||
|
fromPeerID: alice.myPeerID
|
||||||
|
)
|
||||||
|
alice._test_onOutboundPacket = nil
|
||||||
|
bob._test_onOutboundPacket = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func receiptRetryRechecksBit9AtDeferredTransportBoundary() async throws {
|
||||||
|
let root = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(
|
||||||
|
"private-media-retry-proof-race-\(UUID().uuidString)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let alice = makeService(
|
||||||
|
baseDirectory: root.appendingPathComponent(
|
||||||
|
"alice",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let bob = makeService(
|
||||||
|
baseDirectory: root.appendingPathComponent(
|
||||||
|
"bob",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let receiptCapabilities: PeerCapabilities = [
|
||||||
|
.privateMedia,
|
||||||
|
.privateMediaReceipts
|
||||||
|
]
|
||||||
|
alice._test_seedConnectedPeer(
|
||||||
|
bob.myPeerID,
|
||||||
|
nickname: "Bob",
|
||||||
|
capabilities: receiptCapabilities,
|
||||||
|
noisePublicKey: bob.noiseStaticPublicKeyData()
|
||||||
|
)
|
||||||
|
bob._test_seedConnectedPeer(
|
||||||
|
alice.myPeerID,
|
||||||
|
nickname: "Alice",
|
||||||
|
capabilities: receiptCapabilities,
|
||||||
|
noisePublicKey: alice.noiseStaticPublicKeyData()
|
||||||
|
)
|
||||||
|
try await establishSession(alice: alice, bob: bob)
|
||||||
|
#expect(
|
||||||
|
alice.authenticatedPrivateMediaReceiptSessionGeneration(
|
||||||
|
to: bob.myPeerID
|
||||||
|
) != nil
|
||||||
|
)
|
||||||
|
|
||||||
|
let privateMediaOnly = try authenticatedPeerStatePacket(
|
||||||
|
from: bob,
|
||||||
|
to: alice,
|
||||||
|
capabilities: .privateMedia
|
||||||
|
)
|
||||||
|
let transferID =
|
||||||
|
"receipt-proof-race-\(UUID().uuidString)"
|
||||||
|
let tap = PacketTap()
|
||||||
|
let boundaryProofs = ReceiptCapabilityRecorder()
|
||||||
|
let rejections = TransferCancellationRecorder()
|
||||||
|
let cancellable = TransferProgressManager.shared.publisher.sink {
|
||||||
|
rejections.record($0)
|
||||||
|
}
|
||||||
|
alice._test_onOutboundPacket = tap.record
|
||||||
|
alice._test_beforePrivateMediaDeferredSend = { id in
|
||||||
|
guard id == transferID else { return }
|
||||||
|
boundaryProofs.record(
|
||||||
|
alice
|
||||||
|
.authenticatedPrivateMediaReceiptSessionGeneration(
|
||||||
|
to: bob.myPeerID
|
||||||
|
) != nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
defer {
|
||||||
|
alice._test_beforePrivateMediaDeferredSend = nil
|
||||||
|
alice._test_onOutboundPacket = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate authenticated state before the deferred retry reaches its
|
||||||
|
// admission boundary.
|
||||||
|
alice._test_handlePacket(
|
||||||
|
privateMediaOnly,
|
||||||
|
fromPeerID: bob.myPeerID
|
||||||
|
)
|
||||||
|
let content = Data("%PDF-1.7\nreceipt-proof-race".utf8)
|
||||||
|
alice.sendFilePrivateReceiptRetry(
|
||||||
|
BitchatFilePacket(
|
||||||
|
fileName: "receipt-proof-race.pdf",
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
content: content
|
||||||
|
),
|
||||||
|
to: bob.myPeerID,
|
||||||
|
transferId: transferID
|
||||||
|
)
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{ boundaryProofs.snapshot() == [false] },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
await alice._test_drainPrivateMediaSendPipeline()
|
||||||
|
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{ rejections.contains(transferID) },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
#expect(tap.snapshot().allSatisfy {
|
||||||
|
$0.type != MessageType.fileTransfer.rawValue
|
||||||
|
&& !(
|
||||||
|
$0.type == MessageType.noiseEncrypted.rawValue
|
||||||
|
&& $0.version == 2
|
||||||
|
)
|
||||||
|
})
|
||||||
|
let state = alice._test_privateMediaTransferState(
|
||||||
|
transferId: transferID
|
||||||
|
)
|
||||||
|
#expect(!state.admissionActive)
|
||||||
|
#expect(!state.pendingNoise)
|
||||||
|
_ = cancellable
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func capabilityAnnounceCannotPoisonPinWithoutMatchingNoiseAuthentication() async throws {
|
func capabilityAnnounceCannotPoisonPinWithoutMatchingNoiseAuthentication() async throws {
|
||||||
let root = FileManager.default.temporaryDirectory
|
let root = FileManager.default.temporaryDirectory
|
||||||
@@ -682,13 +893,18 @@ struct PrivateMediaEndToEndTests {
|
|||||||
#expect(!identity.hasObservedPrivateMediaCapability(
|
#expect(!identity.hasObservedPrivateMediaCapability(
|
||||||
fingerprint: impostorKey.sha256Fingerprint()
|
fingerprint: impostorKey.sha256Fingerprint()
|
||||||
))
|
))
|
||||||
|
#expect(identity.hasObservedPrivateMediaCapability(
|
||||||
|
fingerprint: bob.noiseStaticPublicKeyData().sha256Fingerprint()
|
||||||
|
))
|
||||||
alice._test_seedConnectedPeer(
|
alice._test_seedConnectedPeer(
|
||||||
bob.myPeerID,
|
bob.myPeerID,
|
||||||
nickname: "Bob",
|
nickname: "Bob",
|
||||||
capabilities: [],
|
capabilities: [],
|
||||||
noisePublicKey: impostorKey
|
noisePublicKey: impostorKey
|
||||||
)
|
)
|
||||||
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent)
|
// The exact live Noise identity remains authoritative over a later
|
||||||
|
// impostor registry rewrite.
|
||||||
|
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -866,7 +1082,7 @@ struct PrivateMediaEndToEndTests {
|
|||||||
+ marker
|
+ marker
|
||||||
+ Data(repeating: 0x4A, count: 6 * 1024)
|
+ Data(repeating: 0x4A, count: 6 * 1024)
|
||||||
try await assertPrivateMediaRoundTrip(
|
try await assertPrivateMediaRoundTrip(
|
||||||
fileName: "private.jpg",
|
fileName: "img_20260725_120000_11111111-1111-1111-1111-111111111111.jpg",
|
||||||
mimeType: "image/jpeg",
|
mimeType: "image/jpeg",
|
||||||
content: content,
|
content: content,
|
||||||
marker: marker,
|
marker: marker,
|
||||||
@@ -1137,6 +1353,17 @@ struct PrivateMediaEndToEndTests {
|
|||||||
#expect(message.isPrivate)
|
#expect(message.isPrivate)
|
||||||
#expect(message.senderPeerID == alice.myPeerID)
|
#expect(message.senderPeerID == alice.myPeerID)
|
||||||
#expect(message.content.hasPrefix(expectedMessagePrefix))
|
#expect(message.content.hasPrefix(expectedMessagePrefix))
|
||||||
|
if let stableMessageID = PrivateMediaMessageIdentity.stableID(
|
||||||
|
for: file,
|
||||||
|
senderPeerID: alice.myPeerID,
|
||||||
|
recipientPeerID: bob.myPeerID
|
||||||
|
) {
|
||||||
|
#expect(message.id == stableMessageID)
|
||||||
|
} else {
|
||||||
|
// Generic/legacy filenames retain random per-arrival IDs so two
|
||||||
|
// unrelated "photo.jpg" transfers are never deduplicated.
|
||||||
|
#expect(!message.id.hasPrefix("media-"))
|
||||||
|
}
|
||||||
|
|
||||||
let stored = recursivelyStoredFiles(under: bobRoot)
|
let stored = recursivelyStoredFiles(under: bobRoot)
|
||||||
#expect(stored.count == 1)
|
#expect(stored.count == 1)
|
||||||
@@ -1279,6 +1506,8 @@ struct PrivateMediaEndToEndTests {
|
|||||||
|
|
||||||
return enumerator.compactMap { item in
|
return enumerator.compactMap { item in
|
||||||
guard let url = item as? URL,
|
guard let url = item as? URL,
|
||||||
|
!url.pathComponents.contains(".private-media-receipts"),
|
||||||
|
url.lastPathComponent != ".private-media-receipts.json",
|
||||||
(try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true else {
|
(try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1379,6 +1608,23 @@ private final class PrivateMediaPolicyRecorder: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class ReceiptCapabilityRecorder: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var values: [Bool] = []
|
||||||
|
|
||||||
|
func record(_ value: Bool) {
|
||||||
|
lock.lock()
|
||||||
|
values.append(value)
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot() -> [Bool] {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private final class MessageCaptureDelegate: BitchatDelegate, @unchecked Sendable {
|
private final class MessageCaptureDelegate: BitchatDelegate, @unchecked Sendable {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var messages: [BitchatMessage] = []
|
private var messages: [BitchatMessage] = []
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import BitFoundation
|
|||||||
|
|
||||||
/// Mock Transport implementation for testing ChatViewModel in isolation.
|
/// Mock Transport implementation for testing ChatViewModel in isolation.
|
||||||
/// Records all method calls and allows test code to verify interactions.
|
/// Records all method calls and allows test code to verify interactions.
|
||||||
final class MockTransport: Transport {
|
final class MockTransport: Transport, PrivateMediaDeletionPersisting {
|
||||||
|
|
||||||
// MARK: - Protocol Properties
|
// MARK: - Protocol Properties
|
||||||
|
|
||||||
@@ -38,6 +38,11 @@ final class MockTransport: Transport {
|
|||||||
private(set) var sentPrivateFiles: [(packet: BitchatFilePacket, peerID: PeerID, transferID: String)] = []
|
private(set) var sentPrivateFiles: [(packet: BitchatFilePacket, peerID: PeerID, transferID: String)] = []
|
||||||
private(set) var sentPrivateFileLegacyAllowances: [Bool] = []
|
private(set) var sentPrivateFileLegacyAllowances: [Bool] = []
|
||||||
private(set) var cancelledTransfers: [String] = []
|
private(set) var cancelledTransfers: [String] = []
|
||||||
|
private(set) var deletedPrivateMediaMessageIDBatches: [[String]] = []
|
||||||
|
private(set) var deletedPrivateMediaRelativePaths: [
|
||||||
|
[String: String]
|
||||||
|
] = []
|
||||||
|
private(set) var protectedPrivateMediaRelativePaths: [Set<String>] = []
|
||||||
private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||||
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||||
private(set) var sentCourierMessages: [(content: String, messageID: String, recipientNoiseKey: Data, couriers: [PeerID])] = []
|
private(set) var sentCourierMessages: [(content: String, messageID: String, recipientNoiseKey: Data, couriers: [PeerID])] = []
|
||||||
@@ -60,6 +65,15 @@ final class MockTransport: Transport {
|
|||||||
var peerFingerprints: [PeerID: String] = [:]
|
var peerFingerprints: [PeerID: String] = [:]
|
||||||
var peerNoiseStates: [PeerID: LazyHandshakeState] = [:]
|
var peerNoiseStates: [PeerID: LazyHandshakeState] = [:]
|
||||||
var privateMediaPolicies: [PeerID: PrivateMediaSendPolicy] = [:]
|
var privateMediaPolicies: [PeerID: PrivateMediaSendPolicy] = [:]
|
||||||
|
var privateMediaReceiptSessionGenerations: [PeerID: UUID] = [:]
|
||||||
|
var persistDeletedPrivateMediaResult = true
|
||||||
|
var deferDeletedPrivateMediaPersistence = false
|
||||||
|
private var pendingDeletedPrivateMediaCompletions: [
|
||||||
|
@MainActor (Bool) -> Void
|
||||||
|
] = []
|
||||||
|
/// Optional synchronous hook for send-ordering tests (for example, an ack
|
||||||
|
/// arriving before the router's send call returns).
|
||||||
|
var onSendPrivateMessage: (@MainActor (_ messageID: String) -> Void)?
|
||||||
private let mockKeychain = MockKeychain()
|
private let mockKeychain = MockKeychain()
|
||||||
|
|
||||||
// MARK: - Transport Protocol Implementation
|
// MARK: - Transport Protocol Implementation
|
||||||
@@ -164,6 +178,11 @@ final class MockTransport: Transport {
|
|||||||
|
|
||||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
sentPrivateMessages.append((content, peerID, recipientNickname, messageID))
|
sentPrivateMessages.append((content, peerID, recipientNickname, messageID))
|
||||||
|
if let onSendPrivateMessage {
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
onSendPrivateMessage(messageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||||
@@ -205,6 +224,12 @@ final class MockTransport: Transport {
|
|||||||
privateMediaPolicies[peerID] ?? .encrypted
|
privateMediaPolicies[peerID] ?? .encrypted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func authenticatedPrivateMediaReceiptSessionGeneration(
|
||||||
|
to peerID: PeerID
|
||||||
|
) -> UUID? {
|
||||||
|
privateMediaReceiptSessionGenerations[peerID]
|
||||||
|
}
|
||||||
|
|
||||||
func resolvePrivateMediaSendPolicy(
|
func resolvePrivateMediaSendPolicy(
|
||||||
to peerID: PeerID,
|
to peerID: PeerID,
|
||||||
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||||
@@ -217,6 +242,34 @@ final class MockTransport: Transport {
|
|||||||
cancelledTransfers.append(transferId)
|
cancelledTransfers.append(transferId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func persistDeletedPrivateMedia(
|
||||||
|
messageIDs: [String],
|
||||||
|
payloadRelativePaths: [String: String],
|
||||||
|
protectedPayloadRelativePaths: Set<String>,
|
||||||
|
completion: @escaping @MainActor (Bool) -> Void
|
||||||
|
) {
|
||||||
|
deletedPrivateMediaMessageIDBatches.append(messageIDs)
|
||||||
|
deletedPrivateMediaRelativePaths.append(payloadRelativePaths)
|
||||||
|
protectedPrivateMediaRelativePaths.append(
|
||||||
|
protectedPayloadRelativePaths
|
||||||
|
)
|
||||||
|
if deferDeletedPrivateMediaPersistence {
|
||||||
|
pendingDeletedPrivateMediaCompletions.append(completion)
|
||||||
|
} else {
|
||||||
|
completion(persistDeletedPrivateMediaResult)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func resolveNextDeletedPrivateMediaPersistence(
|
||||||
|
_ result: Bool? = nil
|
||||||
|
) {
|
||||||
|
guard !pendingDeletedPrivateMediaCompletions.isEmpty else { return }
|
||||||
|
let completion = pendingDeletedPrivateMediaCompletions.removeFirst()
|
||||||
|
completion(result ?? persistDeletedPrivateMediaResult)
|
||||||
|
}
|
||||||
|
|
||||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||||||
sentVerifyChallenges.append((peerID, noiseKeyHex, nonceA))
|
sentVerifyChallenges.append((peerID, noiseKeyHex, nonceA))
|
||||||
}
|
}
|
||||||
@@ -264,6 +317,9 @@ final class MockTransport: Transport {
|
|||||||
sentBroadcastFiles.removeAll()
|
sentBroadcastFiles.removeAll()
|
||||||
sentPrivateFiles.removeAll()
|
sentPrivateFiles.removeAll()
|
||||||
cancelledTransfers.removeAll()
|
cancelledTransfers.removeAll()
|
||||||
|
deletedPrivateMediaMessageIDBatches.removeAll()
|
||||||
|
deletedPrivateMediaRelativePaths.removeAll()
|
||||||
|
protectedPrivateMediaRelativePaths.removeAll()
|
||||||
sentVerifyChallenges.removeAll()
|
sentVerifyChallenges.removeAll()
|
||||||
sentVerifyResponses.removeAll()
|
sentVerifyResponses.removeAll()
|
||||||
startServicesCallCount = 0
|
startServicesCallCount = 0
|
||||||
|
|||||||
@@ -8,25 +8,9 @@ import XCTest
|
|||||||
/// the pooled subscription must come up exactly once and go down exactly once.
|
/// the pooled subscription must come up exactly once and go down exactly once.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class NearbyNotesCounterTests: XCTestCase {
|
final class NearbyNotesCounterTests: XCTestCase {
|
||||||
private var previousNotesEnabled: Any?
|
|
||||||
|
|
||||||
override func setUp() {
|
|
||||||
super.setUp()
|
|
||||||
previousNotesEnabled = UserDefaults.standard.object(forKey: "locationNotes.enabled")
|
|
||||||
UserDefaults.standard.set(true, forKey: "locationNotes.enabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDown() {
|
|
||||||
if let previous = previousNotesEnabled as? Bool {
|
|
||||||
UserDefaults.standard.set(previous, forKey: "locationNotes.enabled")
|
|
||||||
} else {
|
|
||||||
UserDefaults.standard.removeObject(forKey: "locationNotes.enabled")
|
|
||||||
}
|
|
||||||
super.tearDown()
|
|
||||||
}
|
|
||||||
|
|
||||||
func test_counterOnlySubscribesAfterReveal_countsUnexpiredNotes_andUnsubscribesOnDeactivate() async throws {
|
func test_counterOnlySubscribesAfterReveal_countsUnexpiredNotes_andUnsubscribesOnDeactivate() async throws {
|
||||||
let relays = SubscriptionRecorder()
|
let relays = SubscriptionRecorder()
|
||||||
|
let settings = LocationNotesSettingsStub()
|
||||||
let locationManager = try await makeAuthorizedLocationManager()
|
let locationManager = try await makeAuthorizedLocationManager()
|
||||||
let buildingGeohash = try XCTUnwrap(
|
let buildingGeohash = try XCTUnwrap(
|
||||||
locationManager.availableChannels.first(where: { $0.level == .building })?.geohash
|
locationManager.availableChannels.first(where: { $0.level == .building })?.geohash
|
||||||
@@ -35,7 +19,9 @@ final class NearbyNotesCounterTests: XCTestCase {
|
|||||||
let counter = NearbyNotesCounter(
|
let counter = NearbyNotesCounter(
|
||||||
locationManager: locationManager,
|
locationManager: locationManager,
|
||||||
managerFactory: { LocationNotesManager(geohash: $0, dependencies: relays.dependencies) },
|
managerFactory: { LocationNotesManager(geohash: $0, dependencies: relays.dependencies) },
|
||||||
releaseManager: { $0?.cancel() }
|
releaseManager: { $0?.cancel() },
|
||||||
|
locationNotesEnabled: { settings.enabled },
|
||||||
|
locationNotesSettings: settings.changes
|
||||||
)
|
)
|
||||||
|
|
||||||
counter.activate()
|
counter.activate()
|
||||||
@@ -93,11 +79,14 @@ final class NearbyNotesCounterTests: XCTestCase {
|
|||||||
|
|
||||||
func test_permissionRevocation_releasesBuildingSubscriptionDespiteCachedChannels() async throws {
|
func test_permissionRevocation_releasesBuildingSubscriptionDespiteCachedChannels() async throws {
|
||||||
let relays = SubscriptionRecorder()
|
let relays = SubscriptionRecorder()
|
||||||
|
let settings = LocationNotesSettingsStub()
|
||||||
let locationManager = try await makeAuthorizedLocationManager()
|
let locationManager = try await makeAuthorizedLocationManager()
|
||||||
let counter = NearbyNotesCounter(
|
let counter = NearbyNotesCounter(
|
||||||
locationManager: locationManager,
|
locationManager: locationManager,
|
||||||
managerFactory: { LocationNotesManager(geohash: $0, dependencies: relays.dependencies) },
|
managerFactory: { LocationNotesManager(geohash: $0, dependencies: relays.dependencies) },
|
||||||
releaseManager: { $0?.cancel() }
|
releaseManager: { $0?.cancel() },
|
||||||
|
locationNotesEnabled: { settings.enabled },
|
||||||
|
locationNotesSettings: settings.changes
|
||||||
)
|
)
|
||||||
|
|
||||||
counter.activate()
|
counter.activate()
|
||||||
@@ -122,24 +111,27 @@ final class NearbyNotesCounterTests: XCTestCase {
|
|||||||
|
|
||||||
func test_locationNotesKillSwitch_releasesAndCanReacquireBuildingSubscription() async throws {
|
func test_locationNotesKillSwitch_releasesAndCanReacquireBuildingSubscription() async throws {
|
||||||
let relays = SubscriptionRecorder()
|
let relays = SubscriptionRecorder()
|
||||||
|
let settings = LocationNotesSettingsStub()
|
||||||
let locationManager = try await makeAuthorizedLocationManager()
|
let locationManager = try await makeAuthorizedLocationManager()
|
||||||
let counter = NearbyNotesCounter(
|
let counter = NearbyNotesCounter(
|
||||||
locationManager: locationManager,
|
locationManager: locationManager,
|
||||||
managerFactory: { LocationNotesManager(geohash: $0, dependencies: relays.dependencies) },
|
managerFactory: { LocationNotesManager(geohash: $0, dependencies: relays.dependencies) },
|
||||||
releaseManager: { $0?.cancel() }
|
releaseManager: { $0?.cancel() },
|
||||||
|
locationNotesEnabled: { settings.enabled },
|
||||||
|
locationNotesSettings: settings.changes
|
||||||
)
|
)
|
||||||
|
|
||||||
counter.activate()
|
counter.activate()
|
||||||
counter.reveal()
|
counter.reveal()
|
||||||
XCTAssertEqual(relays.subscribeCount, 1)
|
XCTAssertEqual(relays.subscribeCount, 1)
|
||||||
|
|
||||||
LocationNotesSettings.enabled = false
|
settings.setEnabled(false)
|
||||||
|
|
||||||
let released = await waitUntil { relays.unsubscribeCount == 1 }
|
let released = await waitUntil { relays.unsubscribeCount == 1 }
|
||||||
XCTAssertTrue(released)
|
XCTAssertTrue(released)
|
||||||
XCTAssertEqual(counter.noteCount, 0)
|
XCTAssertEqual(counter.noteCount, 0)
|
||||||
|
|
||||||
LocationNotesSettings.enabled = true
|
settings.setEnabled(true)
|
||||||
|
|
||||||
let reacquired = await waitUntil { relays.subscribeCount == 2 }
|
let reacquired = await waitUntil { relays.subscribeCount == 2 }
|
||||||
XCTAssertTrue(reacquired)
|
XCTAssertTrue(reacquired)
|
||||||
@@ -149,10 +141,13 @@ final class NearbyNotesCounterTests: XCTestCase {
|
|||||||
|
|
||||||
func test_checkNotesHint_requiresAuthorizedLocationPermission() {
|
func test_checkNotesHint_requiresAuthorizedLocationPermission() {
|
||||||
let relays = SubscriptionRecorder()
|
let relays = SubscriptionRecorder()
|
||||||
|
let settings = LocationNotesSettingsStub()
|
||||||
let counter = NearbyNotesCounter(
|
let counter = NearbyNotesCounter(
|
||||||
locationManager: makeBareLocationManager(),
|
locationManager: makeBareLocationManager(),
|
||||||
managerFactory: { LocationNotesManager(geohash: $0, dependencies: relays.dependencies) },
|
managerFactory: { LocationNotesManager(geohash: $0, dependencies: relays.dependencies) },
|
||||||
releaseManager: { $0?.cancel() }
|
releaseManager: { $0?.cancel() },
|
||||||
|
locationNotesEnabled: { settings.enabled },
|
||||||
|
locationNotesSettings: settings.changes
|
||||||
)
|
)
|
||||||
|
|
||||||
// An unauthorized install must never see the hint: the tap can't
|
// An unauthorized install must never see the hint: the tap can't
|
||||||
@@ -164,9 +159,9 @@ final class NearbyNotesCounterTests: XCTestCase {
|
|||||||
XCTAssertTrue(counter.offersRevealHint(permissionState: .authorized))
|
XCTAssertTrue(counter.offersRevealHint(permissionState: .authorized))
|
||||||
|
|
||||||
// The app-info kill switch hides it too.
|
// The app-info kill switch hides it too.
|
||||||
LocationNotesSettings.enabled = false
|
settings.setEnabled(false)
|
||||||
XCTAssertFalse(counter.offersRevealHint(permissionState: .authorized))
|
XCTAssertFalse(counter.offersRevealHint(permissionState: .authorized))
|
||||||
LocationNotesSettings.enabled = true
|
settings.setEnabled(true)
|
||||||
|
|
||||||
// Once revealed, the hint yields to the live strip and count.
|
// Once revealed, the hint yields to the live strip and count.
|
||||||
counter.reveal()
|
counter.reveal()
|
||||||
@@ -411,6 +406,21 @@ final class NearbyNotesCounterTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class LocationNotesSettingsStub {
|
||||||
|
private let changesSubject = PassthroughSubject<Void, Never>()
|
||||||
|
private(set) var enabled = true
|
||||||
|
|
||||||
|
var changes: AnyPublisher<Void, Never> {
|
||||||
|
changesSubject.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setEnabled(_ enabled: Bool) {
|
||||||
|
self.enabled = enabled
|
||||||
|
changesSubject.send(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Stub relay layer: counts REQs, captures the last filter/handler, and never
|
/// Stub relay layer: counts REQs, captures the last filter/handler, and never
|
||||||
/// touches the network.
|
/// touches the network.
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"id":"8f4ac4680de5f972a586267bc7b6b5102ba548a34f618bdee47edd08929ee1e8","pubkey":"6981231b5745520fd982f66f485fa1b42f8f91ad25ab32242d4afb839d696b0a","created_at":1783727308,"kind":1059,"tags":[["p","c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"]],"content":"v2:ETvtGOrkDQ2JIiiMFMdxBlrYNLh07-QKmLds0xsjkgs-v54CP4E4uoKl1L_VvKvaREg-EviPQmksd3DOYHrBVAd5E6xuRn1o9vkoss4MYV7I71mu4RfADRt77ohZaNbc-KhrmgyRTgE-WnEsqErax8LUN6GUukjkKncVA9MAmC1WUB1MI1AR8c4BQ0IOc_ubrEqi640a8AeBZaCZOYutCb3ttqNSPBxR63XErE761KNg1uiq5pgBVo8iKvLO-N2ei7IWvQhmDTapBaEU7LexeIdHDEwMdXDoAtr5Nmd54H1VN12tBvk1wXvHnENgZCOLOR5J2E1eSwrFXXBto-ohrNpBaLKIXBTPGEoepa7x0gC0Vrh1OTf4tCI7JJ5UWnkUAQFGUgPGlTRYC8MdESgEthqmdgKT3Jc0N6sylTmv6zSVx5dXqO4fvSLHC6_7it8F_V_8-uAxUYstJz65oK4F9CwOEVglUuUdfn2mN_3cMBzASLOlKvL8jbkwwo5aMBVrShGiEwDix02hfGMNMKf7OKsLlfNiBAVSPh6MQSvAWhxbDCDnWN-yHKWF4TYxbnH70X2KGl_ZD9pXTphKVIpFuRmP7UNM-01oG53NKcv9puBSxAkLbTZd122uFL_zQebxid1ukOXT8WgSB_WYJYoAlQekeu4ITryB4I60vAAzMAa4AppCUNnf6T8jwWxuC-8G0TPf18MTxpeNkzcSpPVyVE0jtLaOwsJDXs_Pg82Id03Qc-b_fWMm9V07UzGmEnMqQ1gBMLmEXHb_4Ebg5X7TdzuXy87O36CJzau7Dm5ZfoalryF-16Z4MxzQOyXb1G61yFthRsGCT6sYi-68YkhPScMf7u_BobtMuGWiMiWoBqN_IrQ_ecMHVfaeEYvpCz5NYlrE26iAktNmzCBUDNcIr6P_nHdb6I3Q1rOOmWwEF7jsLbvnU_w_82_nXE_yfdGsoly24A2wB0L0SpdnyyEWgvKWjjfS3J3vkIVW4_iM0FT0jNelANc2X_ryb3EPTmGenlqm_qRGh86PYk_R07hKYu3ULNEgLzDTijeZ9-bP23tsMXUDdJS2VR7LP5063ygVuSC0J-GL1FmQ2c-DmJaWQSeqp0NN3sCND3pSIRzwQRwChnMNjVB65mJj","sig":"c53f339b19b9de021765588b0ee4f5f1e0c2423a34d35fe2cbcc6ce89e12e2fedbdf88a0b6a9d719c2c8580002c7574f1ace93aedeab1e255b6231d4ebb6f9b2"}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt
|
||||||
|
index a5bd956..544a29b 100644
|
||||||
|
--- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt
|
||||||
|
+++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt
|
||||||
|
@@ -10,0 +11,21 @@ class NostrProtocolTest {
|
||||||
|
+ @Test
|
||||||
|
+ fun emitCrossPlatformFixtures() {
|
||||||
|
+ val sender = NostrIdentity.fromPrivateKey(
|
||||||
|
+ "0000000000000000000000000000000000000000000000000000000000000001"
|
||||||
|
+ )
|
||||||
|
+ val recipient = NostrIdentity.fromPrivateKey(
|
||||||
|
+ "0000000000000000000000000000000000000000000000000000000000000002"
|
||||||
|
+ )
|
||||||
|
+ val giftWrap = NostrProtocol.createPrivateMessage(
|
||||||
|
+ content = "legacy fixture from Android b7f0b33d",
|
||||||
|
+ recipientPubkey = recipient.publicKeyHex,
|
||||||
|
+ senderIdentity = sender
|
||||||
|
+ ).single()
|
||||||
|
+
|
||||||
|
+ assertEquals(NostrKind.GIFT_WRAP, giftWrap.kind)
|
||||||
|
+ assertEquals(listOf(listOf("p", recipient.publicKeyHex)), giftWrap.tags)
|
||||||
|
+ println("BITCHAT_FIXTURE_EVENT=${gson.toJson(giftWrap)}")
|
||||||
|
+ println("BITCHAT_FIXTURE_RECIPIENT_PRIVATE_KEY=${recipient.privateKeyHex}")
|
||||||
|
+ println("BITCHAT_FIXTURE_SENDER_PUBLIC_KEY=${sender.publicKeyHex}")
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
@Test
|
||||||
|
fun decryptPrivateMessage_acceptsAuthenticatedSeal() {
|
||||||
|
val sender = NostrIdentity.generate()
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"android_commit": "b7f0b33d3a267c770d3d5a65ee2d8c7e755450db",
|
||||||
|
"generator": "NostrProtocol.createPrivateMessage",
|
||||||
|
"generator_patch": "AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch",
|
||||||
|
"generator_patch_sha256": "e7ad29d6a247638cc16fb2ec06937266e6a03e7a115ae00913330a86a88fa56a",
|
||||||
|
"gradle_test": "ANDROID_HOME=/opt/homebrew/share/android-commandlinetools ANDROID_SDK_ROOT=/opt/homebrew/share/android-commandlinetools JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home ./gradlew testDebugUnitTest --tests com.bitchat.android.nostr.NostrProtocolTest.emitCrossPlatformFixtures --info",
|
||||||
|
"fixture_sha256": "d2df3d0b7ffd84c5cb25e0b3f4a89ad14f72a105bddadab77081f98c661b080e",
|
||||||
|
"recipient_private_key": "0000000000000000000000000000000000000000000000000000000000000002",
|
||||||
|
"sender_public_key": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"content":"v2:SorfnTaoQ_Rv0XLKA8b3FZojgaFvnWgx66JR6Gj20ztnorQyL-hUYBZwAa5ohFC6ioR9hlJARJm0cgNTvWgSZuNTcfSAvoMdSG6mXK73kzsp99x351zUB_lc1_5ZXm0qqePAEz3Dl5jj5EsfAb8ZzsCd-BZENCsTwqjqC_hCFl7RitlRfIL2Tq_n4TUFEFandDCplNz3W4L7V07V89aGEoUlhzcwPU44BcxfvQjeqVKq0IRf2AetypoOduPrjSqkv674ubWaRcHtw3Asrqgo5XSYbpOlSk1PF_TttrQSPZGViNe5MuiK2P-7d-XqKXuDf_bUgAzW884KXogbct-wtIJZJbVM-utMd-dHrpC1mY81lgpS4_kPuhj0Z6Ro9hU5nCcEk2K4_vNoSM9m9QbcXP34h47qSPsw9ikmz8UoD00-1fXQVB4YJcBVUSVI06IzbZEWulo8SPXvQ4pJjV3nwPgYqRgwnrNWMNfeuKojlF8yA17UNOWvD2U7r1cs84HL8dxztzX9NdN0DGxvjMILvt3D4eWrMbcSrsIkBgyvV-uskEPd4eX3fc9GmX8MPkMgxRErcxbuq6JBUNXikOJXNH_qspOt4UIw5dCIajrHsKycKd8A_3rSgLEQteirOWMGaD2gOJEzpbe4iT72dmPkvRq7k-wDjLbO5dOSuUvpFM-ipkB07ATJz_1uUcRpl8fD_oSlcdAzdPjGKG5Y-tNv3AcUstkqCi52E5x-aO8EDUNBFi_OCbD86nkTUv1jOpAQwejowSiiOnCZ91zUEg5pRQyhBV0Ozib-j0Wf8S8VAz9M8bqQQaKedPCEowDn6csOKbFdBtqSeROf1XWKCkm1mSJGHWW9V44MiMiHebVrRUpbP0PqvxiIeJE0","created_at":1783710443,"id":"46425f8d4e8007af43abb67b3668b59604bd34c86dee1b1c3702559086927cb9","kind":1059,"pubkey":"960e391e314a7fb00bbdd85eccb0a93c17e981b6fed38487cf891f1ed6b66aeb","sig":"975c88c1c4d11f0b623603f8ecc7c181fea7385e8feacdb4a64a6b4f7536b1337ff556aee165ca337c9ec501cfab3e9703026c6df2b12bb8c702378875f00722","tags":[["p","1c108500bf53da288d30530718bac4bf80d661d1fe38854060c5b5c79eb77755"]]}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"recipient_private_key":"8355a5c110cdfef2e644f4ad5d51c39f253b2c2c80ebb6856379fb16531dc1fa"}
|
||||||
@@ -2,18 +2,17 @@
|
|||||||
// NostrProtocolTests.swift
|
// NostrProtocolTests.swift
|
||||||
// bitchatTests
|
// bitchatTests
|
||||||
//
|
//
|
||||||
// Tests for NIP-17 gift-wrapped private messages
|
// Tests for BitChat's proprietary private-envelope transport over Nostr.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
import CryptoKit
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
struct NostrProtocolTests {
|
struct NostrProtocolTests {
|
||||||
|
|
||||||
@Test func nip17MessageRoundTrip() throws {
|
@Test func privateEnvelopeRoundTrip() throws {
|
||||||
// Create sender and recipient identities
|
// Create sender and recipient identities
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
@@ -22,21 +21,19 @@ struct NostrProtocolTests {
|
|||||||
print("Recipient pubkey: \(recipient.publicKeyHex)")
|
print("Recipient pubkey: \(recipient.publicKeyHex)")
|
||||||
|
|
||||||
// Create a test message
|
// Create a test message
|
||||||
let originalContent = "Hello from NIP-17 test!"
|
let originalContent = "Hello from BitChat private-envelope test!"
|
||||||
|
|
||||||
// Create encrypted gift wrap
|
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
|
||||||
content: originalContent,
|
content: originalContent,
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
print("Gift wrap created with ID: \(giftWrap.id)")
|
print("Private envelope created with ID: \(envelope.id)")
|
||||||
print("Gift wrap pubkey: \(giftWrap.pubkey)")
|
print("Private envelope pubkey: \(envelope.pubkey)")
|
||||||
|
|
||||||
// Decrypt the gift wrap
|
let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateMessage(
|
envelope: envelope,
|
||||||
giftWrap: giftWrap,
|
|
||||||
recipientIdentity: recipient
|
recipientIdentity: recipient
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,43 +49,410 @@ struct NostrProtocolTests {
|
|||||||
print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey) at \(messageDate)")
|
print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey) at \(messageDate)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func giftWrapUsesUniqueEphemeralKeys() throws {
|
@Test func privateEnvelopesUseUniqueEphemeralKeys() throws {
|
||||||
// Create identities
|
// Create identities
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
|
|
||||||
// Create two messages
|
// Create two messages
|
||||||
let message1 = try NostrProtocol.createPrivateMessage(
|
let message1 = try NostrProtocol.createPrivateEnvelope(
|
||||||
content: "Message 1",
|
content: "Message 1",
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
let message2 = try NostrProtocol.createPrivateMessage(
|
let message2 = try NostrProtocol.createPrivateEnvelope(
|
||||||
content: "Message 2",
|
content: "Message 2",
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
// Gift wrap pubkeys should be different (unique ephemeral keys)
|
// Public envelope keys must be one-time use.
|
||||||
#expect(message1.pubkey != message2.pubkey)
|
#expect(message1.pubkey != message2.pubkey)
|
||||||
|
|
||||||
print("Message 1 gift wrap pubkey: \(message1.pubkey)")
|
print("Message 1 envelope pubkey: \(message1.pubkey)")
|
||||||
print("Message 2 gift wrap pubkey: \(message2.pubkey)")
|
print("Message 2 envelope pubkey: \(message2.pubkey)")
|
||||||
|
|
||||||
// Both should decrypt successfully
|
// Both should decrypt successfully
|
||||||
let (content1, _, _) = try NostrProtocol.decryptPrivateMessage(
|
let (content1, _, _) = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
giftWrap: message1,
|
envelope: message1,
|
||||||
recipientIdentity: recipient
|
recipientIdentity: recipient
|
||||||
)
|
)
|
||||||
let (content2, _, _) = try NostrProtocol.decryptPrivateMessage(
|
let (content2, _, _) = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
giftWrap: message2,
|
envelope: message2,
|
||||||
recipientIdentity: recipient
|
recipientIdentity: recipient
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(content1 == "Message 1")
|
#expect(content1 == "Message 1")
|
||||||
#expect(content2 == "Message 2")
|
#expect(content2 == "Message 2")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func privateEnvelopeUsesBitChatWireFormatAtEveryLayer() throws {
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||||
|
content: "bitchat-specific",
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
senderIdentity: sender
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(envelope.kind == NostrProtocol.EventKind.privateEnvelope.rawValue)
|
||||||
|
#expect(envelope.kind != NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue)
|
||||||
|
#expect(envelope.content.hasPrefix(NostrProtocol.privateEnvelopeContentPrefix))
|
||||||
|
#expect(!envelope.content.hasPrefix("v2:"))
|
||||||
|
#expect(envelope.tags == [["p", recipient.publicKeyHex]])
|
||||||
|
#expect(envelope.created_at <= Int(Date().timeIntervalSince1970))
|
||||||
|
|
||||||
|
let layers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
|
||||||
|
envelope: envelope,
|
||||||
|
recipientIdentity: recipient
|
||||||
|
)
|
||||||
|
#expect(layers.seal.kind == NostrProtocol.EventKind.privateSeal.rawValue)
|
||||||
|
#expect(layers.seal.content.hasPrefix(NostrProtocol.privateEnvelopeContentPrefix))
|
||||||
|
#expect(layers.seal.tags.isEmpty)
|
||||||
|
#expect(layers.message.kind == NostrProtocol.EventKind.privateMessage.rawValue)
|
||||||
|
#expect(layers.message.tags.isEmpty)
|
||||||
|
#expect(layers.message.sig == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func decryptAcceptsReceiveOnlyLegacyBitChatEnvelope() throws {
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let envelope = try NostrProtocol.createLegacyPrivateEnvelopeForTesting(
|
||||||
|
content: "legacy in-flight message",
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
senderIdentity: sender
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(envelope.kind == NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue)
|
||||||
|
#expect(envelope.content.hasPrefix("v2:"))
|
||||||
|
|
||||||
|
let result = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
|
envelope: envelope,
|
||||||
|
recipientIdentity: recipient
|
||||||
|
)
|
||||||
|
#expect(result.content == "legacy in-flight message")
|
||||||
|
#expect(result.senderPubkey == sender.publicKeyHex)
|
||||||
|
|
||||||
|
let layers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
|
||||||
|
envelope: envelope,
|
||||||
|
recipientIdentity: recipient
|
||||||
|
)
|
||||||
|
#expect(layers.seal.kind == NostrProtocol.EventKind.legacyNIP59Seal.rawValue)
|
||||||
|
#expect(layers.message.kind == NostrProtocol.EventKind.legacyNIP17DirectMessage.rawValue)
|
||||||
|
#expect(layers.message.tags.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func decryptAcceptsCurrentAndroidLegacyInnerRecipientTag() throws {
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
|
||||||
|
// Current Android's `createPrivateMessage` emits an unsigned kind-14
|
||||||
|
// inner event with exactly [["p", recipient]], while its outer/seal
|
||||||
|
// layers use the deployed BitChat legacy v2 crypto. This isolated
|
||||||
|
// generator reproduces that cross-platform wire shape without making
|
||||||
|
// the production encoder depend on Android's historical tag choice.
|
||||||
|
let envelope = try NostrProtocol.createLegacyPrivateEnvelopeForTesting(
|
||||||
|
content: "legacy message from Android",
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
senderIdentity: sender,
|
||||||
|
innerMessageTags: [["p", recipient.publicKeyHex]]
|
||||||
|
)
|
||||||
|
|
||||||
|
let layers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
|
||||||
|
envelope: envelope,
|
||||||
|
recipientIdentity: recipient
|
||||||
|
)
|
||||||
|
#expect(layers.message.tags == [["p", recipient.publicKeyHex]])
|
||||||
|
|
||||||
|
let result = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
|
envelope: envelope,
|
||||||
|
recipientIdentity: recipient
|
||||||
|
)
|
||||||
|
#expect(result.content == "legacy message from Android")
|
||||||
|
#expect(result.senderPubkey == sender.publicKeyHex)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func decryptsFrozenLegacyEnvelopeProducedByAndroidB7f0b33d() throws {
|
||||||
|
let eventData = try Data(contentsOf: fixtureURL(
|
||||||
|
name: "AndroidLegacyPrivateEnvelopeB7f0b33d"
|
||||||
|
))
|
||||||
|
let metadataData = try Data(contentsOf: fixtureURL(
|
||||||
|
name: "AndroidLegacyPrivateEnvelopeB7f0b33dMetadata"
|
||||||
|
))
|
||||||
|
let envelope = try JSONDecoder().decode(NostrEvent.self, from: eventData)
|
||||||
|
let metadata = try JSONDecoder().decode(AndroidLegacyEnvelopeFixture.self, from: metadataData)
|
||||||
|
let recipientKey = try #require(Data(hexString: metadata.recipientPrivateKey))
|
||||||
|
let recipient = try NostrIdentity(privateKeyData: recipientKey)
|
||||||
|
|
||||||
|
#expect(metadata.androidCommit == "b7f0b33d3a267c770d3d5a65ee2d8c7e755450db")
|
||||||
|
#expect(metadata.generator == "NostrProtocol.createPrivateMessage")
|
||||||
|
#expect(metadata.generatorPatch == "AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch")
|
||||||
|
#expect(metadata.fixtureSHA256 == eventData.sha256Fingerprint())
|
||||||
|
#expect(metadata.gradleTest.contains("NostrProtocolTest.emitCrossPlatformFixtures"))
|
||||||
|
let generatorPatch = try String(contentsOf: fixtureURL(
|
||||||
|
name: "AndroidLegacyPrivateEnvelopeB7f0b33dGenerator",
|
||||||
|
extension: "patch"
|
||||||
|
))
|
||||||
|
#expect(metadata.generatorPatchSHA256 == Data(generatorPatch.utf8).sha256Fingerprint())
|
||||||
|
#expect(generatorPatch.contains("fun emitCrossPlatformFixtures()"))
|
||||||
|
#expect(generatorPatch.contains(metadata.recipientPrivateKey))
|
||||||
|
#expect(envelope.isValidSignature())
|
||||||
|
#expect(envelope.kind == NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue)
|
||||||
|
#expect(envelope.tags == [["p", recipient.publicKeyHex]])
|
||||||
|
|
||||||
|
let layers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
|
||||||
|
envelope: envelope,
|
||||||
|
recipientIdentity: recipient
|
||||||
|
)
|
||||||
|
#expect(layers.message.tags == [["p", recipient.publicKeyHex]])
|
||||||
|
|
||||||
|
let result = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
|
envelope: envelope,
|
||||||
|
recipientIdentity: recipient
|
||||||
|
)
|
||||||
|
#expect(result.content == "legacy fixture from Android b7f0b33d")
|
||||||
|
#expect(result.senderPubkey == metadata.senderPublicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func decryptRejectsAlternateLegacyInnerTags() throws {
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let otherRecipient = try NostrIdentity.generate()
|
||||||
|
let invalidTagShapes = [
|
||||||
|
[["p", otherRecipient.publicKeyHex]],
|
||||||
|
[["p", recipient.publicKeyHex], ["p", recipient.publicKeyHex]],
|
||||||
|
[["p", recipient.publicKeyHex, "unexpected"]],
|
||||||
|
[["x", recipient.publicKeyHex]]
|
||||||
|
]
|
||||||
|
|
||||||
|
for tags in invalidTagShapes {
|
||||||
|
let envelope = try NostrProtocol.createLegacyPrivateEnvelopeForTesting(
|
||||||
|
content: "invalid Android-shaped tags",
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
senderIdentity: sender,
|
||||||
|
innerMessageTags: tags
|
||||||
|
)
|
||||||
|
expectInvalidEvent {
|
||||||
|
_ = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
|
envelope: envelope,
|
||||||
|
recipientIdentity: recipient
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func newPrivateEnvelopeRejectsInnerRecipientTag() throws {
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let envelope = try NostrProtocol.createPrivateEnvelopeWithInnerTagsForTesting(
|
||||||
|
content: "new format stays strict",
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
senderIdentity: sender,
|
||||||
|
innerMessageTags: [["p", recipient.publicKeyHex]]
|
||||||
|
)
|
||||||
|
|
||||||
|
expectInvalidEvent {
|
||||||
|
_ = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
|
envelope: envelope,
|
||||||
|
recipientIdentity: recipient
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func decryptsFrozenLegacyEnvelopeProducedByRelease733098bb() throws {
|
||||||
|
let eventData = try Data(contentsOf: fixtureURL(
|
||||||
|
name: "LegacyPrivateEnvelope733098bb"
|
||||||
|
))
|
||||||
|
let keyData = try Data(contentsOf: fixtureURL(
|
||||||
|
name: "LegacyPrivateEnvelope733098bbRecipientKey"
|
||||||
|
))
|
||||||
|
let envelope = try JSONDecoder().decode(NostrEvent.self, from: eventData)
|
||||||
|
let keyFixture = try JSONDecoder().decode(LegacyRecipientKeyFixture.self, from: keyData)
|
||||||
|
let recipientKey = try #require(Data(hexString: keyFixture.recipientPrivateKey))
|
||||||
|
let recipient = try NostrIdentity(privateKeyData: recipientKey)
|
||||||
|
|
||||||
|
#expect(envelope.isValidSignature())
|
||||||
|
let result = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
|
envelope: envelope,
|
||||||
|
recipientIdentity: recipient
|
||||||
|
)
|
||||||
|
#expect(result.content == "legacy fixture from 733098bb")
|
||||||
|
#expect(result.senderPubkey == "2e3d79df7047204f02b726c574e256f8de1dd80510f7dcb8b0d12df13acb87e6")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func publicationBatchAlwaysDualPublishesForCoordinatedMigration() throws {
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
|
||||||
|
let migrationBatch = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||||
|
content: "mixed-version",
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
senderIdentity: sender
|
||||||
|
)
|
||||||
|
#expect(migrationBatch.map(\.kind) == [
|
||||||
|
NostrProtocol.EventKind.privateEnvelope.rawValue,
|
||||||
|
NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue
|
||||||
|
])
|
||||||
|
for envelope in migrationBatch {
|
||||||
|
let result = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
|
envelope: envelope,
|
||||||
|
recipientIdentity: recipient
|
||||||
|
)
|
||||||
|
#expect(result.content == "mixed-version")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The compatibility copy retains the exact released-iOS legacy shape,
|
||||||
|
// which current Android also accepts: kinds 1059/13/14, v2 prefix, and
|
||||||
|
// no inner tags. There is no wall-clock branch that can silently stop
|
||||||
|
// old-client delivery.
|
||||||
|
let compatibilityLayers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
|
||||||
|
envelope: migrationBatch[1],
|
||||||
|
recipientIdentity: recipient
|
||||||
|
)
|
||||||
|
#expect(migrationBatch[1].content.hasPrefix("v2:"))
|
||||||
|
#expect(compatibilityLayers.seal.kind == NostrProtocol.EventKind.legacyNIP59Seal.rawValue)
|
||||||
|
#expect(compatibilityLayers.message.kind == NostrProtocol.EventKind.legacyNIP17DirectMessage.rawValue)
|
||||||
|
#expect(compatibilityLayers.message.tags.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func mailboxLookbackCoversDeliveryWindowAndroidFuzzAndClockSkew() {
|
||||||
|
let sentAt = Date(timeIntervalSince1970: 1_800_000_000)
|
||||||
|
let earliestAndroidTimestamp = sentAt.addingTimeInterval(
|
||||||
|
-TransportConfig.nostrLegacyAndroidTimestampFuzzSeconds
|
||||||
|
)
|
||||||
|
let subscribeAtDeliveryBoundary = sentAt.addingTimeInterval(
|
||||||
|
TransportConfig.nostrPrivateEnvelopeDeliveryWindowSeconds
|
||||||
|
)
|
||||||
|
let filterSince = subscribeAtDeliveryBoundary.addingTimeInterval(
|
||||||
|
-TransportConfig.nostrDMSubscribeLookbackSeconds
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(TransportConfig.nostrPrivateEnvelopeDeliveryWindowSeconds == 24 * 60 * 60)
|
||||||
|
#expect(TransportConfig.nostrLegacyAndroidTimestampFuzzSeconds == 48 * 60 * 60)
|
||||||
|
#expect(TransportConfig.nostrDMSubscribeClockSkewSeconds == 15 * 60)
|
||||||
|
let expectedLookback: TimeInterval = 72 * 60 * 60 + 15 * 60
|
||||||
|
#expect(TransportConfig.nostrDMSubscribeLookbackSeconds == expectedLookback)
|
||||||
|
#expect(filterSince == earliestAndroidTimestamp.addingTimeInterval(-(15 * 60)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func largePrivateEnvelopeFitsLayerSpecificExpansionLimits() throws {
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
// Large enough that the nested Base64 seal exceeds the inner 32 KiB
|
||||||
|
// cap, while the inner message JSON itself remains below that cap.
|
||||||
|
let content = String(repeating: "A", count: 30 * 1024)
|
||||||
|
|
||||||
|
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||||
|
content: content,
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
senderIdentity: sender
|
||||||
|
)
|
||||||
|
let decrypted = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
|
envelope: envelope,
|
||||||
|
recipientIdentity: recipient
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(decrypted.content == content)
|
||||||
|
#expect(envelope.content.utf8.count <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func privateEnvelopeRejectsOversizedCiphertextBeforeDecoding() throws {
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let wrapper = try NostrIdentity.generate()
|
||||||
|
let oversizedContent = NostrProtocol.privateEnvelopeContentPrefix
|
||||||
|
+ String(
|
||||||
|
repeating: "A",
|
||||||
|
count: NostrProtocol.maximumPrivateEnvelopeCiphertextBytes
|
||||||
|
)
|
||||||
|
let event = NostrEvent(
|
||||||
|
pubkey: wrapper.publicKeyHex,
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .privateEnvelope,
|
||||||
|
tags: [["p", recipient.publicKeyHex]],
|
||||||
|
content: oversizedContent
|
||||||
|
)
|
||||||
|
let signed = try event.sign(with: wrapper.schnorrSigningKey())
|
||||||
|
|
||||||
|
expectInvalidCiphertext {
|
||||||
|
_ = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
|
envelope: signed,
|
||||||
|
recipientIdentity: recipient
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func privateEnvelopeRejectsOversizedPlaintextBeforeEncryption() throws {
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let oversizedPlaintext = String(
|
||||||
|
repeating: "x",
|
||||||
|
count: NostrProtocol.maximumPrivateEnvelopePlaintextBytes + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
expectInvalidCiphertext {
|
||||||
|
_ = try NostrProtocol.createPrivateEnvelope(
|
||||||
|
content: oversizedPlaintext,
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
senderIdentity: sender
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func privateEnvelopePublicationBatchRejectsMaximumComposerPayload() throws {
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let composerMaximum = String(
|
||||||
|
repeating: "x",
|
||||||
|
count: InputValidator.Limits.maxMessageLength
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
composerMaximum.utf8.count
|
||||||
|
> NostrProtocol.maximumPrivateEnvelopePlaintextBytes
|
||||||
|
)
|
||||||
|
expectInvalidCiphertext {
|
||||||
|
_ = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||||
|
content: composerMaximum,
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
senderIdentity: sender
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func privateEnvelopeRejectsOversizedNestedJSONBeforeParsing() {
|
||||||
|
let oversizedJSON = String(
|
||||||
|
repeating: "{",
|
||||||
|
count: NostrProtocol.maximumPrivateEnvelopePlaintextBytes + 1
|
||||||
|
)
|
||||||
|
expectInvalidCiphertext {
|
||||||
|
_ = try NostrProtocol.decodePrivateEnvelopeEventJSONForTesting(oversizedJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func decryptDoesNotMisinterpretStandardNIP44PayloadAsLegacyBitChat() throws {
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let wrapper = try NostrIdentity.generate()
|
||||||
|
// A valid NIP-44 v2 payload from the official test vectors. Its wire
|
||||||
|
// format starts with a version byte in standard Base64, not BitChat's
|
||||||
|
// historical `v2:` prefix.
|
||||||
|
let standardPayload = "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb"
|
||||||
|
let event = NostrEvent(
|
||||||
|
pubkey: wrapper.publicKeyHex,
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .legacyNIP59GiftWrap,
|
||||||
|
tags: [["p", recipient.publicKeyHex]],
|
||||||
|
content: standardPayload
|
||||||
|
)
|
||||||
|
let signed = try event.sign(with: wrapper.schnorrSigningKey())
|
||||||
|
|
||||||
|
expectInvalidCiphertext {
|
||||||
|
_ = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
|
envelope: signed,
|
||||||
|
recipientIdentity: recipient
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test func decryptionFailsWithWrongRecipient() throws {
|
@Test func decryptionFailsWithWrongRecipient() throws {
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
@@ -96,68 +460,58 @@ struct NostrProtocolTests {
|
|||||||
let wrongRecipient = try NostrIdentity.generate()
|
let wrongRecipient = try NostrIdentity.generate()
|
||||||
|
|
||||||
// Create message for recipient
|
// Create message for recipient
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||||
content: "Secret message",
|
content: "Secret message",
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
// Try to decrypt with wrong recipient
|
expectInvalidEvent {
|
||||||
if #available(macOS 14.4, iOS 17.4, *) {
|
_ = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
#expect(throws: CryptoKitError.authenticationFailure) {
|
envelope: envelope,
|
||||||
try NostrProtocol.decryptPrivateMessage(
|
recipientIdentity: wrongRecipient
|
||||||
giftWrap: giftWrap,
|
)
|
||||||
recipientIdentity: wrongRecipient
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
#expect(throws: (any Error).self) {
|
|
||||||
try NostrProtocol.decryptPrivateMessage(
|
|
||||||
giftWrap: giftWrap,
|
|
||||||
recipientIdentity: wrongRecipient
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func decryptRejectsInvalidSealSignature() throws {
|
@Test func decryptRejectsInvalidSealSignature() throws {
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessageWithInvalidSealSignatureForTesting(
|
let envelope = try NostrProtocol.createPrivateEnvelopeWithInvalidSealSignatureForTesting(
|
||||||
content: "forged signature",
|
content: "forged signature",
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
expectInvalidEvent {
|
expectInvalidEvent {
|
||||||
_ = try NostrProtocol.decryptPrivateMessage(
|
_ = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
giftWrap: giftWrap,
|
envelope: envelope,
|
||||||
recipientIdentity: recipient
|
recipientIdentity: recipient
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func decryptRejectsSealRumorPubkeyMismatch() throws {
|
@Test func decryptRejectsSealMessagePubkeyMismatch() throws {
|
||||||
let claimedSender = try NostrIdentity.generate()
|
let claimedSender = try NostrIdentity.generate()
|
||||||
let sealSigner = try NostrIdentity.generate()
|
let sealSigner = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessageWithMismatchedSealRumorPubkeyForTesting(
|
let envelope = try NostrProtocol.createPrivateEnvelopeWithMismatchedSealMessagePubkeyForTesting(
|
||||||
content: "spoofed sender",
|
content: "spoofed sender",
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
rumorIdentity: claimedSender,
|
messageIdentity: claimedSender,
|
||||||
sealSignerIdentity: sealSigner
|
sealSignerIdentity: sealSigner
|
||||||
)
|
)
|
||||||
|
|
||||||
expectInvalidEvent {
|
expectInvalidEvent {
|
||||||
_ = try NostrProtocol.decryptPrivateMessage(
|
_ = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
giftWrap: giftWrap,
|
envelope: envelope,
|
||||||
recipientIdentity: recipient
|
recipientIdentity: recipient
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func testAckRoundTripNIP44V2_Delivered() throws {
|
func deliveredAckRoundTripsInsidePrivateEnvelope() throws {
|
||||||
// Identities
|
// Identities
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
@@ -171,19 +525,17 @@ struct NostrProtocolTests {
|
|||||||
"Failed to embed delivered ack"
|
"Failed to embed delivered ack"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create NIP-17 gift wrap to recipient (uses NIP-44 v2 internally)
|
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
|
||||||
content: embedded,
|
content: embedded,
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
// Ensure v2 format was used for ciphertext
|
#expect(envelope.content.hasPrefix(NostrProtocol.privateEnvelopeContentPrefix))
|
||||||
#expect(giftWrap.content.hasPrefix("v2:"))
|
|
||||||
|
|
||||||
// Decrypt as recipient
|
// Decrypt as recipient
|
||||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
giftWrap: giftWrap,
|
envelope: envelope,
|
||||||
recipientIdentity: recipient
|
recipientIdentity: recipient
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -208,7 +560,7 @@ struct NostrProtocolTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func ackRoundTripNIP44V2_ReadReceipt() throws {
|
@Test func readReceiptRoundTripsInsidePrivateEnvelope() throws {
|
||||||
// Identities
|
// Identities
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
@@ -220,16 +572,16 @@ struct NostrProtocolTests {
|
|||||||
"Failed to embed read ack"
|
"Failed to embed read ack"
|
||||||
)
|
)
|
||||||
|
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||||
content: embedded,
|
content: embedded,
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(giftWrap.content.hasPrefix("v2:"))
|
#expect(envelope.content.hasPrefix(NostrProtocol.privateEnvelopeContentPrefix))
|
||||||
|
|
||||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
giftWrap: giftWrap,
|
envelope: envelope,
|
||||||
recipientIdentity: recipient
|
recipientIdentity: recipient
|
||||||
)
|
)
|
||||||
#expect(senderPubkey == sender.publicKeyHex)
|
#expect(senderPubkey == sender.publicKeyHex)
|
||||||
@@ -290,7 +642,6 @@ struct NostrProtocolTests {
|
|||||||
#expect(object["limit"] as? Int == 42)
|
#expect(object["limit"] as? Int == 42)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Test func inboundNostrEventRejectsTooManyTags() throws {
|
@Test func inboundNostrEventRejectsTooManyTags() throws {
|
||||||
var eventDict = Self.validInboundEventDict()
|
var eventDict = Self.validInboundEventDict()
|
||||||
eventDict["tags"] = Array(
|
eventDict["tags"] = Array(
|
||||||
@@ -336,6 +687,33 @@ struct NostrProtocolTests {
|
|||||||
#expect(event.tags.count == 2)
|
#expect(event.tags.count == 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func privateEnvelopeFiltersGiveEachMigrationKindAnIndependentRecoveryBudget() throws {
|
||||||
|
let since = Date(timeIntervalSince1970: 1_234_567)
|
||||||
|
let filters = NostrFilter.privateEnvelopeFiltersFor(
|
||||||
|
pubkey: "recipient",
|
||||||
|
since: since
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(filters.count == 2)
|
||||||
|
let objects = try filters.map { filter in
|
||||||
|
let data = try JSONEncoder().encode(filter)
|
||||||
|
return try #require(
|
||||||
|
try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#expect(objects.compactMap { $0["kinds"] as? [Int] } == [
|
||||||
|
[NostrProtocol.EventKind.privateEnvelope.rawValue],
|
||||||
|
[NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue]
|
||||||
|
])
|
||||||
|
for object in objects {
|
||||||
|
#expect(object["#p"] as? [String] == ["recipient"])
|
||||||
|
#expect(object["since"] as? Int == 1_234_567)
|
||||||
|
#expect(object["limit"] as? Int ==
|
||||||
|
TransportConfig.nostrPrivateEnvelopeFetchLimitPerKind
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
private static func validInboundEventDict() -> [String: Any] {
|
private static func validInboundEventDict() -> [String: Any] {
|
||||||
[
|
[
|
||||||
@@ -349,6 +727,45 @@ struct NostrProtocolTests {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct LegacyRecipientKeyFixture: Decodable {
|
||||||
|
let recipientPrivateKey: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case recipientPrivateKey = "recipient_private_key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AndroidLegacyEnvelopeFixture: Decodable {
|
||||||
|
let androidCommit: String
|
||||||
|
let generator: String
|
||||||
|
let generatorPatch: String
|
||||||
|
let generatorPatchSHA256: String
|
||||||
|
let gradleTest: String
|
||||||
|
let fixtureSHA256: String
|
||||||
|
let recipientPrivateKey: String
|
||||||
|
let senderPublicKey: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case androidCommit = "android_commit"
|
||||||
|
case generator
|
||||||
|
case generatorPatch = "generator_patch"
|
||||||
|
case generatorPatchSHA256 = "generator_patch_sha256"
|
||||||
|
case gradleTest = "gradle_test"
|
||||||
|
case fixtureSHA256 = "fixture_sha256"
|
||||||
|
case recipientPrivateKey = "recipient_private_key"
|
||||||
|
case senderPublicKey = "sender_public_key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fixtureURL(name: String, extension fileExtension: String = "json") throws -> URL {
|
||||||
|
#if SWIFT_PACKAGE
|
||||||
|
let bundle = Bundle.module
|
||||||
|
#else
|
||||||
|
let bundle = Bundle(for: MockKeychain.self)
|
||||||
|
#endif
|
||||||
|
return try #require(bundle.url(forResource: name, withExtension: fileExtension))
|
||||||
|
}
|
||||||
|
|
||||||
private static func base64URLDecode(_ s: String) -> Data? {
|
private static func base64URLDecode(_ s: String) -> Data? {
|
||||||
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||||
let rem = str.count % 4
|
let rem = str.count % 4
|
||||||
@@ -366,4 +783,15 @@ struct NostrProtocolTests {
|
|||||||
Issue.record("Expected NostrError.invalidEvent, got \(error)")
|
Issue.record("Expected NostrError.invalidEvent, got \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func expectInvalidCiphertext(_ operation: () throws -> Void) {
|
||||||
|
do {
|
||||||
|
try operation()
|
||||||
|
Issue.record("Expected NostrError.invalidCiphertext")
|
||||||
|
} catch NostrError.invalidCiphertext {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
Issue.record("Expected NostrError.invalidCiphertext, got \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -690,12 +690,29 @@ private final class PerfDeliveryContext: ChatDeliveryContext {
|
|||||||
|
|
||||||
func notifyUIChanged() {}
|
func notifyUIChanged() {}
|
||||||
func markMessageDelivered(_ messageID: String) {}
|
func markMessageDelivered(_ messageID: String) {}
|
||||||
|
func markMessageDelivered(_ messageID: String, from peerIDs: Set<PeerID>) {}
|
||||||
|
func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set<PeerID>) -> Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||||
store.setDeliveryStatus(status, forMessageID: messageID)
|
store.setDeliveryStatus(status, forMessageID: messageID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func setDeliveryStatus(
|
||||||
|
_ status: DeliveryStatus,
|
||||||
|
forMessageID messageID: String,
|
||||||
|
inDirectPeerAliases peerIDs: Set<PeerID>
|
||||||
|
) -> Bool {
|
||||||
|
store.setDeliveryStatus(
|
||||||
|
status,
|
||||||
|
forMessageID: messageID,
|
||||||
|
inDirectPeerAliases: peerIDs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
|
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
|
||||||
store.deliveryStatus(forMessageID: messageID)
|
store.deliveryStatus(forMessageID: messageID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitFoundation
|
||||||
import XCTest
|
import XCTest
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
@@ -73,4 +74,85 @@ final class BitchatFilePacketTests: XCTestCase {
|
|||||||
XCTAssertEqual(decoded.fileSize, UInt64(content.count))
|
XCTAssertEqual(decoded.fileSize, UInt64(content.count))
|
||||||
XCTAssertEqual(decoded.content, content)
|
XCTAssertEqual(decoded.content, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testPrivateMediaMessageIdentityConvergesAcrossPeerIDAliases() throws {
|
||||||
|
let senderKey = Data(repeating: 0x11, count: 32)
|
||||||
|
let recipientKey = Data(repeating: 0x22, count: 32)
|
||||||
|
let senderStable = PeerID(hexData: senderKey)
|
||||||
|
let recipientStable = PeerID(hexData: recipientKey)
|
||||||
|
let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||||
|
|
||||||
|
let senderID = try XCTUnwrap(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: senderStable.toShort(),
|
||||||
|
recipientPeerID: PeerID(str: "mesh:\(recipientStable.toShort().bare)"),
|
||||||
|
fileName: fileName
|
||||||
|
))
|
||||||
|
let receiverID = try XCTUnwrap(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: senderStable,
|
||||||
|
recipientPeerID: recipientStable.toShort(),
|
||||||
|
fileName: fileName
|
||||||
|
))
|
||||||
|
|
||||||
|
XCTAssertEqual(senderID, receiverID)
|
||||||
|
XCTAssertTrue(senderID.hasPrefix("media-"))
|
||||||
|
XCTAssertEqual(senderID.count, 38)
|
||||||
|
XCTAssertTrue(PrivateMediaMessageIdentity.isStableID(senderID))
|
||||||
|
XCTAssertFalse(PrivateMediaMessageIdentity.isStableID("media-\(String(repeating: "A", count: 32))"))
|
||||||
|
XCTAssertFalse(PrivateMediaMessageIdentity.isStableID("media-\(String(repeating: "a", count: 31))"))
|
||||||
|
XCTAssertFalse(PrivateMediaMessageIdentity.isStableID(UUID().uuidString))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPrivateMediaMessageIdentitySeparatesDirectionAndFilename() throws {
|
||||||
|
let alice = PeerID(str: "0011223344556677")
|
||||||
|
let bob = PeerID(str: "8899aabbccddeeff")
|
||||||
|
let firstName = "voice_20260725_105708_11111111-1111-1111-1111-111111111111.m4a"
|
||||||
|
let secondName = "voice_20260725_105709_22222222-2222-2222-2222-222222222222.m4a"
|
||||||
|
let first = try XCTUnwrap(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: alice,
|
||||||
|
recipientPeerID: bob,
|
||||||
|
fileName: firstName
|
||||||
|
))
|
||||||
|
|
||||||
|
XCTAssertNotEqual(first, PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: bob,
|
||||||
|
recipientPeerID: alice,
|
||||||
|
fileName: firstName
|
||||||
|
))
|
||||||
|
XCTAssertNotEqual(first, PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: alice,
|
||||||
|
recipientPeerID: bob,
|
||||||
|
fileName: secondName
|
||||||
|
))
|
||||||
|
XCTAssertNil(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: alice,
|
||||||
|
recipientPeerID: bob,
|
||||||
|
fileName: nil
|
||||||
|
))
|
||||||
|
XCTAssertNil(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: alice,
|
||||||
|
recipientPeerID: bob,
|
||||||
|
fileName: "photo.jpg"
|
||||||
|
))
|
||||||
|
XCTAssertNil(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: alice,
|
||||||
|
recipientPeerID: bob,
|
||||||
|
fileName: "img_11111111-1111-1111-1111-111111111111.pdf"
|
||||||
|
))
|
||||||
|
XCTAssertNotNil(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: alice,
|
||||||
|
recipientPeerID: bob,
|
||||||
|
fileName: "voice_0011223344556677.m4a"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPrivateMediaMessageIdentityMatchesVersionOneGoldenVector() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: PeerID(str: "0011223344556677"),
|
||||||
|
recipientPeerID: PeerID(str: "8899aabbccddeeff"),
|
||||||
|
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||||
|
),
|
||||||
|
"media-910bd42c65060ab76bb6406f220c4516"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,17 +13,109 @@ struct BLEFileTransferHandlerTests {
|
|||||||
|
|
||||||
var signatureVerifyCount = 0
|
var signatureVerifyCount = 0
|
||||||
var signedNameQueries: [PeerID] = []
|
var signedNameQueries: [PeerID] = []
|
||||||
|
var blockedPeers: Set<PeerID> = []
|
||||||
var trackedPackets: [BitchatPacket] = []
|
var trackedPackets: [BitchatPacket] = []
|
||||||
var quotaReservations: [Int] = []
|
var quotaReservations: [Int] = []
|
||||||
var saveCalls: [(data: Data, preferredName: String?, subdirectory: String, fallbackExtension: String?, defaultPrefix: String)] = []
|
var saveCalls: [(data: Data, preferredName: String?, subdirectory: String, fallbackExtension: String?, defaultPrefix: String)] = []
|
||||||
|
var receiptStates: [String: BLEPrivateMediaReceiptState] = [:]
|
||||||
|
var receiptCommits: [(messageID: String, storedURL: URL)] = []
|
||||||
|
var receiptCommitSucceeds = true
|
||||||
|
var removedIncomingFiles: [URL] = []
|
||||||
|
var finishedIncomingFileDeliveries: [URL] = []
|
||||||
var lastSeenUpdates: [PeerID] = []
|
var lastSeenUpdates: [PeerID] = []
|
||||||
|
var deliveryAcks: [(messageID: String, peerID: PeerID)] = []
|
||||||
var deliveredMessages: [BitchatMessage] = []
|
var deliveredMessages: [BitchatMessage] = []
|
||||||
|
var shouldAcceptDelivery = true
|
||||||
|
var deliveryOutcome = TransportEventDeliveryOutcome.accepted
|
||||||
|
var saveOverride: ((
|
||||||
|
_ data: Data,
|
||||||
|
_ preferredName: String?,
|
||||||
|
_ subdirectory: String,
|
||||||
|
_ fallbackExtension: String?,
|
||||||
|
_ defaultPrefix: String
|
||||||
|
) -> URL?)?
|
||||||
|
var receiptStateOverride: ((String) -> BLEPrivateMediaReceiptState)?
|
||||||
|
var receiptCommitOverride: ((String, URL) -> Bool)?
|
||||||
|
var removeIncomingFileOverride: ((URL) -> Void)?
|
||||||
|
var finishIncomingFileDeliveryOverride: ((URL) -> Void)?
|
||||||
}
|
}
|
||||||
|
|
||||||
private let localPeerID = PeerID(str: "0102030405060708")
|
private let localPeerID = PeerID(str: "0102030405060708")
|
||||||
private let remotePeerID = PeerID(str: "1122334455667788")
|
private let remotePeerID = PeerID(str: "1122334455667788")
|
||||||
private let sampleSigningKey = Data(repeating: 0xAB, count: 32)
|
private let sampleSigningKey = Data(repeating: 0xAB, count: 32)
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func deliveryGateFinalizesInitialRejection() {
|
||||||
|
var completions = 0
|
||||||
|
var finalizations = 0
|
||||||
|
|
||||||
|
TransportEventDeliveryGate.attempt(
|
||||||
|
shouldDeliver: { false },
|
||||||
|
deliver: {
|
||||||
|
Issue.record("delivery sink must not run")
|
||||||
|
return .accepted
|
||||||
|
},
|
||||||
|
completion: { completions += 1 },
|
||||||
|
finalization: { _ in finalizations += 1 }
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(completions == 0)
|
||||||
|
#expect(finalizations == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func deliveryGateFinalizesMissingOrRejectingSink() {
|
||||||
|
var completions = 0
|
||||||
|
var finalizations = 0
|
||||||
|
|
||||||
|
TransportEventDeliveryGate.attempt(
|
||||||
|
shouldDeliver: { true },
|
||||||
|
deliver: { .rejected },
|
||||||
|
completion: { completions += 1 },
|
||||||
|
finalization: { _ in finalizations += 1 }
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(completions == 0)
|
||||||
|
#expect(finalizations == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func deliveryGateFinalizesPostInsertionRejection() {
|
||||||
|
var deliveryChecks = 0
|
||||||
|
var completions = 0
|
||||||
|
var finalizations = 0
|
||||||
|
|
||||||
|
TransportEventDeliveryGate.attempt(
|
||||||
|
shouldDeliver: {
|
||||||
|
deliveryChecks += 1
|
||||||
|
return deliveryChecks == 1
|
||||||
|
},
|
||||||
|
deliver: { .accepted },
|
||||||
|
completion: { completions += 1 },
|
||||||
|
finalization: { _ in finalizations += 1 }
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(deliveryChecks == 2)
|
||||||
|
#expect(completions == 0)
|
||||||
|
#expect(finalizations == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func deliveryGatePreservesInvokedUnconfirmedOutcomeWithoutAck() {
|
||||||
|
var completions = 0
|
||||||
|
var outcomes: [TransportEventDeliveryOutcome] = []
|
||||||
|
|
||||||
|
TransportEventDeliveryGate.attempt(
|
||||||
|
shouldDeliver: { true },
|
||||||
|
deliver: { .invokedUnconfirmed },
|
||||||
|
completion: { completions += 1 },
|
||||||
|
finalization: { outcomes.append($0) }
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(completions == 0)
|
||||||
|
#expect(outcomes == [.invokedUnconfirmed])
|
||||||
|
}
|
||||||
|
|
||||||
private func makeHandler(recorder: Recorder) -> BLEFileTransferHandler {
|
private func makeHandler(recorder: Recorder) -> BLEFileTransferHandler {
|
||||||
let environment = BLEFileTransferHandlerEnvironment(
|
let environment = BLEFileTransferHandlerEnvironment(
|
||||||
localPeerID: { [localPeerID] in localPeerID },
|
localPeerID: { [localPeerID] in localPeerID },
|
||||||
@@ -46,13 +138,56 @@ struct BLEFileTransferHandlerTests {
|
|||||||
},
|
},
|
||||||
saveIncomingFile: { data, preferredName, subdirectory, fallbackExtension, defaultPrefix in
|
saveIncomingFile: { data, preferredName, subdirectory, fallbackExtension, defaultPrefix in
|
||||||
recorder.saveCalls.append((data, preferredName, subdirectory, fallbackExtension, defaultPrefix))
|
recorder.saveCalls.append((data, preferredName, subdirectory, fallbackExtension, defaultPrefix))
|
||||||
|
if let saveOverride = recorder.saveOverride {
|
||||||
|
return saveOverride(data, preferredName, subdirectory, fallbackExtension, defaultPrefix)
|
||||||
|
}
|
||||||
return recorder.saveResult
|
return recorder.saveResult
|
||||||
},
|
},
|
||||||
|
privateMediaReceiptState: { messageID in
|
||||||
|
if let receiptStateOverride = recorder.receiptStateOverride {
|
||||||
|
return receiptStateOverride(messageID)
|
||||||
|
}
|
||||||
|
return recorder.receiptStates[messageID] ?? .absent
|
||||||
|
},
|
||||||
|
commitPrivateMediaFile: { messageID, storedURL in
|
||||||
|
recorder.receiptCommits.append((messageID, storedURL))
|
||||||
|
if let receiptCommitOverride = recorder.receiptCommitOverride {
|
||||||
|
return receiptCommitOverride(messageID, storedURL)
|
||||||
|
}
|
||||||
|
guard recorder.receiptCommitSucceeds else { return false }
|
||||||
|
recorder.receiptStates[messageID] = .accepted(storedURL)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
removeIncomingFile: { storedURL in
|
||||||
|
recorder.removedIncomingFiles.append(storedURL)
|
||||||
|
recorder.removeIncomingFileOverride?(storedURL)
|
||||||
|
},
|
||||||
|
finishIncomingFileDelivery: { storedURL in
|
||||||
|
recorder.finishedIncomingFileDeliveries.append(storedURL)
|
||||||
|
recorder.finishIncomingFileDeliveryOverride?(storedURL)
|
||||||
|
},
|
||||||
|
isPrivateMediaSenderBlocked: { peerID in
|
||||||
|
recorder.blockedPeers.contains(peerID)
|
||||||
|
},
|
||||||
updatePeerLastSeen: { peerID in
|
updatePeerLastSeen: { peerID in
|
||||||
recorder.lastSeenUpdates.append(peerID)
|
recorder.lastSeenUpdates.append(peerID)
|
||||||
},
|
},
|
||||||
deliverMessage: { message in
|
acknowledgePrivateMedia: { messageID, peerID in
|
||||||
|
recorder.deliveryAcks.append((messageID, peerID))
|
||||||
|
},
|
||||||
|
deliverMessage: { message, shouldDeliver, completion, finalization in
|
||||||
|
var outcome = TransportEventDeliveryOutcome.rejected
|
||||||
|
defer { finalization(outcome) }
|
||||||
|
guard recorder.shouldAcceptDelivery else { return }
|
||||||
|
guard shouldDeliver() else { return }
|
||||||
recorder.deliveredMessages.append(message)
|
recorder.deliveredMessages.append(message)
|
||||||
|
guard shouldDeliver() else { return }
|
||||||
|
if recorder.deliveryOutcome == .invokedUnconfirmed {
|
||||||
|
outcome = .invokedUnconfirmed
|
||||||
|
return
|
||||||
|
}
|
||||||
|
outcome = .accepted
|
||||||
|
completion()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return BLEFileTransferHandler(environment: environment)
|
return BLEFileTransferHandler(environment: environment)
|
||||||
@@ -284,6 +419,7 @@ struct BLEFileTransferHandlerTests {
|
|||||||
#expect(recorder.lastSeenUpdates == [remotePeerID])
|
#expect(recorder.lastSeenUpdates == [remotePeerID])
|
||||||
#expect(recorder.deliveredMessages.count == 1)
|
#expect(recorder.deliveredMessages.count == 1)
|
||||||
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
||||||
|
#expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false)
|
||||||
// Must be explicit: BitchatMessage defaults private messages to
|
// Must be explicit: BitchatMessage defaults private messages to
|
||||||
// .sending, which the media views render as an in-flight send
|
// .sending, which the media views render as an in-flight send
|
||||||
// (empty reveal mask, disabled reveal tap).
|
// (empty reveal mask, disabled reveal tap).
|
||||||
@@ -291,13 +427,14 @@ struct BLEFileTransferHandlerTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func decryptedPrivateFileUsesValidationQuotaAndPrivateDeliveryWithoutRawSignature() throws {
|
func bit8EncryptedPrivateFileKeepsStableIDAndAckWithoutBit9Proof() throws {
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||||
let handler = makeHandler(recorder: recorder)
|
let handler = makeHandler(recorder: recorder)
|
||||||
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
||||||
|
let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||||
let file = BitchatFilePacket(
|
let file = BitchatFilePacket(
|
||||||
fileName: "secret.jpg",
|
fileName: fileName,
|
||||||
fileSize: UInt64(content.count),
|
fileSize: UInt64(content.count),
|
||||||
mimeType: "image/jpeg",
|
mimeType: "image/jpeg",
|
||||||
content: content
|
content: content
|
||||||
@@ -316,6 +453,480 @@ struct BLEFileTransferHandlerTests {
|
|||||||
#expect(recorder.deliveredMessages.count == 1)
|
#expect(recorder.deliveredMessages.count == 1)
|
||||||
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
||||||
#expect(recorder.deliveredMessages.first?.timestamp == timestamp)
|
#expect(recorder.deliveredMessages.first?.timestamp == timestamp)
|
||||||
|
#expect(recorder.deliveredMessages.first?.id == PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: remotePeerID,
|
||||||
|
recipientPeerID: localPeerID,
|
||||||
|
fileName: fileName
|
||||||
|
))
|
||||||
|
#expect(recorder.receiptCommits.count == 1)
|
||||||
|
#expect(recorder.deliveryAcks.count == 1)
|
||||||
|
#expect(recorder.deliveryAcks.first?.messageID == recorder.deliveredMessages.first?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func rejectedStableDeliveryReleasesPendingPayloadOwnership() throws {
|
||||||
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||||
|
"private-media-handler-rejected-\(UUID().uuidString)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let store = BLEIncomingFileStore(baseDirectory: root)
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(
|
||||||
|
remotePeerID,
|
||||||
|
nickname: "Alice",
|
||||||
|
isVerified: true
|
||||||
|
)]
|
||||||
|
recorder.shouldAcceptDelivery = false
|
||||||
|
recorder.saveOverride = {
|
||||||
|
store.save(
|
||||||
|
data: $0,
|
||||||
|
preferredName: $1,
|
||||||
|
subdirectory: $2,
|
||||||
|
fallbackExtension: $3,
|
||||||
|
defaultPrefix: $4
|
||||||
|
)
|
||||||
|
}
|
||||||
|
recorder.receiptStateOverride = {
|
||||||
|
store.privateMediaReceiptState(messageID: $0)
|
||||||
|
}
|
||||||
|
recorder.receiptCommitOverride = {
|
||||||
|
store.commitPrivateMediaFile(messageID: $0, storedURL: $1)
|
||||||
|
}
|
||||||
|
recorder.removeIncomingFileOverride = {
|
||||||
|
store.removeIncomingFile(at: $0)
|
||||||
|
}
|
||||||
|
recorder.finishIncomingFileDeliveryOverride = {
|
||||||
|
store.finishIncomingFileDelivery(at: $0)
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||||
|
let fileName =
|
||||||
|
"img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||||
|
let file = BitchatFilePacket(
|
||||||
|
fileName: fileName,
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let payload = try #require(file.encode())
|
||||||
|
let stableID = try #require(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: remotePeerID,
|
||||||
|
recipientPeerID: localPeerID,
|
||||||
|
fileName: fileName
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(makeHandler(recorder: recorder).handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||||
|
))
|
||||||
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
|
#expect(recorder.deliveryAcks.isEmpty)
|
||||||
|
#expect(recorder.finishedIncomingFileDeliveries.count == 1)
|
||||||
|
#expect(store.reservePrivateMediaDeletion(
|
||||||
|
messageIDs: [stableID],
|
||||||
|
payloadRelativePaths: [:]
|
||||||
|
) != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func rawLegacyPrivateFileWithRetryShapedNameNeverUsesReceiptLedger() throws {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(
|
||||||
|
remotePeerID,
|
||||||
|
nickname: "Alice",
|
||||||
|
isVerified: true,
|
||||||
|
signingPublicKey: sampleSigningKey
|
||||||
|
)]
|
||||||
|
recorder.signatureVerifies = true
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||||
|
let packet = try makeFileTransferPacket(
|
||||||
|
sender: remotePeerID,
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content,
|
||||||
|
recipientID: Data(hexString: localPeerID.id),
|
||||||
|
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(handler.handle(packet, from: remotePeerID))
|
||||||
|
#expect(recorder.receiptCommits.isEmpty)
|
||||||
|
#expect(recorder.deliveryAcks.isEmpty)
|
||||||
|
#expect(recorder.deliveredMessages.count == 1)
|
||||||
|
#expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func rejectedRawDeliveryRemovesUIUnownedPayload() throws {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(
|
||||||
|
remotePeerID,
|
||||||
|
nickname: "Alice",
|
||||||
|
isVerified: true,
|
||||||
|
signingPublicKey: sampleSigningKey
|
||||||
|
)]
|
||||||
|
recorder.signatureVerifies = true
|
||||||
|
recorder.shouldAcceptDelivery = false
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let packet = try makeFileTransferPacket(
|
||||||
|
sender: remotePeerID,
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: Data([0xFF, 0xD8, 0xFF, 0xD9]),
|
||||||
|
recipientID: Data(hexString: localPeerID.id),
|
||||||
|
fileName: "raw-rejected.jpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(handler.handle(packet, from: remotePeerID))
|
||||||
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
|
#expect(recorder.removedIncomingFiles.count == 1)
|
||||||
|
#expect(recorder.removedIncomingFiles.first == recorder.saveResult)
|
||||||
|
#expect(recorder.finishedIncomingFileDeliveries.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func plainDelegateRawDeliveryPreservesPayloadWithoutSynchronousAck() throws {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(
|
||||||
|
remotePeerID,
|
||||||
|
nickname: "Alice",
|
||||||
|
isVerified: true,
|
||||||
|
signingPublicKey: sampleSigningKey
|
||||||
|
)]
|
||||||
|
recorder.signatureVerifies = true
|
||||||
|
recorder.deliveryOutcome = .invokedUnconfirmed
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let packet = try makeFileTransferPacket(
|
||||||
|
sender: remotePeerID,
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: Data([0xFF, 0xD8, 0xFF, 0xD9]),
|
||||||
|
recipientID: Data(hexString: localPeerID.id),
|
||||||
|
fileName: "plain-delegate.jpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(handler.handle(packet, from: remotePeerID))
|
||||||
|
#expect(recorder.deliveredMessages.count == 1)
|
||||||
|
#expect(recorder.deliveryAcks.isEmpty)
|
||||||
|
#expect(recorder.removedIncomingFiles.isEmpty)
|
||||||
|
#expect(recorder.finishedIncomingFileDeliveries.count == 1)
|
||||||
|
#expect(
|
||||||
|
recorder.finishedIncomingFileDeliveries.first
|
||||||
|
== recorder.saveResult
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func repeatedLegacyPrivateImageNamesKeepDistinctRandomMessageIDs() throws {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
||||||
|
let file = BitchatFilePacket(
|
||||||
|
fileName: "photo.jpg",
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let payload = try #require(file.encode())
|
||||||
|
|
||||||
|
#expect(handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||||
|
))
|
||||||
|
#expect(handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_235)
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(recorder.deliveredMessages.count == 2)
|
||||||
|
#expect(recorder.deliveredMessages[0].id != recorder.deliveredMessages[1].id)
|
||||||
|
#expect(recorder.deliveredMessages.allSatisfy { !$0.id.hasPrefix("media-") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func lostCapabilityProofThenStableRetryReusesDurableIDWithoutSecondDiskWrite() throws {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
||||||
|
let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||||
|
let file = BitchatFilePacket(
|
||||||
|
fileName: fileName,
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let payload = try #require(file.encode())
|
||||||
|
let expectedID = try #require(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: remotePeerID,
|
||||||
|
recipientPeerID: localPeerID,
|
||||||
|
fileName: fileName
|
||||||
|
))
|
||||||
|
|
||||||
|
// First encrypted arrival may precede the sender's authenticated bit-9
|
||||||
|
// proof. It still uses the bit-8 stable ID/ACK contract.
|
||||||
|
#expect(handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||||
|
))
|
||||||
|
// A later automatic retry after proof must resolve the same durable ID
|
||||||
|
// rather than create a legacy random-ID bubble.
|
||||||
|
#expect(handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_235)
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(recorder.quotaReservations == [content.count])
|
||||||
|
#expect(recorder.saveCalls.count == 1)
|
||||||
|
// The handler re-offers a durable duplicate so a relaunched UI can
|
||||||
|
// restore its bubble; the synchronous conversation sink deduplicates.
|
||||||
|
#expect(recorder.deliveredMessages.count == 2)
|
||||||
|
#expect(recorder.lastSeenUpdates == [remotePeerID, remotePeerID])
|
||||||
|
#expect(recorder.deliveryAcks.count == 2)
|
||||||
|
#expect(recorder.deliveryAcks.allSatisfy {
|
||||||
|
$0.messageID == expectedID && $0.peerID == remotePeerID
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func acceptedPrivateMediaAfterRelaunchRedeliversDurableURLBeforeAck() throws {
|
||||||
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||||
|
"private-media-handler-relaunch-\(UUID().uuidString)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let store = BLEIncomingFileStore(baseDirectory: root)
|
||||||
|
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||||
|
let file = BitchatFilePacket(
|
||||||
|
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let payload = try #require(file.encode())
|
||||||
|
|
||||||
|
func configure(_ recorder: Recorder) {
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(
|
||||||
|
remotePeerID,
|
||||||
|
nickname: "Alice",
|
||||||
|
isVerified: true
|
||||||
|
)]
|
||||||
|
recorder.saveOverride = { data, preferredName, subdirectory, fallbackExtension, defaultPrefix in
|
||||||
|
store.save(
|
||||||
|
data: data,
|
||||||
|
preferredName: preferredName,
|
||||||
|
subdirectory: subdirectory,
|
||||||
|
fallbackExtension: fallbackExtension,
|
||||||
|
defaultPrefix: defaultPrefix
|
||||||
|
)
|
||||||
|
}
|
||||||
|
recorder.receiptStateOverride = {
|
||||||
|
store.privateMediaReceiptState(messageID: $0)
|
||||||
|
}
|
||||||
|
recorder.receiptCommitOverride = {
|
||||||
|
store.commitPrivateMediaFile(messageID: $0, storedURL: $1)
|
||||||
|
}
|
||||||
|
recorder.removeIncomingFileOverride = {
|
||||||
|
store.removeIncomingFile(at: $0)
|
||||||
|
}
|
||||||
|
recorder.finishIncomingFileDeliveryOverride = {
|
||||||
|
store.finishIncomingFileDelivery(at: $0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let first = Recorder()
|
||||||
|
configure(first)
|
||||||
|
#expect(makeHandler(recorder: first).handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||||
|
))
|
||||||
|
let originalMessage = try #require(first.deliveredMessages.first)
|
||||||
|
#expect(first.deliveryAcks.count == 1)
|
||||||
|
|
||||||
|
// A fresh handler models process relaunch: its in-memory reservation
|
||||||
|
// cache is empty, so only the durable receipt can suppress disk work.
|
||||||
|
let relaunched = Recorder()
|
||||||
|
configure(relaunched)
|
||||||
|
#expect(makeHandler(recorder: relaunched).handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_235)
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(relaunched.quotaReservations.isEmpty)
|
||||||
|
#expect(relaunched.saveCalls.isEmpty)
|
||||||
|
#expect(relaunched.receiptCommits.isEmpty)
|
||||||
|
#expect(relaunched.deliveredMessages.count == 1)
|
||||||
|
#expect(relaunched.deliveredMessages.first?.id == originalMessage.id)
|
||||||
|
#expect(relaunched.deliveredMessages.first?.content == originalMessage.content)
|
||||||
|
#expect(relaunched.deliveryAcks.count == 1)
|
||||||
|
#expect(relaunched.deliveryAcks.first?.messageID == originalMessage.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func inFlightStableDuplicateIsNotAcknowledgedAndFailedSaveRemainsRetryable() throws {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||||
|
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
||||||
|
let file = BitchatFilePacket(
|
||||||
|
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let payload = try #require(file.encode())
|
||||||
|
var handler: BLEFileTransferHandler!
|
||||||
|
var nestedResult: Bool?
|
||||||
|
var failFirstSave = true
|
||||||
|
recorder.saveOverride = { _, _, _, _, _ in
|
||||||
|
if failFirstSave {
|
||||||
|
failFirstSave = false
|
||||||
|
nestedResult = handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: self.remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_235)
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return recorder.saveResult
|
||||||
|
}
|
||||||
|
handler = makeHandler(recorder: recorder)
|
||||||
|
|
||||||
|
// The nested arrival sees the first reservation as pending. It is
|
||||||
|
// coalesced without an ACK; then the first durable save fails.
|
||||||
|
#expect(!handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||||
|
))
|
||||||
|
#expect(nestedResult == true)
|
||||||
|
#expect(recorder.saveCalls.count == 1)
|
||||||
|
#expect(recorder.deliveryAcks.isEmpty)
|
||||||
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
|
|
||||||
|
// Failure released the reservation, so the sender's later retry can
|
||||||
|
// persist and deliver normally.
|
||||||
|
#expect(handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_236)
|
||||||
|
))
|
||||||
|
#expect(recorder.saveCalls.count == 2)
|
||||||
|
#expect(recorder.deliveryAcks.count == 1)
|
||||||
|
#expect(recorder.deliveredMessages.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func unavailableDurableReceiptStateWithholdsDiskDeliveryAndAck() throws {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(
|
||||||
|
remotePeerID,
|
||||||
|
nickname: "Alice",
|
||||||
|
isVerified: true
|
||||||
|
)]
|
||||||
|
let fileName =
|
||||||
|
"img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||||
|
let messageID = try #require(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: remotePeerID,
|
||||||
|
recipientPeerID: localPeerID,
|
||||||
|
fileName: fileName
|
||||||
|
))
|
||||||
|
recorder.receiptStates[messageID] = .unavailable
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||||
|
let payload = try #require(BitchatFilePacket(
|
||||||
|
fileName: fileName,
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content
|
||||||
|
).encode())
|
||||||
|
|
||||||
|
#expect(handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||||
|
))
|
||||||
|
#expect(recorder.quotaReservations.isEmpty)
|
||||||
|
#expect(recorder.saveCalls.isEmpty)
|
||||||
|
#expect(recorder.receiptCommits.isEmpty)
|
||||||
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
|
#expect(recorder.deliveryAcks.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func durableReceiptCommitFailureRollsBackAndWithholdsDeliveryAck() throws {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(
|
||||||
|
remotePeerID,
|
||||||
|
nickname: "Alice",
|
||||||
|
isVerified: true
|
||||||
|
)]
|
||||||
|
recorder.receiptCommitSucceeds = false
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||||
|
let payload = try #require(BitchatFilePacket(
|
||||||
|
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content
|
||||||
|
).encode())
|
||||||
|
|
||||||
|
#expect(!handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||||
|
))
|
||||||
|
#expect(recorder.saveCalls.count == 1)
|
||||||
|
#expect(recorder.receiptCommits.count == 1)
|
||||||
|
#expect(recorder.removedIncomingFiles.count == 1)
|
||||||
|
#expect(recorder.removedIncomingFiles.first == recorder.saveResult)
|
||||||
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
|
#expect(recorder.deliveryAcks.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func blockedPrivateMediaIsDroppedBeforeQuotaDiskAndDedupState() throws {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.blockedPeers = [remotePeerID]
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
||||||
|
let file = BitchatFilePacket(
|
||||||
|
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let payload = try #require(file.encode())
|
||||||
|
|
||||||
|
#expect(handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(recorder.quotaReservations.isEmpty)
|
||||||
|
#expect(recorder.saveCalls.isEmpty)
|
||||||
|
#expect(recorder.lastSeenUpdates.isEmpty)
|
||||||
|
#expect(recorder.deliveryAcks.isEmpty)
|
||||||
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
|
|
||||||
|
// Unblocking must allow a retry through; the blocked attempt cannot
|
||||||
|
// poison the stable-ID dedup reservation.
|
||||||
|
recorder.blockedPeers = []
|
||||||
|
#expect(handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_235)
|
||||||
|
))
|
||||||
|
#expect(recorder.saveCalls.count == 1)
|
||||||
|
#expect(recorder.deliveredMessages.count == 1)
|
||||||
|
#expect(recorder.deliveryAcks.count == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -337,6 +948,7 @@ struct BLEFileTransferHandlerTests {
|
|||||||
#expect(recorder.quotaReservations.isEmpty)
|
#expect(recorder.quotaReservations.isEmpty)
|
||||||
#expect(recorder.saveCalls.isEmpty)
|
#expect(recorder.saveCalls.isEmpty)
|
||||||
#expect(recorder.lastSeenUpdates.isEmpty)
|
#expect(recorder.lastSeenUpdates.isEmpty)
|
||||||
|
#expect(recorder.deliveryAcks.isEmpty)
|
||||||
#expect(recorder.deliveredMessages.isEmpty)
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,6 +1080,94 @@ struct BLEFileTransferHandlerTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func panicWipeClearsCachedPrivateMediaReceiptDecisions() throws {
|
||||||
|
let base = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(
|
||||||
|
"panic-receipt-cache-\(UUID().uuidString)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
defer { try? FileManager.default.removeItem(at: base) }
|
||||||
|
let messageID = "media-00112233445566778899aabbccddeeff"
|
||||||
|
|
||||||
|
let seed = BLEPrivateMediaReceiptStore(baseDirectory: base)
|
||||||
|
let payload = base
|
||||||
|
.appendingPathComponent(
|
||||||
|
"files/images/incoming",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
.appendingPathComponent("panic-receipt.jpg")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: payload.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try Data("secret".utf8).write(to: payload, options: .atomic)
|
||||||
|
#expect(seed.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: payload
|
||||||
|
))
|
||||||
|
#expect(seed.recordDeleted(messageID: messageID))
|
||||||
|
|
||||||
|
let store = BLEIncomingFileStore(baseDirectory: base)
|
||||||
|
#expect(
|
||||||
|
store.privateMediaReceiptState(messageID: messageID)
|
||||||
|
== .tombstoned
|
||||||
|
)
|
||||||
|
|
||||||
|
try store.panicWipe()
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
store.privateMediaReceiptState(messageID: messageID)
|
||||||
|
== .absent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func panicWipeInvalidatesPayloadCoordinationReservations() throws {
|
||||||
|
let base = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(
|
||||||
|
"panic-payload-coordination-\(UUID().uuidString)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
defer { try? FileManager.default.removeItem(at: base) }
|
||||||
|
let store = BLEIncomingFileStore(baseDirectory: base)
|
||||||
|
let pendingName = "pending-before-panic.jpg"
|
||||||
|
let pendingURL = try #require(store.save(
|
||||||
|
data: Data("old".utf8),
|
||||||
|
preferredName: pendingName,
|
||||||
|
subdirectory: "images/incoming",
|
||||||
|
fallbackExtension: "jpg",
|
||||||
|
defaultPrefix: "image"
|
||||||
|
))
|
||||||
|
let messageID = "media-aabbccddeeff00112233445566778899"
|
||||||
|
let reservation = try #require(store.reservePrivateMediaDeletion(
|
||||||
|
messageIDs: [messageID],
|
||||||
|
payloadRelativePaths: [
|
||||||
|
messageID: "images/incoming/delete-before-panic.jpg"
|
||||||
|
]
|
||||||
|
))
|
||||||
|
|
||||||
|
try store.panicWipe()
|
||||||
|
|
||||||
|
#expect(!store.commitPrivateMediaDeletion(
|
||||||
|
reservation: reservation,
|
||||||
|
messageIDs: [messageID],
|
||||||
|
payloadRelativePaths: [
|
||||||
|
messageID: "images/incoming/delete-before-panic.jpg"
|
||||||
|
],
|
||||||
|
protectedPayloadRelativePaths: []
|
||||||
|
))
|
||||||
|
let postPanicURL = try #require(store.save(
|
||||||
|
data: Data("new".utf8),
|
||||||
|
preferredName: pendingName,
|
||||||
|
subdirectory: "images/incoming",
|
||||||
|
fallbackExtension: "jpg",
|
||||||
|
defaultPrefix: "image"
|
||||||
|
))
|
||||||
|
#expect(pendingURL.lastPathComponent == pendingName)
|
||||||
|
#expect(postPanicURL.lastPathComponent == pendingName)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func panicWipeAttemptsDeletionWhenMarkerPersistenceFails() throws {
|
func panicWipeAttemptsDeletionWhenMarkerPersistenceFails() throws {
|
||||||
enum MarkerFailure: Error { case unavailable }
|
enum MarkerFailure: Error { case unavailable }
|
||||||
|
|||||||
@@ -111,5 +111,8 @@ struct BLENoiseReconnectPolicyTests {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
#expect(PeerCapabilities.localSupported.contains(.privateMedia))
|
#expect(PeerCapabilities.localSupported.contains(.privateMedia))
|
||||||
|
#expect(
|
||||||
|
PeerCapabilities.localSupported.contains(.privateMediaReceipts)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,866 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
struct BLEPrivateMediaReceiptStoreTests {
|
||||||
|
private struct TestError: Error {}
|
||||||
|
private struct ReceiptFixture: Codable {
|
||||||
|
let kind: String
|
||||||
|
let relativePath: String?
|
||||||
|
let recordedAt: Date
|
||||||
|
}
|
||||||
|
private struct JournalEntryFixture: Codable {
|
||||||
|
let relativePaths: [String]
|
||||||
|
let recordedAt: Date
|
||||||
|
}
|
||||||
|
private struct JournalFixture: Codable {
|
||||||
|
let version: Int
|
||||||
|
let entries: [String: JournalEntryFixture]
|
||||||
|
}
|
||||||
|
|
||||||
|
private let messageID = "media-00112233445566778899aabbccddeeff"
|
||||||
|
private let secondMessageID = "media-ffeeddccbbaa99887766554433221100"
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func acceptedReceiptPersistsAcrossStoreInstances() throws {
|
||||||
|
let root = makeRoot("persist")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
|
||||||
|
let first = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(first.commitAccepted(messageID: messageID, storedURL: payload))
|
||||||
|
#expect(first.state(for: messageID) == .accepted(payload))
|
||||||
|
|
||||||
|
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(relaunched.state(for: messageID) == .accepted(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func acceptedReceiptRejectsOutgoingPayloadAndCrossIDPathReuse() throws {
|
||||||
|
let root = makeRoot("accepted-ownership")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let incoming = try makePayload(in: root)
|
||||||
|
let outgoingDirectory = root.appendingPathComponent(
|
||||||
|
"files/images/outgoing",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: outgoingDirectory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let outgoing = outgoingDirectory.appendingPathComponent("image.jpg")
|
||||||
|
try Data([0x01]).write(to: outgoing)
|
||||||
|
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
|
||||||
|
#expect(!store.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: outgoing
|
||||||
|
))
|
||||||
|
#expect(store.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: incoming
|
||||||
|
))
|
||||||
|
#expect(!store.commitAccepted(
|
||||||
|
messageID: secondMessageID,
|
||||||
|
storedURL: incoming
|
||||||
|
))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: incoming.path))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: outgoing.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func liveAcceptedPathSurvivesTTLAndCapacityPressure() throws {
|
||||||
|
let root = makeRoot("accepted-retention")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let firstPayload = try makePayload(in: root, name: "first.jpg")
|
||||||
|
let secondPayload = try makePayload(in: root, name: "second.jpg")
|
||||||
|
let recordedAt = Date(timeIntervalSince1970: 2_000)
|
||||||
|
let seed = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
capacity: 1,
|
||||||
|
ttl: 1,
|
||||||
|
now: { recordedAt }
|
||||||
|
)
|
||||||
|
#expect(seed.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: firstPayload
|
||||||
|
))
|
||||||
|
|
||||||
|
let relaunched = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
capacity: 1,
|
||||||
|
ttl: 1,
|
||||||
|
now: { recordedAt.addingTimeInterval(2) }
|
||||||
|
)
|
||||||
|
#expect(relaunched.state(for: messageID) == .accepted(firstPayload))
|
||||||
|
#expect(!relaunched.commitAccepted(
|
||||||
|
messageID: secondMessageID,
|
||||||
|
storedURL: secondPayload
|
||||||
|
))
|
||||||
|
#expect(relaunched.state(for: messageID) == .accepted(firstPayload))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func incomingAllocationDoesNotReuseReceiptOwnedMissingPath() throws {
|
||||||
|
let root = makeRoot("accepted-reservation")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let original = try makePayload(in: root)
|
||||||
|
#expect(BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root
|
||||||
|
).commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: original
|
||||||
|
))
|
||||||
|
try FileManager.default.removeItem(at: original)
|
||||||
|
|
||||||
|
let stored = BLEIncomingFileStore(baseDirectory: root).save(
|
||||||
|
data: Data([0x03]),
|
||||||
|
preferredName: "image.jpg",
|
||||||
|
subdirectory: "images/incoming",
|
||||||
|
fallbackExtension: "jpg",
|
||||||
|
defaultPrefix: "image"
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(stored?.lastPathComponent != "image.jpg")
|
||||||
|
#expect(stored.map {
|
||||||
|
FileManager.default.fileExists(atPath: $0.path)
|
||||||
|
} == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func pendingRawArrivalBlocksDeletionFallbackPathReuse() throws {
|
||||||
|
let root = makeRoot("pending-raw-arrival")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let incoming = BLEIncomingFileStore(baseDirectory: root)
|
||||||
|
|
||||||
|
// The old stable bubble names image.jpg, but its receipt and payload
|
||||||
|
// have already been pruned. A raw arrival saves that basename before
|
||||||
|
// its main-actor bubble is inserted.
|
||||||
|
let rawArrival = incoming.save(
|
||||||
|
data: Data([0x03]),
|
||||||
|
preferredName: "image.jpg",
|
||||||
|
subdirectory: "images/incoming",
|
||||||
|
fallbackExtension: "jpg",
|
||||||
|
defaultPrefix: "image"
|
||||||
|
)
|
||||||
|
#expect(rawArrival?.lastPathComponent == "image.jpg")
|
||||||
|
|
||||||
|
let reservation = incoming.reservePrivateMediaDeletion(
|
||||||
|
messageIDs: [messageID],
|
||||||
|
payloadRelativePaths: [
|
||||||
|
messageID: "images/incoming/image.jpg"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
#expect(reservation == nil)
|
||||||
|
#expect(rawArrival.map {
|
||||||
|
FileManager.default.fileExists(atPath: $0.path)
|
||||||
|
} == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func quotaDoesNotEvictOrReusePendingDeliveryPath() throws {
|
||||||
|
let root = makeRoot("pending-quota")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let incoming = BLEIncomingFileStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
quotaBytes: 1
|
||||||
|
)
|
||||||
|
let first = try #require(incoming.save(
|
||||||
|
data: Data([0x01, 0x02]),
|
||||||
|
preferredName: "image.jpg",
|
||||||
|
subdirectory: "images/incoming",
|
||||||
|
fallbackExtension: "jpg",
|
||||||
|
defaultPrefix: "image"
|
||||||
|
))
|
||||||
|
|
||||||
|
incoming.enforceQuota(reservingBytes: 1)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: first.path))
|
||||||
|
|
||||||
|
let second = try #require(incoming.save(
|
||||||
|
data: Data([0x03]),
|
||||||
|
preferredName: "image.jpg",
|
||||||
|
subdirectory: "images/incoming",
|
||||||
|
fallbackExtension: "jpg",
|
||||||
|
defaultPrefix: "image"
|
||||||
|
))
|
||||||
|
#expect(second != first)
|
||||||
|
#expect(second.lastPathComponent == "image (1).jpg")
|
||||||
|
#expect(FileManager.default.fileExists(atPath: first.path))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: second.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func invalidReceiptAndJournalCannotTargetOutgoingPayload() throws {
|
||||||
|
let root = makeRoot("invalid-owned-path")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let outgoingDirectory = root.appendingPathComponent(
|
||||||
|
"files/images/outgoing",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: outgoingDirectory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let victim = outgoingDirectory.appendingPathComponent("victim.jpg")
|
||||||
|
try Data([0x02]).write(to: victim)
|
||||||
|
let receiptURL = receiptRecord(in: root)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: receiptURL.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let fixture = ReceiptFixture(
|
||||||
|
kind: "tombstone",
|
||||||
|
relativePath: "images/outgoing/victim.jpg",
|
||||||
|
recordedAt: Date()
|
||||||
|
)
|
||||||
|
try JSONEncoder().encode(fixture).write(
|
||||||
|
to: receiptURL,
|
||||||
|
options: .atomic
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
.state(for: messageID) == .unavailable
|
||||||
|
)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: victim.path))
|
||||||
|
|
||||||
|
try FileManager.default.removeItem(at: receiptURL)
|
||||||
|
let journal = JournalFixture(
|
||||||
|
version: 1,
|
||||||
|
entries: [messageID: JournalEntryFixture(
|
||||||
|
relativePaths: ["images/outgoing/victim.jpg"],
|
||||||
|
recordedAt: fixture.recordedAt
|
||||||
|
)]
|
||||||
|
)
|
||||||
|
try JSONEncoder().encode(journal).write(
|
||||||
|
to: deletionJournal(in: root),
|
||||||
|
options: .atomic
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
.state(for: messageID) == .unavailable
|
||||||
|
)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: victim.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func directoryEnumerationFailureIsUnavailableAndRetriesWithoutCachingEmpty() throws {
|
||||||
|
let root = makeRoot("list-failure")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
#expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: payload
|
||||||
|
))
|
||||||
|
let record = receiptRecord(in: root)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: record.path))
|
||||||
|
|
||||||
|
var shouldFail = true
|
||||||
|
let store = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
directoryReader: { directory in
|
||||||
|
if shouldFail {
|
||||||
|
shouldFail = false
|
||||||
|
throw TestError()
|
||||||
|
}
|
||||||
|
return try FileManager.default.contentsOfDirectory(
|
||||||
|
at: directory,
|
||||||
|
includingPropertiesForKeys: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(store.state(for: messageID) == .unavailable)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: record.path))
|
||||||
|
#expect(store.state(for: messageID) == .accepted(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func recordReadFailureIsUnavailableAndPreservesReceiptForRetry() throws {
|
||||||
|
let root = makeRoot("read-failure")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
#expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: payload
|
||||||
|
))
|
||||||
|
let record = receiptRecord(in: root)
|
||||||
|
|
||||||
|
var shouldFail = true
|
||||||
|
let store = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
dataReader: { url in
|
||||||
|
if shouldFail {
|
||||||
|
shouldFail = false
|
||||||
|
throw TestError()
|
||||||
|
}
|
||||||
|
return try Data(contentsOf: url)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(store.state(for: messageID) == .unavailable)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: record.path))
|
||||||
|
#expect(store.state(for: messageID) == .accepted(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func decodeFailureIsUnavailableAndDoesNotDeleteOrCachePastRepair() throws {
|
||||||
|
let root = makeRoot("decode-failure")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
#expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: payload
|
||||||
|
))
|
||||||
|
let record = receiptRecord(in: root)
|
||||||
|
let durableBytes = try Data(contentsOf: record)
|
||||||
|
let corruptBytes = Data("{not-json".utf8)
|
||||||
|
try corruptBytes.write(to: record, options: .atomic)
|
||||||
|
|
||||||
|
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(store.state(for: messageID) == .unavailable)
|
||||||
|
#expect(!store.commitAccepted(messageID: messageID, storedURL: payload))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: record.path))
|
||||||
|
#expect(try Data(contentsOf: record) == corruptBytes)
|
||||||
|
|
||||||
|
try durableBytes.write(to: record, options: .atomic)
|
||||||
|
#expect(store.state(for: messageID) == .accepted(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func unreadableTombstoneNeverBecomesAbsentOrGetsDeleted() throws {
|
||||||
|
let root = makeRoot("tombstone-decode")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
let seed = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(seed.commitAccepted(messageID: messageID, storedURL: payload))
|
||||||
|
#expect(seed.recordDeleted(messageID: messageID))
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
|
||||||
|
let record = receiptRecord(in: root)
|
||||||
|
let durableBytes = try Data(contentsOf: record)
|
||||||
|
try Data([0xFF, 0x00, 0x7B]).write(to: record, options: .atomic)
|
||||||
|
|
||||||
|
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(relaunched.state(for: messageID) == .unavailable)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: record.path))
|
||||||
|
|
||||||
|
try durableBytes.write(to: record, options: .atomic)
|
||||||
|
#expect(relaunched.state(for: messageID) == .tombstoned)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func deletionBatchCommitsEveryIDBeforeRemovingPayloads() throws {
|
||||||
|
let root = makeRoot("batch")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let firstPayload = try makePayload(in: root, name: "first.jpg")
|
||||||
|
let secondPayload = try makePayload(in: root, name: "second.jpg")
|
||||||
|
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(store.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: firstPayload
|
||||||
|
))
|
||||||
|
#expect(store.commitAccepted(
|
||||||
|
messageID: secondMessageID,
|
||||||
|
storedURL: secondPayload
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(store.recordDeleted(
|
||||||
|
messageIDs: [secondMessageID, messageID]
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(store.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(store.state(for: secondMessageID) == .tombstoned)
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: firstPayload.path))
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: secondPayload.path))
|
||||||
|
#expect(!FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func failedJournalCommitPreservesAcceptedStateAndPayloads() throws {
|
||||||
|
let root = makeRoot("journal-failure")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let firstPayload = try makePayload(in: root, name: "first.jpg")
|
||||||
|
let secondPayload = try makePayload(in: root, name: "second.jpg")
|
||||||
|
let seed = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(seed.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: firstPayload
|
||||||
|
))
|
||||||
|
#expect(seed.commitAccepted(
|
||||||
|
messageID: secondMessageID,
|
||||||
|
storedURL: secondPayload
|
||||||
|
))
|
||||||
|
|
||||||
|
let failing = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
dataWriter: { _, _, _ in throw TestError() }
|
||||||
|
)
|
||||||
|
#expect(!failing.recordDeleted(
|
||||||
|
messageIDs: [messageID, secondMessageID]
|
||||||
|
))
|
||||||
|
|
||||||
|
// A failed commit must not install a volatile tombstone. Otherwise a
|
||||||
|
// sender retry could be ACKed although the caller kept both bubbles.
|
||||||
|
#expect(failing.state(for: messageID) == .accepted(firstPayload))
|
||||||
|
#expect(
|
||||||
|
failing.state(for: secondMessageID) == .accepted(secondPayload)
|
||||||
|
)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: firstPayload.path))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: secondPayload.path))
|
||||||
|
#expect(!FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func journalRecoversBatchAfterCrashDuringMaterialization() throws {
|
||||||
|
let root = makeRoot("crash-recovery")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let firstPayload = try makePayload(in: root, name: "first.jpg")
|
||||||
|
let secondPayload = try makePayload(in: root, name: "second.jpg")
|
||||||
|
let seed = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(seed.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: firstPayload
|
||||||
|
))
|
||||||
|
#expect(seed.commitAccepted(
|
||||||
|
messageID: secondMessageID,
|
||||||
|
storedURL: secondPayload
|
||||||
|
))
|
||||||
|
|
||||||
|
let interrupted = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
dataWriter: { data, url, options in
|
||||||
|
let isJournal =
|
||||||
|
url.lastPathComponent == ".deletion-journal.json"
|
||||||
|
let isFirstRecord =
|
||||||
|
url.deletingPathExtension().lastPathComponent == messageID
|
||||||
|
guard isJournal || isFirstRecord else {
|
||||||
|
throw TestError()
|
||||||
|
}
|
||||||
|
try data.write(to: url, options: options)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
#expect(interrupted.recordDeleted(
|
||||||
|
messageIDs: [messageID, secondMessageID]
|
||||||
|
))
|
||||||
|
#expect(FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: firstPayload.path))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: secondPayload.path))
|
||||||
|
#expect(interrupted.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(interrupted.state(for: secondMessageID) == .tombstoned)
|
||||||
|
|
||||||
|
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(relaunched.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(relaunched.state(for: secondMessageID) == .tombstoned)
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: firstPayload.path))
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: secondPayload.path))
|
||||||
|
#expect(!FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func journalRetriesPayloadUnlinkAfterRelaunch() throws {
|
||||||
|
let root = makeRoot("unlink-recovery")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
let seed = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(seed.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: payload
|
||||||
|
))
|
||||||
|
|
||||||
|
let unlinkFailure = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
payloadRemover: { _ in throw TestError() }
|
||||||
|
)
|
||||||
|
#expect(unlinkFailure.recordDeleted(messageID: messageID))
|
||||||
|
#expect(unlinkFailure.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
#expect(FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
|
||||||
|
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(relaunched.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
#expect(!FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func expiredReceiptUsesFallbackPathForCrashSafeCleanup() throws {
|
||||||
|
let root = makeRoot("expired-fallback")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
let recordedAt = Date(timeIntervalSince1970: 1_000)
|
||||||
|
let seed = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
ttl: 10,
|
||||||
|
now: { recordedAt }
|
||||||
|
)
|
||||||
|
#expect(seed.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: payload
|
||||||
|
))
|
||||||
|
// Simulate a receipt pruned by an older app version while its bubble
|
||||||
|
// and payload remain. Current code retains live accepted-path owners.
|
||||||
|
try FileManager.default.removeItem(at: receiptRecord(in: root))
|
||||||
|
|
||||||
|
let afterExpiry = recordedAt.addingTimeInterval(11)
|
||||||
|
let interrupted = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
ttl: 10,
|
||||||
|
now: { afterExpiry },
|
||||||
|
payloadRemover: { _ in throw TestError() }
|
||||||
|
)
|
||||||
|
#expect(interrupted.recordDeleted(
|
||||||
|
messageIDs: [messageID],
|
||||||
|
payloadRelativePaths: [
|
||||||
|
messageID: "images/incoming/image.jpg"
|
||||||
|
]
|
||||||
|
))
|
||||||
|
#expect(interrupted.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
#expect(FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
|
||||||
|
let relaunched = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
ttl: 10,
|
||||||
|
now: { afterExpiry }
|
||||||
|
)
|
||||||
|
#expect(relaunched.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
#expect(!FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func retryAtSuffixedPathDeletesReceiptAndBubblePayloads() throws {
|
||||||
|
let root = makeRoot("retry-suffix")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let original = try makePayload(in: root)
|
||||||
|
let seed = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(seed.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: original
|
||||||
|
))
|
||||||
|
|
||||||
|
// Simulate an older build pruning only the receipt. A retry must use a
|
||||||
|
// suffixed filename while the old bubble still references image.jpg.
|
||||||
|
try FileManager.default.removeItem(at: receiptRecord(in: root))
|
||||||
|
let retry = try makePayload(in: root, name: "image (1).jpg")
|
||||||
|
let retried = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(retried.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: retry
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(retried.recordDeleted(
|
||||||
|
messageIDs: [messageID],
|
||||||
|
payloadRelativePaths: [
|
||||||
|
messageID: "images/incoming/image.jpg"
|
||||||
|
]
|
||||||
|
))
|
||||||
|
#expect(retried.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: original.path))
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: retry.path))
|
||||||
|
#expect(!FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
|
||||||
|
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(relaunched.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: original.path))
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: retry.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func protectedUIPathRejectsAcceptedReceiptDeletion() throws {
|
||||||
|
let root = makeRoot("protected-ui-owner")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(store.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: payload
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(!store.recordDeleted(
|
||||||
|
messageIDs: [messageID],
|
||||||
|
protectedPayloadRelativePaths: [
|
||||||
|
"images/incoming/image.jpg"
|
||||||
|
]
|
||||||
|
))
|
||||||
|
#expect(store.state(for: messageID) == .accepted(payload))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func pathlessNewDeletionFailsWithoutChangingReceiverState() {
|
||||||
|
let root = makeRoot("pathless-delete")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
|
||||||
|
#expect(!store.recordDeleted(messageID: messageID))
|
||||||
|
#expect(store.state(for: messageID) == .absent)
|
||||||
|
#expect(!FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func completedTombstoneNeverDeletesReusedPayloadPath() throws {
|
||||||
|
let root = makeRoot("path-reuse")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let original = try makePayload(in: root)
|
||||||
|
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(store.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: original
|
||||||
|
))
|
||||||
|
#expect(store.recordDeleted(messageID: messageID))
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: original.path))
|
||||||
|
|
||||||
|
let reused = try makePayload(in: root)
|
||||||
|
#expect(store.commitAccepted(
|
||||||
|
messageID: secondMessageID,
|
||||||
|
storedURL: reused
|
||||||
|
))
|
||||||
|
#expect(store.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: reused.path))
|
||||||
|
|
||||||
|
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(relaunched.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: reused.path))
|
||||||
|
#expect(
|
||||||
|
relaunched.state(for: secondMessageID) == .accepted(reused)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func expiredLegacyPathfulTombstoneDoesNotDeleteAcceptedOwner() throws {
|
||||||
|
let root = makeRoot("legacy-path-conflict")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
let receiptDirectory = receiptRecord(in: root)
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: receiptDirectory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let recordedAt = Date(timeIntervalSince1970: 3_000)
|
||||||
|
try JSONEncoder().encode(ReceiptFixture(
|
||||||
|
kind: "tombstone",
|
||||||
|
relativePath: "images/incoming/image.jpg",
|
||||||
|
recordedAt: recordedAt
|
||||||
|
)).write(
|
||||||
|
to: receiptRecord(in: root),
|
||||||
|
options: .atomic
|
||||||
|
)
|
||||||
|
try JSONEncoder().encode(ReceiptFixture(
|
||||||
|
kind: "accepted",
|
||||||
|
relativePath: "images/incoming/image.jpg",
|
||||||
|
recordedAt: recordedAt
|
||||||
|
)).write(
|
||||||
|
to: receiptRecord(
|
||||||
|
in: root,
|
||||||
|
messageID: secondMessageID
|
||||||
|
),
|
||||||
|
options: .atomic
|
||||||
|
)
|
||||||
|
|
||||||
|
let store = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
ttl: 1,
|
||||||
|
now: { recordedAt.addingTimeInterval(2) }
|
||||||
|
)
|
||||||
|
#expect(store.state(for: messageID) == .absent)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
#expect(
|
||||||
|
store.state(for: secondMessageID) == .accepted(payload)
|
||||||
|
)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func expiredLegacyPathfulTombstonePreservesAmbiguousPayload() throws {
|
||||||
|
let root = makeRoot("legacy-expired-cleanup")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
let receiptURL = receiptRecord(in: root)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: receiptURL.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let recordedAt = Date(timeIntervalSince1970: 3_000)
|
||||||
|
try JSONEncoder().encode(ReceiptFixture(
|
||||||
|
kind: "tombstone",
|
||||||
|
relativePath: "images/incoming/image.jpg",
|
||||||
|
recordedAt: recordedAt
|
||||||
|
)).write(to: receiptURL, options: .atomic)
|
||||||
|
|
||||||
|
let store = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
ttl: 1,
|
||||||
|
now: { recordedAt.addingTimeInterval(2) }
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(store.state(for: messageID) == .absent)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: receiptURL.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func deletionJournalNeverRecursivelyRemovesDirectoryTarget() throws {
|
||||||
|
let root = makeRoot("journal-directory")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let directory = root.appendingPathComponent(
|
||||||
|
"files/images/incoming/archive",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: directory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let child = directory.appendingPathComponent("child.jpg")
|
||||||
|
try Data([0x01]).write(to: child)
|
||||||
|
let receiptDirectory = receiptRecord(in: root)
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: receiptDirectory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
#expect(!BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root
|
||||||
|
).recordDeleted(
|
||||||
|
messageIDs: [messageID],
|
||||||
|
payloadRelativePaths: [
|
||||||
|
messageID: "images/incoming/archive"
|
||||||
|
]
|
||||||
|
))
|
||||||
|
#expect(!FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
try JSONEncoder().encode(JournalFixture(
|
||||||
|
version: 1,
|
||||||
|
entries: [messageID: JournalEntryFixture(
|
||||||
|
relativePaths: ["images/incoming/archive"],
|
||||||
|
recordedAt: Date()
|
||||||
|
)]
|
||||||
|
)).write(to: deletionJournal(in: root), options: .atomic)
|
||||||
|
|
||||||
|
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(store.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: directory.path))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: child.path))
|
||||||
|
#expect(FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func corruptDeletionJournalFailsClosedWithoutRemovingPayload() throws {
|
||||||
|
let root = makeRoot("corrupt-journal")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
#expect(BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root
|
||||||
|
).commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: payload
|
||||||
|
))
|
||||||
|
let journal = deletionJournal(in: root)
|
||||||
|
try Data("{not-json".utf8).write(to: journal, options: .atomic)
|
||||||
|
|
||||||
|
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(relaunched.state(for: messageID) == .unavailable)
|
||||||
|
#expect(!relaunched.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: payload
|
||||||
|
))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
#expect(try Data(contentsOf: journal) == Data("{not-json".utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func unreleasedAggregateLedgerIsIgnoredAndLeftUntouched() throws {
|
||||||
|
let root = makeRoot("no-legacy-migration")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let files = root.appendingPathComponent("files", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: files,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let legacy = files.appendingPathComponent(
|
||||||
|
".private-media-receipts.json",
|
||||||
|
isDirectory: false
|
||||||
|
)
|
||||||
|
let bytes = Data(
|
||||||
|
#"{"entries":{"media-00112233445566778899aabbccddeeff":{"relativePath":"images/incoming/old.jpg","acceptedAt":0}}}"#
|
||||||
|
.utf8
|
||||||
|
)
|
||||||
|
try bytes.write(to: legacy, options: .atomic)
|
||||||
|
|
||||||
|
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(store.state(for: messageID) == .absent)
|
||||||
|
#expect(try Data(contentsOf: legacy) == bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeRoot(_ label: String) -> URL {
|
||||||
|
FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||||
|
"private-media-receipt-\(label)-\(UUID().uuidString)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makePayload(
|
||||||
|
in root: URL,
|
||||||
|
name: String = "image.jpg"
|
||||||
|
) throws -> URL {
|
||||||
|
let directory = root.appendingPathComponent(
|
||||||
|
"files/images/incoming",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: directory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let payload = directory.appendingPathComponent(name)
|
||||||
|
try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: payload)
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
private func receiptRecord(
|
||||||
|
in root: URL,
|
||||||
|
messageID requestedMessageID: String? = nil
|
||||||
|
) -> URL {
|
||||||
|
root
|
||||||
|
.appendingPathComponent(
|
||||||
|
"files/.private-media-receipts",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
.appendingPathComponent(requestedMessageID ?? messageID)
|
||||||
|
.appendingPathExtension("json")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deletionJournal(in root: URL) -> URL {
|
||||||
|
root
|
||||||
|
.appendingPathComponent(
|
||||||
|
"files/.private-media-receipts",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
.appendingPathComponent(".deletion-journal.json")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -203,6 +203,134 @@ struct BridgeCourierServiceTests {
|
|||||||
#expect(confirmed.sealRequests.isEmpty)
|
#expect(confirmed.sealRequests.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func sameMessageIDIsScopedByRecipientAcrossRejectedActiveAndPersistedState() {
|
||||||
|
let fileURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("bridge-dedup-\(UUID().uuidString).json")
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
let rejectedKey = Fixture.randomKey()
|
||||||
|
let firstKey = Fixture.randomKey()
|
||||||
|
let secondKey = Fixture.randomKey()
|
||||||
|
let thirdKey = Fixture.randomKey()
|
||||||
|
let messageID = "recipient-scoped-collision"
|
||||||
|
|
||||||
|
let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
|
||||||
|
fixture.sealResult = makeEnvelope(
|
||||||
|
recipientKey: rejectedKey,
|
||||||
|
ciphertext: Data(
|
||||||
|
repeating: 7,
|
||||||
|
count: BridgeCourierService.Limits.maxDropEnvelopeBytes + 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
var rejectedResults: [Bool] = []
|
||||||
|
fixture.service.depositDrop(
|
||||||
|
content: "rejected",
|
||||||
|
messageID: messageID,
|
||||||
|
recipientNoiseKey: rejectedKey
|
||||||
|
) { rejectedResults.append($0) }
|
||||||
|
#expect(rejectedResults == [false])
|
||||||
|
|
||||||
|
fixture.sealResult = makeEnvelope(recipientKey: firstKey)
|
||||||
|
fixture.automaticPublishResult = nil
|
||||||
|
var firstResults: [Bool] = []
|
||||||
|
var secondResults: [Bool] = []
|
||||||
|
var duplicateFirstResults: [Bool] = []
|
||||||
|
fixture.service.depositDrop(
|
||||||
|
content: "first",
|
||||||
|
messageID: messageID,
|
||||||
|
recipientNoiseKey: firstKey
|
||||||
|
) { firstResults.append($0) }
|
||||||
|
fixture.service.depositDrop(
|
||||||
|
content: "second",
|
||||||
|
messageID: messageID,
|
||||||
|
recipientNoiseKey: secondKey
|
||||||
|
) { secondResults.append($0) }
|
||||||
|
fixture.service.depositDrop(
|
||||||
|
content: "first duplicate",
|
||||||
|
messageID: messageID,
|
||||||
|
recipientNoiseKey: firstKey
|
||||||
|
) { duplicateFirstResults.append($0) }
|
||||||
|
|
||||||
|
#expect(fixture.publishedEvents.count == 2)
|
||||||
|
#expect(fixture.pendingPublishCompletions.count == 2)
|
||||||
|
#expect(duplicateFirstResults == [false])
|
||||||
|
#expect(firstResults.isEmpty)
|
||||||
|
#expect(secondResults.isEmpty)
|
||||||
|
|
||||||
|
fixture.resolveNextPublish(true)
|
||||||
|
fixture.resolveNextPublish(true)
|
||||||
|
#expect(firstResults == [true])
|
||||||
|
#expect(secondResults == [true])
|
||||||
|
fixture.service.flushDedupSnapshot()
|
||||||
|
|
||||||
|
let relaunched = Fixture(
|
||||||
|
dedupStore: BridgeDropDedupStore(fileURL: fileURL)
|
||||||
|
)
|
||||||
|
relaunched.sealResult = makeEnvelope(recipientKey: thirdKey)
|
||||||
|
var relaunchResults: [Bool] = []
|
||||||
|
relaunched.service.depositDrop(
|
||||||
|
content: "first",
|
||||||
|
messageID: messageID,
|
||||||
|
recipientNoiseKey: firstKey
|
||||||
|
) { relaunchResults.append($0) }
|
||||||
|
relaunched.service.depositDrop(
|
||||||
|
content: "second",
|
||||||
|
messageID: messageID,
|
||||||
|
recipientNoiseKey: secondKey
|
||||||
|
) { relaunchResults.append($0) }
|
||||||
|
relaunched.service.depositDrop(
|
||||||
|
content: "third",
|
||||||
|
messageID: messageID,
|
||||||
|
recipientNoiseKey: thirdKey
|
||||||
|
) { relaunchResults.append($0) }
|
||||||
|
|
||||||
|
#expect(relaunchResults == [false, false, true])
|
||||||
|
#expect(relaunched.sealRequests.count == 1)
|
||||||
|
#expect(relaunched.sealRequests.first?.key == thirdKey)
|
||||||
|
#expect(relaunched.publishedEvents.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func legacyPublishedMessageIDIsWildcardUntilItsOriginalExpiry() {
|
||||||
|
let fileURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("bridge-dedup-\(UUID().uuidString).json")
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
var date = Date()
|
||||||
|
let messageID = "legacy-wildcard"
|
||||||
|
let recipientKey = Fixture.randomKey()
|
||||||
|
let store = BridgeDropDedupStore(fileURL: fileURL)
|
||||||
|
store.save(BridgeDropDedupStore.Snapshot(
|
||||||
|
publishedDropKeys: [messageID: date],
|
||||||
|
seenDropEventIDs: [:]
|
||||||
|
))
|
||||||
|
|
||||||
|
let fixture = Fixture(
|
||||||
|
now: { date },
|
||||||
|
dedupStore: BridgeDropDedupStore(fileURL: fileURL)
|
||||||
|
)
|
||||||
|
fixture.sealResult = makeEnvelope(recipientKey: recipientKey)
|
||||||
|
var results: [Bool] = []
|
||||||
|
fixture.service.depositDrop(
|
||||||
|
content: "legacy",
|
||||||
|
messageID: messageID,
|
||||||
|
recipientNoiseKey: recipientKey
|
||||||
|
) { results.append($0) }
|
||||||
|
#expect(results == [false])
|
||||||
|
#expect(fixture.sealRequests.isEmpty)
|
||||||
|
|
||||||
|
date = date.addingTimeInterval(CourierEnvelope.maxLifetimeSeconds + 1)
|
||||||
|
fixture.service.depositDrop(
|
||||||
|
content: "after expiry",
|
||||||
|
messageID: messageID,
|
||||||
|
recipientNoiseKey: recipientKey
|
||||||
|
) { results.append($0) }
|
||||||
|
#expect(results == [false, true])
|
||||||
|
#expect(fixture.publishedEvents.count == 1)
|
||||||
|
fixture.service.flushDedupSnapshot()
|
||||||
|
|
||||||
|
let snapshot = BridgeDropDedupStore(fileURL: fileURL).load()
|
||||||
|
#expect(snapshot.publishedDropKeys[messageID] == nil)
|
||||||
|
#expect(snapshot.publishedDropKeys.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
@Test func panicWipeInvalidatesInFlightPublishCompletion() throws {
|
@Test func panicWipeInvalidatesInFlightPublishCompletion() throws {
|
||||||
let fileURL = FileManager.default.temporaryDirectory
|
let fileURL = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("bridge-dedup-\(UUID().uuidString).json")
|
.appendingPathComponent("bridge-dedup-\(UUID().uuidString).json")
|
||||||
@@ -297,8 +425,10 @@ struct BridgeCourierServiceTests {
|
|||||||
#expect(firstResults == [false])
|
#expect(firstResults == [false])
|
||||||
|
|
||||||
// The evicted first drop is deposit-able again (slot released).
|
// The evicted first drop is deposit-able again (slot released).
|
||||||
|
let sealCountBeforeRetry = fixture.sealRequests.count
|
||||||
fixture.service.depositDrop(content: "0-retry", messageID: firstID, recipientNoiseKey: key)
|
fixture.service.depositDrop(content: "0-retry", messageID: firstID, recipientNoiseKey: key)
|
||||||
#expect(fixture.service.pendingDrops.last?.dedupKey == firstID)
|
#expect(fixture.sealRequests.count == sealCountBeforeRetry + 1)
|
||||||
|
#expect(fixture.service.pendingDrops.count == BridgeCourierService.Limits.maxPendingDrops)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func oversizeDropConsumesSlotInsteadOfChurning() {
|
@Test func oversizeDropConsumesSlotInsteadOfChurning() {
|
||||||
|
|||||||
@@ -207,6 +207,42 @@ struct MessageOutboxStoreTests {
|
|||||||
#expect(MessageOutboxStore(keychain: keychain, fileURL: fileURL).load().isEmpty)
|
#expect(MessageOutboxStore(keychain: keychain, fileURL: fileURL).load().isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func deferredScopedRemovalTombstoneFiltersOnlySelectedPeer() {
|
||||||
|
let fileURL = makeTempURL()
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let acknowledgedPeer = PeerID(str: "0000000000000001")
|
||||||
|
let otherPeer = PeerID(str: "0000000000000002")
|
||||||
|
MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([
|
||||||
|
acknowledgedPeer: [makeMessage("shared-id", content: "for acknowledged peer")],
|
||||||
|
otherPeer: [makeMessage("shared-id", content: "for other peer")]
|
||||||
|
])
|
||||||
|
|
||||||
|
var protectedDataUnavailable = true
|
||||||
|
let restored = MessageOutboxStore(
|
||||||
|
keychain: keychain,
|
||||||
|
fileURL: fileURL,
|
||||||
|
readData: { url in
|
||||||
|
if protectedDataUnavailable {
|
||||||
|
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
|
||||||
|
}
|
||||||
|
return try Data(contentsOf: url)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
#expect(restored.load().isEmpty)
|
||||||
|
restored.recordRemoval(messageID: "shared-id", for: [acknowledgedPeer])
|
||||||
|
restored.save([:])
|
||||||
|
|
||||||
|
protectedDataUnavailable = false
|
||||||
|
let recovered = restored.retryDeferredLoad()
|
||||||
|
#expect(recovered?[acknowledgedPeer] == nil)
|
||||||
|
#expect(recovered?[otherPeer]?.map(\.messageID) == ["shared-id"])
|
||||||
|
|
||||||
|
let relaunched = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load()
|
||||||
|
#expect(relaunched[acknowledgedPeer] == nil)
|
||||||
|
#expect(relaunched[otherPeer]?.map(\.messageID) == ["shared-id"])
|
||||||
|
}
|
||||||
|
|
||||||
@Test func wipeRemovesFileAndKey() {
|
@Test func wipeRemovesFileAndKey() {
|
||||||
let fileURL = makeTempURL()
|
let fileURL = makeTempURL()
|
||||||
let keychain = MockKeychain()
|
let keychain = MockKeychain()
|
||||||
|
|||||||
@@ -79,18 +79,231 @@ struct MessageRouterTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func sendPrivate_connectedSendIsNotRetained() async {
|
func peerBoundDeliveryAckCannotClearAnotherPeersRetainedMessage() async {
|
||||||
|
let intendedPeer = PeerID(str: "0000000000000023")
|
||||||
|
let otherPeer = PeerID(str: "0000000000000024")
|
||||||
|
let transport = MockTransport()
|
||||||
|
transport.reachablePeers = [intendedPeer, otherPeer]
|
||||||
|
|
||||||
|
let router = MessageRouter(transports: [transport])
|
||||||
|
router.sendPrivate(
|
||||||
|
"Secret",
|
||||||
|
to: intendedPeer,
|
||||||
|
recipientNickname: "Intended",
|
||||||
|
messageID: "peer-bound-ack"
|
||||||
|
)
|
||||||
|
#expect(transport.sentPrivateMessages.count == 1)
|
||||||
|
|
||||||
|
// Even a receipt arriving over another authenticated conversation
|
||||||
|
// must not terminalize the intended peer's retained retry.
|
||||||
|
router.markDelivered("peer-bound-ack", from: [otherPeer])
|
||||||
|
router.flushOutbox(for: intendedPeer)
|
||||||
|
#expect(transport.sentPrivateMessages.count == 2)
|
||||||
|
|
||||||
|
router.markDelivered("peer-bound-ack", from: [intendedPeer])
|
||||||
|
router.flushOutbox(for: intendedPeer)
|
||||||
|
#expect(transport.sentPrivateMessages.count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func sendPrivate_connectedSecureSendRetainsUntilDeliveryAck() async {
|
||||||
let peerID = PeerID(str: "0000000000000007")
|
let peerID = PeerID(str: "0000000000000007")
|
||||||
let transport = MockTransport()
|
let transport = MockTransport()
|
||||||
transport.connectedPeers.insert(peerID)
|
transport.connectedPeers.insert(peerID)
|
||||||
transport.reachablePeers.insert(peerID)
|
transport.reachablePeers.insert(peerID)
|
||||||
|
transport.securePeers = [peerID]
|
||||||
|
|
||||||
let router = MessageRouter(transports: [transport])
|
let router = MessageRouter(transports: [transport])
|
||||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m7")
|
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m7")
|
||||||
#expect(transport.sentPrivateMessages.count == 1)
|
#expect(transport.sentPrivateMessages.count == 1)
|
||||||
|
|
||||||
router.flushOutbox(for: peerID)
|
// A newly authenticated/replacement session retries the retained
|
||||||
|
// message instead of losing the first ciphertext to a stale session.
|
||||||
|
router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
|
||||||
|
#expect(transport.sentPrivateMessages.count == 2)
|
||||||
|
|
||||||
|
router.markDelivered("m7")
|
||||||
|
router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
|
||||||
|
#expect(transport.sentPrivateMessages.count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func authenticationRetry_matchesStableOutboxAliasWithoutDoubleSending() async {
|
||||||
|
let shortPeerID = PeerID(str: "0000000000000019")
|
||||||
|
let stablePeerID = PeerID(hexData: Data(repeating: 0x19, count: 32))
|
||||||
|
let transport = MockTransport()
|
||||||
|
transport.connectedPeers.insert(stablePeerID)
|
||||||
|
transport.securePeers = [stablePeerID]
|
||||||
|
|
||||||
|
let router = MessageRouter(transports: [transport])
|
||||||
|
router.sendPrivate("Hello", to: stablePeerID, recipientNickname: "Peer", messageID: "alias-retry")
|
||||||
|
|
||||||
|
router.retrySecurePrivateMessagesAfterAuthentication(for: [shortPeerID, stablePeerID, stablePeerID])
|
||||||
|
|
||||||
|
#expect(transport.sentPrivateMessages.map(\.messageID) == ["alias-retry", "alias-retry"])
|
||||||
|
#expect(transport.sentPrivateMessages.allSatisfy { $0.peerID == stablePeerID })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func authenticationRetry_preservesFIFOAcrossSplitAliases() async {
|
||||||
|
let shortPeerID = PeerID(str: "0000000000000022")
|
||||||
|
let stablePeerID = PeerID(hexData: Data(repeating: 0x22, count: 32))
|
||||||
|
let transport = MockTransport()
|
||||||
|
transport.connectedPeers = [shortPeerID, stablePeerID]
|
||||||
|
transport.securePeers = [shortPeerID, stablePeerID]
|
||||||
|
let clock = MutableTestClock()
|
||||||
|
let router = MessageRouter(transports: [transport], now: { clock.now })
|
||||||
|
|
||||||
|
// The older message lives under the stable key, even though the auth
|
||||||
|
// callback supplies the ephemeral alias first.
|
||||||
|
router.sendPrivate("Older", to: stablePeerID, recipientNickname: "Peer", messageID: "fifo-old")
|
||||||
|
clock.now = clock.now.addingTimeInterval(1)
|
||||||
|
router.sendPrivate("Newer", to: shortPeerID, recipientNickname: "Peer", messageID: "fifo-new")
|
||||||
|
transport.resetRecordings()
|
||||||
|
|
||||||
|
router.retrySecurePrivateMessagesAfterAuthentication(for: [shortPeerID, stablePeerID])
|
||||||
|
|
||||||
|
#expect(transport.sentPrivateMessages.map(\.messageID) == ["fifo-old", "fifo-new"])
|
||||||
|
#expect(transport.sentPrivateMessages.map(\.peerID) == [stablePeerID, shortPeerID])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func authenticationRetry_doesNotDuplicateNormalPendingHandshakeSend() async {
|
||||||
|
let peerID = PeerID(str: "0000000000000020")
|
||||||
|
let transport = MockTransport()
|
||||||
|
transport.connectedPeers.insert(peerID)
|
||||||
|
transport.securePeers = []
|
||||||
|
|
||||||
|
let router = MessageRouter(transports: [transport])
|
||||||
|
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "normal-handshake")
|
||||||
#expect(transport.sentPrivateMessages.count == 1)
|
#expect(transport.sentPrivateMessages.count == 1)
|
||||||
|
|
||||||
|
// BLE owns this pending send and drains it after authentication. Once
|
||||||
|
// the session becomes secure, the router's targeted auth retry must
|
||||||
|
// stay silent instead of producing a second copy.
|
||||||
|
transport.securePeers = [peerID]
|
||||||
|
router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
|
||||||
|
#expect(transport.sentPrivateMessages.count == 1)
|
||||||
|
|
||||||
|
router.markDelivered("normal-handshake")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func authenticationRetry_scopesCollidingMessageIDsByPeer() async {
|
||||||
|
let securePeer = PeerID(str: "0000000000000025")
|
||||||
|
let pendingPeer = PeerID(str: "0000000000000026")
|
||||||
|
let transport = MockTransport()
|
||||||
|
transport.connectedPeers = [securePeer, pendingPeer]
|
||||||
|
transport.securePeers = [securePeer]
|
||||||
|
|
||||||
|
let router = MessageRouter(transports: [transport])
|
||||||
|
let promotedID = "collision-promoted"
|
||||||
|
let clearedID = "collision-cleared"
|
||||||
|
|
||||||
|
// Pending B then secure A: an ID-global marker falsely promotes B.
|
||||||
|
router.sendPrivate(
|
||||||
|
"pending promoted",
|
||||||
|
to: pendingPeer,
|
||||||
|
recipientNickname: "Pending",
|
||||||
|
messageID: promotedID
|
||||||
|
)
|
||||||
|
router.sendPrivate(
|
||||||
|
"secure promoted",
|
||||||
|
to: securePeer,
|
||||||
|
recipientNickname: "Secure",
|
||||||
|
messageID: promotedID
|
||||||
|
)
|
||||||
|
|
||||||
|
// Secure A then pending B: an ID-global removal falsely clears A.
|
||||||
|
router.sendPrivate(
|
||||||
|
"secure cleared",
|
||||||
|
to: securePeer,
|
||||||
|
recipientNickname: "Secure",
|
||||||
|
messageID: clearedID
|
||||||
|
)
|
||||||
|
router.sendPrivate(
|
||||||
|
"pending cleared",
|
||||||
|
to: pendingPeer,
|
||||||
|
recipientNickname: "Pending",
|
||||||
|
messageID: clearedID
|
||||||
|
)
|
||||||
|
|
||||||
|
transport.resetRecordings()
|
||||||
|
transport.securePeers = [securePeer, pendingPeer]
|
||||||
|
|
||||||
|
router.retrySecurePrivateMessagesAfterAuthentication(for: [pendingPeer])
|
||||||
|
#expect(transport.sentPrivateMessages.isEmpty)
|
||||||
|
|
||||||
|
router.retrySecurePrivateMessagesAfterAuthentication(for: [securePeer])
|
||||||
|
#expect(transport.sentPrivateMessages.count == 2)
|
||||||
|
#expect(Set(transport.sentPrivateMessages.map(\.messageID)) == [promotedID, clearedID])
|
||||||
|
#expect(transport.sentPrivateMessages.allSatisfy { $0.peerID == securePeer })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func authenticationRetry_doesNotDuplicateMessageRequeuedByBLEForHandshake() async {
|
||||||
|
let peerID = PeerID(str: "0000000000000021")
|
||||||
|
let transport = MockTransport()
|
||||||
|
transport.connectedPeers.insert(peerID)
|
||||||
|
transport.securePeers = [peerID]
|
||||||
|
|
||||||
|
let router = MessageRouter(transports: [transport])
|
||||||
|
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "session-lost")
|
||||||
|
#expect(transport.sentPrivateMessages.count == 1)
|
||||||
|
|
||||||
|
// The session disappears before a normal outbox flush. That send is
|
||||||
|
// now owned by BLE's pending-handshake queue, so it clears the
|
||||||
|
// router's secure-auth retry marker.
|
||||||
|
transport.securePeers = []
|
||||||
|
router.flushOutbox(for: peerID)
|
||||||
|
#expect(transport.sentPrivateMessages.count == 2)
|
||||||
|
|
||||||
|
transport.securePeers = [peerID]
|
||||||
|
router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
|
||||||
|
#expect(transport.sentPrivateMessages.count == 2)
|
||||||
|
|
||||||
|
router.markDelivered("session-lost")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func sendPrivate_fastDeliveryAckCannotRaceAheadOfRetention() async {
|
||||||
|
let peerID = PeerID(str: "0000000000000017")
|
||||||
|
let transport = MockTransport()
|
||||||
|
transport.connectedPeers.insert(peerID)
|
||||||
|
transport.securePeers = [peerID]
|
||||||
|
|
||||||
|
let router = MessageRouter(transports: [transport])
|
||||||
|
transport.onSendPrivateMessage = { messageID in
|
||||||
|
router.markDelivered(messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "fast-ack")
|
||||||
|
#expect(transport.sentPrivateMessages.map(\.messageID) == ["fast-ack"])
|
||||||
|
|
||||||
|
transport.onSendPrivateMessage = nil
|
||||||
|
router.flushOutbox(for: peerID)
|
||||||
|
#expect(transport.sentPrivateMessages.map(\.messageID) == ["fast-ack"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func flushOutbox_synchronousAckDoesNotResurrectSnapshotEntry() async {
|
||||||
|
let peerID = PeerID(str: "0000000000000018")
|
||||||
|
let transport = MockTransport()
|
||||||
|
transport.connectedPeers.insert(peerID)
|
||||||
|
transport.securePeers = [peerID]
|
||||||
|
|
||||||
|
let router = MessageRouter(transports: [transport])
|
||||||
|
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "flush-fast-ack")
|
||||||
|
transport.onSendPrivateMessage = { messageID in
|
||||||
|
router.markDelivered(messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
router.flushOutbox(for: peerID)
|
||||||
|
#expect(transport.sentPrivateMessages.count == 2)
|
||||||
|
|
||||||
|
transport.onSendPrivateMessage = nil
|
||||||
|
router.flushOutbox(for: peerID)
|
||||||
|
#expect(transport.sentPrivateMessages.count == 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
@@ -392,10 +605,31 @@ struct MessageRouterTests {
|
|||||||
#expect(transport.sentPrivateMessages.count == 11)
|
#expect(transport.sentPrivateMessages.count == 11)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// With an established secure session the connected fast-path stays
|
|
||||||
/// exactly as before: trusted outright, no retained copy, no courier.
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func sendPrivate_connectedWithSecureSessionIsTrustedOutright() async {
|
func authenticationRetry_capsActualSecureTransmissions() async {
|
||||||
|
let peerID = PeerID(str: "00000000000000ad")
|
||||||
|
let transport = MockTransport()
|
||||||
|
transport.connectedPeers.insert(peerID)
|
||||||
|
transport.securePeers = [peerID]
|
||||||
|
|
||||||
|
let router = MessageRouter(transports: [transport])
|
||||||
|
var dropped: [String] = []
|
||||||
|
router.onMessageDropped = { messageID, _ in dropped.append(messageID) }
|
||||||
|
|
||||||
|
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "secure-retry")
|
||||||
|
for _ in 0..<10 {
|
||||||
|
router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(dropped == ["secure-retry"])
|
||||||
|
#expect(transport.sentPrivateMessages.count == 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With an established secure session the connected fast-path sends
|
||||||
|
/// immediately and never leaks to couriers, but retains a local encrypted
|
||||||
|
/// outbox copy until the peer confirms receipt.
|
||||||
|
@Test @MainActor
|
||||||
|
func sendPrivate_connectedWithSecureSessionRetainsLocallyWithoutCourier() async {
|
||||||
let peerID = PeerID(str: "00000000000000ab")
|
let peerID = PeerID(str: "00000000000000ab")
|
||||||
let peerKey = Data(repeating: 0xAB, count: 32)
|
let peerKey = Data(repeating: 0xAB, count: 32)
|
||||||
let courier = PeerID(str: "00000000000000cc")
|
let courier = PeerID(str: "00000000000000cc")
|
||||||
@@ -415,7 +649,11 @@ struct MessageRouterTests {
|
|||||||
#expect(transport.sentPrivateMessages.map(\.messageID) == ["cs2"])
|
#expect(transport.sentPrivateMessages.map(\.messageID) == ["cs2"])
|
||||||
#expect(transport.sentCourierMessages.isEmpty)
|
#expect(transport.sentCourierMessages.isEmpty)
|
||||||
router.flushOutbox(for: peerID)
|
router.flushOutbox(for: peerID)
|
||||||
#expect(transport.sentPrivateMessages.count == 1)
|
#expect(transport.sentPrivateMessages.count == 2)
|
||||||
|
#expect(transport.sentCourierMessages.isEmpty)
|
||||||
|
router.markDelivered("cs2")
|
||||||
|
router.flushOutbox(for: peerID)
|
||||||
|
#expect(transport.sentPrivateMessages.count == 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
@@ -527,6 +765,52 @@ struct MessageRouterTests {
|
|||||||
#expect(carried == ["bridge-ack"])
|
#expect(carried == ["bridge-ack"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func bridgeDepositsScopeCollidingMessageIDsByRecipient() async {
|
||||||
|
let firstRecipient = PeerID(str: "00000000000000b1")
|
||||||
|
let secondRecipient = PeerID(str: "00000000000000b2")
|
||||||
|
let firstKey = Data(repeating: 0xB1, count: 32)
|
||||||
|
let secondKey = Data(repeating: 0xB2, count: 32)
|
||||||
|
let recipientKeys = [
|
||||||
|
firstRecipient: firstKey,
|
||||||
|
secondRecipient: secondKey
|
||||||
|
]
|
||||||
|
let router = MessageRouter(
|
||||||
|
transports: [MockTransport()],
|
||||||
|
courierDirectory: CourierDirectory(
|
||||||
|
noiseKey: { recipientKeys[$0] },
|
||||||
|
isTrustedCourier: { _ in false }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
var requestedKeys: [Data] = []
|
||||||
|
var completions: [@MainActor (Bool) -> Void] = []
|
||||||
|
router.bridgeCourierDeposit = { _, _, recipientKey, completion in
|
||||||
|
requestedKeys.append(recipientKey)
|
||||||
|
completions.append(completion)
|
||||||
|
}
|
||||||
|
var carriedPeers: [PeerID] = []
|
||||||
|
router.onMessageCarried = { _, peerID in carriedPeers.append(peerID) }
|
||||||
|
|
||||||
|
router.sendPrivate(
|
||||||
|
"First",
|
||||||
|
to: firstRecipient,
|
||||||
|
recipientNickname: "First",
|
||||||
|
messageID: "bridge-collision"
|
||||||
|
)
|
||||||
|
router.sendPrivate(
|
||||||
|
"Second",
|
||||||
|
to: secondRecipient,
|
||||||
|
recipientNickname: "Second",
|
||||||
|
messageID: "bridge-collision"
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(completions.count == 2)
|
||||||
|
#expect(Set(requestedKeys) == [firstKey, secondKey])
|
||||||
|
|
||||||
|
completions.forEach { $0(true) }
|
||||||
|
#expect(Set(carriedPeers) == [firstRecipient, secondRecipient])
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Outbox persistence
|
// MARK: - Outbox persistence
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
@@ -582,6 +866,82 @@ struct MessageRouterTests {
|
|||||||
#expect(transport2.sentPrivateMessages.isEmpty)
|
#expect(transport2.sentPrivateMessages.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func scopedDeliveryAckClearsOnlySelectedPeerWhenMessageIDsCollide() async {
|
||||||
|
let fileURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("router-outbox-scoped-ack-\(UUID().uuidString).sealed")
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let acknowledgedPeer = PeerID(str: "00000000000000d1")
|
||||||
|
let otherPeer = PeerID(str: "00000000000000d2")
|
||||||
|
let transport = MockTransport()
|
||||||
|
let router = MessageRouter(
|
||||||
|
transports: [transport],
|
||||||
|
outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL)
|
||||||
|
)
|
||||||
|
router.sendPrivate("For acknowledged peer", to: acknowledgedPeer, recipientNickname: "One", messageID: "shared-id")
|
||||||
|
router.sendPrivate("For other peer", to: otherPeer, recipientNickname: "Two", messageID: "shared-id")
|
||||||
|
|
||||||
|
#expect(router.markDelivered("shared-id", for: [acknowledgedPeer]))
|
||||||
|
transport.reachablePeers.formUnion([acknowledgedPeer, otherPeer])
|
||||||
|
router.flushOutbox(for: acknowledgedPeer)
|
||||||
|
router.flushOutbox(for: otherPeer)
|
||||||
|
|
||||||
|
#expect(transport.sentPrivateMessages.map(\.peerID) == [otherPeer])
|
||||||
|
let persisted = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load()
|
||||||
|
#expect(persisted[acknowledgedPeer] == nil)
|
||||||
|
#expect(persisted[otherPeer]?.map(\.messageID) == ["shared-id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func scopedAckWhileColdLoadIsLockedPreventsOnlyTargetPeerResurrection() async {
|
||||||
|
let fileURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("router-locked-scoped-ack-\(UUID().uuidString).sealed")
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let acknowledgedPeer = PeerID(str: "00000000000000d3")
|
||||||
|
let otherPeer = PeerID(str: "00000000000000d4")
|
||||||
|
let durable = MessageOutboxStore.QueuedMessage(
|
||||||
|
content: "Queued before reboot",
|
||||||
|
nickname: "Peer",
|
||||||
|
messageID: "shared-locked-id",
|
||||||
|
timestamp: Date()
|
||||||
|
)
|
||||||
|
MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([
|
||||||
|
acknowledgedPeer: [durable],
|
||||||
|
otherPeer: [durable]
|
||||||
|
])
|
||||||
|
|
||||||
|
var protectedDataUnavailable = true
|
||||||
|
let restoredStore = MessageOutboxStore(
|
||||||
|
keychain: keychain,
|
||||||
|
fileURL: fileURL,
|
||||||
|
readData: { url in
|
||||||
|
if protectedDataUnavailable {
|
||||||
|
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
|
||||||
|
}
|
||||||
|
return try Data(contentsOf: url)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let transport = MockTransport()
|
||||||
|
transport.reachablePeers.formUnion([acknowledgedPeer, otherPeer])
|
||||||
|
let router = MessageRouter(transports: [transport], outboxStore: restoredStore)
|
||||||
|
|
||||||
|
router.markDelivered(
|
||||||
|
"shared-locked-id",
|
||||||
|
from: [acknowledgedPeer]
|
||||||
|
)
|
||||||
|
protectedDataUnavailable = false
|
||||||
|
restoredStore.retryDeferredLoad()
|
||||||
|
await Task.yield()
|
||||||
|
await Task.yield()
|
||||||
|
|
||||||
|
#expect(transport.sentPrivateMessages.map(\.peerID) == [otherPeer])
|
||||||
|
let persisted = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load()
|
||||||
|
#expect(persisted[acknowledgedPeer] == nil)
|
||||||
|
#expect(persisted[otherPeer]?.map(\.messageID) == ["shared-locked-id"])
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func protectedDataRecoveryMergesDurableAndLockedWakeMessagesIntoRouter() async {
|
func protectedDataRecoveryMergesDurableAndLockedWakeMessagesIntoRouter() async {
|
||||||
let fileURL = FileManager.default.temporaryDirectory
|
let fileURL = FileManager.default.temporaryDirectory
|
||||||
@@ -780,12 +1140,14 @@ struct MessageRouterTests {
|
|||||||
protectedDataUnavailable = false
|
protectedDataUnavailable = false
|
||||||
restoredStore.retryDeferredLoad() // captures unseen durable + known wake
|
restoredStore.retryDeferredLoad() // captures unseen durable + known wake
|
||||||
|
|
||||||
// Secure direct flush removes the wake message before recovery's
|
// A secure direct retry followed by its delivery ack removes the wake
|
||||||
// MainActor merge. It must remain removed, while the unseen durable
|
// message before recovery's MainActor merge. It must remain removed,
|
||||||
// message still arrives through the pending recovery claim.
|
// while the unseen durable message still arrives through the pending
|
||||||
|
// recovery claim.
|
||||||
transport.connectedPeers.insert(peerID)
|
transport.connectedPeers.insert(peerID)
|
||||||
transport.securePeers = [peerID]
|
transport.securePeers = [peerID]
|
||||||
router.flushOutbox(for: peerID)
|
router.flushOutbox(for: peerID)
|
||||||
|
router.markDelivered("recovery-gap-known")
|
||||||
await Task.yield()
|
await Task.yield()
|
||||||
await Task.yield()
|
await Task.yield()
|
||||||
|
|
||||||
@@ -856,7 +1218,8 @@ struct MessageRouterTests {
|
|||||||
restoredStore.retryDeferredLoad() // persists D+W and queues recovery
|
restoredStore.retryDeferredLoad() // persists D+W and queues recovery
|
||||||
transport.connectedPeers.insert(peerID)
|
transport.connectedPeers.insert(peerID)
|
||||||
transport.securePeers = [peerID]
|
transport.securePeers = [peerID]
|
||||||
router.flushOutbox(for: peerID) // removes W before queued callback
|
router.flushOutbox(for: peerID)
|
||||||
|
router.markDelivered("recovery-write-failure-known") // removes W before queued callback
|
||||||
|
|
||||||
// The gap save may remove W, but it must leave unseen D durable until
|
// The gap save may remove W, but it must leave unseen D durable until
|
||||||
// MessageRouter receives the pending recovery callback.
|
// MessageRouter receives the pending recovery callback.
|
||||||
|
|||||||
@@ -267,6 +267,588 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, TransportConfig.nostrPendingSendQueueCap)
|
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, TransportConfig.nostrPendingSendQueueCap)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func test_privateEnvelopeBatchEvictsEphemeralTrafficAndRemainsAtomic() async throws {
|
||||||
|
let relayURL = "wss://private-batch-priority.example"
|
||||||
|
let context = makeContext(
|
||||||
|
permission: .denied,
|
||||||
|
userTorEnabled: true,
|
||||||
|
torEnforced: true,
|
||||||
|
torIsReady: false
|
||||||
|
)
|
||||||
|
var ephemeralIDs: [String] = []
|
||||||
|
for i in 0..<(TransportConfig.nostrPendingSendQueueCap - 1) {
|
||||||
|
let event = try makeSignedEvent(
|
||||||
|
content: "ephemeral-\(i)",
|
||||||
|
kind: .ephemeralEvent
|
||||||
|
)
|
||||||
|
ephemeralIDs.append(event.id)
|
||||||
|
context.manager.sendEvent(event, to: [relayURL])
|
||||||
|
}
|
||||||
|
let regular = try makeSignedEvent(content: "regular survives")
|
||||||
|
context.manager.sendEvent(regular, to: [relayURL])
|
||||||
|
XCTAssertEqual(
|
||||||
|
context.manager.debugPendingMessageQueueCount,
|
||||||
|
TransportConfig.nostrPendingSendQueueCap
|
||||||
|
)
|
||||||
|
|
||||||
|
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
||||||
|
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
||||||
|
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch([primary, legacy], to: [relayURL]))
|
||||||
|
|
||||||
|
let batches = context.manager.debugPendingMessageQueueEventIDsByBatch
|
||||||
|
let allIDs = Set(batches.flatMap { $0 })
|
||||||
|
XCTAssertEqual(
|
||||||
|
context.manager.debugPendingMessageQueueCount,
|
||||||
|
TransportConfig.nostrPendingSendQueueCap
|
||||||
|
)
|
||||||
|
XCTAssertTrue(batches.contains([primary.id, legacy.id]))
|
||||||
|
XCTAssertTrue(allIDs.contains(regular.id))
|
||||||
|
XCTAssertFalse(allIDs.contains(ephemeralIDs[0]))
|
||||||
|
XCTAssertFalse(allIDs.contains(ephemeralIDs[1]))
|
||||||
|
|
||||||
|
// Later low-priority traffic may evict another ephemeral event, but
|
||||||
|
// never one half (or both halves) of the protected private batch.
|
||||||
|
let lateEphemeral = try makeSignedEvent(content: "late", kind: .geohashPresence)
|
||||||
|
context.manager.sendEvent(lateEphemeral, to: [relayURL])
|
||||||
|
XCTAssertTrue(
|
||||||
|
context.manager.debugPendingMessageQueueEventIDsByBatch
|
||||||
|
.contains([primary.id, legacy.id])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_privateEnvelopeBatchDoesNotWriteStaleSocketUntilTorRecovers() async throws {
|
||||||
|
let relayURL = "wss://private-batch-tor-gate.example"
|
||||||
|
let context = makeContext(
|
||||||
|
permission: .denied,
|
||||||
|
userTorEnabled: true,
|
||||||
|
torEnforced: true,
|
||||||
|
torIsReady: true
|
||||||
|
)
|
||||||
|
context.manager.ensureConnections(to: [relayURL])
|
||||||
|
let connected = await waitUntil {
|
||||||
|
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
|
||||||
|
}
|
||||||
|
XCTAssertTrue(connected)
|
||||||
|
let connection = try XCTUnwrap(context.sessionFactory.latestConnection(for: relayURL))
|
||||||
|
|
||||||
|
context.torWaiter.isReady = false
|
||||||
|
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
||||||
|
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
||||||
|
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch([primary, legacy], to: [relayURL]))
|
||||||
|
|
||||||
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
XCTAssertTrue(connection.sentStrings.isEmpty)
|
||||||
|
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
|
||||||
|
XCTAssertEqual(context.torWaiter.awaitCallCount, 1)
|
||||||
|
|
||||||
|
context.torWaiter.resolve(true)
|
||||||
|
let flushed = await waitUntil {
|
||||||
|
connection.sentStrings.count == 2
|
||||||
|
}
|
||||||
|
XCTAssertTrue(flushed)
|
||||||
|
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
|
||||||
|
try await emitAcceptedPair([primary, legacy], on: connection)
|
||||||
|
let acknowledged = await waitUntil {
|
||||||
|
context.manager.debugPendingMessageQueueCount == 0
|
||||||
|
}
|
||||||
|
XCTAssertTrue(acknowledged)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_privateEnvelopeBatchPrunesTerminalRelayAfterHealthyDelivery() async throws {
|
||||||
|
let healthyURL = "wss://private-batch-healthy.example"
|
||||||
|
let deadURL = "wss://private-batch-dead.example"
|
||||||
|
let context = makeContext(permission: .denied)
|
||||||
|
context.sessionFactory.pingErrorByURL[deadURL] = NSError(
|
||||||
|
domain: NSURLErrorDomain,
|
||||||
|
code: NSURLErrorCannotFindHost
|
||||||
|
)
|
||||||
|
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
||||||
|
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
||||||
|
var terminalFailureCount = 0
|
||||||
|
|
||||||
|
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch(
|
||||||
|
[primary, legacy],
|
||||||
|
to: [healthyURL, deadURL],
|
||||||
|
terminalFailure: { terminalFailureCount += 1 }
|
||||||
|
))
|
||||||
|
|
||||||
|
let healthyConnection = try XCTUnwrap(
|
||||||
|
context.sessionFactory.latestConnection(for: healthyURL)
|
||||||
|
)
|
||||||
|
let written = await waitUntil {
|
||||||
|
healthyConnection.sentStrings.count == 2
|
||||||
|
}
|
||||||
|
XCTAssertTrue(written)
|
||||||
|
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
|
||||||
|
try await emitAcceptedPair([primary, legacy], on: healthyConnection)
|
||||||
|
let initiallyAcknowledged = await waitUntil {
|
||||||
|
context.manager.debugPendingMessageQueueCount == 0
|
||||||
|
}
|
||||||
|
XCTAssertTrue(initiallyAcknowledged)
|
||||||
|
XCTAssertEqual(terminalFailureCount, 0)
|
||||||
|
|
||||||
|
// The terminal target is excluded during cooldown. More than one full
|
||||||
|
// protected-capacity worth of subsequent logical sends must continue
|
||||||
|
// to drain through the healthy relay instead of wedging globally.
|
||||||
|
for _ in 0...100 {
|
||||||
|
let sentCountBefore = healthyConnection.sentStrings.count
|
||||||
|
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch(
|
||||||
|
[primary, legacy],
|
||||||
|
to: [healthyURL, deadURL]
|
||||||
|
))
|
||||||
|
let written = await waitUntil {
|
||||||
|
healthyConnection.sentStrings.count == sentCountBefore + 2
|
||||||
|
}
|
||||||
|
XCTAssertTrue(written)
|
||||||
|
try await emitAcceptedPair([primary, legacy], on: healthyConnection)
|
||||||
|
let acknowledged = await waitUntil {
|
||||||
|
context.manager.debugPendingMessageQueueCount == 0
|
||||||
|
}
|
||||||
|
XCTAssertTrue(acknowledged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_privateEnvelopeBatchAllTerminalTargetsReportsWholeBatchFailure() async throws {
|
||||||
|
let deadURL = "wss://private-batch-all-dead.example"
|
||||||
|
let context = makeContext(permission: .denied)
|
||||||
|
context.sessionFactory.pingErrorByURL[deadURL] = NSError(
|
||||||
|
domain: NSURLErrorDomain,
|
||||||
|
code: NSURLErrorCannotFindHost
|
||||||
|
)
|
||||||
|
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
||||||
|
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
||||||
|
var terminalFailureCount = 0
|
||||||
|
|
||||||
|
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch(
|
||||||
|
[primary, legacy],
|
||||||
|
to: [deadURL],
|
||||||
|
terminalFailure: { terminalFailureCount += 1 }
|
||||||
|
))
|
||||||
|
|
||||||
|
let failed = await waitUntil {
|
||||||
|
terminalFailureCount == 1 &&
|
||||||
|
context.manager.debugPendingMessageQueueCount == 0
|
||||||
|
}
|
||||||
|
XCTAssertTrue(failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_privateEnvelopeBatchRejectsBeforeWritingWhenProtectedCapacityIsFull() async throws {
|
||||||
|
let stalledRelayURL = "wss://private-batch-full.example"
|
||||||
|
let connectedRelayURL = "wss://private-batch-connected.example"
|
||||||
|
let context = makeContext(
|
||||||
|
permission: .denied,
|
||||||
|
userTorEnabled: true,
|
||||||
|
torEnforced: true,
|
||||||
|
torIsReady: true,
|
||||||
|
torIsForeground: true
|
||||||
|
)
|
||||||
|
context.manager.ensureConnections(to: [connectedRelayURL])
|
||||||
|
let connected = await waitUntil {
|
||||||
|
context.manager.relays.first(where: { $0.url == connectedRelayURL })?.isConnected == true
|
||||||
|
}
|
||||||
|
XCTAssertTrue(connected)
|
||||||
|
context.torForeground.value = false
|
||||||
|
|
||||||
|
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
||||||
|
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
||||||
|
let pair = [primary, legacy]
|
||||||
|
|
||||||
|
for _ in 0..<(TransportConfig.nostrPendingSendQueueCap / pair.count) {
|
||||||
|
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch(pair, to: [stalledRelayURL]))
|
||||||
|
}
|
||||||
|
XCTAssertEqual(
|
||||||
|
context.manager.debugPendingMessageQueueCount,
|
||||||
|
TransportConfig.nostrPendingSendQueueCap
|
||||||
|
)
|
||||||
|
|
||||||
|
let rejectedPrimary = try makeSignedEvent(content: "rejected-primary", kind: .privateEnvelope)
|
||||||
|
let rejectedLegacy = try makeSignedEvent(content: "rejected-legacy", kind: .legacyNIP59GiftWrap)
|
||||||
|
XCTAssertFalse(
|
||||||
|
context.manager.sendPrivateEnvelopeBatch(
|
||||||
|
[rejectedPrimary, rejectedLegacy],
|
||||||
|
to: [connectedRelayURL]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
XCTAssertTrue(
|
||||||
|
context.sessionFactory.latestConnection(for: connectedRelayURL)?.sentStrings.isEmpty == true
|
||||||
|
)
|
||||||
|
let queuedIDs = Set(
|
||||||
|
context.manager.debugPendingMessageQueueEventIDsByBatch.flatMap { $0 }
|
||||||
|
)
|
||||||
|
XCTAssertFalse(queuedIDs.contains(rejectedPrimary.id))
|
||||||
|
XCTAssertFalse(queuedIDs.contains(rejectedLegacy.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_privateEnvelopeBatchStaysQueuedAndRetriesAfterFirstWriteFails() async throws {
|
||||||
|
try await assertPrivateEnvelopeBatchWriteFailure(
|
||||||
|
failureSequence: [NSError(domain: "send", code: 1)],
|
||||||
|
expectedFirstConnectionWrites: 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_privateEnvelopeBatchStaysQueuedAndRetriesAfterSecondWriteFails() async throws {
|
||||||
|
try await assertPrivateEnvelopeBatchWriteFailure(
|
||||||
|
failureSequence: [nil, NSError(domain: "send", code: 2)],
|
||||||
|
expectedFirstConnectionWrites: 2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_privateEnvelopeBatchRemainsPendingUntilBothRelayOKsArrive() async throws {
|
||||||
|
let relayURL = "wss://private-batch-two-write-commit.example"
|
||||||
|
let context = makeContext(permission: .denied)
|
||||||
|
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
||||||
|
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
||||||
|
|
||||||
|
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch([primary, legacy], to: [relayURL]))
|
||||||
|
let connection = try XCTUnwrap(context.sessionFactory.latestConnection(for: relayURL))
|
||||||
|
connection.deferSendCompletions = true
|
||||||
|
|
||||||
|
let firstWriteStarted = await waitUntil { connection.sentStrings.count == 1 }
|
||||||
|
XCTAssertTrue(firstWriteStarted)
|
||||||
|
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
|
||||||
|
|
||||||
|
connection.flushDeferredSendCompletions()
|
||||||
|
let secondWriteStarted = await waitUntil { connection.sentStrings.count == 2 }
|
||||||
|
XCTAssertTrue(secondWriteStarted)
|
||||||
|
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
|
||||||
|
|
||||||
|
connection.flushDeferredSendCompletions()
|
||||||
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
|
||||||
|
|
||||||
|
try connection.emitOK(eventID: primary.id, success: true, reason: "accepted")
|
||||||
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
|
||||||
|
|
||||||
|
try connection.emitOK(eventID: legacy.id, success: true, reason: "accepted")
|
||||||
|
let committed = await waitUntil {
|
||||||
|
context.manager.debugPendingMessageQueueCount == 0
|
||||||
|
}
|
||||||
|
XCTAssertTrue(committed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_privateEnvelopeBatchRejectedHalfReportsWholeBatchFailure() async throws {
|
||||||
|
let relayURL = "wss://private-batch-rejected-half.example"
|
||||||
|
let context = makeContext(permission: .denied)
|
||||||
|
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
||||||
|
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
||||||
|
var terminalFailureCount = 0
|
||||||
|
|
||||||
|
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch(
|
||||||
|
[primary, legacy],
|
||||||
|
to: [relayURL],
|
||||||
|
terminalFailure: { terminalFailureCount += 1 }
|
||||||
|
))
|
||||||
|
let connection = try XCTUnwrap(
|
||||||
|
context.sessionFactory.latestConnection(for: relayURL)
|
||||||
|
)
|
||||||
|
let written = await waitUntil { connection.sentStrings.count == 2 }
|
||||||
|
XCTAssertTrue(written)
|
||||||
|
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
|
||||||
|
|
||||||
|
try connection.emitOK(eventID: primary.id, success: true, reason: "accepted")
|
||||||
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
|
||||||
|
XCTAssertEqual(terminalFailureCount, 0)
|
||||||
|
|
||||||
|
try connection.emitOK(eventID: legacy.id, success: false, reason: "blocked: policy")
|
||||||
|
let failed = await waitUntil {
|
||||||
|
context.manager.debugPendingMessageQueueCount == 0 &&
|
||||||
|
terminalFailureCount == 1
|
||||||
|
}
|
||||||
|
XCTAssertTrue(failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_privateEnvelopeBatchDuplicateOKsCountAsDurableAcceptance() async throws {
|
||||||
|
let relayURL = "wss://private-batch-duplicate.example"
|
||||||
|
let context = makeContext(permission: .denied)
|
||||||
|
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
||||||
|
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
||||||
|
var terminalFailureCount = 0
|
||||||
|
|
||||||
|
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch(
|
||||||
|
[primary, legacy],
|
||||||
|
to: [relayURL],
|
||||||
|
terminalFailure: { terminalFailureCount += 1 }
|
||||||
|
))
|
||||||
|
let connection = try XCTUnwrap(
|
||||||
|
context.sessionFactory.latestConnection(for: relayURL)
|
||||||
|
)
|
||||||
|
let written = await waitUntil { connection.sentStrings.count == 2 }
|
||||||
|
XCTAssertTrue(written)
|
||||||
|
|
||||||
|
for event in [primary, legacy] {
|
||||||
|
try connection.emitOK(
|
||||||
|
eventID: event.id,
|
||||||
|
success: false,
|
||||||
|
reason: " DuPlIcAtE: already have this event"
|
||||||
|
)
|
||||||
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
}
|
||||||
|
let acknowledged = await waitUntil {
|
||||||
|
context.manager.debugPendingMessageQueueCount == 0
|
||||||
|
}
|
||||||
|
XCTAssertTrue(acknowledged)
|
||||||
|
XCTAssertEqual(terminalFailureCount, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_privateEnvelopeBatchAcknowledgementTimeoutReportsWholeBatchFailure() async throws {
|
||||||
|
let relayURL = "wss://private-batch-ok-timeout.example"
|
||||||
|
let context = makeContext(permission: .denied)
|
||||||
|
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
||||||
|
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
||||||
|
var terminalFailureCount = 0
|
||||||
|
|
||||||
|
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch(
|
||||||
|
[primary, legacy],
|
||||||
|
to: [relayURL],
|
||||||
|
terminalFailure: { terminalFailureCount += 1 }
|
||||||
|
))
|
||||||
|
let connection = try XCTUnwrap(
|
||||||
|
context.sessionFactory.latestConnection(for: relayURL)
|
||||||
|
)
|
||||||
|
let timeoutScheduled = await waitUntil {
|
||||||
|
connection.sentStrings.count == 2 &&
|
||||||
|
context.scheduler.scheduled.contains {
|
||||||
|
$0.delay == TransportConfig.nostrConfirmedSendAckTimeoutSeconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XCTAssertTrue(timeoutScheduled)
|
||||||
|
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
|
||||||
|
NostrRelayManager.registerPendingPrivateEnvelope(id: primary.id)
|
||||||
|
NostrRelayManager.registerPendingPrivateEnvelope(id: legacy.id)
|
||||||
|
|
||||||
|
context.scheduler.runNext()
|
||||||
|
let failed = await waitUntil {
|
||||||
|
context.manager.debugPendingMessageQueueCount == 0 &&
|
||||||
|
terminalFailureCount == 1
|
||||||
|
}
|
||||||
|
XCTAssertTrue(failed)
|
||||||
|
|
||||||
|
try connection.emitOK(eventID: primary.id, success: true, reason: "late")
|
||||||
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
XCTAssertEqual(terminalFailureCount, 1)
|
||||||
|
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 0)
|
||||||
|
XCTAssertFalse(NostrRelayManager.pendingPrivateEnvelopeIDs.contains(primary.id))
|
||||||
|
XCTAssertFalse(NostrRelayManager.pendingPrivateEnvelopeIDs.contains(legacy.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_privateEnvelopeBatchFastPrimaryRejectionStillWritesCompatibilityHalf() async throws {
|
||||||
|
let relayURL = "wss://private-batch-early-reject.example"
|
||||||
|
let context = makeContext(permission: .denied)
|
||||||
|
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
||||||
|
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
||||||
|
var terminalFailureCount = 0
|
||||||
|
|
||||||
|
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch(
|
||||||
|
[primary, legacy],
|
||||||
|
to: [relayURL],
|
||||||
|
terminalFailure: { terminalFailureCount += 1 }
|
||||||
|
))
|
||||||
|
let connection = try XCTUnwrap(
|
||||||
|
context.sessionFactory.latestConnection(for: relayURL)
|
||||||
|
)
|
||||||
|
connection.deferSendCompletions = true
|
||||||
|
let firstWriteStarted = await waitUntil { connection.sentStrings.count == 1 }
|
||||||
|
XCTAssertTrue(firstWriteStarted)
|
||||||
|
NostrRelayManager.registerPendingPrivateEnvelope(id: primary.id)
|
||||||
|
NostrRelayManager.registerPendingPrivateEnvelope(id: legacy.id)
|
||||||
|
|
||||||
|
try connection.emitOK(eventID: primary.id, success: false, reason: "invalid: rejected")
|
||||||
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
|
||||||
|
XCTAssertEqual(terminalFailureCount, 0)
|
||||||
|
XCTAssertEqual(connection.sentStrings.count, 1)
|
||||||
|
|
||||||
|
// Completing the primary write must attempt the legacy 1059 even
|
||||||
|
// though the relay has already rejected the provisional 1402.
|
||||||
|
connection.flushDeferredSendCompletions()
|
||||||
|
let compatibilityWriteStarted = await waitUntil {
|
||||||
|
connection.sentStrings.count == 2
|
||||||
|
}
|
||||||
|
XCTAssertTrue(compatibilityWriteStarted)
|
||||||
|
let compatibilityRequest = try XCTUnwrap(connection.sentStrings.last)
|
||||||
|
let compatibilityJSON = try XCTUnwrap(
|
||||||
|
JSONSerialization.jsonObject(
|
||||||
|
with: Data(compatibilityRequest.utf8)
|
||||||
|
) as? [Any]
|
||||||
|
)
|
||||||
|
let compatibilityEvent = try XCTUnwrap(
|
||||||
|
compatibilityJSON.dropFirst().first as? [String: Any]
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
compatibilityEvent["kind"] as? Int,
|
||||||
|
NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue
|
||||||
|
)
|
||||||
|
XCTAssertEqual(compatibilityEvent["id"] as? String, legacy.id)
|
||||||
|
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
|
||||||
|
XCTAssertEqual(terminalFailureCount, 0)
|
||||||
|
|
||||||
|
connection.flushDeferredSendCompletions()
|
||||||
|
let failed = await waitUntil {
|
||||||
|
context.manager.debugPendingMessageQueueCount == 0 &&
|
||||||
|
terminalFailureCount == 1
|
||||||
|
}
|
||||||
|
XCTAssertTrue(failed)
|
||||||
|
XCTAssertEqual(connection.cancelCallCount, 0)
|
||||||
|
XCTAssertEqual(
|
||||||
|
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
XCTAssertEqual(terminalFailureCount, 1)
|
||||||
|
XCTAssertFalse(NostrRelayManager.pendingPrivateEnvelopeIDs.contains(primary.id))
|
||||||
|
XCTAssertFalse(NostrRelayManager.pendingPrivateEnvelopeIDs.contains(legacy.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_privateEnvelopeBatchFastRejectionThenDisconnectReplaysWholePair() async throws {
|
||||||
|
let relayURL = "wss://private-batch-early-reject-replay.example"
|
||||||
|
let context = makeContext(permission: .denied)
|
||||||
|
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
||||||
|
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
||||||
|
var terminalFailureCount = 0
|
||||||
|
|
||||||
|
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch(
|
||||||
|
[primary, legacy],
|
||||||
|
to: [relayURL],
|
||||||
|
terminalFailure: { terminalFailureCount += 1 }
|
||||||
|
))
|
||||||
|
let firstConnection = try XCTUnwrap(
|
||||||
|
context.sessionFactory.latestConnection(for: relayURL)
|
||||||
|
)
|
||||||
|
firstConnection.deferSendCompletions = true
|
||||||
|
let firstWriteStarted = await waitUntil {
|
||||||
|
firstConnection.sentStrings.count == 1
|
||||||
|
}
|
||||||
|
XCTAssertTrue(firstWriteStarted)
|
||||||
|
|
||||||
|
try firstConnection.emitOK(
|
||||||
|
eventID: primary.id,
|
||||||
|
success: false,
|
||||||
|
reason: "invalid: rejected"
|
||||||
|
)
|
||||||
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
|
||||||
|
XCTAssertEqual(terminalFailureCount, 0)
|
||||||
|
|
||||||
|
// Replacing the connection before the sibling write clears the
|
||||||
|
// attempt-scoped rejection. The durable pair must replay in full.
|
||||||
|
context.manager.disconnect()
|
||||||
|
context.manager.connect()
|
||||||
|
let replacementReady = await waitUntil {
|
||||||
|
let connections = context.sessionFactory.connectionsByURL[relayURL] ?? []
|
||||||
|
guard connections.count == 2, let replacement = connections.last else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return replacement.sentStrings.count == 2
|
||||||
|
}
|
||||||
|
XCTAssertTrue(replacementReady)
|
||||||
|
let replacement = try XCTUnwrap(
|
||||||
|
context.sessionFactory.latestConnection(for: relayURL)
|
||||||
|
)
|
||||||
|
try await emitAcceptedPair([primary, legacy], on: replacement)
|
||||||
|
let acknowledged = await waitUntil {
|
||||||
|
context.manager.debugPendingMessageQueueCount == 0
|
||||||
|
}
|
||||||
|
XCTAssertTrue(acknowledged)
|
||||||
|
XCTAssertEqual(terminalFailureCount, 0)
|
||||||
|
|
||||||
|
// The canceled connection's late completion cannot send its sibling
|
||||||
|
// or mutate the replacement attempt.
|
||||||
|
firstConnection.flushDeferredSendCompletions()
|
||||||
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
XCTAssertEqual(firstConnection.sentStrings.count, 1)
|
||||||
|
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 0)
|
||||||
|
XCTAssertEqual(terminalFailureCount, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_privateEnvelopeBatchDisconnectDuringWriteReplaysOnReplacementConnection() async throws {
|
||||||
|
let relayURL = "wss://private-batch-background-reconnect.example"
|
||||||
|
let context = makeContext(permission: .denied)
|
||||||
|
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
||||||
|
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
||||||
|
|
||||||
|
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch([primary, legacy], to: [relayURL]))
|
||||||
|
let firstConnection = try XCTUnwrap(
|
||||||
|
context.sessionFactory.latestConnection(for: relayURL)
|
||||||
|
)
|
||||||
|
firstConnection.deferSendCompletions = true
|
||||||
|
let firstWriteStarted = await waitUntil { firstConnection.sentStrings.count == 1 }
|
||||||
|
XCTAssertTrue(firstWriteStarted)
|
||||||
|
|
||||||
|
// Backgrounding cancels the socket while its first write callback is
|
||||||
|
// still outstanding. The pair must remain replayable in the queue.
|
||||||
|
context.manager.disconnect()
|
||||||
|
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
|
||||||
|
context.manager.connect()
|
||||||
|
|
||||||
|
let replacementReady = await waitUntil {
|
||||||
|
let connections = context.sessionFactory.connectionsByURL[relayURL] ?? []
|
||||||
|
guard connections.count == 2, let replacement = connections.last else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return replacement.sentStrings.count == 2
|
||||||
|
}
|
||||||
|
XCTAssertTrue(replacementReady)
|
||||||
|
let replacement = try XCTUnwrap(
|
||||||
|
context.sessionFactory.latestConnection(for: relayURL)
|
||||||
|
)
|
||||||
|
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
|
||||||
|
try await emitAcceptedPair([primary, legacy], on: replacement)
|
||||||
|
let acknowledged = await waitUntil {
|
||||||
|
context.manager.debugPendingMessageQueueCount == 0
|
||||||
|
}
|
||||||
|
XCTAssertTrue(acknowledged)
|
||||||
|
|
||||||
|
// A late success callback from the canceled socket must neither send
|
||||||
|
// the legacy half on that stale socket nor disturb the completed pair.
|
||||||
|
firstConnection.flushDeferredSendCompletions()
|
||||||
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
XCTAssertEqual(firstConnection.sentStrings.count, 1)
|
||||||
|
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func assertPrivateEnvelopeBatchWriteFailure(
|
||||||
|
failureSequence: [Error?],
|
||||||
|
expectedFirstConnectionWrites: Int
|
||||||
|
) async throws {
|
||||||
|
let relayURL = "wss://private-batch-write-failure.example"
|
||||||
|
let context = makeContext(permission: .denied)
|
||||||
|
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
||||||
|
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
||||||
|
|
||||||
|
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch([primary, legacy], to: [relayURL]))
|
||||||
|
let firstConnection = try XCTUnwrap(context.sessionFactory.latestConnection(for: relayURL))
|
||||||
|
firstConnection.sendErrorSequence = failureSequence
|
||||||
|
|
||||||
|
let stayedQueued = await waitUntil {
|
||||||
|
firstConnection.sentStrings.count == expectedFirstConnectionWrites &&
|
||||||
|
context.manager.debugPendingMessageQueueEventIDsByBatch.contains([primary.id, legacy.id])
|
||||||
|
}
|
||||||
|
XCTAssertTrue(stayedQueued)
|
||||||
|
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
|
||||||
|
|
||||||
|
// The failed socket is disconnected with bounded backoff. Its queue
|
||||||
|
// item is reused—not re-admitted—and the replacement retries both
|
||||||
|
// copies before the pair is finally removed.
|
||||||
|
XCTAssertFalse(context.scheduler.scheduled.isEmpty)
|
||||||
|
context.scheduler.runNext()
|
||||||
|
let replacementReady = await waitUntil {
|
||||||
|
guard let replacement = context.sessionFactory.latestConnection(for: relayURL),
|
||||||
|
replacement !== firstConnection else { return false }
|
||||||
|
return replacement.sentStrings.count == 2
|
||||||
|
}
|
||||||
|
XCTAssertTrue(replacementReady)
|
||||||
|
let replacement = try XCTUnwrap(
|
||||||
|
context.sessionFactory.latestConnection(for: relayURL)
|
||||||
|
)
|
||||||
|
try await emitAcceptedPair([primary, legacy], on: replacement)
|
||||||
|
let acknowledged = await waitUntil {
|
||||||
|
context.manager.debugPendingMessageQueueCount == 0
|
||||||
|
}
|
||||||
|
XCTAssertTrue(acknowledged)
|
||||||
|
}
|
||||||
|
|
||||||
func test_sendEvent_waitsForTorReadinessBeforeSending() async throws {
|
func test_sendEvent_waitsForTorReadinessBeforeSending() async throws {
|
||||||
let relayURL = "wss://tor-ready.example"
|
let relayURL = "wss://tor-ready.example"
|
||||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||||
@@ -548,6 +1130,52 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
XCTAssertEqual(context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count, 1)
|
XCTAssertEqual(context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func test_subscribe_multiplePrivateEnvelopeFiltersEncodesIndependentLimits() async throws {
|
||||||
|
let relayURL = "wss://private-recovery-filters.example"
|
||||||
|
let context = makeContext(permission: .denied)
|
||||||
|
let filters = NostrFilter.privateEnvelopeFiltersFor(
|
||||||
|
pubkey: "recipient",
|
||||||
|
since: Date(timeIntervalSince1970: 1_234_567)
|
||||||
|
)
|
||||||
|
|
||||||
|
context.manager.subscribe(
|
||||||
|
filters: filters,
|
||||||
|
id: "private-recovery",
|
||||||
|
relayUrls: [relayURL],
|
||||||
|
handler: { _ in }
|
||||||
|
)
|
||||||
|
|
||||||
|
let sent = await waitUntil {
|
||||||
|
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
|
||||||
|
}
|
||||||
|
XCTAssertTrue(sent)
|
||||||
|
let request = try XCTUnwrap(
|
||||||
|
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.first
|
||||||
|
)
|
||||||
|
let data = try XCTUnwrap(request.data(using: .utf8))
|
||||||
|
let array = try XCTUnwrap(
|
||||||
|
try JSONSerialization.jsonObject(with: data) as? [Any]
|
||||||
|
)
|
||||||
|
XCTAssertEqual(array.count, 4)
|
||||||
|
XCTAssertEqual(array[0] as? String, "REQ")
|
||||||
|
XCTAssertEqual(array[1] as? String, "private-recovery")
|
||||||
|
let encodedFilters = try [array[2], array[3]].map {
|
||||||
|
try XCTUnwrap($0 as? [String: Any])
|
||||||
|
}
|
||||||
|
XCTAssertEqual(encodedFilters.compactMap { $0["kinds"] as? [Int] }, [
|
||||||
|
[NostrProtocol.EventKind.privateEnvelope.rawValue],
|
||||||
|
[NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue]
|
||||||
|
])
|
||||||
|
for filter in encodedFilters {
|
||||||
|
XCTAssertEqual(
|
||||||
|
filter["limit"] as? Int,
|
||||||
|
TransportConfig.nostrPrivateEnvelopeFetchLimitPerKind
|
||||||
|
)
|
||||||
|
XCTAssertEqual(filter["#p"] as? [String], ["recipient"])
|
||||||
|
XCTAssertEqual(filter["since"] as? Int, 1_234_567)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func test_subscribe_coalescesDuplicateRequestsBeforeTorReadyAndDefersEOSE() async throws {
|
func test_subscribe_coalescesDuplicateRequestsBeforeTorReadyAndDefersEOSE() async throws {
|
||||||
let relayURL = "wss://tor-subscribe-coalesce.example"
|
let relayURL = "wss://tor-subscribe-coalesce.example"
|
||||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||||
@@ -893,11 +1521,11 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
XCTAssertTrue(secondDelivered)
|
XCTAssertTrue(secondDelivered)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_okMessages_clearPendingGiftWrapIDs() async throws {
|
func test_okMessages_clearPendingPrivateEnvelopeIDs() async throws {
|
||||||
let relayURL = "wss://ok.example"
|
let relayURL = "wss://ok.example"
|
||||||
let context = makeContext(permission: .denied)
|
let context = makeContext(permission: .denied)
|
||||||
let successID = "gift-wrap-success"
|
let successID = "private-envelope-success"
|
||||||
let failureID = "gift-wrap-failure"
|
let failureID = "private-envelope-failure"
|
||||||
|
|
||||||
context.manager.ensureConnections(to: [relayURL])
|
context.manager.ensureConnections(to: [relayURL])
|
||||||
let connected = await waitUntil {
|
let connected = await waitUntil {
|
||||||
@@ -906,17 +1534,17 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
XCTAssertTrue(connected)
|
XCTAssertTrue(connected)
|
||||||
|
|
||||||
NostrRelayManager.registerPendingGiftWrap(id: successID)
|
NostrRelayManager.registerPendingPrivateEnvelope(id: successID)
|
||||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: successID, success: true, reason: "ok")
|
try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: successID, success: true, reason: "ok")
|
||||||
let successCleared = await waitUntil {
|
let successCleared = await waitUntil {
|
||||||
!NostrRelayManager.pendingGiftWrapIDs.contains(successID)
|
!NostrRelayManager.pendingPrivateEnvelopeIDs.contains(successID)
|
||||||
}
|
}
|
||||||
XCTAssertTrue(successCleared)
|
XCTAssertTrue(successCleared)
|
||||||
|
|
||||||
NostrRelayManager.registerPendingGiftWrap(id: failureID)
|
NostrRelayManager.registerPendingPrivateEnvelope(id: failureID)
|
||||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: failureID, success: false, reason: "rejected")
|
try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: failureID, success: false, reason: "rejected")
|
||||||
let failureCleared = await waitUntil {
|
let failureCleared = await waitUntil {
|
||||||
!NostrRelayManager.pendingGiftWrapIDs.contains(failureID)
|
!NostrRelayManager.pendingPrivateEnvelopeIDs.contains(failureID)
|
||||||
}
|
}
|
||||||
XCTAssertTrue(failureCleared)
|
XCTAssertTrue(failureCleared)
|
||||||
}
|
}
|
||||||
@@ -1646,12 +2274,31 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
return filter
|
return filter
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeSignedEvent(content: String) throws -> NostrEvent {
|
private func emitAcceptedPair(
|
||||||
|
_ events: [NostrEvent],
|
||||||
|
on connection: MockRelayConnection
|
||||||
|
) async throws {
|
||||||
|
for event in events {
|
||||||
|
try connection.emitOK(
|
||||||
|
eventID: event.id,
|
||||||
|
success: true,
|
||||||
|
reason: "accepted"
|
||||||
|
)
|
||||||
|
// Each inbound frame consumes the mock receive handler. Let the
|
||||||
|
// manager re-arm it before delivering the next relay response.
|
||||||
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeSignedEvent(
|
||||||
|
content: String,
|
||||||
|
kind: NostrProtocol.EventKind = .textNote
|
||||||
|
) throws -> NostrEvent {
|
||||||
let identity = try NostrIdentity.generate()
|
let identity = try NostrIdentity.generate()
|
||||||
let event = NostrEvent(
|
let event = NostrEvent(
|
||||||
pubkey: identity.publicKeyHex,
|
pubkey: identity.publicKeyHex,
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
kind: .textNote,
|
kind: kind,
|
||||||
tags: [],
|
tags: [],
|
||||||
content: content
|
content: content
|
||||||
)
|
)
|
||||||
@@ -1791,6 +2438,7 @@ private final class MockRelaySessionFactory: NostrRelaySessionProtocol {
|
|||||||
private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
||||||
private let pingError: Error?
|
private let pingError: Error?
|
||||||
var sendError: Error?
|
var sendError: Error?
|
||||||
|
var sendErrorSequence: [Error?] = []
|
||||||
private var receiveHandler: ((Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
|
private var receiveHandler: ((Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
|
||||||
private(set) var resumeCallCount = 0
|
private(set) var resumeCallCount = 0
|
||||||
private(set) var cancelCallCount = 0
|
private(set) var cancelCallCount = 0
|
||||||
@@ -1820,14 +2468,15 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var deferSendCompletions = false
|
var deferSendCompletions = false
|
||||||
private var deferredSendCompletions: [(Error?) -> Void] = []
|
private var deferredSendCompletions: [(error: Error?, completion: (Error?) -> Void)] = []
|
||||||
|
|
||||||
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) {
|
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) {
|
||||||
sentMessages.append(message)
|
sentMessages.append(message)
|
||||||
|
let error = sendErrorSequence.isEmpty ? sendError : sendErrorSequence.removeFirst()
|
||||||
if deferSendCompletions {
|
if deferSendCompletions {
|
||||||
deferredSendCompletions.append(completionHandler)
|
deferredSendCompletions.append((error, completionHandler))
|
||||||
} else {
|
} else {
|
||||||
completionHandler(sendError)
|
completionHandler(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1835,7 +2484,7 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
|||||||
let pending = deferredSendCompletions
|
let pending = deferredSendCompletions
|
||||||
deferredSendCompletions = []
|
deferredSendCompletions = []
|
||||||
pending.forEach {
|
pending.forEach {
|
||||||
$0(sendError)
|
$0.completion($0.error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ struct NostrTransportTests {
|
|||||||
#expect(undeliverable)
|
#expect(undeliverable)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Private message resolves short peer ID and emits decryptable packet")
|
@Test("Private message resolves short peer ID and emits both migration formats")
|
||||||
@MainActor
|
@MainActor
|
||||||
func sendPrivateMessageResolvesShortPeerID() async throws {
|
func sendPrivateMessageResolvesShortPeerID() async throws {
|
||||||
let keychain = MockKeychain()
|
let keychain = MockKeychain()
|
||||||
@@ -153,8 +153,8 @@ struct NostrTransportTests {
|
|||||||
favoriteStatusForNoiseKey: { _ in nil },
|
favoriteStatusForNoiseKey: { _ in nil },
|
||||||
favoriteStatusForPeerID: { $0 == shortPeerID ? relationship : nil },
|
favoriteStatusForPeerID: { $0 == shortPeerID ? relationship : nil },
|
||||||
currentIdentity: { sender },
|
currentIdentity: { sender },
|
||||||
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||||
sendEvent: probe.record(event:),
|
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
|
||||||
scheduleAfter: { delay, action in
|
scheduleAfter: { delay, action in
|
||||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||||
}
|
}
|
||||||
@@ -164,8 +164,12 @@ struct NostrTransportTests {
|
|||||||
|
|
||||||
transport.sendPrivateMessage("hello over nostr", to: shortPeerID, recipientNickname: "Carol", messageID: "pm-1")
|
transport.sendPrivateMessage("hello over nostr", to: shortPeerID, recipientNickname: "Carol", messageID: "pm-1")
|
||||||
|
|
||||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
|
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 5.0)
|
||||||
#expect(didSend)
|
#expect(didSend)
|
||||||
|
#expect(probe.sentEvents.map(\.kind) == [
|
||||||
|
NostrProtocol.EventKind.privateEnvelope.rawValue,
|
||||||
|
NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue
|
||||||
|
])
|
||||||
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
||||||
let privateMessage = try decodePrivateMessage(from: result.payload)
|
let privateMessage = try decodePrivateMessage(from: result.payload)
|
||||||
|
|
||||||
@@ -173,7 +177,583 @@ struct NostrTransportTests {
|
|||||||
#expect(privateMessage.messageID == "pm-1")
|
#expect(privateMessage.messageID == "pm-1")
|
||||||
#expect(privateMessage.content == "hello over nostr")
|
#expect(privateMessage.content == "hello over nostr")
|
||||||
#expect(result.packet.recipientID == shortPeerID.routingData)
|
#expect(result.packet.recipientID == shortPeerID.routingData)
|
||||||
#expect(probe.pendingGiftWrapIDs.isEmpty)
|
#expect(probe.pendingPrivateEnvelopeIDs.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Coordinated migration always publishes primary and compatibility envelopes")
|
||||||
|
@MainActor
|
||||||
|
func migrationAlwaysDualPublishes() async throws {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let noiseKey = Data((192..<224).map(UInt8.init))
|
||||||
|
let peerID = PeerID(hexData: noiseKey)
|
||||||
|
let relationship = makeRelationship(
|
||||||
|
peerNoisePublicKey: noiseKey,
|
||||||
|
peerNostrPublicKey: recipient.npub,
|
||||||
|
peerNickname: "Migration peer"
|
||||||
|
)
|
||||||
|
|
||||||
|
let migrationProbe = NostrTransportProbe()
|
||||||
|
let migrationTransport = NostrTransport(
|
||||||
|
keychain: keychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
dependencies: makeDependencies(
|
||||||
|
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||||
|
currentIdentity: { sender },
|
||||||
|
sendPrivateEnvelopeBatch: { events, _ in migrationProbe.record(batch: events) }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
migrationTransport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||||
|
migrationTransport.sendPrivateMessage(
|
||||||
|
"migration payload",
|
||||||
|
to: peerID,
|
||||||
|
recipientNickname: "Migration peer",
|
||||||
|
messageID: "migration-pm"
|
||||||
|
)
|
||||||
|
|
||||||
|
let sentPair = await TestHelpers.waitUntil(
|
||||||
|
{ migrationProbe.sentEvents.count == 2 },
|
||||||
|
timeout: 5.0
|
||||||
|
)
|
||||||
|
#expect(sentPair)
|
||||||
|
#expect(migrationProbe.sentEvents.map(\.kind) == [
|
||||||
|
NostrProtocol.EventKind.privateEnvelope.rawValue,
|
||||||
|
NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue
|
||||||
|
])
|
||||||
|
for event in migrationProbe.sentEvents {
|
||||||
|
let result = try decodeEmbeddedPayload(from: event, recipient: recipient)
|
||||||
|
let message = try decodePrivateMessage(from: result.payload)
|
||||||
|
#expect(message.messageID == "migration-pm")
|
||||||
|
#expect(message.content == "migration payload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Rejected migration batch does not register half-delivery state")
|
||||||
|
@MainActor
|
||||||
|
func rejectedMigrationBatchRegistersNothing() async throws {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let probe = NostrTransportProbe()
|
||||||
|
var rejectedKinds: [Int] = []
|
||||||
|
let transport = NostrTransport(
|
||||||
|
keychain: keychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
dependencies: makeDependencies(
|
||||||
|
currentIdentity: { sender },
|
||||||
|
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||||
|
sendPrivateEnvelopeBatch: { events, _ in
|
||||||
|
rejectedKinds = events.map(\.kind)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
transport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||||
|
|
||||||
|
transport.sendPrivateMessageGeohash(
|
||||||
|
content: "must stay atomic",
|
||||||
|
toRecipientHex: recipient.publicKeyHex,
|
||||||
|
from: sender,
|
||||||
|
messageID: "atomic-reject"
|
||||||
|
)
|
||||||
|
|
||||||
|
let attempted = await TestHelpers.waitUntil({ rejectedKinds.count == 2 }, timeout: 5.0)
|
||||||
|
#expect(attempted)
|
||||||
|
#expect(rejectedKinds == [
|
||||||
|
NostrProtocol.EventKind.privateEnvelope.rawValue,
|
||||||
|
NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue
|
||||||
|
])
|
||||||
|
#expect(probe.pendingPrivateEnvelopeIDs.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Rejected user message emits a visible failed-delivery event")
|
||||||
|
@MainActor
|
||||||
|
func rejectedUserMessageEmitsFailureEvent() async throws {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let eventProbe = NostrTransportEventProbe()
|
||||||
|
let transport = NostrTransport(
|
||||||
|
keychain: keychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
dependencies: makeDependencies(
|
||||||
|
currentIdentity: { sender },
|
||||||
|
sendPrivateEnvelopeBatch: { _, _ in false }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
transport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||||
|
transport.eventDelegate = eventProbe
|
||||||
|
|
||||||
|
transport.sendPrivateMessageGeohash(
|
||||||
|
content: "must fail visibly",
|
||||||
|
toRecipientHex: recipient.publicKeyHex,
|
||||||
|
from: sender,
|
||||||
|
messageID: "visible-reject"
|
||||||
|
)
|
||||||
|
|
||||||
|
let reported = await TestHelpers.waitUntil(
|
||||||
|
{ eventProbe.failedMessageIDs == ["visible-reject"] },
|
||||||
|
timeout: 5.0
|
||||||
|
)
|
||||||
|
#expect(reported)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Envelope construction failure rejects visibly without publishing")
|
||||||
|
@MainActor
|
||||||
|
func envelopeConstructionFailureRejectsVisibly() async throws {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let eventProbe = NostrTransportEventProbe()
|
||||||
|
let publicationProbe = NostrTransportProbe()
|
||||||
|
let transport = NostrTransport(
|
||||||
|
keychain: keychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
dependencies: makeDependencies(
|
||||||
|
currentIdentity: { sender },
|
||||||
|
sendPrivateEnvelopeBatch: { events, _ in
|
||||||
|
publicationProbe.record(batch: events)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
transport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||||
|
transport.eventDelegate = eventProbe
|
||||||
|
|
||||||
|
// Non-empty but invalid hex reaches envelope construction after the
|
||||||
|
// embedded BitChat packet succeeds, reproducing the throwing batch
|
||||||
|
// builder path without allocating an oversized test fixture.
|
||||||
|
let accepted = transport.sendPrivateMessageGeohash(
|
||||||
|
content: "must fail before sent",
|
||||||
|
toRecipientHex: "not-a-hex-public-key",
|
||||||
|
from: sender,
|
||||||
|
messageID: "build-reject"
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(!accepted)
|
||||||
|
#expect(publicationProbe.sentEvents.isEmpty)
|
||||||
|
#expect(eventProbe.failedMessageIDs == ["build-reject"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Unencodable user message rejects visibly without publishing")
|
||||||
|
@MainActor
|
||||||
|
func unencodableUserMessageRejectsVisibly() async throws {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let eventProbe = NostrTransportEventProbe()
|
||||||
|
let publicationProbe = NostrTransportProbe()
|
||||||
|
let transport = NostrTransport(
|
||||||
|
keychain: keychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
dependencies: makeDependencies(
|
||||||
|
currentIdentity: { sender },
|
||||||
|
sendPrivateEnvelopeBatch: { events, _ in
|
||||||
|
publicationProbe.record(batch: events)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
transport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||||
|
transport.eventDelegate = eventProbe
|
||||||
|
|
||||||
|
// PrivateMessagePacket uses the deployed UInt8 content length. Do not
|
||||||
|
// create a larger wire shape that released clients cannot decode.
|
||||||
|
let accepted = transport.sendPrivateMessageGeohash(
|
||||||
|
content: String(repeating: "x", count: 256),
|
||||||
|
toRecipientHex: recipient.publicKeyHex,
|
||||||
|
from: sender,
|
||||||
|
messageID: "packet-reject"
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(!accepted)
|
||||||
|
#expect(publicationProbe.sentEvents.isEmpty)
|
||||||
|
#expect(eventProbe.failedMessageIDs == ["packet-reject"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Unencodable direct message emits a visible failure")
|
||||||
|
@MainActor
|
||||||
|
func unencodableDirectMessageEmitsVisibleFailure() async throws {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let noiseKey = Data((224..<256).map(UInt8.init))
|
||||||
|
let peerID = PeerID(hexData: noiseKey)
|
||||||
|
let relationship = makeRelationship(
|
||||||
|
peerNoisePublicKey: noiseKey,
|
||||||
|
peerNostrPublicKey: recipient.npub,
|
||||||
|
peerNickname: "Oversized peer"
|
||||||
|
)
|
||||||
|
let eventProbe = NostrTransportEventProbe()
|
||||||
|
let publicationProbe = NostrTransportProbe()
|
||||||
|
let transport = NostrTransport(
|
||||||
|
keychain: keychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
dependencies: makeDependencies(
|
||||||
|
favoriteStatusForNoiseKey: {
|
||||||
|
$0 == noiseKey ? relationship : nil
|
||||||
|
},
|
||||||
|
currentIdentity: { sender },
|
||||||
|
sendPrivateEnvelopeBatch: { events, _ in
|
||||||
|
publicationProbe.record(batch: events)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
transport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||||
|
transport.eventDelegate = eventProbe
|
||||||
|
|
||||||
|
transport.sendPrivateMessage(
|
||||||
|
String(repeating: "x", count: 256),
|
||||||
|
to: peerID,
|
||||||
|
recipientNickname: "Oversized peer",
|
||||||
|
messageID: "direct-packet-reject"
|
||||||
|
)
|
||||||
|
|
||||||
|
let failed = await TestHelpers.waitUntil(
|
||||||
|
{
|
||||||
|
eventProbe.failedMessageIDs
|
||||||
|
== ["direct-packet-reject"]
|
||||||
|
},
|
||||||
|
timeout: 5.0
|
||||||
|
)
|
||||||
|
#expect(failed)
|
||||||
|
#expect(publicationProbe.sentEvents.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Direct message with an invalid npub emits a visible failure")
|
||||||
|
@MainActor
|
||||||
|
func invalidDirectRecipientNpubEmitsVisibleFailure() async throws {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let noiseKey = Data(repeating: 0xA5, count: 32)
|
||||||
|
let peerID = PeerID(hexData: noiseKey)
|
||||||
|
let relationship = makeRelationship(
|
||||||
|
peerNoisePublicKey: noiseKey,
|
||||||
|
peerNostrPublicKey: "not-a-valid-npub",
|
||||||
|
peerNickname: "Invalid npub peer"
|
||||||
|
)
|
||||||
|
let eventProbe = NostrTransportEventProbe()
|
||||||
|
let publicationProbe = NostrTransportProbe()
|
||||||
|
let transport = NostrTransport(
|
||||||
|
keychain: keychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
dependencies: makeDependencies(
|
||||||
|
favoriteStatusForNoiseKey: {
|
||||||
|
$0 == noiseKey ? relationship : nil
|
||||||
|
},
|
||||||
|
currentIdentity: { sender },
|
||||||
|
sendPrivateEnvelopeBatch: { events, _ in
|
||||||
|
publicationProbe.record(batch: events)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
transport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||||
|
transport.eventDelegate = eventProbe
|
||||||
|
|
||||||
|
transport.sendPrivateMessage(
|
||||||
|
"must fail before publication",
|
||||||
|
to: peerID,
|
||||||
|
recipientNickname: "Invalid npub peer",
|
||||||
|
messageID: "invalid-npub"
|
||||||
|
)
|
||||||
|
|
||||||
|
let failed = await TestHelpers.waitUntil(
|
||||||
|
{ eventProbe.failedMessageIDs == ["invalid-npub"] },
|
||||||
|
timeout: 5.0
|
||||||
|
)
|
||||||
|
#expect(failed)
|
||||||
|
#expect(publicationProbe.sentEvents.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Direct message without a resolvable recipient emits a visible failure")
|
||||||
|
@MainActor
|
||||||
|
func missingDirectRecipientEmitsVisibleFailure() async throws {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let peerID = PeerID(hexData: Data(repeating: 0xC3, count: 32))
|
||||||
|
let eventProbe = NostrTransportEventProbe()
|
||||||
|
let publicationProbe = NostrTransportProbe()
|
||||||
|
let transport = NostrTransport(
|
||||||
|
keychain: keychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
dependencies: makeDependencies(
|
||||||
|
currentIdentity: { sender },
|
||||||
|
sendPrivateEnvelopeBatch: { events, _ in
|
||||||
|
publicationProbe.record(batch: events)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
transport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||||
|
transport.eventDelegate = eventProbe
|
||||||
|
|
||||||
|
transport.sendPrivateMessage(
|
||||||
|
"must fail without recipient",
|
||||||
|
to: peerID,
|
||||||
|
recipientNickname: "Unknown peer",
|
||||||
|
messageID: "missing-recipient"
|
||||||
|
)
|
||||||
|
|
||||||
|
let failed = await TestHelpers.waitUntil(
|
||||||
|
{ eventProbe.failedMessageIDs == ["missing-recipient"] },
|
||||||
|
timeout: 5.0
|
||||||
|
)
|
||||||
|
#expect(failed)
|
||||||
|
#expect(publicationProbe.sentEvents.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Direct message without a current identity emits a visible failure")
|
||||||
|
@MainActor
|
||||||
|
func missingDirectSenderIdentityEmitsVisibleFailure() async throws {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let noiseKey = Data(repeating: 0x5A, count: 32)
|
||||||
|
let peerID = PeerID(hexData: noiseKey)
|
||||||
|
let relationship = makeRelationship(
|
||||||
|
peerNoisePublicKey: noiseKey,
|
||||||
|
peerNostrPublicKey: recipient.npub,
|
||||||
|
peerNickname: "Missing identity peer"
|
||||||
|
)
|
||||||
|
let eventProbe = NostrTransportEventProbe()
|
||||||
|
let publicationProbe = NostrTransportProbe()
|
||||||
|
let transport = NostrTransport(
|
||||||
|
keychain: keychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
dependencies: makeDependencies(
|
||||||
|
favoriteStatusForNoiseKey: {
|
||||||
|
$0 == noiseKey ? relationship : nil
|
||||||
|
},
|
||||||
|
currentIdentity: { nil },
|
||||||
|
sendPrivateEnvelopeBatch: { events, _ in
|
||||||
|
publicationProbe.record(batch: events)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
transport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||||
|
transport.eventDelegate = eventProbe
|
||||||
|
|
||||||
|
transport.sendPrivateMessage(
|
||||||
|
"must fail without identity",
|
||||||
|
to: peerID,
|
||||||
|
recipientNickname: "Missing identity peer",
|
||||||
|
messageID: "missing-identity"
|
||||||
|
)
|
||||||
|
|
||||||
|
let failed = await TestHelpers.waitUntil(
|
||||||
|
{ eventProbe.failedMessageIDs == ["missing-identity"] },
|
||||||
|
timeout: 5.0
|
||||||
|
)
|
||||||
|
#expect(failed)
|
||||||
|
#expect(publicationProbe.sentEvents.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Rejected favorite notification retains and retries the exact pair")
|
||||||
|
@MainActor
|
||||||
|
func rejectedFavoriteNotificationRetriesExactPair() async throws {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let noiseKey = Data((96..<128).map(UInt8.init))
|
||||||
|
let fullPeerID = PeerID(hexData: noiseKey)
|
||||||
|
let relationship = makeRelationship(
|
||||||
|
peerNoisePublicKey: noiseKey,
|
||||||
|
peerNostrPublicKey: recipient.npub,
|
||||||
|
peerNickname: "Retry favorite"
|
||||||
|
)
|
||||||
|
let probe = NostrTransportProbe()
|
||||||
|
var attempts: [[String]] = []
|
||||||
|
weak var releasedTransport: NostrTransport?
|
||||||
|
do {
|
||||||
|
let transport = NostrTransport(
|
||||||
|
keychain: keychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
dependencies: makeDependencies(
|
||||||
|
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||||
|
currentIdentity: { sender },
|
||||||
|
sendPrivateEnvelopeBatch: { events, _ in
|
||||||
|
attempts.append(events.map(\.id))
|
||||||
|
return attempts.count > 1
|
||||||
|
},
|
||||||
|
scheduleAfter: { delay, action in
|
||||||
|
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
transport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||||
|
releasedTransport = transport
|
||||||
|
|
||||||
|
transport.sendFavoriteNotification(to: fullPeerID, isFavorite: true)
|
||||||
|
let retryScheduled = await TestHelpers.waitUntil(
|
||||||
|
{ attempts.count == 1 && probe.scheduledActionCount == 1 },
|
||||||
|
timeout: 5.0
|
||||||
|
)
|
||||||
|
#expect(retryScheduled)
|
||||||
|
}
|
||||||
|
#expect(releasedTransport == nil)
|
||||||
|
#expect(probe.runNextScheduledAction())
|
||||||
|
|
||||||
|
let retried = await TestHelpers.waitUntil({ attempts.count == 2 }, timeout: 5.0)
|
||||||
|
#expect(retried)
|
||||||
|
#expect(attempts[0] == attempts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Rejected delivery acknowledgement remains queued for retry")
|
||||||
|
@MainActor
|
||||||
|
func rejectedDeliveryAckRetries() async throws {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let noiseKey = Data((128..<160).map(UInt8.init))
|
||||||
|
let fullPeerID = PeerID(hexData: noiseKey)
|
||||||
|
let relationship = makeRelationship(
|
||||||
|
peerNoisePublicKey: noiseKey,
|
||||||
|
peerNostrPublicKey: recipient.npub,
|
||||||
|
peerNickname: "Retry ack"
|
||||||
|
)
|
||||||
|
let probe = NostrTransportProbe()
|
||||||
|
var attempts: [[String]] = []
|
||||||
|
let transport = NostrTransport(
|
||||||
|
keychain: keychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
dependencies: makeDependencies(
|
||||||
|
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||||
|
currentIdentity: { sender },
|
||||||
|
sendPrivateEnvelopeBatch: { events, _ in
|
||||||
|
attempts.append(events.map(\.id))
|
||||||
|
return attempts.count > 1
|
||||||
|
},
|
||||||
|
scheduleAfter: { delay, action in
|
||||||
|
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
transport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||||
|
|
||||||
|
transport.sendDeliveryAck(for: "retry-ack", to: fullPeerID)
|
||||||
|
let firstAttempt = await TestHelpers.waitUntil(
|
||||||
|
{ attempts.count == 1 && probe.scheduledActionCount >= 1 },
|
||||||
|
timeout: 5.0
|
||||||
|
)
|
||||||
|
#expect(firstAttempt)
|
||||||
|
|
||||||
|
for _ in 0..<3 where attempts.count < 2 {
|
||||||
|
_ = probe.runNextScheduledAction()
|
||||||
|
_ = await TestHelpers.waitUntil(
|
||||||
|
{ attempts.count == 2 || probe.scheduledActionCount > 0 },
|
||||||
|
timeout: 1.0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#expect(attempts.count == 2)
|
||||||
|
#expect(attempts[0] == attempts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Control retry queue is bounded and evicted callbacks are harmless")
|
||||||
|
@MainActor
|
||||||
|
func controlRetryQueueIsBounded() async throws {
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let events = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||||
|
content: "bounded retry fixture",
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
senderIdentity: sender
|
||||||
|
)
|
||||||
|
let probe = NostrTransportProbe()
|
||||||
|
var retryAttempts = 0
|
||||||
|
let queue = NostrPrivateEnvelopeRetryQueue(
|
||||||
|
sendPrivateEnvelopeBatch: { _, _ in
|
||||||
|
retryAttempts += 1
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
registerPendingPrivateEnvelope: { _ in },
|
||||||
|
scheduleAfter: { delay, action in
|
||||||
|
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for index in 0...TransportConfig.nostrPrivateEnvelopeRetryQueueCap {
|
||||||
|
queue.enqueue(
|
||||||
|
key: "control-\(index)",
|
||||||
|
events: events,
|
||||||
|
registerPending: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(queue.debugPendingCount == TransportConfig.nostrPrivateEnvelopeRetryQueueCap)
|
||||||
|
#expect(!queue.debugContains(key: "control-0"))
|
||||||
|
#expect(queue.debugContains(key: "control-1"))
|
||||||
|
|
||||||
|
// The oldest callback was scheduled before eviction. Running it must
|
||||||
|
// observe the missing key and return without touching dependencies.
|
||||||
|
#expect(probe.runNextScheduledAction())
|
||||||
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
#expect(retryAttempts == 0)
|
||||||
|
|
||||||
|
queue.removeAll()
|
||||||
|
#expect(queue.debugPendingCount == 0)
|
||||||
|
#expect(probe.runNextScheduledAction())
|
||||||
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
#expect(retryAttempts == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Multiple transports share one globally bounded control retry owner")
|
||||||
|
@MainActor
|
||||||
|
func multipleTransportsShareControlRetryQueue() async throws {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let events = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||||
|
content: "shared retry fixture",
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
senderIdentity: sender
|
||||||
|
)
|
||||||
|
let probe = NostrTransportProbe()
|
||||||
|
var retryAttempts = 0
|
||||||
|
let sharedQueue = NostrPrivateEnvelopeRetryQueue(
|
||||||
|
sendPrivateEnvelopeBatch: { _, _ in
|
||||||
|
retryAttempts += 1
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
registerPendingPrivateEnvelope: { _ in },
|
||||||
|
scheduleAfter: { delay, action in
|
||||||
|
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let first = NostrTransport(
|
||||||
|
keychain: keychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
dependencies: makeDependencies(envelopeRetryQueue: sharedQueue)
|
||||||
|
)
|
||||||
|
let second = NostrTransport(
|
||||||
|
keychain: keychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
dependencies: makeDependencies(envelopeRetryQueue: sharedQueue)
|
||||||
|
)
|
||||||
|
|
||||||
|
first.debugEnqueueControlRetry(key: "shared", events: events)
|
||||||
|
second.debugEnqueueControlRetry(key: "shared", events: events)
|
||||||
|
#expect(first.debugControlRetryCount == 1)
|
||||||
|
#expect(second.debugControlRetryCount == 1)
|
||||||
|
|
||||||
|
for index in 0...TransportConfig.nostrPrivateEnvelopeRetryQueueCap {
|
||||||
|
let transport = index.isMultiple(of: 2) ? first : second
|
||||||
|
transport.debugEnqueueControlRetry(key: "global-\(index)", events: events)
|
||||||
|
}
|
||||||
|
#expect(first.debugControlRetryCount == TransportConfig.nostrPrivateEnvelopeRetryQueueCap)
|
||||||
|
#expect(second.debugControlRetryCount == TransportConfig.nostrPrivateEnvelopeRetryQueueCap)
|
||||||
|
|
||||||
|
// The first scheduled callback belongs to the now-evicted shared key.
|
||||||
|
#expect(probe.runNextScheduledAction())
|
||||||
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
#expect(retryAttempts == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Favorite notification embeds current npub")
|
@Test("Favorite notification embeds current npub")
|
||||||
@@ -198,8 +778,8 @@ struct NostrTransportTests {
|
|||||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||||
favoriteStatusForPeerID: { _ in nil },
|
favoriteStatusForPeerID: { _ in nil },
|
||||||
currentIdentity: { sender },
|
currentIdentity: { sender },
|
||||||
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||||
sendEvent: probe.record(event:),
|
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
|
||||||
scheduleAfter: { delay, action in
|
scheduleAfter: { delay, action in
|
||||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||||
}
|
}
|
||||||
@@ -209,7 +789,7 @@ struct NostrTransportTests {
|
|||||||
|
|
||||||
transport.sendFavoriteNotification(to: fullPeerID, isFavorite: true)
|
transport.sendFavoriteNotification(to: fullPeerID, isFavorite: true)
|
||||||
|
|
||||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
|
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 5.0)
|
||||||
#expect(didSend)
|
#expect(didSend)
|
||||||
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
||||||
let privateMessage = try decodePrivateMessage(from: result.payload)
|
let privateMessage = try decodePrivateMessage(from: result.payload)
|
||||||
@@ -239,8 +819,8 @@ struct NostrTransportTests {
|
|||||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||||
favoriteStatusForPeerID: { _ in nil },
|
favoriteStatusForPeerID: { _ in nil },
|
||||||
currentIdentity: { sender },
|
currentIdentity: { sender },
|
||||||
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||||
sendEvent: probe.record(event:),
|
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
|
||||||
scheduleAfter: { delay, action in
|
scheduleAfter: { delay, action in
|
||||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||||
}
|
}
|
||||||
@@ -250,7 +830,7 @@ struct NostrTransportTests {
|
|||||||
|
|
||||||
transport.sendDeliveryAck(for: "ack-1", to: fullPeerID)
|
transport.sendDeliveryAck(for: "ack-1", to: fullPeerID)
|
||||||
|
|
||||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
|
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 5.0)
|
||||||
#expect(didSend)
|
#expect(didSend)
|
||||||
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
||||||
|
|
||||||
@@ -259,9 +839,9 @@ struct NostrTransportTests {
|
|||||||
#expect(result.packet.recipientID == fullPeerID.toShort().routingData)
|
#expect(result.packet.recipientID == fullPeerID.toShort().routingData)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Geohash private message registers pending gift wrap")
|
@Test("Geohash private message registers pending private envelope")
|
||||||
@MainActor
|
@MainActor
|
||||||
func sendPrivateMessageGeohashRegistersPendingGiftWrap() async throws {
|
func sendPrivateMessageGeohashRegistersPendingPrivateEnvelope() async throws {
|
||||||
let keychain = MockKeychain()
|
let keychain = MockKeychain()
|
||||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
@@ -272,8 +852,8 @@ struct NostrTransportTests {
|
|||||||
idBridge: idBridge,
|
idBridge: idBridge,
|
||||||
dependencies: makeDependencies(
|
dependencies: makeDependencies(
|
||||||
currentIdentity: { sender },
|
currentIdentity: { sender },
|
||||||
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||||
sendEvent: probe.record(event:),
|
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
|
||||||
scheduleAfter: { delay, action in
|
scheduleAfter: { delay, action in
|
||||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||||
}
|
}
|
||||||
@@ -288,7 +868,7 @@ struct NostrTransportTests {
|
|||||||
messageID: "geo-1"
|
messageID: "geo-1"
|
||||||
)
|
)
|
||||||
|
|
||||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
|
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 5.0)
|
||||||
#expect(didSend)
|
#expect(didSend)
|
||||||
let event = probe.sentEvents[0]
|
let event = probe.sentEvents[0]
|
||||||
let result = try decodeEmbeddedPayload(from: event, recipient: recipient)
|
let result = try decodeEmbeddedPayload(from: event, recipient: recipient)
|
||||||
@@ -297,7 +877,7 @@ struct NostrTransportTests {
|
|||||||
#expect(privateMessage.messageID == "geo-1")
|
#expect(privateMessage.messageID == "geo-1")
|
||||||
#expect(privateMessage.content == "geo hello")
|
#expect(privateMessage.content == "geo hello")
|
||||||
#expect(result.packet.recipientID == nil)
|
#expect(result.packet.recipientID == nil)
|
||||||
#expect(probe.pendingGiftWrapIDs == [event.id])
|
#expect(probe.pendingPrivateEnvelopeIDs == probe.sentEvents.map(\.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Read receipt queue sends in order and waits for scheduler")
|
@Test("Read receipt queue sends in order and waits for scheduler")
|
||||||
@@ -322,8 +902,8 @@ struct NostrTransportTests {
|
|||||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||||
favoriteStatusForPeerID: { _ in nil },
|
favoriteStatusForPeerID: { _ in nil },
|
||||||
currentIdentity: { sender },
|
currentIdentity: { sender },
|
||||||
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||||
sendEvent: probe.record(event:),
|
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
|
||||||
scheduleAfter: { delay, action in
|
scheduleAfter: { delay, action in
|
||||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||||
}
|
}
|
||||||
@@ -338,20 +918,20 @@ struct NostrTransportTests {
|
|||||||
transport.sendReadReceipt(second, to: fullPeerID)
|
transport.sendReadReceipt(second, to: fullPeerID)
|
||||||
|
|
||||||
let readReceiptTimeout: TimeInterval = 5.0
|
let readReceiptTimeout: TimeInterval = 5.0
|
||||||
let sentFirst = await TestHelpers.waitUntil({ probe.sentEvents.count >= 1 }, timeout: readReceiptTimeout)
|
let sentFirst = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: readReceiptTimeout)
|
||||||
try #require(sentFirst, "Expected first queued read receipt event")
|
try #require(sentFirst, "Expected first queued read receipt pair")
|
||||||
let scheduledThrottle = await TestHelpers.waitUntil({ probe.scheduledActionCount == 1 }, timeout: readReceiptTimeout)
|
let scheduledThrottle = await TestHelpers.waitUntil({ probe.scheduledActionCount == 1 }, timeout: readReceiptTimeout)
|
||||||
try #require(scheduledThrottle, "Expected queued throttle action after first read receipt")
|
try #require(scheduledThrottle, "Expected queued throttle action after first read receipt")
|
||||||
let firstEvent = try #require(probe.sentEvents.first, "Expected first queued read receipt event")
|
let firstEvent = try #require(probe.sentEvents.first, "Expected first queued read receipt pair")
|
||||||
let firstPayload = try decodeEmbeddedPayload(from: firstEvent, recipient: recipient).payload
|
let firstPayload = try decodeEmbeddedPayload(from: firstEvent, recipient: recipient).payload
|
||||||
#expect(firstPayload.type == .readReceipt)
|
#expect(firstPayload.type == .readReceipt)
|
||||||
#expect(String(data: firstPayload.data, encoding: .utf8) == "read-1")
|
#expect(String(data: firstPayload.data, encoding: .utf8) == "read-1")
|
||||||
|
|
||||||
try #require(probe.runNextScheduledAction(), "Expected queued throttle action after first read receipt")
|
try #require(probe.runNextScheduledAction(), "Expected queued throttle action after first read receipt")
|
||||||
|
|
||||||
let sentSecond = await TestHelpers.waitUntil({ probe.sentEvents.count >= 2 }, timeout: readReceiptTimeout)
|
let sentSecond = await TestHelpers.waitUntil({ probe.sentEvents.count == 4 }, timeout: readReceiptTimeout)
|
||||||
try #require(sentSecond, "Expected second read receipt after running throttle action")
|
try #require(sentSecond, "Expected second read receipt pair after running throttle action")
|
||||||
let secondEvent = try #require(probe.sentEvents.last, "Expected second queued read receipt event")
|
let secondEvent = probe.sentEvents[2]
|
||||||
let secondPayload = try decodeEmbeddedPayload(from: secondEvent, recipient: recipient).payload
|
let secondPayload = try decodeEmbeddedPayload(from: secondEvent, recipient: recipient).payload
|
||||||
#expect(secondPayload.type == .readReceipt)
|
#expect(secondPayload.type == .readReceipt)
|
||||||
#expect(String(data: secondPayload.data, encoding: .utf8) == "read-2")
|
#expect(String(data: secondPayload.data, encoding: .utf8) == "read-2")
|
||||||
@@ -419,10 +999,14 @@ struct NostrTransportTests {
|
|||||||
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoriteRelationship? = { _ in nil },
|
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoriteRelationship? = { _ in nil },
|
||||||
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoriteRelationship? = { _ in nil },
|
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoriteRelationship? = { _ in nil },
|
||||||
currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil },
|
currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil },
|
||||||
registerPendingGiftWrap: @escaping @MainActor (String) -> Void = { _ in },
|
registerPendingPrivateEnvelope: @escaping @MainActor (String) -> Void = { _ in },
|
||||||
sendEvent: @escaping @MainActor (NostrEvent) -> Void = { _ in },
|
sendPrivateEnvelopeBatch: @escaping @MainActor (
|
||||||
|
[NostrEvent],
|
||||||
|
@escaping @MainActor () -> Void
|
||||||
|
) -> Bool = { _, _ in true },
|
||||||
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() }
|
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never> = { Just(false).eraseToAnyPublisher() },
|
||||||
|
envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue? = nil
|
||||||
) -> NostrTransport.Dependencies {
|
) -> NostrTransport.Dependencies {
|
||||||
NostrTransport.Dependencies(
|
NostrTransport.Dependencies(
|
||||||
notificationCenter: notificationCenter,
|
notificationCenter: notificationCenter,
|
||||||
@@ -430,10 +1014,11 @@ struct NostrTransportTests {
|
|||||||
favoriteStatusForNoiseKey: favoriteStatusForNoiseKey,
|
favoriteStatusForNoiseKey: favoriteStatusForNoiseKey,
|
||||||
favoriteStatusForPeerID: favoriteStatusForPeerID,
|
favoriteStatusForPeerID: favoriteStatusForPeerID,
|
||||||
currentIdentity: currentIdentity,
|
currentIdentity: currentIdentity,
|
||||||
registerPendingGiftWrap: registerPendingGiftWrap,
|
registerPendingPrivateEnvelope: registerPendingPrivateEnvelope,
|
||||||
sendEvent: sendEvent,
|
sendPrivateEnvelopeBatch: sendPrivateEnvelopeBatch,
|
||||||
scheduleAfter: scheduleAfter,
|
scheduleAfter: scheduleAfter,
|
||||||
relayConnectivity: relayConnectivity
|
relayConnectivity: relayConnectivity,
|
||||||
|
envelopeRetryQueue: envelopeRetryQueue
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,8 +1042,8 @@ struct NostrTransportTests {
|
|||||||
from event: NostrEvent,
|
from event: NostrEvent,
|
||||||
recipient: NostrIdentity
|
recipient: NostrIdentity
|
||||||
) throws -> (packet: BitchatPacket, payload: NoisePayload, senderPubkey: String) {
|
) throws -> (packet: BitchatPacket, payload: NoisePayload, senderPubkey: String) {
|
||||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
giftWrap: event,
|
envelope: event,
|
||||||
recipientIdentity: recipient
|
recipientIdentity: recipient
|
||||||
)
|
)
|
||||||
guard content.hasPrefix("bitchat1:") else {
|
guard content.hasPrefix("bitchat1:") else {
|
||||||
@@ -488,6 +1073,17 @@ private enum NostrTransportTestError: Error {
|
|||||||
case invalidPrivateMessage
|
case invalidPrivateMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class NostrTransportEventProbe: TransportEventDelegate {
|
||||||
|
private(set) var failedMessageIDs: [String] = []
|
||||||
|
|
||||||
|
func didReceiveTransportEvent(_ event: TransportEvent) {
|
||||||
|
guard case .messageDeliveryStatusUpdated(let messageID, let status) = event,
|
||||||
|
case .failed = status else { return }
|
||||||
|
failedMessageIDs.append(messageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func base64URLDecode(_ string: String) -> Data? {
|
private func base64URLDecode(_ string: String) -> Data? {
|
||||||
var candidate = string
|
var candidate = string
|
||||||
let padding = (4 - (candidate.count % 4)) % 4
|
let padding = (4 - (candidate.count % 4)) % 4
|
||||||
@@ -503,7 +1099,7 @@ private func base64URLDecode(_ string: String) -> Data? {
|
|||||||
private final class NostrTransportProbe: @unchecked Sendable {
|
private final class NostrTransportProbe: @unchecked Sendable {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var sentEventsStorage: [NostrEvent] = []
|
private var sentEventsStorage: [NostrEvent] = []
|
||||||
private var pendingGiftWrapIDsStorage: [String] = []
|
private var pendingPrivateEnvelopeIDsStorage: [String] = []
|
||||||
private var scheduledActionsStorage: [(@Sendable () -> Void)] = []
|
private var scheduledActionsStorage: [(@Sendable () -> Void)] = []
|
||||||
|
|
||||||
var sentEvents: [NostrEvent] {
|
var sentEvents: [NostrEvent] {
|
||||||
@@ -512,10 +1108,10 @@ private final class NostrTransportProbe: @unchecked Sendable {
|
|||||||
return sentEventsStorage
|
return sentEventsStorage
|
||||||
}
|
}
|
||||||
|
|
||||||
var pendingGiftWrapIDs: [String] {
|
var pendingPrivateEnvelopeIDs: [String] {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
return pendingGiftWrapIDsStorage
|
return pendingPrivateEnvelopeIDsStorage
|
||||||
}
|
}
|
||||||
|
|
||||||
var scheduledActionCount: Int {
|
var scheduledActionCount: Int {
|
||||||
@@ -524,15 +1120,16 @@ private final class NostrTransportProbe: @unchecked Sendable {
|
|||||||
return scheduledActionsStorage.count
|
return scheduledActionsStorage.count
|
||||||
}
|
}
|
||||||
|
|
||||||
func record(event: NostrEvent) {
|
func record(batch: [NostrEvent]) -> Bool {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
sentEventsStorage.append(event)
|
sentEventsStorage.append(contentsOf: batch)
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func recordPendingGiftWrap(id: String) {
|
func recordPendingPrivateEnvelope(id: String) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
pendingGiftWrapIDsStorage.append(id)
|
pendingPrivateEnvelopeIDsStorage.append(id)
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -551,6 +551,89 @@ struct ViewSmokeTests {
|
|||||||
#expect(featureModels.privateConversationModel.selectedHeaderState?.headerPeerID == peerID)
|
#expect(featureModels.privateConversationModel.selectedHeaderState?.headerPeerID == peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Root Bluetooth alert waits for location and notices sheets")
|
||||||
|
func rootBluetoothAlertGuard_includesHeaderSheets() {
|
||||||
|
#expect(!ContentRootModalPresentationState().hasPresentation)
|
||||||
|
#expect(
|
||||||
|
ContentRootModalPresentationState(
|
||||||
|
isLocationChannelsSheetPresented: true
|
||||||
|
).hasPresentation
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
ContentRootModalPresentationState(
|
||||||
|
isNoticesSheetPresented: true
|
||||||
|
).hasPresentation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("People-sheet Bluetooth alert waits for local verification sheet")
|
||||||
|
func peopleSheetBluetoothAlertGuard_includesVerificationSheet() {
|
||||||
|
#expect(!ContentPeopleSheetModalPresentationState().hasPresentation)
|
||||||
|
#expect(
|
||||||
|
ContentPeopleSheetModalPresentationState(
|
||||||
|
isVerificationSheetPresented: true
|
||||||
|
).hasPresentation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Root Bluetooth alert waits for screenshot privacy alert")
|
||||||
|
@MainActor
|
||||||
|
func rootBluetoothAlertGuard_tracksScreenshotPrivacyState() {
|
||||||
|
let (viewModel, _, _) = makeSmokeViewModel()
|
||||||
|
let featureModels = makeSmokeFeatureModels(for: viewModel)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
!ContentRootModalPresentationState(
|
||||||
|
appChromeModel: featureModels.appChromeModel
|
||||||
|
).hasPresentation
|
||||||
|
)
|
||||||
|
|
||||||
|
featureModels.appChromeModel.showScreenshotPrivacyWarning = true
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
ContentRootModalPresentationState(
|
||||||
|
appChromeModel: featureModels.appChromeModel
|
||||||
|
).hasPresentation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("People-sheet Bluetooth alert waits for legacy media consent")
|
||||||
|
@MainActor
|
||||||
|
func peopleSheetBluetoothAlertGuard_tracksLegacyConsentState() async {
|
||||||
|
let (viewModel, _, _) = makeSmokeViewModel()
|
||||||
|
let featureModels = makeSmokeFeatureModels(for: viewModel)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
!ContentPeopleSheetModalPresentationState(
|
||||||
|
legacyPrivateMediaConsentRequest:
|
||||||
|
featureModels.conversationUIModel
|
||||||
|
.legacyPrivateMediaConsentRequest
|
||||||
|
).hasPresentation
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.enqueueLegacyPrivateMediaConsent(
|
||||||
|
for: PeerID(str: "5152535455565758"),
|
||||||
|
transferId: "legacy-consent-transfer",
|
||||||
|
messageID: "legacy-consent-message"
|
||||||
|
) { _ in }
|
||||||
|
defer { viewModel.cancelAllLegacyPrivateMediaConsents() }
|
||||||
|
|
||||||
|
let consentPropagated = await TestHelpers.waitUntil {
|
||||||
|
featureModels.conversationUIModel
|
||||||
|
.legacyPrivateMediaConsentRequest != nil
|
||||||
|
}
|
||||||
|
#expect(
|
||||||
|
consentPropagated
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
ContentPeopleSheetModalPresentationState(
|
||||||
|
legacyPrivateMediaConsentRequest:
|
||||||
|
featureModels.conversationUIModel
|
||||||
|
.legacyPrivateMediaConsentRequest
|
||||||
|
).hasPresentation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func geohashAndTextMessageViews_renderCoreBranches() {
|
func geohashAndTextMessageViews_renderCoreBranches() {
|
||||||
let (viewModel, _, _) = makeSmokeViewModel()
|
let (viewModel, _, _) = makeSmokeViewModel()
|
||||||
|
|||||||
@@ -13,17 +13,16 @@ import Testing
|
|||||||
private final class VoiceRecorderTestSession: SessionApplying, @unchecked Sendable {
|
private final class VoiceRecorderTestSession: SessionApplying, @unchecked Sendable {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private let activationGate = DispatchSemaphore(value: 0)
|
private let activationGate = DispatchSemaphore(value: 0)
|
||||||
|
private let activationBeganGate = DispatchSemaphore(value: 0)
|
||||||
private let shouldGateFirstActivation: Bool
|
private let shouldGateFirstActivation: Bool
|
||||||
private var gatedFirstActivation = false
|
private var gatedFirstActivation = false
|
||||||
private var _activationCalls: [Bool] = []
|
private var _activationCalls: [Bool] = []
|
||||||
private var _activationBegan = false
|
|
||||||
|
|
||||||
init(gateFirstActivation: Bool = false) {
|
init(gateFirstActivation: Bool = false) {
|
||||||
self.shouldGateFirstActivation = gateFirstActivation
|
self.shouldGateFirstActivation = gateFirstActivation
|
||||||
}
|
}
|
||||||
|
|
||||||
var activationCalls: [Bool] { lock.withLock { _activationCalls } }
|
var activationCalls: [Bool] { lock.withLock { _activationCalls } }
|
||||||
var activationBegan: Bool { lock.withLock { _activationBegan } }
|
|
||||||
|
|
||||||
func setCategory(_ category: AudioSessionCoordinator.Category) throws {}
|
func setCategory(_ category: AudioSessionCoordinator.Category) throws {}
|
||||||
|
|
||||||
@@ -32,14 +31,28 @@ private final class VoiceRecorderTestSession: SessionApplying, @unchecked Sendab
|
|||||||
_activationCalls.append(active)
|
_activationCalls.append(active)
|
||||||
guard active, shouldGateFirstActivation, !gatedFirstActivation else { return false }
|
guard active, shouldGateFirstActivation, !gatedFirstActivation else { return false }
|
||||||
gatedFirstActivation = true
|
gatedFirstActivation = true
|
||||||
_activationBegan = true
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if shouldWait {
|
if shouldWait {
|
||||||
|
activationBeganGate.signal()
|
||||||
activationGate.wait()
|
activationGate.wait()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func waitUntilActivationBegan(
|
||||||
|
timeout: DispatchTimeInterval = .seconds(5)
|
||||||
|
) async -> Bool {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
continuation.resume(
|
||||||
|
returning: self.activationBeganGate.wait(
|
||||||
|
timeout: DispatchTime.now() + timeout
|
||||||
|
) == .success
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func resumeActivation() {
|
func resumeActivation() {
|
||||||
activationGate.signal()
|
activationGate.signal()
|
||||||
}
|
}
|
||||||
@@ -142,26 +155,38 @@ private final class TestVoiceAudioRecorderFactory: VoiceAudioRecorderCreating {
|
|||||||
/// this remains deterministic when the full test suite saturates the executor.
|
/// this remains deterministic when the full test suite saturates the executor.
|
||||||
private final class VoiceRecorderPaddingGate: @unchecked Sendable {
|
private final class VoiceRecorderPaddingGate: @unchecked Sendable {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var _entered = false
|
private let enteredGate = DispatchSemaphore(value: 0)
|
||||||
private var isOpen = false
|
private var isOpen = false
|
||||||
private var openWaiters: [CheckedContinuation<Void, Never>] = []
|
private var openWaiters: [CheckedContinuation<Void, Never>] = []
|
||||||
|
|
||||||
var entered: Bool { lock.withLock { _entered } }
|
|
||||||
|
|
||||||
func wait() async {
|
func wait() async {
|
||||||
await withCheckedContinuation { continuation in
|
await withCheckedContinuation { continuation in
|
||||||
let resumeImmediately = lock.withLock { () -> Bool in
|
let resumeImmediately = lock.withLock { () -> Bool in
|
||||||
_entered = true
|
|
||||||
guard !isOpen else { return true }
|
guard !isOpen else { return true }
|
||||||
openWaiters.append(continuation)
|
openWaiters.append(continuation)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
enteredGate.signal()
|
||||||
if resumeImmediately {
|
if resumeImmediately {
|
||||||
continuation.resume()
|
continuation.resume()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func waitUntilEntered(
|
||||||
|
timeout: DispatchTimeInterval = .seconds(5)
|
||||||
|
) async -> Bool {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
continuation.resume(
|
||||||
|
returning: self.enteredGate.wait(
|
||||||
|
timeout: DispatchTime.now() + timeout
|
||||||
|
) == .success
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func open() {
|
func open() {
|
||||||
let waiters = lock.withLock { () -> [CheckedContinuation<Void, Never>] in
|
let waiters = lock.withLock { () -> [CheckedContinuation<Void, Never>] in
|
||||||
isOpen = true
|
isOpen = true
|
||||||
@@ -181,18 +206,6 @@ struct VoiceRecorderTests {
|
|||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
private func waitUntil(
|
|
||||||
_ condition: () -> Bool,
|
|
||||||
sourceLocation: SourceLocation = #_sourceLocation
|
|
||||||
) async {
|
|
||||||
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
|
|
||||||
while !condition(), ContinuousClock.now < deadline {
|
|
||||||
await Task.yield()
|
|
||||||
try? await Task.sleep(nanoseconds: 1_000_000)
|
|
||||||
}
|
|
||||||
#expect(condition(), sourceLocation: sourceLocation)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func cancelWhileSessionAcquireIsInFlightNeverCreatesARecorder() async throws {
|
@Test func cancelWhileSessionAcquireIsInFlightNeverCreatesARecorder() async throws {
|
||||||
let directory = try makeTemporaryDirectory()
|
let directory = try makeTemporaryDirectory()
|
||||||
defer { try? FileManager.default.removeItem(at: directory) }
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
@@ -210,7 +223,7 @@ struct VoiceRecorderTests {
|
|||||||
let owner = VoiceRecorder.RecordingOwner()
|
let owner = VoiceRecorder.RecordingOwner()
|
||||||
|
|
||||||
let startTask = Task { try await voiceRecorder.startRecording(owner: owner) }
|
let startTask = Task { try await voiceRecorder.startRecording(owner: owner) }
|
||||||
await waitUntil { session.activationBegan }
|
#expect(await session.waitUntilActivationBegan())
|
||||||
|
|
||||||
await voiceRecorder.cancelRecording(owner: owner)
|
await voiceRecorder.cancelRecording(owner: owner)
|
||||||
session.resumeActivation()
|
session.resumeActivation()
|
||||||
@@ -308,7 +321,7 @@ struct VoiceRecorderTests {
|
|||||||
try await finishingHold.start()
|
try await finishingHold.start()
|
||||||
let firstURL = try #require(factory.urls.first)
|
let firstURL = try #require(factory.urls.first)
|
||||||
let finishTask = Task { await finishingHold.finish() }
|
let finishTask = Task { await finishingHold.finish() }
|
||||||
await waitUntil { paddingGate.entered }
|
#expect(await paddingGate.waitUntilEntered())
|
||||||
|
|
||||||
await #expect(throws: VoiceRecorder.RecorderError.recordingInProgress) {
|
await #expect(throws: VoiceRecorder.RecorderError.recordingInProgress) {
|
||||||
try await rejectedHold.start()
|
try await rejectedHold.start()
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ peer's Noise session before BLE fragmentation.
|
|||||||
`0x21`. A valid bit-8 proof selects Noise `0x20`; a valid no-bit proof or a
|
`0x21`. A valid bit-8 proof selects Noise `0x20`; a valid no-bit proof or a
|
||||||
no-proof timeout reaches the explicit legacy-consent path for an unpinned
|
no-proof timeout reaches the explicit legacy-consent path for an unpinned
|
||||||
peer. No timeout automatically sends raw bytes.
|
peer. No timeout automatically sends raw bytes.
|
||||||
|
- `PeerCapabilities.privateMediaReceipts` is bit 9. Its exact-session
|
||||||
|
authenticated proof enables bounded sender-side automatic retry; a public
|
||||||
|
announce never does. It does not replace bit 8: encrypted `0x20` media from
|
||||||
|
bit-8-only prior iOS clients keeps the same deterministic stable ID and
|
||||||
|
delivery ACK. Receivers durably commit that ID before UI delivery or ACK, so
|
||||||
|
a lost proof followed by a later bit-9 retry cannot create a second,
|
||||||
|
random-ID bubble.
|
||||||
- An unpinned peer with a stable Noise key but without that capability is
|
- An unpinned peer with a stable Noise key but without that capability is
|
||||||
eligible for one signed, directed
|
eligible for one signed, directed
|
||||||
`fileTransfer`, matching the pre-migration wire form used by older iOS and
|
`fileTransfer`, matching the pre-migration wire form used by older iOS and
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ Public archives contain content already intended for public mesh/board distribut
|
|||||||
|
|
||||||
## Nostr and Mesh Bridge
|
## Nostr and Mesh Bridge
|
||||||
|
|
||||||
- NIP-17/NIP-44 v2 private fallback protects plaintext with secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305. Relays still see event and network metadata.
|
- BitChat's proprietary private-envelope fallback protects plaintext with secp256k1 key agreement, HKDF-SHA256 with a BitChat-specific domain separator, and XChaCha20-Poly1305. It is not NIP-17, NIP-44, or NIP-59 compatible and does not provide forward secrecy against later compromise of the recipient's static Nostr private key. Relays still see the recipient public-key tag, event timing and size, and network metadata.
|
||||||
- Bridge courier drops use a throwaway publisher key, an opaque Noise-sealed envelope, and a day-rotating recipient tag. Only a party already holding the recipient's Noise static key can compute candidate tags.
|
- Bridge courier drops use a throwaway publisher key, an opaque Noise-sealed envelope, and a day-rotating recipient tag. Only a party already holding the recipient's Noise static key can compute candidate tags.
|
||||||
- Relay publication is considered successful only after an explicit NIP-20 `OK true` from at least one target relay. Rejected, disconnected, timed-out, or merely socket-written events stay retryable.
|
- Relay publication is considered successful only after an explicit NIP-01 `OK true` from at least one target relay. Rejected, disconnected, timed-out, or merely socket-written events stay retryable.
|
||||||
- When mesh bridge is enabled, public mesh messages not marked “nearby only” are signed under a per-cell Nostr identity and published to a neighborhood rendezvous geohash. Presence and public bridge traffic therefore expose a coarse area to relays and participants.
|
- When mesh bridge is enabled, public mesh messages not marked “nearby only” are signed under a per-cell Nostr identity and published to a neighborhood rendezvous geohash. Presence and public bridge traffic therefore expose a coarse area to relays and participants.
|
||||||
- A bridge gateway can carry signed bridge/location events and opaque courier drops for nearby mesh-only peers. It cannot validly publish a neighbor's radio-only message because the author must first sign the bridge event.
|
- A bridge gateway can carry signed bridge/location events and opaque courier drops for nearby mesh-only peers. It cannot validly publish a neighbor's radio-only message because the author must first sign the bridge event.
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable {
|
|||||||
/// before outer BLE fragmentation. Peers that omit this bit require the
|
/// before outer BLE fragmentation. Peers that omit this bit require the
|
||||||
/// signed directed raw-file migration fallback.
|
/// signed directed raw-file migration fallback.
|
||||||
public static let privateMedia = PeerCapabilities(rawValue: 1 << 8)
|
public static let privateMedia = PeerCapabilities(rawValue: 1 << 8)
|
||||||
|
/// Stable private-media IDs are durably deduplicated by the receiver and
|
||||||
|
/// correlated delivery/read receipts permit bounded automatic resend.
|
||||||
|
///
|
||||||
|
/// Bit 8 remains the encrypted-media compatibility contract. Bit 9 only
|
||||||
|
/// enables sender-side automatic retry after exact-session proof.
|
||||||
|
public static let privateMediaReceipts =
|
||||||
|
PeerCapabilities(rawValue: 1 << 9)
|
||||||
/// Reserved for test builds that briefly advertised non-destructive Noise
|
/// Reserved for test builds that briefly advertised non-destructive Noise
|
||||||
/// replacement. Current clients intentionally do not advertise or act on
|
/// replacement. Current clients intentionally do not advertise or act on
|
||||||
/// this bit; keep it decodable so the wire assignment is never reused.
|
/// this bit; keep it decodable so the wire assignment is never reused.
|
||||||
|
|||||||
@@ -18,14 +18,30 @@ struct PeerCapabilitiesTests {
|
|||||||
#expect(PeerCapabilities.meshDiagnostics.encoded() == Data([0x40]))
|
#expect(PeerCapabilities.meshDiagnostics.encoded() == Data([0x40]))
|
||||||
#expect(PeerCapabilities.privateMedia.encoded() == Data([0x00, 0x01]))
|
#expect(PeerCapabilities.privateMedia.encoded() == Data([0x00, 0x01]))
|
||||||
|
|
||||||
let high = PeerCapabilities(rawValue: 1 << 9)
|
#expect(
|
||||||
#expect(high.encoded() == Data([0x00, 0x02]))
|
PeerCapabilities.privateMediaReceipts.encoded()
|
||||||
|
== Data([0x00, 0x02])
|
||||||
|
)
|
||||||
#expect(
|
#expect(
|
||||||
PeerCapabilities.nonDestructiveNoiseReplacement.encoded()
|
PeerCapabilities.nonDestructiveNoiseReplacement.encoded()
|
||||||
== Data([0x00, 0x04])
|
== Data([0x00, 0x04])
|
||||||
)
|
)
|
||||||
|
|
||||||
let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics, .privateMedia]
|
let high = PeerCapabilities(rawValue: 1 << 11)
|
||||||
|
#expect(high.encoded() == Data([0x00, 0x08]))
|
||||||
|
|
||||||
|
let all: PeerCapabilities = [
|
||||||
|
.prekeys,
|
||||||
|
.wifiBulk,
|
||||||
|
.gateway,
|
||||||
|
.groups,
|
||||||
|
.board,
|
||||||
|
.vouch,
|
||||||
|
.meshDiagnostics,
|
||||||
|
.privateMedia,
|
||||||
|
.privateMediaReceipts,
|
||||||
|
.nonDestructiveNoiseReplacement
|
||||||
|
]
|
||||||
#expect(PeerCapabilities(encoded: all.encoded()) == all)
|
#expect(PeerCapabilities(encoded: all.encoded()) == all)
|
||||||
#expect(PeerCapabilities(encoded: high.encoded()) == high)
|
#expect(PeerCapabilities(encoded: high.encoded()) == high)
|
||||||
#expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == [])
|
#expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == [])
|
||||||
|
|||||||
Reference in New Issue
Block a user