Compare commits

..
46 changed files with 7363 additions and 801 deletions
+2 -2
View File
@@ -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
View File
@@ -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")
] ]
) )
] ]
+44 -3
View File
@@ -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
View File
@@ -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.25.3) means compromise of a recipient's static key can expose retained ciphertext addressed to that key.
## 9. Future Work ## 9. Future Work
+4 -4
View File
@@ -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)
+2 -1
View File
@@ -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
+425 -211
View File
@@ -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
@@ -26,144 +40,289 @@ struct NostrProtocol {
case courierDrop = 1401 case courierDrop = 1401
} }
/// Create a NIP-17 private message /// Prefix for BitChat private-envelope ciphertext. The suffix is
static func createPrivateMessage( /// 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 BitChat private envelope for relay transport.
static func createPrivateEnvelope(
content: String, content: String,
recipientPubkey: String, recipientPubkey: String,
senderIdentity: NostrIdentity senderIdentity: NostrIdentity
) throws -> NostrEvent { ) throws -> NostrEvent {
try createPrivateEnvelope(
content: content,
recipientPubkey: recipientPubkey,
senderIdentity: senderIdentity,
format: .bitchatV1
)
}
// Creating private message /// 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]
}
// 1. Create the rumor (unsigned event) private static func createPrivateEnvelope(
let rumor = NostrEvent( 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
do {
seal = try unwrapGiftWrap(
giftWrap: giftWrap,
recipientKey: recipientIdentity.schnorrSigningKey()
) )
// Successfully unwrapped gift wrap return (
} catch { content: layers.message.content,
SecureLogger.error("❌ Failed to unwrap gift wrap: \(error)", category: .session) senderPubkey: layers.seal.pubkey,
throw error timestamp: layers.message.created_at
}
// 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( private static func createPrivateEnvelopeEvent(
seal: NostrEvent, seal: NostrEvent,
recipientPubkey: String recipientPubkey: String,
format: PrivateEnvelopeWireFormat
) throws -> NostrEvent { ) throws -> NostrEvent {
// A fresh signing/encryption key for every public envelope keeps the
let sealJSON = try seal.jsonString() // stable sender identity inside ciphertext.
let envelopeKey = try P256K.Schnorr.PrivateKey()
// Create new ephemeral key for gift wrap
let wrapKey = try P256K.Schnorr.PrivateKey()
// Creating gift wrap with ephemeral key
// Encrypt the seal with the new ephemeral key (not the seal's key)
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,
maximumPlaintextBytes: maximumPrivateEnvelopeSealPlaintextBytes
)
let seal = try decodePrivateEnvelopeEventJSON(
sealJSON,
maximumBytes: maximumPrivateEnvelopeSealPlaintextBytes
)
guard seal.kind == format.sealKind.rawValue,
seal.tags.isEmpty,
seal.isValidSignature() else {
throw NostrError.invalidEvent
} }
private static func openSeal( let messageJSON = try decrypt(
seal: NostrEvent,
recipientKey: P256K.Schnorr.PrivateKey
) throws -> NostrEvent {
let decrypted = 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(
messageJSON,
maximumBytes: maximumPrivateEnvelopePlaintextBytes
) )
guard let data = decrypted.data(using: .utf8), // The inner message is intentionally unsigned; sender authentication
let rumorDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { // 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 {
throw NostrError.cryptographicFailure
}
let pt = Data(plaintext.utf8) let plaintextData = Data(plaintext.utf8)
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24) guard plaintextData.count <= maximumPlaintextBytes else {
throw NostrError.invalidCiphertext
}
let sealed = try XChaCha20Poly1305Compat.seal(
plaintext: plaintextData,
key: key,
nonce24: nonce24
)
// v2: base64url(nonce24 || ciphertext || tag)
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
}
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) let oddKey = Data([0x03]) + senderPubkeyData
return String(data: pt, encoding: .utf8) ?? "" plaintext = try attemptDecrypt(using: oddKey)
} }
} else {
plaintext = try attemptDecrypt(using: senderPubkeyData)
}
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
@@ -40,6 +40,9 @@ struct BLEFileTransferHandlerEnvironment {
let commitPrivateMediaFile: (_ messageID: String, _ storedURL: URL) -> Bool let commitPrivateMediaFile: (_ messageID: String, _ storedURL: URL) -> Bool
/// Rolls back a saved payload when its durable receipt commit fails. /// Rolls back a saved payload when its durable receipt commit fails.
let removeIncomingFile: (_ storedURL: URL) -> Void 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. /// Checks the authenticated sender before any private-media disk work.
let isPrivateMediaSenderBlocked: (PeerID) -> Bool 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).
@@ -49,11 +52,14 @@ struct BLEFileTransferHandlerEnvironment {
let acknowledgePrivateMedia: (_ messageID: String, _ peerID: PeerID) -> Void let acknowledgePrivateMedia: (_ messageID: String, _ peerID: PeerID) -> Void
/// Delivers `.messageReceived` as one main-actor hop while /// Delivers `.messageReceived` as one main-actor hop while
/// `shouldDeliver` remains true before and after the synchronous sink. /// `shouldDeliver` remains true before and after the synchronous sink.
/// The completion authorizes the stable-media ACK. /// The completion authorizes the stable-media ACK. Finalization runs after
/// every delivery attempt, including rejection, so allocator ownership
/// cannot leak indefinitely.
let deliverMessage: ( let deliverMessage: (
_ message: BitchatMessage, _ message: BitchatMessage,
_ shouldDeliver: @escaping () -> Bool, _ shouldDeliver: @escaping () -> Bool,
_ completion: @escaping () -> Void _ completion: @escaping () -> Void,
_ finalization: @escaping (TransportEventDeliveryOutcome) -> Void
) -> Void ) -> Void
} }
@@ -357,7 +363,24 @@ final class BLEFileTransferHandler {
env: env env: env
) )
} else { } else {
env.deliverMessage(message, { true }, {}) 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 return true
} }
@@ -381,6 +404,9 @@ final class BLEFileTransferHandler {
}, },
{ {
env.acknowledgePrivateMedia(messageID, peerID) env.acknowledgePrivateMedia(messageID, peerID)
},
{ _ in
env.finishIncomingFileDelivery(expectedURL)
} }
) )
} }
+160 -15
View File
@@ -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,7 +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 privateMediaReceipts: BLEPrivateMediaReceiptStore
private let payloadCoordination: PayloadCoordination
init( init(
fileManager: FileManager = .default, fileManager: FileManager = .default,
@@ -142,17 +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( self.privateMediaReceipts = BLEPrivateMediaReceiptStore(
fileManager: fileManager, fileManager: fileManager,
baseDirectory: baseDirectory, baseDirectory: baseDirectory,
now: dateProvider 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
@@ -165,10 +179,21 @@ struct BLEIncomingFileStore {
func panicWipe( func panicWipe(
hasDurablePendingMarker: Bool = false hasDurablePendingMarker: Bool = false
) throws { ) throws {
// The receipt index caches tombstones as well as accepted payloads. // The receipt index caches tombstones as well as accepted payloads,
// Always invalidate it on return, including partial-failure paths, so // while payload coordination retains save/delete reservations. Always
// no pre-panic receiver decision survives after identity reset. // invalidate both on return, including partial-failure paths, so no
defer { privateMediaReceipts.resetForPanic() } // 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 {
@@ -251,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)
@@ -259,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)
@@ -284,8 +330,75 @@ struct BLEIncomingFileStore {
) )
} }
/// 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. /// Best-effort rollback for a payload whose durable receipt commit failed.
func removeIncomingFile(at storedURL: URL) { func removeIncomingFile(at storedURL: URL) {
payloadCoordination.lock.lock()
defer { payloadCoordination.lock.unlock() }
payloadCoordination.pendingDeliveryPaths.remove(
storedURL.standardizedFileURL.path
)
guard isURLInsideFilesDirectory(storedURL) else { return } guard isURLInsideFilesDirectory(storedURL) else { return }
do { do {
try fileManager.removeItem(at: storedURL) try fileManager.removeItem(at: storedURL)
@@ -303,6 +416,9 @@ struct BLEIncomingFileStore {
/// 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 = [
@@ -328,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
@@ -423,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 {
@@ -435,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
} }
} }
@@ -23,7 +23,15 @@ enum BLEPrivateMediaReceiptState: Equatable {
final class BLEPrivateMediaReceiptStore: @unchecked Sendable { final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
typealias DirectoryReader = (_ directory: URL) throws -> [URL] typealias DirectoryReader = (_ directory: URL) throws -> [URL]
typealias DataReader = (_ url: URL) throws -> Data typealias DataReader = (_ url: URL) throws -> Data
typealias DataWriter = (
_ data: Data,
_ url: URL,
_ options: Data.WritingOptions
) throws -> Void
typealias PayloadRemover = (_ url: URL) throws -> Void
private static let receiptDirectoryName = ".private-media-receipts" private static let receiptDirectoryName = ".private-media-receipts"
private static let deletionJournalFileName = ".deletion-journal.json"
private static let maximumDeletionPathsPerMessage = 2
private struct ReceiptRecord: Codable, Equatable { private struct ReceiptRecord: Codable, Equatable {
enum Kind: String, Codable { enum Kind: String, Codable {
@@ -38,10 +46,23 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
let recordedAt: Date let recordedAt: Date
} }
/// One atomic write of this journal is the commit point for an entire
/// explicit-deletion batch. Per-ID records and payload unlinks are
/// idempotent materialization performed only after that commit.
private struct DeletionJournalEntry: Codable, Equatable {
let relativePaths: [String]
let recordedAt: Date
}
private struct DeletionJournal: Codable {
let version: Int
let entries: [String: DeletionJournalEntry]
}
private final class Runtime: @unchecked Sendable { private final class Runtime: @unchecked Sendable {
let lock = NSLock() let lock = NSLock()
var records: [String: ReceiptRecord]? var records: [String: ReceiptRecord]?
var volatileTombstones: [String: Date] = [:] var deletionJournal: [String: DeletionJournalEntry]?
} }
private let fileManager: FileManager private let fileManager: FileManager
@@ -51,6 +72,8 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
private let now: () -> Date private let now: () -> Date
private let directoryReader: DirectoryReader? private let directoryReader: DirectoryReader?
private let dataReader: DataReader? private let dataReader: DataReader?
private let dataWriter: DataWriter
private let payloadRemover: PayloadRemover
private let runtime = Runtime() private let runtime = Runtime()
init( init(
@@ -60,7 +83,11 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
ttl: TimeInterval = TransportConfig.privateMediaReceivedLedgerTTLSeconds, ttl: TimeInterval = TransportConfig.privateMediaReceivedLedgerTTLSeconds,
now: @escaping () -> Date = Date.init, now: @escaping () -> Date = Date.init,
directoryReader: DirectoryReader? = nil, directoryReader: DirectoryReader? = nil,
dataReader: DataReader? = nil dataReader: DataReader? = nil,
dataWriter: @escaping DataWriter = {
try $0.write(to: $1, options: $2)
},
payloadRemover: PayloadRemover? = nil
) { ) {
self.fileManager = fileManager self.fileManager = fileManager
self.baseDirectory = baseDirectory self.baseDirectory = baseDirectory
@@ -69,6 +96,10 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
self.now = now self.now = now
self.directoryReader = directoryReader self.directoryReader = directoryReader
self.dataReader = dataReader self.dataReader = dataReader
self.dataWriter = dataWriter
self.payloadRemover = payloadRemover ?? {
try fileManager.removeItem(at: $0)
}
} }
/// Drops process-lifetime decisions after the enclosing media directory /// Drops process-lifetime decisions after the enclosing media directory
@@ -78,7 +109,7 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
func resetForPanic() { func resetForPanic() {
runtime.lock.lock() runtime.lock.lock()
runtime.records = nil runtime.records = nil
runtime.volatileTombstones.removeAll(keepingCapacity: false) runtime.deletionJournal = nil
runtime.lock.unlock() runtime.lock.unlock()
} }
@@ -91,29 +122,52 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
defer { runtime.lock.unlock() } defer { runtime.lock.unlock() }
let date = now() let date = now()
if let tombstonedAt = runtime.volatileTombstones[messageID] {
if !isExpired(tombstonedAt, at: date) {
return .tombstoned
}
runtime.volatileTombstones.removeValue(forKey: messageID)
}
guard let directory = resolvedReceiptDirectory(), guard let directory = resolvedReceiptDirectory(),
var records = loadIndexIfNeeded(from: directory, at: date) else { loadDurableStateIfNeeded(from: directory, at: date) else {
return .unavailable return .unavailable
} }
guard let record = records[messageID] else { return .absent } _ = recoverDeletionJournal(in: directory)
if runtime.deletionJournal?[messageID] != nil {
return .tombstoned
}
guard var records = runtime.records else { return .unavailable }
guard var record = records[messageID] else { return .absent }
if isExpired(record.recordedAt, at: date) { if record.kind == .tombstone, record.relativePath != nil {
guard scrubLegacyTombstone(
record,
messageID: messageID,
in: directory,
records: &records
) else {
return .tombstoned
}
guard let scrubbed = records[messageID] else {
return .unavailable
}
record = scrubbed
}
let retainsAcceptedPath =
record.kind == .accepted
&& record.relativePath.flatMap(existingPayload) != nil
if isExpired(record.recordedAt, at: date),
!retainsAcceptedPath {
guard removeRecord(
messageID: messageID,
from: directory
) else {
return record.kind == .tombstone
? .tombstoned
: .unavailable
}
records.removeValue(forKey: messageID) records.removeValue(forKey: messageID)
runtime.records = records runtime.records = records
removeRecord(messageID: messageID, from: directory)
return .absent return .absent
} }
switch record.kind { switch record.kind {
case .tombstone: case .tombstone:
removePayloadRecordedByTombstone(record)
return .tombstoned return .tombstoned
case .accepted: case .accepted:
@@ -121,9 +175,14 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
let existingURL = existingPayload(relativePath: relativePath) else { let existingURL = existingPayload(relativePath: relativePath) else {
// Quota cleanup is not explicit deletion. Remove the stale // Quota cleanup is not explicit deletion. Remove the stale
// receipt so a sender retry can restore the payload and bubble. // receipt so a sender retry can restore the payload and bubble.
guard removeRecord(
messageID: messageID,
from: directory
) else {
return .unavailable
}
records.removeValue(forKey: messageID) records.removeValue(forKey: messageID)
runtime.records = records runtime.records = records
removeRecord(messageID: messageID, from: directory)
return .absent return .absent
} }
return .accepted(existingURL) return .accepted(existingURL)
@@ -144,13 +203,24 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
defer { runtime.lock.unlock() } defer { runtime.lock.unlock() }
let date = now() let date = now()
if let tombstonedAt = runtime.volatileTombstones[messageID], guard let directory = resolvedReceiptDirectory(),
!isExpired(tombstonedAt, at: date) { loadDurableStateIfNeeded(from: directory, at: date) else {
return false return false
} }
_ = recoverDeletionJournal(in: directory)
guard let directory = resolvedReceiptDirectory(), guard runtime.deletionJournal?[messageID] == nil,
var records = loadIndexIfNeeded(from: directory, at: date) else { var records = runtime.records else {
return false
}
if runtime.deletionJournal?.values.contains(where: {
$0.relativePaths.contains(relativePath)
}) == true {
return false
}
if records.contains(where: { existingMessageID, record in
existingMessageID != messageID
&& record.relativePath == relativePath
}) {
return false return false
} }
if let existing = records[messageID], if let existing = records[messageID],
@@ -188,73 +258,232 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
return true return true
} }
/// Foundation for explicit media deletion. This branch does not wire the /// Atomically commits explicit deletion for every stable ID in `messageIDs`.
/// chat-clear UI; it only makes a tombstone durable and fail closed. ///
func recordDeleted(messageID: String) -> Bool { /// The journal is the single batch commit point: a failed write changes
guard PrivateMediaMessageIdentity.isStableID(messageID) else { /// neither durable nor in-memory receiver state, so callers must preserve
return false /// their bubbles. Once the write succeeds, every entry is tombstoned even
} /// if the process exits before per-ID materialization or payload unlink.
/// Recovery retries both operations on the next lookup or launch.
func recordDeleted(
messageIDs: [String],
payloadRelativePaths: [String: String] = [:],
protectedPayloadRelativePaths: Set<String> = []
) -> Bool {
let stableIDs = Array(
Set(messageIDs.filter(PrivateMediaMessageIdentity.isStableID))
).sorted()
guard !stableIDs.isEmpty else { return true }
guard stableIDs.count <= capacity else { return false }
runtime.lock.lock() runtime.lock.lock()
defer { runtime.lock.unlock() } defer { runtime.lock.unlock() }
let date = now() let date = now()
addVolatileTombstone(messageID, at: date)
guard let directory = resolvedReceiptDirectory(), guard let directory = resolvedReceiptDirectory(),
var records = loadIndexIfNeeded(from: directory, at: date) else { loadDurableStateIfNeeded(from: directory, at: date) else {
runtime.volatileTombstones.removeValue(forKey: messageID) return false
}
_ = recoverDeletionJournal(in: directory)
guard let records = runtime.records,
var journal = runtime.deletionJournal else {
return false
}
let pendingIDs = Set(journal.keys)
let newIDs = stableIDs.filter { messageID in
if pendingIDs.contains(messageID) { return false }
if let current = records[messageID],
current.kind == .tombstone,
!isExpired(current.recordedAt, at: date) {
return false return false
} }
if let existing = records[messageID],
existing.kind == .tombstone,
!isExpired(existing.recordedAt, at: date) {
runtime.volatileTombstones.removeValue(forKey: messageID)
removePayloadRecordedByTombstone(existing)
return true return true
} }
guard !newIDs.isEmpty else {
let victim = capacityVictim( return true
for: .tombstone, }
replacing: messageID, guard Set(journal.keys).union(newIDs).count <= capacity else {
in: records
)
if records[messageID]?.kind != .tombstone,
records.values.lazy.filter({ $0.kind == .tombstone }).count >= capacity,
victim == nil {
runtime.volatileTombstones.removeValue(forKey: messageID)
return false return false
} }
let tombstone = ReceiptRecord( var newEntries: [String: DeletionJournalEntry] = [:]
kind: .tombstone, for messageID in newIDs {
// Retain the accepted path so a crash between the atomic record // Accepted receipts normally supply the exact stored path. The UI
// write and payload unlink can finish cleanup after relaunch. // fallback is required when that receipt aged or was capacity
relativePath: records[messageID]?.relativePath, // evicted while its bubble and payload remain. They may differ
// after a retry selected a suffixed filename, so journal both.
// Never commit a new pathless tombstone: it could retire
// successfully while leaving an untracked payload behind.
let relativePaths = Array(Set([
records[messageID]?.relativePath,
payloadRelativePaths[messageID]
].compactMap { $0 })).sorted()
guard !relativePaths.isEmpty,
relativePaths.count <=
Self.maximumDeletionPathsPerMessage,
relativePaths.allSatisfy({
isSafeDeletionTarget(relativePath: $0)
}),
protectedPayloadRelativePaths.isDisjoint(
with: relativePaths
),
!records.contains(where: { otherMessageID, record in
otherMessageID != messageID
&& !stableIDs.contains(otherMessageID)
&& record.relativePath.map(
relativePaths.contains
) == true
}),
!journal.contains(where: { otherMessageID, entry in
otherMessageID != messageID
&& !stableIDs.contains(otherMessageID)
&& !Set(entry.relativePaths).isDisjoint(
with: relativePaths
)
}) else {
return false
}
newEntries[messageID] = DeletionJournalEntry(
// The journal retains every owned path so payload deletion
// remains recoverable across a crash or unlink failure.
relativePaths: relativePaths,
recordedAt: date recordedAt: date
) )
guard persist(tombstone, messageID: messageID, to: directory) else { }
runtime.volatileTombstones.removeValue(forKey: messageID) journal.merge(newEntries) { _, new in new }
// Do not install any process-local tombstone before this succeeds.
// A failed delete must continue to resolve to its prior accepted
// state, otherwise a retry could be falsely ACKed while UI remains.
guard persistDeletionJournal(journal, in: directory) else {
return false return false
} }
records[messageID] = tombstone runtime.deletionJournal = journal
if let victim, victim != messageID { _ = recoverDeletionJournal(in: directory)
records.removeValue(forKey: victim)
removeRecord(messageID: victim, from: directory)
}
runtime.records = records
runtime.volatileTombstones.removeValue(forKey: messageID)
removePayloadRecordedByTombstone(tombstone)
return true return true
} }
private func loadIndexIfNeeded( func recordDeleted(messageID: String) -> Bool {
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
return false
}
return recordDeleted(messageIDs: [messageID])
}
/// Resolves every path a deletion transaction may target. The incoming
/// allocator reserves this set at the journal barrier so a concurrent raw
/// arrival cannot reuse a missing UI fallback.
func prospectiveDeletionPayloadPaths(
messageIDs: [String],
payloadRelativePaths: [String: String]
) -> Set<String>? {
let stableIDs = Set(
messageIDs.filter(PrivateMediaMessageIdentity.isStableID)
)
guard !stableIDs.isEmpty else { return [] }
runtime.lock.lock()
defer { runtime.lock.unlock() }
let date = now()
guard let directory = resolvedReceiptDirectory(),
loadDurableStateIfNeeded(from: directory, at: date) else {
return nil
}
_ = recoverDeletionJournal(in: directory)
guard let journal = runtime.deletionJournal,
let records = runtime.records else {
return nil
}
var paths: Set<String> = []
for messageID in stableIDs {
if let entry = journal[messageID] {
paths.formUnion(entry.relativePaths)
continue
}
if let relativePath = records[messageID]?.relativePath {
paths.insert(relativePath)
}
if let relativePath = payloadRelativePaths[messageID] {
paths.insert(relativePath)
}
}
guard paths.allSatisfy({
candidatePayload(relativePath: $0) != nil
}) else {
return nil
}
return Set(paths.compactMap {
candidatePayload(relativePath: $0)?
.standardizedFileURL.path
})
}
/// Paths owned by accepted receipts, legacy pathful tombstones, or the
/// deletion journal. Incoming allocation must not reuse any of them for a
/// different ID.
func reservedPayloadPaths() -> Set<String>? {
runtime.lock.lock()
defer { runtime.lock.unlock() }
let date = now()
guard let directory = resolvedReceiptDirectory(),
loadDurableStateIfNeeded(from: directory, at: date) else {
return nil
}
_ = recoverDeletionJournal(in: directory)
guard let journal = runtime.deletionJournal,
let records = runtime.records else {
return nil
}
let recordPaths = records.values.compactMap(\.relativePath)
let journalPaths = journal.values.flatMap(\.relativePaths)
return Set((recordPaths + journalPaths).compactMap { relativePath in
candidatePayload(relativePath: relativePath)?
.standardizedFileURL.path
})
}
private func scrubLegacyTombstone(
_ tombstone: ReceiptRecord,
messageID: String,
in directory: URL,
records: inout [String: ReceiptRecord]
) -> Bool {
guard tombstone.kind == .tombstone,
tombstone.relativePath != nil else {
return true
}
// Pre-journal tombstones cannot prove that the current file is still
// the payload they originally described. Older allocators did not
// reserve these paths, so a raw/public arrival may have reused the
// basename. Shed the ambiguous path without unlinking anything.
let pathless = ReceiptRecord(
kind: .tombstone,
relativePath: nil,
recordedAt: tombstone.recordedAt
)
guard persist(
pathless,
messageID: messageID,
to: directory
) else {
return false
}
records[messageID] = pathless
runtime.records = records
return true
}
private func loadDurableStateIfNeeded(
from directory: URL, from directory: URL,
at date: Date at date: Date
) -> [String: ReceiptRecord]? { ) -> Bool {
if let records = runtime.records { if runtime.records != nil, runtime.deletionJournal != nil {
return records return true
} }
do { do {
@@ -268,7 +497,7 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
"❌ Failed to create private-media receipt directory: \(error)", "❌ Failed to create private-media receipt directory: \(error)",
category: .session category: .session
) )
return nil return false
} }
let urls: [URL] let urls: [URL]
@@ -287,12 +516,13 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
"❌ Failed to enumerate private-media receipts: \(error)", "❌ Failed to enumerate private-media receipts: \(error)",
category: .session category: .session
) )
return nil return false
} }
var records: [String: ReceiptRecord] = [:] var records: [String: ReceiptRecord] = [:]
var scannedRecords: [String: ReceiptRecord] = [:]
var expired: [String] = [] var expired: [String] = []
var tombstones: [ReceiptRecord] = [] var tombstones: [(messageID: String, record: ReceiptRecord)] = []
for url in urls { for url in urls {
guard url.pathExtension == "json" else { continue } guard url.pathExtension == "json" else { continue }
let messageID = url.deletingPathExtension().lastPathComponent let messageID = url.deletingPathExtension().lastPathComponent
@@ -311,7 +541,7 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
"❌ Failed to read private-media receipt \(messageID.prefix(12))…: \(error)", "❌ Failed to read private-media receipt \(messageID.prefix(12))…: \(error)",
category: .session category: .session
) )
return nil return false
} }
guard isStructurallyValid(record) else { guard isStructurallyValid(record) else {
@@ -319,16 +549,21 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
"❌ Invalid private-media receipt \(messageID.prefix(12))", "❌ Invalid private-media receipt \(messageID.prefix(12))",
category: .session category: .session
) )
return nil return false
} }
if isExpired(record.recordedAt, at: date) { scannedRecords[messageID] = record
if record.kind == .tombstone {
tombstones.append((messageID, record))
}
let retainsAcceptedPath =
record.kind == .accepted
&& record.relativePath.flatMap(existingPayload) != nil
if isExpired(record.recordedAt, at: date),
!retainsAcceptedPath {
expired.append(messageID) expired.append(messageID)
continue continue
} }
records[messageID] = record records[messageID] = record
if record.kind == .tombstone {
tombstones.append(record)
}
} }
let overflow = overflowVictims(in: records) let overflow = overflowVictims(in: records)
@@ -336,17 +571,229 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
records.removeValue(forKey: messageID) records.removeValue(forKey: messageID)
} }
// Install the index only after every stable-ID record was read and let journal: [String: DeletionJournalEntry]
// validated successfully. Cleanup cannot influence a failed scan. let journalURL = deletionJournalURL(in: directory)
runtime.records = records if fileManager.fileExists(atPath: journalURL.path) {
do {
let data = try dataReader?(journalURL)
?? Data(contentsOf: journalURL)
let snapshot = try JSONDecoder().decode(
DeletionJournal.self,
from: data
)
guard snapshot.version == 1,
snapshot.entries.count <= capacity,
snapshot.entries.allSatisfy({ messageID, entry in
PrivateMediaMessageIdentity.isStableID(messageID)
&& !entry.relativePaths.isEmpty
&& entry.relativePaths.count <=
Self.maximumDeletionPathsPerMessage
&& Set(entry.relativePaths).count
== entry.relativePaths.count
&& entry.relativePaths.allSatisfy {
candidatePayload(relativePath: $0) != nil
}
}) else {
SecureLogger.error(
"❌ Invalid private-media deletion journal",
category: .session
)
return false
}
journal = snapshot.entries
} catch {
// The journal is the all-ID commit record. It may never be
// skipped or treated as empty when unreadable.
SecureLogger.error(
"❌ Failed to read private-media deletion journal: \(error)",
category: .session
)
return false
}
} else {
journal = [:]
}
var protectedFromPruning: Set<String> = []
// Old per-ID tombstones may still carry a payload path from before the
// deletion journal existed. That path is inherently ambiguous because
// old allocators did not reserve it. Convert it to a pathless
// tombstone without unlinking any current file.
for (messageID, tombstone) in tombstones
where journal[messageID] == nil
&& tombstone.relativePath != nil {
let pathless = ReceiptRecord(
kind: .tombstone,
relativePath: nil,
recordedAt: tombstone.recordedAt
)
guard persist(
pathless,
messageID: messageID,
to: directory
) else {
records[messageID] = tombstone
protectedFromPruning.insert(messageID)
continue
}
scannedRecords[messageID] = pathless
if records[messageID] != nil {
records[messageID] = pathless
}
}
for messageID in expired + overflow { for messageID in expired + overflow {
removeRecord(messageID: messageID, from: directory) guard !protectedFromPruning.contains(messageID) else { continue }
if !removeRecord(messageID: messageID, from: directory),
let record = scannedRecords[messageID] {
records[messageID] = record
} }
for tombstone in tombstones {
removePayloadRecordedByTombstone(tombstone)
} }
return records
// Install caches only after every per-ID record and the batch journal
// have been read and validated. Failed legacy cleanup remains indexed
// and path-reserved instead of blocking unrelated media.
runtime.records = records
runtime.deletionJournal = journal
return true
}
/// Idempotently materializes the write-ahead journal. An entry leaves the
/// journal only after its per-ID tombstone is durable and every recorded
/// payload is absent. Any failure keeps the journal authoritative for a
/// later lookup or process restart.
@discardableResult
private func recoverDeletionJournal(in directory: URL) -> Bool {
guard let journal = runtime.deletionJournal,
!journal.isEmpty,
var records = runtime.records else {
return true
}
let preservedMessageIDs = Set(journal.keys)
var remaining = journal
let orderedEntries = journal.sorted { lhs, rhs in
if lhs.value.recordedAt == rhs.value.recordedAt {
return lhs.key < rhs.key
}
return lhs.value.recordedAt < rhs.value.recordedAt
}
for (messageID, journalEntry) in orderedEntries {
let pathlessTombstone = ReceiptRecord(
kind: .tombstone,
relativePath: nil,
recordedAt: journalEntry.recordedAt
)
if records[messageID] != pathlessTombstone {
let victim = capacityVictim(
for: .tombstone,
replacing: messageID,
in: records,
preserving: preservedMessageIDs
)
if records[messageID]?.kind != .tombstone,
records.values.lazy.filter({
$0.kind == .tombstone
}).count >= capacity,
victim == nil {
continue
}
guard persist(
pathlessTombstone,
messageID: messageID,
to: directory
) else {
continue
}
records[messageID] = pathlessTombstone
if let victim, victim != messageID {
records.removeValue(forKey: victim)
removeRecord(messageID: victim, from: directory)
}
}
// Only the journal retains the paths. Once every unlink succeeds,
// the durable per-ID tombstone is pathless and cannot later
// delete a different payload that reused the basename.
let removedEveryPayload = journalEntry.relativePaths.allSatisfy {
removePayloadRecordedByTombstone(ReceiptRecord(
kind: .tombstone,
relativePath: $0,
recordedAt: journalEntry.recordedAt
))
}
guard removedEveryPayload else {
continue
}
remaining.removeValue(forKey: messageID)
}
runtime.records = records
guard remaining != journal else { return false }
if remaining.isEmpty {
guard removeDeletionJournal(in: directory) else { return false }
} else {
guard persistDeletionJournal(remaining, in: directory) else {
return false
}
}
runtime.deletionJournal = remaining
return remaining.isEmpty
}
private func persistDeletionJournal(
_ entries: [String: DeletionJournalEntry],
in directory: URL
) -> Bool {
do {
try fileManager.createDirectory(
at: directory,
withIntermediateDirectories: true,
attributes: nil
)
let data = try JSONEncoder().encode(
DeletionJournal(version: 1, entries: entries)
)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(
.completeFileProtectionUntilFirstUserAuthentication
)
#endif
try dataWriter(data, deletionJournalURL(in: directory), options)
return true
} catch {
SecureLogger.error(
"❌ Failed to persist private-media deletion journal: \(error)",
category: .session
)
return false
}
}
private func removeDeletionJournal(in directory: URL) -> Bool {
let url = deletionJournalURL(in: directory)
guard fileManager.fileExists(atPath: url.path) else { return true }
do {
try fileManager.removeItem(at: url)
return true
} catch {
SecureLogger.warning(
"⚠️ Failed to retire private-media deletion journal: \(error)",
category: .session
)
return false
}
}
private func deletionJournalURL(in directory: URL) -> URL {
directory.appendingPathComponent(
Self.deletionJournalFileName,
isDirectory: false
)
} }
private func isStructurallyValid(_ record: ReceiptRecord) -> Bool { private func isStructurallyValid(_ record: ReceiptRecord) -> Bool {
@@ -369,10 +816,14 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
) -> [String] { ) -> [String] {
var victims: [String] = [] var victims: [String] = []
for kind in [ReceiptRecord.Kind.accepted, .tombstone] { for kind in [ReceiptRecord.Kind.accepted, .tombstone] {
let matching = records.filter { $0.value.kind == kind } let allMatching = records.filter { $0.value.kind == kind }
let overflow = matching.count - capacity let overflow = allMatching.count - capacity
guard overflow > 0 else { continue } guard overflow > 0 else { continue }
victims.append(contentsOf: matching.sorted { lhs, rhs in let eligible = allMatching.filter { _, record in
guard kind == .accepted else { return true }
return record.relativePath.flatMap(existingPayload) == nil
}
victims.append(contentsOf: eligible.sorted { lhs, rhs in
if lhs.value.recordedAt == rhs.value.recordedAt { if lhs.value.recordedAt == rhs.value.recordedAt {
return lhs.key < rhs.key return lhs.key < rhs.key
} }
@@ -389,13 +840,24 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
private func capacityVictim( private func capacityVictim(
for incomingKind: ReceiptRecord.Kind, for incomingKind: ReceiptRecord.Kind,
replacing messageID: String, replacing messageID: String,
in records: [String: ReceiptRecord] in records: [String: ReceiptRecord],
preserving preservedMessageIDs: Set<String> = []
) -> String? { ) -> String? {
guard records[messageID]?.kind != incomingKind else { return nil } guard records[messageID]?.kind != incomingKind else { return nil }
// During a live session an accepted receipt is the only durable owner
// of its filename, even after quota removes the payload. Evicting it
// here could let another ID reuse the path while the old bubble still
// exists. The large capacity therefore acts as admission control.
guard incomingKind != .accepted else { return nil }
let matching = records.filter { let matching = records.filter {
$0.key != messageID && $0.value.kind == incomingKind $0.key != messageID
&& !preservedMessageIDs.contains($0.key)
&& $0.value.kind == incomingKind
} }
guard matching.count >= capacity else { return nil } let currentCount = records.values.lazy.filter {
$0.kind == incomingKind
}.count
guard currentCount >= capacity else { return nil }
return matching.min { lhs, rhs in return matching.min { lhs, rhs in
if lhs.value.recordedAt == rhs.value.recordedAt { if lhs.value.recordedAt == rhs.value.recordedAt {
return lhs.key < rhs.key return lhs.key < rhs.key
@@ -421,7 +883,7 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
options.insert(.completeFileProtectionUntilFirstUserAuthentication) options.insert(.completeFileProtectionUntilFirstUserAuthentication)
#endif #endif
let url = recordURL(messageID: messageID, in: directory) let url = recordURL(messageID: messageID, in: directory)
try data.write(to: url, options: options) try dataWriter(data, url, options)
return true return true
} catch { } catch {
SecureLogger.error( SecureLogger.error(
@@ -432,16 +894,22 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
} }
} }
private func removeRecord(messageID: String, from directory: URL) { @discardableResult
private func removeRecord(
messageID: String,
from directory: URL
) -> Bool {
let url = recordURL(messageID: messageID, in: directory) let url = recordURL(messageID: messageID, in: directory)
guard fileManager.fileExists(atPath: url.path) else { return } guard fileManager.fileExists(atPath: url.path) else { return true }
do { do {
try fileManager.removeItem(at: url) try fileManager.removeItem(at: url)
return true
} catch { } catch {
SecureLogger.warning( SecureLogger.warning(
"⚠️ Failed to prune private-media receipt \(messageID.prefix(12))…: \(error)", "⚠️ Failed to prune private-media receipt \(messageID.prefix(12))…: \(error)",
category: .session category: .session
) )
return false
} }
} }
@@ -451,39 +919,55 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
.appendingPathExtension("json") .appendingPathExtension("json")
} }
private func removePayloadRecordedByTombstone(_ record: ReceiptRecord) { @discardableResult
private func removePayloadRecordedByTombstone(
_ record: ReceiptRecord
) -> Bool {
guard record.kind == .tombstone, guard record.kind == .tombstone,
let relativePath = record.relativePath, let relativePath = record.relativePath,
let payload = candidatePayload(relativePath: relativePath), let payload = candidatePayload(relativePath: relativePath),
fileManager.fileExists(atPath: payload.path) else { fileManager.fileExists(atPath: payload.path) else {
return return true
}
guard let values = try? payload.resourceValues(
forKeys: [.isRegularFileKey]
),
values.isRegularFile == true else {
SecureLogger.warning(
"⚠️ Refusing to remove non-file private-media payload",
category: .session
)
return false
} }
do { do {
try fileManager.removeItem(at: payload) try payloadRemover(payload)
return !fileManager.fileExists(atPath: payload.path)
} catch { } catch {
SecureLogger.warning( SecureLogger.warning(
"⚠️ Failed to remove explicitly deleted private media: \(error)", "⚠️ Failed to remove explicitly deleted private media: \(error)",
category: .session category: .session
) )
return false
} }
} }
private func addVolatileTombstone(_ messageID: String, at date: Date) { private func isSafeDeletionTarget(relativePath: String) -> Bool {
runtime.volatileTombstones[messageID] = date guard let payload = candidatePayload(relativePath: relativePath) else {
let overflow = runtime.volatileTombstones.count - capacity return false
guard overflow > 0 else { return }
let oldest = runtime.volatileTombstones.sorted {
if $0.value == $1.value { return $0.key < $1.key }
return $0.value < $1.value
} }
for (oldMessageID, _) in oldest.prefix(overflow) { guard fileManager.fileExists(atPath: payload.path) else {
runtime.volatileTombstones.removeValue(forKey: oldMessageID) return true
} }
return (try? payload.resourceValues(
forKeys: [.isRegularFileKey]
).isRegularFile) == true
} }
private func validExistingPayload(_ url: URL) -> URL? { private func validExistingPayload(_ url: URL) -> URL? {
let standardized = url.standardizedFileURL let standardized = url.standardizedFileURL
guard isInsideFilesDirectory(standardized) else { return nil } guard isInsideIncomingMediaDirectory(standardized) else {
return nil
}
var isDirectory: ObjCBool = false var isDirectory: ObjCBool = false
guard fileManager.fileExists( guard fileManager.fileExists(
atPath: standardized.path, atPath: standardized.path,
@@ -520,15 +1004,27 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
let candidate = filesRoot let candidate = filesRoot
.appendingPathComponent(relativePath, isDirectory: false) .appendingPathComponent(relativePath, isDirectory: false)
.standardizedFileURL .standardizedFileURL
guard candidate.path.hasPrefix(filesRoot.path + "/") else { return nil } guard isInsideIncomingMediaDirectory(candidate) else { return nil }
return candidate return candidate
} }
private func isInsideFilesDirectory(_ url: URL) -> Bool { private func isInsideIncomingMediaDirectory(_ url: URL) -> Bool {
guard let filesRoot = try? filesDirectory().standardizedFileURL else { guard let filesRoot = try? filesDirectory().standardizedFileURL else {
return false return false
} }
return url.standardizedFileURL.path.hasPrefix(filesRoot.path + "/") let parentPath = url.standardizedFileURL
.deletingLastPathComponent().path
return [
"voicenotes/incoming",
"images/incoming",
"files/incoming"
].contains { relativeDirectory in
filesRoot.appendingPathComponent(
relativeDirectory,
isDirectory: true
)
.standardizedFileURL.path == parentPath
}
} }
private func resolvedReceiptDirectory() -> URL? { private func resolvedReceiptDirectory() -> URL? {
+141 -18
View File
@@ -2660,6 +2660,18 @@ final class BLEService: NSObject {
removeIncomingFile: { [weak self] storedURL in removeIncomingFile: { [weak self] storedURL in
self?.incomingFileStore.removeIncomingFile(at: storedURL) 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 isPrivateMediaSenderBlocked: { [weak self] peerID in
guard let self else { return false } guard let self else { return false }
let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID) let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID)
@@ -2684,11 +2696,12 @@ final class BLEService: NSObject {
} }
self.sendDeliveryAck(for: messageID, to: peerID) self.sendDeliveryAck(for: messageID, to: peerID)
}, },
deliverMessage: { [weak self] message, shouldDeliver, completion in deliverMessage: { [weak self] message, shouldDeliver, completion, finalization in
self?.emitTransportEvent( self?.emitTransportEvent(
.messageReceived(message), .messageReceived(message),
shouldDeliver: shouldDeliver, shouldDeliver: shouldDeliver,
completion: completion completion: completion,
finalization: finalization
) )
} }
) )
@@ -3479,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
@@ -4384,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
@@ -4409,52 +4513,71 @@ extension BLEService {
private func emitTransportEvent( private func emitTransportEvent(
_ event: TransportEvent, _ event: TransportEvent,
shouldDeliver: (() -> Bool)? = nil, shouldDeliver: (() -> Bool)? = nil,
completion: (() -> Void)? = nil completion: (() -> Void)? = nil,
finalization: ((TransportEventDeliveryOutcome) -> Void)? = nil
) { ) {
notifyUI { [weak self] in guard let generation = capturePanicLifecycleGeneration() else {
guard let self, Task { @MainActor in
shouldDeliver?() ?? true, finalization?(.rejected)
self.deliverTransportEvent(event), }
// Quota cleanup can race the asynchronous main-actor hop or
// the synchronous ConversationStore upsert. ACK only while
// the exact durable mapping and file still resolve.
shouldDeliver?() ?? true else {
return return
} }
completion?() 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
} }
} }
+1 -1
View File
@@ -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 {
@@ -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.
@@ -129,7 +129,7 @@ final class BridgeCourierService: ObservableObject {
/// a newer attempt for the same message. /// a newer attempt for the same message.
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,
@@ -275,7 +275,7 @@ 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. `messageID` is the
/// sender-side dedup key (nil for held/relayed envelopes we don't track); /// sender-side dedup key (nil for held/relayed envelopes we don't track);
/// it rides the pending queue so an evicted or failed drop can release its /// it rides the pending queue so an evicted or failed drop can release its
/// in-flight slot. Completion reports actual NIP-20 relay acceptance. /// in-flight slot. Completion reports actual NIP-01 relay acceptance.
private func publishDrop( private func publishDrop(
_ envelope: CourierEnvelope, _ envelope: CourierEnvelope,
messageID: String? = nil, messageID: String? = nil,
@@ -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()
+10
View File
@@ -158,6 +158,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? {
@@ -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
+416 -35
View File
@@ -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
/// be hidden behind an unobserved MainActor task.
@MainActor
@discardableResult
func sendPrivateMessageGeohash(
content: String,
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) SecureLogger.debug("GeoDM: send PM mid=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else { guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
content: content,
messageID: messageID,
senderPeerID: senderPeerID
) else {
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session) SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
return handlePrivateEnvelopeFailure(
} events: [],
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true) 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 }
}
+13
View File
@@ -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)
} }
+27 -3
View File
@@ -195,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
@@ -223,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
@@ -239,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.
@@ -86,6 +86,10 @@ protocol ChatMediaTransferContext: AnyObject {
@discardableResult @discardableResult
func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool
func removeMessage(withID messageID: String, cleanupFile: Bool) func removeMessage(withID messageID: String, cleanupFile: Bool)
/// Removes a media bubble with direction-scoped cleanup instead of the
/// broad compatibility cleanup path.
func removeUntombstonedMediaMessage(withID messageID: String)
func removeOutgoingMediaMessage(withID messageID: String)
func addSystemMessage(_ content: String) func addSystemMessage(_ content: String)
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`). /// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
func notifyUIChanged() func notifyUIChanged()
@@ -122,6 +126,15 @@ protocol ChatMediaTransferContext: AnyObject {
) )
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
func cancelTransfer(_ transferId: String) func cancelTransfer(_ transferId: String)
/// Receiver-side stable-ID deletion commit. Implementations must invoke
/// completion only after the entire batch is durably tombstoned.
func persistDeletedPrivateMedia(
messageIDs: [String],
completion: @escaping @MainActor (Bool) -> Void
)
/// Whether any current private-chat copy of this stable ID came from a
/// remote peer and therefore requires a receiver tombstone.
func requiresPrivateMediaTombstone(messageID: String) -> Bool
} }
extension ChatViewModel: ChatMediaTransferContext { extension ChatViewModel: ChatMediaTransferContext {
@@ -208,6 +221,130 @@ extension ChatViewModel: ChatMediaTransferContext {
func cancelTransfer(_ transferId: String) { func cancelTransfer(_ transferId: String) {
meshService.cancelTransfer(transferId) meshService.cancelTransfer(transferId)
} }
func removeUntombstonedMediaMessage(withID messageID: String) {
let message = conversations.conversationsByID.values.lazy
.flatMap(\.messages)
.first { $0.id == messageID }
if let message {
if !isIncomingPrivateMessage(message) {
mediaTransferCoordinator.cleanupOutgoingLocalFile(
forMessage: message
)
}
// Legacy/raw incoming media has no durable ID-to-file ownership.
// Its basename may already belong to a pending newer arrival, so
// leave the payload for bounded quota cleanup rather than unlink
// an ambiguous path.
}
removeMessage(withID: messageID, cleanupFile: false)
}
func removeOutgoingMediaMessage(withID messageID: String) {
let message = conversations.conversationsByID.values.lazy
.flatMap(\.messages)
.first { $0.id == messageID }
if let message {
mediaTransferCoordinator.cleanupOutgoingLocalFile(
forMessage: message
)
}
removeMessage(withID: messageID, cleanupFile: false)
}
func persistDeletedPrivateMedia(
messageIDs: [String],
completion: @escaping @MainActor (Bool) -> Void
) {
guard !messageIDs.isEmpty else {
completion(true)
return
}
guard let persistence =
meshService as? any PrivateMediaDeletionPersisting else {
completion(false)
return
}
let requestedIDs = Set(messageIDs)
let incomingPathReferences = Array(
conversations.conversationsByID.values
.lazy
.flatMap(\.messages)
.compactMap { message -> (
messageID: String,
path: String
)? in
guard self.isIncomingPrivateMessage(message),
let path = self.incomingMediaRelativePath(
for: message
) else {
return nil
}
return (message.id, path)
}
)
let ownerIDsByPath = Dictionary(
grouping: incomingPathReferences,
by: { $0.path }
).mapValues { Set($0.map(\.messageID)) }
let protectedPayloadRelativePaths = Set(
ownerIDsByPath.compactMap { path, ownerIDs in
ownerIDs.isSubset(of: requestedIDs) ? nil : path
}
)
var payloadRelativePaths: [String: String] = [:]
for reference in incomingPathReferences
where requestedIDs.contains(reference.messageID)
&& ownerIDsByPath[reference.path, default: []]
.isSubset(of: requestedIDs) {
payloadRelativePaths[reference.messageID] = reference.path
}
persistence.persistDeletedPrivateMedia(
messageIDs: messageIDs,
payloadRelativePaths: payloadRelativePaths,
protectedPayloadRelativePaths:
protectedPayloadRelativePaths,
completion: completion
)
}
func requiresPrivateMediaTombstone(messageID: String) -> Bool {
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
return false
}
return privateChats.values.lazy.flatMap { $0 }.contains { message in
message.id == messageID && isIncomingPrivateMessage(message)
}
}
private func isIncomingPrivateMessage(
_ message: BitchatMessage
) -> Bool {
if let senderPeerID = message.senderPeerID {
return senderPeerID.toShort() != meshService.myPeerID.toShort()
}
return message.sender != nickname
&& !message.sender.hasPrefix(nickname + "#")
}
private func incomingMediaRelativePath(
for message: BitchatMessage
) -> String? {
let categories: [MimeType.Category] = [.audio, .image, .file]
guard let category = categories.first(where: {
message.content.hasPrefix($0.messagePrefix)
}),
let rawFilename = String(
message.content.dropFirst(category.messagePrefix.count)
).trimmedOrNilIfEmpty,
let safeFilename =
(rawFilename as NSString).lastPathComponent.nilIfEmpty,
safeFilename != ".",
safeFilename != ".." else {
return nil
}
return "\(category.mediaDir)/incoming/\(safeFilename)"
}
} }
/// Synchronous boundary between detached image writers and panic deletion. /// Synchronous boundary between detached image writers and panic deletion.
@@ -297,6 +434,7 @@ final class ChatMediaTransferCoordinator {
private(set) var transferIdToMessageIDs: [String: [String]] = [:] private(set) var transferIdToMessageIDs: [String: [String]] = [:]
private(set) var messageIDToTransferId: [String: String] = [:] private(set) var messageIDToTransferId: [String: String] = [:]
private var deletionGeneration: UInt64 = 0
private var reconnectRetryRecords: [ private var reconnectRetryRecords: [
String: PrivateMediaReconnectRetryRecord String: PrivateMediaReconnectRetryRecord
] = [:] ] = [:]
@@ -871,8 +1009,7 @@ final class ChatMediaTransferCoordinator {
cancelActiveTransfer: false cancelActiveTransfer: false
) )
clearTransferMapping(for: messageID) clearTransferMapping(for: messageID)
context.removeMessage(withID: messageID, cleanupFile: true) context.removeOutgoingMediaMessage(withID: messageID)
case .rejected(let id, let reason): case .rejected(let id, let reason):
guard let messageID = currentMessageID(forTransferID: id) else { guard let messageID = currentMessageID(forTransferID: id) else {
return return
@@ -891,6 +1028,40 @@ final class ChatMediaTransferCoordinator {
} }
func cleanupLocalFile(forMessage message: BitchatMessage) { func cleanupLocalFile(forMessage message: BitchatMessage) {
cleanupLocalFile(
forMessage: message,
directions: ["outgoing", "incoming"],
searchAllCategories: true
)
}
/// `/clear` may cancel an outgoing message before receiver tombstones are
/// committed. Restrict cleanup to that message's outgoing directory so a
/// same-name incoming payload cannot be removed prematurely.
func cleanupOutgoingLocalFile(forMessage message: BitchatMessage) {
cleanupLocalFile(
forMessage: message,
directions: ["outgoing"],
searchAllCategories: false
)
}
/// Receiver cleanup runs only after any required tombstone commit. Keep it
/// scoped to the parsed media category and incoming directory so unrelated
/// outgoing or cross-category payloads with the same basename survive.
func cleanupIncomingLocalFile(forMessage message: BitchatMessage) {
cleanupLocalFile(
forMessage: message,
directions: ["incoming"],
searchAllCategories: false
)
}
private func cleanupLocalFile(
forMessage message: BitchatMessage,
directions: [String],
searchAllCategories: Bool
) {
let categories: [MimeType.Category] = [.audio, .image, .file] let categories: [MimeType.Category] = [.audio, .image, .file]
guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }), guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }),
let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty, let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty,
@@ -901,11 +1072,27 @@ final class ChatMediaTransferCoordinator {
return return
} }
let subdirs = categories.flatMap { ["\($0.mediaDir)/outgoing", "\($0.mediaDir)/incoming"] } let targetCategories = searchAllCategories ? categories : [category]
let subdirs = targetCategories.flatMap { category in
directions.map { "\(category.mediaDir)/\($0)" }
}
for subdir in subdirs { for subdir in subdirs {
let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename) let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename)
guard target.path.hasPrefix(base.path) else { continue } guard target.path.hasPrefix(base.path) else { continue }
guard FileManager.default.fileExists(atPath: target.path) else {
continue
}
guard let values = try? target.resourceValues(
forKeys: [.isRegularFileKey]
),
values.isRegularFile == true else {
SecureLogger.warning(
"Refusing to cleanup non-file media target \(safeFilename)",
category: .session
)
continue
}
do { do {
try FileManager.default.removeItem(at: target) try FileManager.default.removeItem(at: target)
} catch CocoaError.fileNoSuchFile { } catch CocoaError.fileNoSuchFile {
@@ -917,33 +1104,83 @@ final class ChatMediaTransferCoordinator {
} }
func cancelMediaSend(messageID: String) { func cancelMediaSend(messageID: String) {
discardReconnectRetry( cancelAllMediaSendOwners(messageID: messageID)
messageID: messageID, context.removeOutgoingMediaMessage(withID: messageID)
cancelActiveTransfer: false
)
if let transferId = messageIDToTransferId[messageID],
let active = transferIdToMessageIDs[transferId]?.first,
active == messageID {
context.cancelTransfer(transferId)
} }
clearTransferMapping(for: messageID)
context.removeMessage(withID: messageID, cleanupFile: true) /// Lets `/clear` cancel send ownership without implicitly deciding which
/// bubbles/files its deletion transaction may remove.
func cancelMediaTransferForConversationClear(messageID: String) {
cancelAllMediaSendOwners(messageID: messageID)
} }
func deleteMediaMessage(messageID: String) { func deleteMediaMessage(messageID: String) {
// Stop every exact sender owner before the durable receiver commit.
// Otherwise a retained retry or admitted legacy send could transmit
// after the bubble and payload have been deleted.
cancelAllMediaSendOwners(messageID: messageID)
guard context.requiresPrivateMediaTombstone(
messageID: messageID
) else {
finishMediaDeletion(
messageID: messageID,
receiverJournalOwnsPayload: false
)
return
}
let generation = deletionGeneration
context.persistDeletedPrivateMedia(
messageIDs: [messageID]
) { [weak self] persisted in
guard let self,
self.deletionGeneration == generation else {
return
}
guard persisted else {
SecureLogger.error(
"Refusing to delete private media without a durable tombstone id=\(messageID.prefix(12))",
category: .session
)
return
}
self.finishMediaDeletion(
messageID: messageID,
receiverJournalOwnsPayload: true
)
}
}
private func finishMediaDeletion(
messageID: String,
receiverJournalOwnsPayload: Bool
) {
if receiverJournalOwnsPayload {
// The journal already owns the exact path. A basename cleanup here
// could delete a different arrival that reused it after unlink.
context.removeMessage(withID: messageID, cleanupFile: false)
} else {
context.removeUntombstonedMediaMessage(withID: messageID)
}
}
private func cancelAllMediaSendOwners(messageID: String) {
// This releases the retained packet and expiry/retry owner. When its
// exact active transfer still owns the mapping, it cancels that owner
// before any deletion persistence or UI mutation can proceed.
discardReconnectRetry( discardReconnectRetry(
messageID: messageID, messageID: messageID,
cancelActiveTransfer: false cancelActiveTransfer: true
) )
// Delete is also a send cancellation. In particular, an approved // In particular, an approved legacy send may still be waiting on
// legacy-clear send may still be waiting on BLEService.messageQueue; // BLEService.messageQueue. Its admission must be canceled before the
// removing only the UI mapping would let that deferred work transmit. // mapping/consent owner is released.
if let transferId = messageIDToTransferId[messageID], if let transferId = messageIDToTransferId[messageID],
transferIdToMessageIDs[transferId]?.first == messageID { transferIdToMessageIDs[transferId]?.first == messageID {
context.cancelTransfer(transferId) context.cancelTransfer(transferId)
} }
clearTransferMapping(for: messageID) clearTransferMapping(for: messageID)
context.removeMessage(withID: messageID, cleanupFile: true)
} }
/// A raw link callback can arrive before the replacement Noise session /// A raw link callback can arrive before the replacement Noise session
@@ -989,6 +1226,7 @@ final class ChatMediaTransferCoordinator {
/// is the last filesystem mutation before the transaction can complete. /// is the last filesystem mutation before the transaction can complete.
func resetForPanic() { func resetForPanic() {
imagePreparationBarrier.invalidateAndWait() imagePreparationBarrier.invalidateAndWait()
deletionGeneration &+= 1
peersResolvingReconnectRetry.removeAll(keepingCapacity: false) peersResolvingReconnectRetry.removeAll(keepingCapacity: false)
for task in reconnectRetryExpiryTasks.values { for task in reconnectRetryExpiryTasks.values {
task.cancel() task.cancel()
@@ -86,7 +86,13 @@ protocol ChatPrivateConversationContext: AnyObject {
@discardableResult @discardableResult
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool func routeReadReceipt(_ receipt: ReadReceipt, to peerID: 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)
@@ -174,7 +180,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 +228,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 +246,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.
@@ -399,13 +418,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(
+284 -1
View File
@@ -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) {
@@ -643,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.
@@ -1275,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.
@@ -1372,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
@@ -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)
} }
} }
} }
+98 -28
View File
@@ -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 }
+2 -1
View File
@@ -504,7 +504,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
+38
View File
@@ -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")
@@ -58,6 +58,8 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
private(set) var appendedPublicMessages: [(message: BitchatMessage, conversationID: ConversationID)] = [] private(set) var appendedPublicMessages: [(message: BitchatMessage, conversationID: ConversationID)] = []
private(set) var removedMessages: [(messageID: String, cleanupFile: Bool)] = [] private(set) var removedMessages: [(messageID: String, cleanupFile: Bool)] = []
private(set) var untombstonedMediaRemovals: [String] = []
private(set) var outgoingMediaRemovals: [String] = []
private(set) var systemMessages: [String] = [] private(set) var systemMessages: [String] = []
private(set) var notifyUIChangedCount = 0 private(set) var notifyUIChangedCount = 0
@@ -71,6 +73,16 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
removedMessages.append((messageID, cleanupFile)) removedMessages.append((messageID, cleanupFile))
} }
func removeUntombstonedMediaMessage(withID messageID: String) {
untombstonedMediaRemovals.append(messageID)
removedMessages.append((messageID, false))
}
func removeOutgoingMediaMessage(withID messageID: String) {
outgoingMediaRemovals.append(messageID)
removedMessages.append((messageID, false))
}
func addSystemMessage(_ content: String) { systemMessages.append(content) } func addSystemMessage(_ content: String) { systemMessages.append(content) }
func notifyUIChanged() { notifyUIChangedCount += 1 } func notifyUIChanged() { notifyUIChangedCount += 1 }
@@ -99,6 +111,13 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
private(set) var broadcastFileSends: [String] = [] private(set) var broadcastFileSends: [String] = []
private(set) var cancelledTransfers: [String] = [] private(set) var cancelledTransfers: [String] = []
private(set) var privateMediaPolicyResolutionRequests: [PeerID] = [] private(set) var privateMediaPolicyResolutionRequests: [PeerID] = []
private(set) var persistedDeletionBatches: [[String]] = []
var requiredTombstoneIDs: Set<String> = []
var deletedMediaPersistenceResult = true
var deferDeletedMediaPersistence = false
private var pendingDeletionCompletions: [
@MainActor (Bool) -> Void
] = []
var privateMediaPolicy: PrivateMediaSendPolicy = .encrypted var privateMediaPolicy: PrivateMediaSendPolicy = .encrypted
var resolvedPrivateMediaPolicy: PrivateMediaSendPolicy? var resolvedPrivateMediaPolicy: PrivateMediaSendPolicy?
var resolvesPrivateMediaPolicyImmediately = true var resolvesPrivateMediaPolicyImmediately = true
@@ -218,6 +237,28 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
func cancelTransfer(_ transferId: String) { func cancelTransfer(_ transferId: String) {
cancelledTransfers.append(transferId) cancelledTransfers.append(transferId)
} }
func persistDeletedPrivateMedia(
messageIDs: [String],
completion: @escaping @MainActor (Bool) -> Void
) {
persistedDeletionBatches.append(messageIDs)
if deferDeletedMediaPersistence {
pendingDeletionCompletions.append(completion)
} else {
completion(deletedMediaPersistenceResult)
}
}
func requiresPrivateMediaTombstone(messageID: String) -> Bool {
requiredTombstoneIDs.contains(messageID)
}
func resolveNextDeletionPersistence(_ result: Bool? = nil) {
guard !pendingDeletionCompletions.isEmpty else { return }
let completion = pendingDeletionCompletions.removeFirst()
completion(result ?? deletedMediaPersistenceResult)
}
} }
private final class PausedVoiceNotePreparer: @unchecked Sendable { private final class PausedVoiceNotePreparer: @unchecked Sendable {
@@ -381,7 +422,8 @@ struct ChatMediaTransferCoordinatorContextTests {
coordinator.handleTransferEvent(.cancelled(id: "t2", sentFragments: 1, totalFragments: 5)) coordinator.handleTransferEvent(.cancelled(id: "t2", sentFragments: 1, totalFragments: 5))
#expect(context.removedMessages.count == 1) #expect(context.removedMessages.count == 1)
#expect(context.removedMessages.first?.messageID == "m2") #expect(context.removedMessages.first?.messageID == "m2")
#expect(context.removedMessages.first?.cleanupFile == true) #expect(context.removedMessages.first?.cleanupFile == false)
#expect(context.outgoingMediaRemovals == ["m2"])
// A pre-start rejection keeps the placeholder visible and failed, // A pre-start rejection keeps the placeholder visible and failed,
// including queued post-handshake encryption failures. // including queued post-handshake encryption failures.
@@ -531,7 +573,172 @@ struct ChatMediaTransferCoordinatorContextTests {
#expect(context.cancelledTransfers == ["approved-delete"]) #expect(context.cancelledTransfers == ["approved-delete"])
#expect(coordinator.messageIDToTransferId["message-delete"] == nil) #expect(coordinator.messageIDToTransferId["message-delete"] == nil)
#expect(context.removedMessages.map(\.messageID) == ["message-delete"]) #expect(context.removedMessages.map(\.messageID) == ["message-delete"])
#expect(context.removedMessages.first?.cleanupFile == true) #expect(context.removedMessages.first?.cleanupFile == false)
#expect(
context.untombstonedMediaRemovals == ["message-delete"]
)
}
@Test @MainActor
func deleteIncomingStableMediaWaitsForDurableTombstone() {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let messageID = "media-00112233445566778899aabbccddeeff"
context.requiredTombstoneIDs = [messageID]
context.deferDeletedMediaPersistence = true
coordinator.registerTransfer(
transferId: "incoming-delete",
messageID: messageID
)
coordinator.deleteMediaMessage(messageID: messageID)
#expect(context.persistedDeletionBatches == [[messageID]])
#expect(context.removedMessages.isEmpty)
#expect(context.cancelledTransfers == ["incoming-delete"])
#expect(coordinator.messageIDToTransferId[messageID] == nil)
context.resolveNextDeletionPersistence(true)
#expect(context.cancelledTransfers == ["incoming-delete"])
#expect(context.removedMessages.map(\.messageID) == [messageID])
#expect(context.removedMessages.first?.cleanupFile == false)
#expect(context.untombstonedMediaRemovals.isEmpty)
#expect(coordinator.messageIDToTransferId[messageID] == nil)
}
@Test @MainActor
func deleteIncomingStableMediaCompletionAfterPanicIsIgnored() {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let messageID = "media-11223344556677889900aabbccddeeff"
context.requiredTombstoneIDs = [messageID]
context.deferDeletedMediaPersistence = true
coordinator.deleteMediaMessage(messageID: messageID)
#expect(context.persistedDeletionBatches == [[messageID]])
coordinator.resetForPanic()
context.resolveNextDeletionPersistence(true)
#expect(context.removedMessages.isEmpty)
#expect(context.untombstonedMediaRemovals.isEmpty)
}
@Test @MainActor
func deleteIncomingStableMediaPreservesStateWhenTombstoneFails() {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let messageID = "media-ffeeddccbbaa99887766554433221100"
context.requiredTombstoneIDs = [messageID]
context.deletedMediaPersistenceResult = false
coordinator.registerTransfer(
transferId: "failed-delete",
messageID: messageID
)
coordinator.deleteMediaMessage(messageID: messageID)
#expect(context.persistedDeletionBatches == [[messageID]])
#expect(context.removedMessages.isEmpty)
#expect(context.cancelledTransfers == ["failed-delete"])
#expect(coordinator.messageIDToTransferId[messageID] == nil)
}
@Test @MainActor
func deleteStableMediaReleasesRetainedRetryBeforeTombstoneCommit()
async throws
{
let context = MockChatMediaTransferContext()
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
context.supportsAuthenticatedPrivateMediaReceipts = true
context.deferDeletedMediaPersistence = true
let fileName = "voice_deadbeefdeadbeef.m4a"
let preparer = StaticVoiceNotePreparer(fileName: fileName)
let coordinator = ChatMediaTransferCoordinator(
context: context,
prepareVoiceNotePacket: { url in
try preparer.prepare(url)
}
)
let url = try makeCoordinatorVoiceURL(fileName: fileName)
defer {
try? FileManager.default.removeItem(
at: url.deletingLastPathComponent()
)
}
coordinator.sendVoiceNote(at: url)
#expect(await TestHelpers.waitUntil(
{ context.privateFileSends.count == 1 },
timeout: TestConstants.longTimeout
))
let messageID = try #require(
context.privateChats[peerID]?.first?.id
)
let transferID = try #require(
context.privateFileSends.first?.transferId
)
context.requiredTombstoneIDs = [messageID]
#expect(coordinator.retainedReconnectRetryCount == 1)
coordinator.deleteMediaMessage(messageID: messageID)
#expect(context.persistedDeletionBatches == [[messageID]])
#expect(coordinator.retainedReconnectRetryCount == 0)
#expect(coordinator.retainedReconnectRetryBytes == 0)
#expect(context.cancelledTransfers == [transferID])
#expect(context.removedMessages.isEmpty)
coordinator.peerDidReconnect(peerID)
#expect(context.privateFileSends.count == 1)
context.resolveNextDeletionPersistence(true)
#expect(context.removedMessages.map(\.messageID) == [messageID])
}
@Test @MainActor
func legacyCleanupNeverRecursivelyDeletesDirectoryTarget() throws {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let incoming = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
.appendingPathComponent(
"files/images/incoming",
isDirectory: true
)
let directoryName = "cleanup-dir-\(UUID().uuidString)"
let directory = incoming.appendingPathComponent(
directoryName,
isDirectory: true
)
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true
)
let child = directory.appendingPathComponent("child.jpg")
try Data([0x01]).write(to: child)
defer { try? FileManager.default.removeItem(at: directory) }
let message = BitchatMessage(
id: UUID().uuidString,
sender: "Peer",
content:
"\(MimeType.Category.image.messagePrefix)\(directoryName)",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Me"
)
coordinator.cleanupIncomingLocalFile(forMessage: message)
#expect(FileManager.default.fileExists(atPath: directory.path))
#expect(FileManager.default.fileExists(atPath: child.path))
} }
@Test @MainActor @Test @MainActor
@@ -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,7 @@ 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
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))
@@ -191,8 +192,14 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
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 +286,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,
@@ -412,6 +424,25 @@ 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 @Test @MainActor
func handleViewingThisChat_clearsUnreadAndSendsRoutedReadReceiptOnce() async { func handleViewingThisChat_clearsUnreadAndSendsRoutedReadReceiptOnce() async {
let context = MockChatPrivateConversationContext() let context = MockChatPrivateConversationContext()
+86 -24
View File
@@ -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 {
_ = try NostrProtocol.createPrivateEnvelope(
content: content, content: content,
recipientPubkey: recipient.publicKeyHex, recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender senderIdentity: sender
) )
Issue.record("Expected oversized private-envelope plaintext to be rejected")
viewModel.subscribeGiftWrap(giftWrap, id: recipient) } 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
+753
View File
@@ -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 {
+42 -1
View File
@@ -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,11 @@ 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 persistDeletedPrivateMediaResult = true
var deferDeletedPrivateMediaPersistence = false
private var pendingDeletedPrivateMediaCompletions: [
@MainActor (Bool) -> Void
] = []
private let mockKeychain = MockKeychain() private let mockKeychain = MockKeychain()
// MARK: - Transport Protocol Implementation // MARK: - Transport Protocol Implementation
@@ -217,6 +227,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 +302,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
@@ -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"}
+484 -56
View File
@@ -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,37 +49,37 @@ 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
) )
@@ -90,74 +87,431 @@ struct NostrProtocolTests {
#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()
let recipient = try NostrIdentity.generate() let recipient = try NostrIdentity.generate()
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(
giftWrap: giftWrap,
recipientIdentity: wrongRecipient 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)")
}
}
} }
@@ -21,9 +21,12 @@ struct BLEFileTransferHandlerTests {
var receiptCommits: [(messageID: String, storedURL: URL)] = [] var receiptCommits: [(messageID: String, storedURL: URL)] = []
var receiptCommitSucceeds = true var receiptCommitSucceeds = true
var removedIncomingFiles: [URL] = [] var removedIncomingFiles: [URL] = []
var finishedIncomingFileDeliveries: [URL] = []
var lastSeenUpdates: [PeerID] = [] var lastSeenUpdates: [PeerID] = []
var deliveryAcks: [(messageID: String, peerID: PeerID)] = [] var deliveryAcks: [(messageID: String, peerID: PeerID)] = []
var deliveredMessages: [BitchatMessage] = [] var deliveredMessages: [BitchatMessage] = []
var shouldAcceptDelivery = true
var deliveryOutcome = TransportEventDeliveryOutcome.accepted
var saveOverride: (( var saveOverride: ((
_ data: Data, _ data: Data,
_ preferredName: String?, _ preferredName: String?,
@@ -34,12 +37,85 @@ struct BLEFileTransferHandlerTests {
var receiptStateOverride: ((String) -> BLEPrivateMediaReceiptState)? var receiptStateOverride: ((String) -> BLEPrivateMediaReceiptState)?
var receiptCommitOverride: ((String, URL) -> Bool)? var receiptCommitOverride: ((String, URL) -> Bool)?
var removeIncomingFileOverride: ((URL) -> Void)? 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 },
@@ -86,6 +162,10 @@ struct BLEFileTransferHandlerTests {
recorder.removedIncomingFiles.append(storedURL) recorder.removedIncomingFiles.append(storedURL)
recorder.removeIncomingFileOverride?(storedURL) recorder.removeIncomingFileOverride?(storedURL)
}, },
finishIncomingFileDelivery: { storedURL in
recorder.finishedIncomingFileDeliveries.append(storedURL)
recorder.finishIncomingFileDeliveryOverride?(storedURL)
},
isPrivateMediaSenderBlocked: { peerID in isPrivateMediaSenderBlocked: { peerID in
recorder.blockedPeers.contains(peerID) recorder.blockedPeers.contains(peerID)
}, },
@@ -95,10 +175,18 @@ struct BLEFileTransferHandlerTests {
acknowledgePrivateMedia: { messageID, peerID in acknowledgePrivateMedia: { messageID, peerID in
recorder.deliveryAcks.append((messageID, peerID)) recorder.deliveryAcks.append((messageID, peerID))
}, },
deliverMessage: { message, shouldDeliver, completion in deliverMessage: { message, shouldDeliver, completion, finalization in
var outcome = TransportEventDeliveryOutcome.rejected
defer { finalization(outcome) }
guard recorder.shouldAcceptDelivery else { return }
guard shouldDeliver() else { return } guard shouldDeliver() else { return }
recorder.deliveredMessages.append(message) recorder.deliveredMessages.append(message)
guard shouldDeliver() else { return } guard shouldDeliver() else { return }
if recorder.deliveryOutcome == .invokedUnconfirmed {
outcome = .invokedUnconfirmed
return
}
outcome = .accepted
completion() completion()
} }
) )
@@ -375,6 +463,73 @@ struct BLEFileTransferHandlerTests {
#expect(recorder.deliveryAcks.first?.messageID == recorder.deliveredMessages.first?.id) #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 @Test
func rawLegacyPrivateFileWithRetryShapedNameNeverUsesReceiptLedger() throws { func rawLegacyPrivateFileWithRetryShapedNameNeverUsesReceiptLedger() throws {
let recorder = Recorder() let recorder = Recorder()
@@ -402,6 +557,64 @@ struct BLEFileTransferHandlerTests {
#expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false) #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 @Test
func repeatedLegacyPrivateImageNamesKeepDistinctRandomMessageIDs() throws { func repeatedLegacyPrivateImageNamesKeepDistinctRandomMessageIDs() throws {
let recorder = Recorder() let recorder = Recorder()
@@ -502,12 +715,7 @@ struct BLEFileTransferHandlerTests {
nickname: "Alice", nickname: "Alice",
isVerified: true isVerified: true
)] )]
recorder.saveOverride = { recorder.saveOverride = { data, preferredName, subdirectory, fallbackExtension, defaultPrefix in
data,
preferredName,
subdirectory,
fallbackExtension,
defaultPrefix in
store.save( store.save(
data: data, data: data,
preferredName: preferredName, preferredName: preferredName,
@@ -525,6 +733,9 @@ struct BLEFileTransferHandlerTests {
recorder.removeIncomingFileOverride = { recorder.removeIncomingFileOverride = {
store.removeIncomingFile(at: $0) store.removeIncomingFile(at: $0)
} }
recorder.finishIncomingFileDeliveryOverride = {
store.finishIncomingFileDelivery(at: $0)
}
} }
let first = Recorder() let first = Recorder()
@@ -880,6 +1091,21 @@ struct BLEFileTransferHandlerTests {
let messageID = "media-00112233445566778899aabbccddeeff" let messageID = "media-00112233445566778899aabbccddeeff"
let seed = BLEPrivateMediaReceiptStore(baseDirectory: base) 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)) #expect(seed.recordDeleted(messageID: messageID))
let store = BLEIncomingFileStore(baseDirectory: base) let store = BLEIncomingFileStore(baseDirectory: base)
@@ -896,6 +1122,52 @@ struct BLEFileTransferHandlerTests {
) )
} }
@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 }
@@ -4,8 +4,22 @@ import Testing
struct BLEPrivateMediaReceiptStoreTests { struct BLEPrivateMediaReceiptStoreTests {
private struct TestError: Error {} 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 messageID = "media-00112233445566778899aabbccddeeff"
private let secondMessageID = "media-ffeeddccbbaa99887766554433221100"
@Test @Test
func acceptedReceiptPersistsAcrossStoreInstances() throws { func acceptedReceiptPersistsAcrossStoreInstances() throws {
@@ -21,6 +35,215 @@ struct BLEPrivateMediaReceiptStoreTests {
#expect(relaunched.state(for: messageID) == .accepted(payload)) #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 @Test
func directoryEnumerationFailureIsUnavailableAndRetriesWithoutCachingEmpty() throws { func directoryEnumerationFailureIsUnavailableAndRetriesWithoutCachingEmpty() throws {
let root = makeRoot("list-failure") let root = makeRoot("list-failure")
@@ -128,25 +351,447 @@ struct BLEPrivateMediaReceiptStoreTests {
} }
@Test @Test
func failedTombstonePersistenceDoesNotPoisonVolatileState() throws { func deletionBatchCommitsEveryIDBeforeRemovingPayloads() throws {
let root = makeRoot("failed-tombstone-write") 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) } defer { try? FileManager.default.removeItem(at: root) }
let store = BLEPrivateMediaReceiptStore(baseDirectory: root) let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(store.state(for: messageID) == .absent)
// Force the atomic record write itself to fail after the store has #expect(!store.recordDeleted(messageID: messageID))
// successfully loaded its empty index. #expect(store.state(for: messageID) == .absent)
let record = receiptRecord(in: root) #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( try FileManager.default.createDirectory(
at: record, at: receiptDirectory,
withIntermediateDirectories: true withIntermediateDirectories: true
) )
#expect(!store.recordDeleted(messageID: messageID)) let recordedAt = Date(timeIntervalSince1970: 3_000)
try FileManager.default.removeItem(at: record) 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
)
// The UI must be able to report the deletion failure without a let store = BLEPrivateMediaReceiptStore(
// process-lifetime tombstone silently hiding a later retry. baseDirectory: root,
ttl: 1,
now: { recordedAt.addingTimeInterval(2) }
)
#expect(store.state(for: messageID) == .absent) #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 @Test
@@ -180,7 +825,10 @@ struct BLEPrivateMediaReceiptStoreTests {
) )
} }
private func makePayload(in root: URL) throws -> URL { private func makePayload(
in root: URL,
name: String = "image.jpg"
) throws -> URL {
let directory = root.appendingPathComponent( let directory = root.appendingPathComponent(
"files/images/incoming", "files/images/incoming",
isDirectory: true isDirectory: true
@@ -189,18 +837,30 @@ struct BLEPrivateMediaReceiptStoreTests {
at: directory, at: directory,
withIntermediateDirectories: true withIntermediateDirectories: true
) )
let payload = directory.appendingPathComponent("image.jpg") let payload = directory.appendingPathComponent(name)
try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: payload) try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: payload)
return payload return payload
} }
private func receiptRecord(in root: URL) -> URL { private func receiptRecord(
in root: URL,
messageID requestedMessageID: String? = nil
) -> URL {
root root
.appendingPathComponent( .appendingPathComponent(
"files/.private-media-receipts", "files/.private-media-receipts",
isDirectory: true isDirectory: true
) )
.appendingPathComponent(messageID) .appendingPathComponent(requestedMessageID ?? messageID)
.appendingPathExtension("json") .appendingPathExtension("json")
} }
private func deletionJournal(in root: URL) -> URL {
root
.appendingPathComponent(
"files/.private-media-receipts",
isDirectory: true
)
.appendingPathComponent(".deletion-journal.json")
}
} }
@@ -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 reusednot re-admittedand 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)
} }
} }
+637 -40
View File
@@ -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()
} }
+2 -2
View File
@@ -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.