mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 20:25:19 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec6e98afeb | ||
|
|
377145763d | ||
|
|
fcb79d6263 | ||
|
|
898e783ae0 | ||
|
|
d202076925 | ||
|
|
54c73fd257 | ||
|
|
4bd6e3d445 | ||
|
|
62e9a1d83d |
+2
-2
@@ -73,7 +73,7 @@ Private group members receive the group's name, roster, key epoch, and encrypted
|
||||
|
||||
Internet-backed features are optional. When enabled or used:
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -103,7 +103,7 @@ Private and public features use different protections:
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- Public mesh, bridge, geohash, and board content is signed or authenticated as appropriate but is intentionally not confidential.
|
||||
|
||||
|
||||
+5
-1
@@ -63,7 +63,11 @@ let package = Package(
|
||||
// Only the vector fixture: declaring the whole "Noise"
|
||||
// directory would claim its .swift test files as resources
|
||||
// and silently drop them from compilation.
|
||||
.process("Noise/NoiseTestVectors.json")
|
||||
.process("Noise/NoiseTestVectors.json"),
|
||||
// Frozen output produced by the released 733098bb private-DM
|
||||
// implementation; proves receive compatibility independently
|
||||
// of the refactored legacy generator.
|
||||
.process("Nostr/Fixtures")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
@@ -19,7 +19,7 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file
|
||||
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
|
||||
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
|
||||
- **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
|
||||
- **Universal App**: Native support for iOS and macOS
|
||||
- **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
|
||||
- **Location Channels**: Geographic chat rooms using geohash coordinates
|
||||
- **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
|
||||
|
||||
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
|
||||
|
||||
#### `mesh #bluetooth`
|
||||
@@ -80,7 +121,7 @@ Private messages use **intelligent transport selection**:
|
||||
2. **Nostr Fallback** (when Bluetooth unavailable)
|
||||
|
||||
- Uses recipient's Nostr public key
|
||||
- NIP-17 gift-wrapping for privacy
|
||||
- BitChat's app-specific private-envelope encryption
|
||||
- Routes through global relay network
|
||||
|
||||
3. **Smart Queuing** (when neither available)
|
||||
|
||||
+10
-4
@@ -25,7 +25,7 @@ bitchat is a decentralized, peer-to-peer messaging application for secure, priva
|
||||
Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`:
|
||||
|
||||
* **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.
|
||||
|
||||
@@ -78,7 +78,9 @@ Courier envelopes are sealed to the recipient's *static* key with the one-way No
|
||||
|
||||
### 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
|
||||
|
||||
@@ -105,7 +107,11 @@ Public broadcast messages are cached (1000 packets) and reconciled between peers
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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.
|
||||
* **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.
|
||||
* **No forward secrecy for sealed mail** (§5.2) is the main cryptographic trade-off of the offline path.
|
||||
* **No forward secrecy for sealed mail or Nostr envelopes** (§5.2–5.3) means compromise of a recipient's static key can expose retained ciphertext addressed to that key.
|
||||
|
||||
## 9. Future Work
|
||||
|
||||
|
||||
@@ -426,6 +426,40 @@ final class ConversationStore: ObservableObject {
|
||||
@discardableResult
|
||||
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||
guard let ids = conversationIDsByMessageID[messageID] else { return false }
|
||||
return applyDeliveryStatus(status, forMessageID: messageID, among: ids)
|
||||
}
|
||||
|
||||
/// Applies an authenticated delivery/read receipt only to the supplied
|
||||
/// direct-conversation aliases. A colliding message ID in another peer's
|
||||
/// conversation (or a public timeline) must not inherit the receipt.
|
||||
///
|
||||
/// Stable and ephemeral aliases can temporarily hold the same message
|
||||
/// instance during handoff. The shared helper republishes every targeted
|
||||
/// alias even when the first mutation already changed that instance.
|
||||
@discardableResult
|
||||
func setDeliveryStatus(
|
||||
_ status: DeliveryStatus,
|
||||
forMessageID messageID: String,
|
||||
inDirectPeerAliases peerIDs: Set<PeerID>
|
||||
) -> Bool {
|
||||
guard !peerIDs.isEmpty,
|
||||
let indexedIDs = conversationIDsByMessageID[messageID] else {
|
||||
return false
|
||||
}
|
||||
let allowedIDs = Set(peerIDs.map { ConversationID.directPeer($0) })
|
||||
return applyDeliveryStatus(
|
||||
status,
|
||||
forMessageID: messageID,
|
||||
among: indexedIDs.intersection(allowedIDs)
|
||||
)
|
||||
}
|
||||
|
||||
private func applyDeliveryStatus(
|
||||
_ status: DeliveryStatus,
|
||||
forMessageID messageID: String,
|
||||
among ids: Set<ConversationID>
|
||||
) -> Bool {
|
||||
guard !ids.isEmpty else { return false }
|
||||
var applied = false
|
||||
var skipped: [ConversationID] = []
|
||||
for id in ids {
|
||||
|
||||
@@ -265,10 +265,10 @@ final class PrivateConversationModel: ObservableObject {
|
||||
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
||||
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
||||
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
||||
// Geo DMs are always routed over Nostr (NIP-17); their nostr_ keys
|
||||
// never resolve to a reachable mesh peer, so resolveAvailability would
|
||||
// report .offline. Report .nostrAvailable so the header shows the
|
||||
// globe instead of a misleading "offline" tag.
|
||||
// Geo DMs are always routed through BitChat private envelopes over
|
||||
// Nostr; their nostr_ keys never resolve to a reachable mesh peer, so
|
||||
// resolveAvailability would report .offline. Report .nostrAvailable
|
||||
// so the header shows the globe instead of a misleading "offline" tag.
|
||||
let availability = conversationPeerID.isGeoDM
|
||||
? .nostrAvailable
|
||||
: resolveAvailability(for: headerPeerID, peer: peer)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import Foundation
|
||||
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 {
|
||||
let privateKey: Data
|
||||
let publicKey: Data
|
||||
|
||||
+441
-231
@@ -1,4 +1,3 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import P256K
|
||||
@@ -7,16 +6,31 @@ import Security
|
||||
// Note: This file depends on Data extension from BinaryEncodingUtils.swift
|
||||
// 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 {
|
||||
|
||||
/// Nostr event kinds
|
||||
enum EventKind: Int {
|
||||
case metadata = 0
|
||||
case textNote = 1
|
||||
case dm = 14 // NIP-17 DM rumor kind
|
||||
case seal = 13 // NIP-17 sealed event
|
||||
case giftWrap = 1059 // NIP-59 gift wrap
|
||||
// Compatibility for BitChat releases that incorrectly emitted the
|
||||
// proprietary payload under standard NIP kinds. Kind 1059 continues
|
||||
// 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 geohashPresence = 20001
|
||||
case deletion = 5 // NIP-09 event deletion request
|
||||
@@ -25,145 +39,286 @@ struct NostrProtocol {
|
||||
/// its NIP-40 expiration — the whole point is store-and-forward.
|
||||
case courierDrop = 1401
|
||||
}
|
||||
|
||||
/// Prefix for BitChat private-envelope ciphertext. The suffix is
|
||||
/// base64url(nonce24 || ciphertext || poly1305Tag).
|
||||
static let privateEnvelopeContentPrefix = "bitchat-pm-v1:"
|
||||
|
||||
/// Bound work before Base64 decoding either encrypted layer. Current
|
||||
/// private messages are normally only a few KiB; 64 KiB leaves ample
|
||||
/// migration headroom without allowing an addressed relay event to drive
|
||||
/// unbounded allocation.
|
||||
static let maximumPrivateEnvelopeCiphertextBytes = 64 * 1024
|
||||
|
||||
/// Bound the inner authenticated message JSON before allocation/parsing.
|
||||
static let maximumPrivateEnvelopePlaintextBytes = 32 * 1024
|
||||
|
||||
/// The outer authenticated seal JSON contains a Base64-encoded encrypted
|
||||
/// copy of the inner JSON, so it needs expansion headroom of its own. Keep
|
||||
/// the layer-specific cap below the public ciphertext ceiling.
|
||||
private static let maximumPrivateEnvelopeSealPlaintextBytes = 48 * 1024
|
||||
|
||||
/// New clients subscribe to the provisional BitChat-specific kind and the
|
||||
/// compatibility legacy kind so both sides of a rolling rollout can
|
||||
/// recover stored messages. Do not remove kind 1059 here until all
|
||||
/// supported iOS and Android releases have migrated.
|
||||
static let acceptedPrivateEnvelopeKinds = [
|
||||
EventKind.privateEnvelope.rawValue,
|
||||
EventKind.legacyNIP59GiftWrap.rawValue
|
||||
]
|
||||
|
||||
private enum PrivateEnvelopeWireFormat {
|
||||
case bitchatV1
|
||||
case legacyMislabelledV2
|
||||
|
||||
init?(outerKind: Int) {
|
||||
switch outerKind {
|
||||
case EventKind.privateEnvelope.rawValue:
|
||||
self = .bitchatV1
|
||||
case EventKind.legacyNIP59GiftWrap.rawValue:
|
||||
self = .legacyMislabelledV2
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var messageKind: EventKind {
|
||||
switch self {
|
||||
case .bitchatV1: .privateMessage
|
||||
case .legacyMislabelledV2: .legacyNIP17DirectMessage
|
||||
}
|
||||
}
|
||||
|
||||
var sealKind: EventKind {
|
||||
switch self {
|
||||
case .bitchatV1: .privateSeal
|
||||
case .legacyMislabelledV2: .legacyNIP59Seal
|
||||
}
|
||||
}
|
||||
|
||||
var envelopeKind: EventKind {
|
||||
switch self {
|
||||
case .bitchatV1: .privateEnvelope
|
||||
case .legacyMislabelledV2: .legacyNIP59GiftWrap
|
||||
}
|
||||
}
|
||||
|
||||
var contentPrefix: String {
|
||||
switch self {
|
||||
case .bitchatV1: NostrProtocol.privateEnvelopeContentPrefix
|
||||
case .legacyMislabelledV2: "v2:"
|
||||
}
|
||||
}
|
||||
|
||||
var hkdfSalt: Data {
|
||||
switch self {
|
||||
case .bitchatV1: Data("bitchat-private-envelope-v1".utf8)
|
||||
case .legacyMislabelledV2: Data()
|
||||
}
|
||||
}
|
||||
|
||||
var hkdfInfo: Data {
|
||||
switch self {
|
||||
case .bitchatV1: Data()
|
||||
case .legacyMislabelledV2: Data("nip44-v2".utf8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a NIP-17 private message
|
||||
static func createPrivateMessage(
|
||||
/// Create a BitChat private envelope for relay transport.
|
||||
static func createPrivateEnvelope(
|
||||
content: String,
|
||||
recipientPubkey: String,
|
||||
senderIdentity: NostrIdentity
|
||||
) throws -> NostrEvent {
|
||||
|
||||
// Creating private message
|
||||
|
||||
// 1. Create the rumor (unsigned event)
|
||||
let rumor = NostrEvent(
|
||||
try createPrivateEnvelope(
|
||||
content: content,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderIdentity: senderIdentity,
|
||||
format: .bitchatV1
|
||||
)
|
||||
}
|
||||
|
||||
/// Events to publish for one logical private payload. The primary
|
||||
/// BitChat-specific format is always first and a legacy copy follows for
|
||||
/// clients that still subscribe only to kind 1059. There is deliberately
|
||||
/// no date-based cutoff: removal requires a coordinated iOS/Android
|
||||
/// release after supported old clients have migrated. Both encrypt the
|
||||
/// exact same embedded BitChat payload, so receive-side logical-payload
|
||||
/// dedup collapses the pair.
|
||||
static func createPrivateEnvelopePublicationBatch(
|
||||
content: String,
|
||||
recipientPubkey: String,
|
||||
senderIdentity: NostrIdentity
|
||||
) throws -> [NostrEvent] {
|
||||
let primary = try createPrivateEnvelope(
|
||||
content: content,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderIdentity: senderIdentity
|
||||
)
|
||||
let compatibilityCopy = try createPrivateEnvelope(
|
||||
content: content,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderIdentity: senderIdentity,
|
||||
format: .legacyMislabelledV2
|
||||
)
|
||||
return [primary, compatibilityCopy]
|
||||
}
|
||||
|
||||
private static func createPrivateEnvelope(
|
||||
content: String,
|
||||
recipientPubkey: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
format: PrivateEnvelopeWireFormat,
|
||||
messageTags: [[String]] = []
|
||||
) throws -> NostrEvent {
|
||||
// 1. Create the unsigned inner BitChat message.
|
||||
let message = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .dm, // NIP-17: DM rumor kind 14
|
||||
tags: [],
|
||||
kind: format.messageKind,
|
||||
tags: messageTags,
|
||||
content: content
|
||||
)
|
||||
|
||||
// 2. Seal the rumor (encrypt to recipient) and sign it with the SENDER'S
|
||||
// real identity key. NIP-17 requires the seal be signed by the sender
|
||||
// so the recipient can authenticate who sent the message; signing with
|
||||
// a throwaway key leaves DMs forgeable/impersonatable.
|
||||
// 2. Encrypt the message to the recipient and sign the private seal
|
||||
// with the sender's stable Nostr identity for sender authentication.
|
||||
let senderKey = try senderIdentity.schnorrSigningKey()
|
||||
let sealedEvent = try createSeal(
|
||||
rumor: rumor,
|
||||
let sealedEvent = try createPrivateSeal(
|
||||
message: message,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderKey: senderKey
|
||||
senderKey: senderKey,
|
||||
format: format
|
||||
)
|
||||
|
||||
// 3. Gift wrap the sealed event with a throwaway ephemeral key (the wrap
|
||||
// layer hides the sender's identity from relays; createGiftWrap mints
|
||||
// its own ephemeral key internally).
|
||||
let giftWrap = try createGiftWrap(
|
||||
// 3. Encrypt the seal under a one-time key so the public envelope does
|
||||
// not reveal the stable sender identity.
|
||||
return try createPrivateEnvelopeEvent(
|
||||
seal: sealedEvent,
|
||||
recipientPubkey: recipientPubkey
|
||||
recipientPubkey: recipientPubkey,
|
||||
format: format
|
||||
)
|
||||
|
||||
// Created gift wrap
|
||||
|
||||
return giftWrap
|
||||
}
|
||||
|
||||
/// Decrypt a received NIP-17 message
|
||||
/// Returns the content, sender pubkey, and the actual message timestamp (not the randomized gift wrap timestamp)
|
||||
static func decryptPrivateMessage(
|
||||
giftWrap: NostrEvent,
|
||||
/// Decrypt a BitChat private envelope. Legacy proprietary envelopes that
|
||||
/// older BitChat releases placed under kinds 1059/13/14 are accepted only
|
||||
/// through the format-isolated receive path.
|
||||
static func decryptPrivateEnvelope(
|
||||
envelope: NostrEvent,
|
||||
recipientIdentity: NostrIdentity
|
||||
) throws -> (content: String, senderPubkey: String, timestamp: Int) {
|
||||
|
||||
// Starting decryption
|
||||
|
||||
// 1. Unwrap the gift wrap
|
||||
let seal: NostrEvent
|
||||
do {
|
||||
seal = try unwrapGiftWrap(
|
||||
giftWrap: giftWrap,
|
||||
recipientKey: recipientIdentity.schnorrSigningKey()
|
||||
)
|
||||
// Successfully unwrapped gift wrap
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to unwrap gift wrap: \(error)", category: .session)
|
||||
throw error
|
||||
}
|
||||
|
||||
// 2. Authenticate the seal. The seal MUST be signed by the sender's real
|
||||
// identity key (NIP-17); without this check a DM is forgeable by anyone
|
||||
// who knows the recipient's npub. Verify the seal's own signature.
|
||||
guard seal.isValidSignature() else {
|
||||
SecureLogger.error("❌ Rejecting DM: seal signature is missing or invalid", category: .session)
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
// 3. Open the seal
|
||||
let rumor: NostrEvent
|
||||
do {
|
||||
rumor = try openSeal(
|
||||
seal: seal,
|
||||
recipientKey: recipientIdentity.schnorrSigningKey()
|
||||
)
|
||||
// Successfully opened seal
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to open seal: \(error)", category: .session)
|
||||
throw error
|
||||
}
|
||||
|
||||
// 4. The sender claimed inside the rumor must match the key that actually
|
||||
// signed the seal, otherwise the sender field is unauthenticated and
|
||||
// spoofable.
|
||||
guard seal.pubkey == rumor.pubkey else {
|
||||
SecureLogger.error("❌ Rejecting DM: rumor pubkey does not match seal signer", category: .session)
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
// Return the seal signer's pubkey as the authenticated sender.
|
||||
return (content: rumor.content, senderPubkey: seal.pubkey, timestamp: rumor.created_at)
|
||||
let layers = try decodePrivateEnvelopeLayers(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipientIdentity
|
||||
)
|
||||
return (
|
||||
content: layers.message.content,
|
||||
senderPubkey: layers.seal.pubkey,
|
||||
timestamp: layers.message.created_at
|
||||
)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
static func createPrivateMessageWithInvalidSealSignatureForTesting(
|
||||
static func createPrivateEnvelopeWithInvalidSealSignatureForTesting(
|
||||
content: String,
|
||||
recipientPubkey: String,
|
||||
senderIdentity: NostrIdentity
|
||||
) throws -> NostrEvent {
|
||||
let rumor = NostrEvent(
|
||||
let format = PrivateEnvelopeWireFormat.bitchatV1
|
||||
let message = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .dm,
|
||||
kind: format.messageKind,
|
||||
tags: [],
|
||||
content: content
|
||||
)
|
||||
var seal = try createSeal(
|
||||
rumor: rumor,
|
||||
var seal = try createPrivateSeal(
|
||||
message: message,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderKey: senderIdentity.schnorrSigningKey()
|
||||
senderKey: senderIdentity.schnorrSigningKey(),
|
||||
format: format
|
||||
)
|
||||
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,
|
||||
recipientPubkey: String,
|
||||
rumorIdentity: NostrIdentity,
|
||||
messageIdentity: NostrIdentity,
|
||||
sealSignerIdentity: NostrIdentity
|
||||
) throws -> NostrEvent {
|
||||
let rumor = NostrEvent(
|
||||
pubkey: rumorIdentity.publicKeyHex,
|
||||
let format = PrivateEnvelopeWireFormat.bitchatV1
|
||||
let message = NostrEvent(
|
||||
pubkey: messageIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .dm,
|
||||
kind: format.messageKind,
|
||||
tags: [],
|
||||
content: content
|
||||
)
|
||||
let seal = try createSeal(
|
||||
rumor: rumor,
|
||||
let seal = try createPrivateSeal(
|
||||
message: message,
|
||||
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
|
||||
|
||||
@@ -400,151 +555,207 @@ struct NostrProtocol {
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private static func createSeal(
|
||||
rumor: NostrEvent,
|
||||
private static func createPrivateSeal(
|
||||
message: NostrEvent,
|
||||
recipientPubkey: String,
|
||||
senderKey: P256K.Schnorr.PrivateKey
|
||||
senderKey: P256K.Schnorr.PrivateKey,
|
||||
format: PrivateEnvelopeWireFormat
|
||||
) throws -> NostrEvent {
|
||||
|
||||
let rumorJSON = try rumor.jsonString()
|
||||
let encrypted = try encrypt(
|
||||
plaintext: rumorJSON,
|
||||
plaintext: message.jsonString(),
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderKey: senderKey
|
||||
senderKey: senderKey,
|
||||
format: format,
|
||||
maximumPlaintextBytes: maximumPrivateEnvelopePlaintextBytes
|
||||
)
|
||||
|
||||
|
||||
let seal = NostrEvent(
|
||||
pubkey: Data(senderKey.xonly.bytes).hexEncodedString(),
|
||||
createdAt: randomizedTimestamp(),
|
||||
kind: .seal,
|
||||
createdAt: randomizedPastTimestamp(),
|
||||
kind: format.sealKind,
|
||||
tags: [],
|
||||
content: encrypted
|
||||
)
|
||||
|
||||
// Sign the seal with the sender's Schnorr private key
|
||||
return try seal.sign(with: senderKey)
|
||||
}
|
||||
|
||||
private static func createGiftWrap(
|
||||
seal: NostrEvent,
|
||||
recipientPubkey: String
|
||||
) throws -> NostrEvent {
|
||||
|
||||
let sealJSON = try seal.jsonString()
|
||||
|
||||
// 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)
|
||||
private static func createPrivateEnvelopeEvent(
|
||||
seal: NostrEvent,
|
||||
recipientPubkey: String,
|
||||
format: PrivateEnvelopeWireFormat
|
||||
) throws -> NostrEvent {
|
||||
// A fresh signing/encryption key for every public envelope keeps the
|
||||
// stable sender identity inside ciphertext.
|
||||
let envelopeKey = try P256K.Schnorr.PrivateKey()
|
||||
let encrypted = try encrypt(
|
||||
plaintext: sealJSON,
|
||||
plaintext: seal.jsonString(),
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderKey: wrapKey // Use the gift wrap ephemeral key
|
||||
senderKey: envelopeKey,
|
||||
format: format,
|
||||
maximumPlaintextBytes: maximumPrivateEnvelopeSealPlaintextBytes
|
||||
)
|
||||
|
||||
let giftWrap = NostrEvent(
|
||||
pubkey: Data(wrapKey.xonly.bytes).hexEncodedString(),
|
||||
createdAt: randomizedTimestamp(),
|
||||
kind: .giftWrap,
|
||||
tags: [["p", recipientPubkey]], // Tag recipient
|
||||
|
||||
let envelope = NostrEvent(
|
||||
pubkey: Data(envelopeKey.xonly.bytes).hexEncodedString(),
|
||||
createdAt: randomizedPastTimestamp(),
|
||||
kind: format.envelopeKind,
|
||||
tags: [["p", recipientPubkey]],
|
||||
content: encrypted
|
||||
)
|
||||
|
||||
// Sign the gift wrap with the wrap Schnorr private key
|
||||
return try giftWrap.sign(with: wrapKey)
|
||||
return try envelope.sign(with: envelopeKey)
|
||||
}
|
||||
|
||||
private static func unwrapGiftWrap(
|
||||
giftWrap: NostrEvent,
|
||||
recipientKey: P256K.Schnorr.PrivateKey
|
||||
) throws -> NostrEvent {
|
||||
|
||||
// Unwrapping gift wrap
|
||||
|
||||
let decrypted = try decrypt(
|
||||
ciphertext: giftWrap.content,
|
||||
senderPubkey: giftWrap.pubkey,
|
||||
recipientKey: recipientKey
|
||||
)
|
||||
|
||||
guard let data = decrypted.data(using: .utf8),
|
||||
let sealDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
|
||||
private static func decodePrivateEnvelopeLayers(
|
||||
envelope: NostrEvent,
|
||||
recipientIdentity: NostrIdentity
|
||||
) throws -> (seal: NostrEvent, message: NostrEvent) {
|
||||
guard envelope.content.utf8.count <= maximumPrivateEnvelopeCiphertextBytes else {
|
||||
throw NostrError.invalidCiphertext
|
||||
}
|
||||
guard let format = PrivateEnvelopeWireFormat(outerKind: envelope.kind),
|
||||
envelope.tags == [["p", recipientIdentity.publicKeyHex]],
|
||||
envelope.isValidSignature() else {
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
let seal = try NostrEvent(from: sealDict)
|
||||
// Unwrapped seal
|
||||
|
||||
return seal
|
||||
}
|
||||
|
||||
private static func openSeal(
|
||||
seal: NostrEvent,
|
||||
recipientKey: P256K.Schnorr.PrivateKey
|
||||
) throws -> NostrEvent {
|
||||
|
||||
let decrypted = try decrypt(
|
||||
|
||||
let recipientKey = try recipientIdentity.schnorrSigningKey()
|
||||
let sealJSON = try decrypt(
|
||||
ciphertext: envelope.content,
|
||||
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
|
||||
}
|
||||
|
||||
let messageJSON = try decrypt(
|
||||
ciphertext: seal.content,
|
||||
senderPubkey: seal.pubkey,
|
||||
recipientKey: recipientKey
|
||||
recipientKey: recipientKey,
|
||||
format: format,
|
||||
maximumPlaintextBytes: maximumPrivateEnvelopePlaintextBytes
|
||||
)
|
||||
|
||||
guard let data = decrypted.data(using: .utf8),
|
||||
let rumorDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
let message = try decodePrivateEnvelopeEventJSON(
|
||||
messageJSON,
|
||||
maximumBytes: maximumPrivateEnvelopePlaintextBytes
|
||||
)
|
||||
|
||||
// The inner message is intentionally unsigned; sender authentication
|
||||
// comes from the seal. Bind its claimed sender and custom kind to that
|
||||
// authenticated layer before exposing content.
|
||||
guard message.kind == format.messageKind.rawValue,
|
||||
validInnerMessageTags(
|
||||
message.tags,
|
||||
format: format,
|
||||
recipientPubkey: recipientIdentity.publicKeyHex
|
||||
),
|
||||
message.sig == nil,
|
||||
seal.pubkey == message.pubkey else {
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
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(
|
||||
plaintext: String,
|
||||
recipientPubkey: String,
|
||||
senderKey: P256K.Schnorr.PrivateKey
|
||||
senderKey: P256K.Schnorr.PrivateKey,
|
||||
format: PrivateEnvelopeWireFormat,
|
||||
maximumPlaintextBytes: Int
|
||||
) throws -> String {
|
||||
|
||||
guard let recipientPubkeyData = Data(hexString: recipientPubkey) else {
|
||||
throw NostrError.invalidPublicKey
|
||||
}
|
||||
|
||||
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
|
||||
|
||||
// Derive shared secret
|
||||
|
||||
let sharedSecret = try deriveSharedSecret(
|
||||
privateKey: senderKey,
|
||||
publicKey: recipientPubkeyData
|
||||
)
|
||||
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info)
|
||||
let key = try deriveNIP44V2Key(from: sharedSecret)
|
||||
|
||||
// 24-byte random nonce for XChaCha20-Poly1305
|
||||
let key = derivePrivateEnvelopeKey(from: sharedSecret, format: format)
|
||||
|
||||
var nonce24 = Data(count: 24)
|
||||
_ = nonce24.withUnsafeMutableBytes { ptr in
|
||||
let randomStatus = nonce24.withUnsafeMutableBytes { ptr in
|
||||
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
|
||||
}
|
||||
|
||||
let pt = Data(plaintext.utf8)
|
||||
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
|
||||
|
||||
// v2: base64url(nonce24 || ciphertext || tag)
|
||||
guard randomStatus == errSecSuccess else {
|
||||
throw NostrError.cryptographicFailure
|
||||
}
|
||||
|
||||
let plaintextData = Data(plaintext.utf8)
|
||||
guard plaintextData.count <= maximumPlaintextBytes else {
|
||||
throw NostrError.invalidCiphertext
|
||||
}
|
||||
let sealed = try XChaCha20Poly1305Compat.seal(
|
||||
plaintext: plaintextData,
|
||||
key: key,
|
||||
nonce24: nonce24
|
||||
)
|
||||
|
||||
var combined = Data()
|
||||
combined.append(nonce24)
|
||||
combined.append(sealed.ciphertext)
|
||||
combined.append(sealed.tag)
|
||||
return "v2:" + Base64URLCoding.encode(combined)
|
||||
return format.contentPrefix + Base64URLCoding.encode(combined)
|
||||
}
|
||||
|
||||
|
||||
private static func decrypt(
|
||||
ciphertext: String,
|
||||
senderPubkey: String,
|
||||
recipientKey: P256K.Schnorr.PrivateKey
|
||||
recipientKey: P256K.Schnorr.PrivateKey,
|
||||
format: PrivateEnvelopeWireFormat,
|
||||
maximumPlaintextBytes: Int
|
||||
) throws -> String {
|
||||
// Expect NIP-44 v2 format
|
||||
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
|
||||
let encoded = String(ciphertext.dropFirst(3))
|
||||
guard ciphertext.utf8.count <= maximumPrivateEnvelopeCiphertextBytes,
|
||||
ciphertext.hasPrefix(format.contentPrefix) else {
|
||||
throw NostrError.invalidCiphertext
|
||||
}
|
||||
let encoded = String(ciphertext.dropFirst(format.contentPrefix.count))
|
||||
guard let data = Base64URLCoding.decode(encoded),
|
||||
data.count > (24 + 16),
|
||||
let senderPubkeyData = Data(hexString: senderPubkey) else {
|
||||
@@ -554,33 +765,40 @@ struct NostrProtocol {
|
||||
let nonce24 = data.prefix(24)
|
||||
let rest = data.dropFirst(24)
|
||||
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 pubKeyData: Data) throws -> Data {
|
||||
let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData)
|
||||
let key = try deriveNIP44V2Key(from: ss)
|
||||
func attemptDecrypt(using publicKeyData: Data) throws -> Data {
|
||||
let sharedSecret = try deriveSharedSecret(
|
||||
privateKey: recipientKey,
|
||||
publicKey: publicKeyData
|
||||
)
|
||||
let key = derivePrivateEnvelopeKey(from: sharedSecret, format: format)
|
||||
return try XChaCha20Poly1305Compat.open(
|
||||
ciphertext: Data(ct),
|
||||
ciphertext: Data(ciphertextBytes),
|
||||
tag: Data(tag),
|
||||
key: key,
|
||||
nonce24: Data(nonce24)
|
||||
)
|
||||
}
|
||||
|
||||
// If 32 bytes (x-only) try both parities, otherwise single try
|
||||
let plaintext: Data
|
||||
if senderPubkeyData.count == 32 {
|
||||
let even = Data([0x02]) + senderPubkeyData
|
||||
if let pt = try? attemptDecrypt(using: even) {
|
||||
return String(data: pt, encoding: .utf8) ?? ""
|
||||
let evenKey = Data([0x02]) + senderPubkeyData
|
||||
if let opened = try? attemptDecrypt(using: evenKey) {
|
||||
plaintext = opened
|
||||
} else {
|
||||
let oddKey = Data([0x03]) + senderPubkeyData
|
||||
plaintext = try attemptDecrypt(using: oddKey)
|
||||
}
|
||||
let odd = Data([0x03]) + senderPubkeyData
|
||||
let pt = try attemptDecrypt(using: odd)
|
||||
return String(data: pt, encoding: .utf8) ?? ""
|
||||
} else {
|
||||
let pt = try attemptDecrypt(using: senderPubkeyData)
|
||||
return String(data: pt, encoding: .utf8) ?? ""
|
||||
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(
|
||||
@@ -640,29 +858,17 @@ struct NostrProtocol {
|
||||
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
||||
// 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
|
||||
}
|
||||
|
||||
private static func randomizedTimestamp() -> Date {
|
||||
// Add random offset to current time for privacy
|
||||
// This prevents timing correlation attacks while the actual message timestamp
|
||||
// is preserved in the encrypted rumor
|
||||
let offset = TimeInterval.random(in: -900...900) // +/- 15 minutes
|
||||
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
|
||||
private static func randomizedPastTimestamp() -> Date {
|
||||
// Keep public timestamps in the past: future-dated events are rejected
|
||||
// by some relays. The actual message timestamp remains encrypted.
|
||||
Date().addingTimeInterval(
|
||||
-TimeInterval.random(in: 0...TransportConfig.nostrPrivateEnvelopeTimestampFuzzSeconds)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -794,16 +1000,20 @@ enum NostrError: Error {
|
||||
case invalidPublicKey
|
||||
case invalidEvent
|
||||
case invalidCiphertext
|
||||
case cryptographicFailure
|
||||
}
|
||||
|
||||
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
|
||||
// MARK: - BitChat private-envelope key derivation
|
||||
|
||||
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(
|
||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
||||
salt: Data(),
|
||||
info: Data("nip44-v2".utf8),
|
||||
salt: format.hkdfSalt,
|
||||
info: format.hkdfInfo,
|
||||
outputByteCount: 32
|
||||
)
|
||||
return derivedKey.withUnsafeBytes { Data($0) }
|
||||
|
||||
@@ -106,10 +106,11 @@ private extension NostrRelayManagerDependencies {
|
||||
@MainActor
|
||||
final class NostrRelayManager: ObservableObject {
|
||||
static let shared = NostrRelayManager()
|
||||
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info
|
||||
private(set) static var pendingGiftWrapIDs = Set<String>()
|
||||
static func registerPendingGiftWrap(id: String) {
|
||||
pendingGiftWrapIDs.insert(id)
|
||||
// Track BitChat private envelopes we initiated so relay rejections can be
|
||||
// reported without misclassifying ordinary public-event failures.
|
||||
private(set) static var pendingPrivateEnvelopeIDs = Set<String>()
|
||||
static func registerPendingPrivateEnvelope(id: String) {
|
||||
pendingPrivateEnvelopeIDs.insert(id)
|
||||
}
|
||||
|
||||
struct Relay: Identifiable {
|
||||
@@ -124,7 +125,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
var nextReconnectTime: Date?
|
||||
}
|
||||
|
||||
// Default relays carry NIP-17 gift wraps, so avoid relays known to reject kind 1059.
|
||||
// Default relays carry persisted BitChat private-envelope events.
|
||||
private static let defaultRelays = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
@@ -137,7 +138,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
@Published private(set) var relays: [Relay] = []
|
||||
@Published private(set) var isConnected = false
|
||||
/// Whether a relay that carries private messages is connected. DMs
|
||||
/// target the default (gift-wrap-capable) relay set, so a connected
|
||||
/// target the default private-envelope relay set, so a connected
|
||||
/// geohash/custom relay alone must not count — sends would still queue.
|
||||
@Published private(set) var isDMRelayConnected = false
|
||||
|
||||
@@ -174,6 +175,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
private var subscribeCoalesce: [String: Date] = [:]
|
||||
private var pendingTorConnectionURLs = Set<String>()
|
||||
private var awaitingTorForConnections = false
|
||||
private var awaitingTorForQueueFlush = false
|
||||
private var torReadyWaitAttempts = 0
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
@@ -208,16 +210,37 @@ final class NostrRelayManager: ObservableObject {
|
||||
private var eoseTrackerEpoch = 0
|
||||
private var pendingEOSECallbacks: [String: () -> Void] = [:]
|
||||
|
||||
// Message queue for reliability
|
||||
// Pending sends held only for relays that are not yet connected.
|
||||
// Message queue for reliability. Pending sends are grouped so a private
|
||||
// envelope's primary and legacy migration copies can never be split by
|
||||
// queue eviction. Private batches are protected; lower-priority traffic
|
||||
// is evicted first and a full protected queue rejects the entire new pair.
|
||||
private enum PendingSendPriority: Int {
|
||||
case ephemeral
|
||||
case regular
|
||||
case privateEnvelope
|
||||
}
|
||||
|
||||
private struct PendingSend {
|
||||
var event: NostrEvent
|
||||
let id: UUID
|
||||
let events: [NostrEvent]
|
||||
var pendingRelays: Set<String>
|
||||
var inFlightConnections: [String: ObjectIdentifier]
|
||||
var hasSuccessfulDelivery: Bool
|
||||
let priority: PendingSendPriority
|
||||
let terminalFailure: (() -> Void)?
|
||||
}
|
||||
|
||||
private struct PendingDelivery {
|
||||
let itemID: UUID
|
||||
let events: [NostrEvent]
|
||||
let priority: PendingSendPriority
|
||||
let relayUrl: String
|
||||
let connection: NostrRelayConnectionProtocol
|
||||
}
|
||||
private var messageQueue: [PendingSend] = []
|
||||
private let messageQueueLock = NSLock()
|
||||
/// Non-queued sends whose callers require relay durability. A WebSocket
|
||||
/// write only proves bytes left this process; NIP-20 OK is the relay's
|
||||
/// write only proves bytes left this process; NIP-01 `OK` is the relay's
|
||||
/// accept/reject acknowledgment.
|
||||
private struct ConfirmedSendState {
|
||||
let token: UUID
|
||||
@@ -304,7 +327,8 @@ final class NostrRelayManager: ObservableObject {
|
||||
/// Disconnect from all relays
|
||||
func disconnect() {
|
||||
connectionGeneration &+= 1
|
||||
for (_, task) in connections {
|
||||
for (relayURL, task) in connections {
|
||||
clearPendingSendInFlight(relayUrl: relayURL, connection: task)
|
||||
task.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
connections.removeAll()
|
||||
@@ -326,6 +350,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
confirmed.forEach { $0(false) }
|
||||
pendingTorConnectionURLs.removeAll()
|
||||
awaitingTorForConnections = false
|
||||
awaitingTorForQueueFlush = false
|
||||
torReadyWaitAttempts = 0
|
||||
updateConnectionStatus()
|
||||
}
|
||||
@@ -350,13 +375,14 @@ final class NostrRelayManager: ObservableObject {
|
||||
pendingEOSECallbacks.removeAll()
|
||||
pendingTorConnectionURLs.removeAll()
|
||||
awaitingTorForConnections = false
|
||||
awaitingTorForQueueFlush = false
|
||||
torReadyWaitAttempts = 0
|
||||
recentInboundEventKeys.removeAll()
|
||||
recentInboundEventKeyOrder.removeAll()
|
||||
duplicateInboundEventDropCount = 0
|
||||
duplicateInboundEventDropCountBySubscription.removeAll()
|
||||
inboundEventLogCount = 0
|
||||
Self.pendingGiftWrapIDs.removeAll()
|
||||
Self.pendingPrivateEnvelopeIDs.removeAll()
|
||||
confirmedSends.removeAll()
|
||||
|
||||
messageQueueLock.lock()
|
||||
@@ -398,43 +424,132 @@ final class NostrRelayManager: ObservableObject {
|
||||
connectToRelays(targets)
|
||||
}
|
||||
|
||||
/// Send an event to specified relays (or all if none specified)
|
||||
/// Send an event to specified relays (or all if none specified).
|
||||
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
||||
// Global network policy gate
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
_ = sendEvents(
|
||||
[event],
|
||||
to: relayUrls,
|
||||
priority: Self.pendingSendPriority(for: event),
|
||||
terminalFailure: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// Atomically admits a complete private-envelope migration pair. The
|
||||
/// queue stores the pair as one protected item, so capacity pressure can
|
||||
/// neither retain only kind 1402 nor only kind 1059. A `false` result means
|
||||
/// the entire pair was rejected before any connected-relay sends began.
|
||||
@discardableResult
|
||||
func sendPrivateEnvelopeBatch(
|
||||
_ events: [NostrEvent],
|
||||
to relayUrls: [String]? = nil,
|
||||
terminalFailure: (() -> Void)? = nil
|
||||
) -> Bool {
|
||||
guard events.map(\.kind) == [
|
||||
NostrProtocol.EventKind.privateEnvelope.rawValue,
|
||||
NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue
|
||||
] else {
|
||||
SecureLogger.error(
|
||||
"Refusing malformed private-envelope migration batch kinds=\(events.map(\.kind))",
|
||||
category: .session
|
||||
)
|
||||
return false
|
||||
}
|
||||
return sendEvents(
|
||||
events,
|
||||
to: relayUrls,
|
||||
priority: .privateEnvelope,
|
||||
terminalFailure: terminalFailure
|
||||
)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func sendEvents(
|
||||
_ events: [NostrEvent],
|
||||
to relayUrls: [String]?,
|
||||
priority: PendingSendPriority,
|
||||
terminalFailure: (() -> Void)?
|
||||
) -> Bool {
|
||||
guard dependencies.activationAllowed(), !events.isEmpty else { return false }
|
||||
let requestedRelays = relayUrls ?? Self.defaultRelays
|
||||
var targetRelays = allowedRelayList(from: requestedRelays)
|
||||
if priority == .privateEnvelope {
|
||||
// Do not admit a protected batch against a relay already in its
|
||||
// terminal cooldown. Such a target has no connection attempt that
|
||||
// could ever retire the queue record; retry callers can try it
|
||||
// again after the cooldown decays.
|
||||
targetRelays.removeAll(where: isPermanentlyFailed)
|
||||
}
|
||||
guard !targetRelays.isEmpty else { return false }
|
||||
|
||||
if priority == .privateEnvelope {
|
||||
// Protected pairs are admitted for every target before any socket
|
||||
// write, including targets already connected. The queue entry
|
||||
// remains authoritative until both writes succeed per relay.
|
||||
guard enqueuePendingSendBatch(
|
||||
events,
|
||||
pendingRelays: Set(targetRelays),
|
||||
priority: priority,
|
||||
terminalFailure: terminalFailure
|
||||
) != nil else { return false }
|
||||
ensureConnections(to: targetRelays)
|
||||
// Preserve the same fail-closed Tor gate as ordinary sends. The
|
||||
// queue is already durable; a stale socket that still appears
|
||||
// connected must not be written while enforced Tor is unready.
|
||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
||||
flushMessageQueueWhenTorIsReady()
|
||||
} else {
|
||||
for relayUrl in targetRelays where connectedConnection(for: relayUrl) != nil {
|
||||
flushMessageQueue(for: relayUrl)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
||||
// Fail-closed: nothing touches the network until Tor is up. Queue the
|
||||
// event locally so it survives a slow bootstrap (queued sends flush
|
||||
// when relays connect), then kick off connection setup, which itself
|
||||
// waits for Tor readiness.
|
||||
let targetRelays = allowedRelayList(from: relayUrls ?? Self.defaultRelays)
|
||||
guard !targetRelays.isEmpty else { return }
|
||||
enqueuePendingSend(event, pendingRelays: Set(targetRelays))
|
||||
// complete item locally so it survives a slow bootstrap, then kick
|
||||
// off connection setup, which itself waits for Tor readiness.
|
||||
guard enqueuePendingSendBatch(
|
||||
events,
|
||||
pendingRelays: Set(targetRelays),
|
||||
priority: priority,
|
||||
terminalFailure: nil
|
||||
) != nil else { return false }
|
||||
ensureConnections(to: targetRelays)
|
||||
return
|
||||
return true
|
||||
}
|
||||
let requestedRelays = relayUrls ?? Self.defaultRelays
|
||||
let targetRelays = allowedRelayList(from: requestedRelays)
|
||||
guard !targetRelays.isEmpty else { return }
|
||||
ensureConnections(to: targetRelays)
|
||||
|
||||
// Attempt immediate send to relays with active connections; queue the rest
|
||||
// Ordinary single events retain their existing immediate-send path.
|
||||
var connectedTargets: [(String, NostrRelayConnectionProtocol)] = []
|
||||
var stillPending = Set<String>()
|
||||
for relayUrl in targetRelays {
|
||||
if let connection = connectedConnection(for: relayUrl) {
|
||||
sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
|
||||
connectedTargets.append((relayUrl, connection))
|
||||
} else {
|
||||
stillPending.insert(relayUrl)
|
||||
}
|
||||
}
|
||||
if !stillPending.isEmpty {
|
||||
enqueuePendingSend(event, pendingRelays: stillPending)
|
||||
guard enqueuePendingSendBatch(
|
||||
events,
|
||||
pendingRelays: stillPending,
|
||||
priority: priority,
|
||||
terminalFailure: nil
|
||||
) != nil else { return false }
|
||||
}
|
||||
|
||||
ensureConnections(to: targetRelays)
|
||||
for (relayUrl, connection) in connectedTargets {
|
||||
for event in events {
|
||||
sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Attempts an event only on currently connected target relays and
|
||||
/// reports whether at least one relay explicitly accepted it via NIP-20
|
||||
/// OK. A successful WebSocket write alone is not durable acceptance.
|
||||
/// reports whether at least one relay explicitly accepted it via NIP-01
|
||||
/// `OK`. A successful WebSocket write alone is not durable acceptance.
|
||||
/// Unlike `sendEvent`, this never enters the process-local pending queue;
|
||||
/// callers use it when success unlocks durable state or user-visible
|
||||
/// delivery progress.
|
||||
@@ -523,66 +638,244 @@ final class NostrRelayManager: ObservableObject {
|
||||
state.completion(false)
|
||||
}
|
||||
|
||||
private func enqueuePendingSend(_ event: NostrEvent, pendingRelays: Set<String>) {
|
||||
messageQueueLock.lock()
|
||||
messageQueue.append(PendingSend(event: event, pendingRelays: pendingRelays))
|
||||
let overflow = messageQueue.count - TransportConfig.nostrPendingSendQueueCap
|
||||
if overflow > 0 {
|
||||
messageQueue.removeFirst(overflow)
|
||||
private static func pendingSendPriority(for event: NostrEvent) -> PendingSendPriority {
|
||||
switch event.kind {
|
||||
case NostrProtocol.EventKind.ephemeralEvent.rawValue,
|
||||
NostrProtocol.EventKind.geohashPresence.rawValue:
|
||||
return .ephemeral
|
||||
default:
|
||||
return .regular
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically append a whole send item while preserving protected private
|
||||
/// batches. Capacity is measured in events, not queue records.
|
||||
private func enqueuePendingSendBatch(
|
||||
_ events: [NostrEvent],
|
||||
pendingRelays: Set<String>,
|
||||
priority: PendingSendPriority,
|
||||
terminalFailure: (() -> Void)?
|
||||
) -> UUID? {
|
||||
guard !events.isEmpty, !pendingRelays.isEmpty,
|
||||
events.count <= TransportConfig.nostrPendingSendQueueCap else {
|
||||
SecureLogger.error("Refusing invalid or oversized relay send batch", category: .session)
|
||||
return nil
|
||||
}
|
||||
|
||||
messageQueueLock.lock()
|
||||
let queuedEventCount = messageQueue.reduce(0) { $0 + $1.events.count }
|
||||
let overflow = queuedEventCount + events.count - TransportConfig.nostrPendingSendQueueCap
|
||||
var removalIndexes: [Int] = []
|
||||
var evictedEventCount = 0
|
||||
if overflow > 0 {
|
||||
var eventsToFree = overflow
|
||||
let candidates = messageQueue.indices
|
||||
.filter {
|
||||
messageQueue[$0].priority != .privateEnvelope &&
|
||||
messageQueue[$0].priority.rawValue <= priority.rawValue
|
||||
}
|
||||
.sorted {
|
||||
let left = messageQueue[$0].priority.rawValue
|
||||
let right = messageQueue[$1].priority.rawValue
|
||||
return left == right ? $0 < $1 : left < right
|
||||
}
|
||||
for index in candidates where eventsToFree > 0 {
|
||||
removalIndexes.append(index)
|
||||
let count = messageQueue[index].events.count
|
||||
evictedEventCount += count
|
||||
eventsToFree -= count
|
||||
}
|
||||
if eventsToFree > 0 {
|
||||
messageQueueLock.unlock()
|
||||
SecureLogger.error(
|
||||
"Relay send queue protected-capacity exhausted; rejected entire \(events.count)-event batch",
|
||||
category: .session
|
||||
)
|
||||
return nil
|
||||
}
|
||||
for index in removalIndexes.sorted(by: >) {
|
||||
messageQueue.remove(at: index)
|
||||
}
|
||||
}
|
||||
let itemID = UUID()
|
||||
messageQueue.append(PendingSend(
|
||||
id: itemID,
|
||||
events: events,
|
||||
pendingRelays: pendingRelays,
|
||||
inFlightConnections: [:],
|
||||
hasSuccessfulDelivery: false,
|
||||
priority: priority,
|
||||
terminalFailure: terminalFailure
|
||||
))
|
||||
messageQueueLock.unlock()
|
||||
guard overflow > 0 else { return }
|
||||
// Dropped events are ephemeral (presence/geo), so no status surfacing
|
||||
// is needed — but the drops should be visible. Sampled so a sustained
|
||||
// relay stall can't flood the log.
|
||||
pendingSendDropCount += overflow
|
||||
if pendingSendDropCount == 1 ||
|
||||
guard evictedEventCount > 0 else { return itemID }
|
||||
let isFirstEviction = pendingSendDropCount == 0
|
||||
pendingSendDropCount += evictedEventCount
|
||||
if isFirstEviction ||
|
||||
pendingSendDropCount.isMultiple(of: TransportConfig.nostrPendingSendDropLogInterval) {
|
||||
SecureLogger.warning(
|
||||
"📤 Relay send queue full — dropped \(pendingSendDropCount) oldest event(s)",
|
||||
"📤 Relay send queue full — evicted \(pendingSendDropCount) lower-priority event(s)",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
return itemID
|
||||
}
|
||||
|
||||
/// Try to flush any queued messages for relays that are now connected.
|
||||
private func flushMessageQueue(for relayUrl: String? = nil) {
|
||||
guard !(shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady()) else {
|
||||
flushMessageQueueWhenTorIsReady()
|
||||
return
|
||||
}
|
||||
var deliveries: [PendingDelivery] = []
|
||||
messageQueueLock.lock()
|
||||
defer { messageQueueLock.unlock() }
|
||||
guard !messageQueue.isEmpty else { return }
|
||||
if let target = relayUrl {
|
||||
// Flush only for a specific relay
|
||||
for i in (0..<messageQueue.count).reversed() {
|
||||
var item = messageQueue[i]
|
||||
if item.pendingRelays.contains(target), let conn = connectedConnection(for: target) {
|
||||
sendToRelay(event: item.event, connection: conn, relayUrl: target)
|
||||
for index in (0..<messageQueue.count).reversed() {
|
||||
var item = messageQueue[index]
|
||||
let targets = relayUrl.map { [$0] } ?? Array(item.pendingRelays)
|
||||
for target in targets {
|
||||
guard item.pendingRelays.contains(target),
|
||||
item.inFlightConnections[target] == nil,
|
||||
let connection = connectedConnection(for: target) else { continue }
|
||||
deliveries.append(PendingDelivery(
|
||||
itemID: item.id,
|
||||
events: item.events,
|
||||
priority: item.priority,
|
||||
relayUrl: target,
|
||||
connection: connection
|
||||
))
|
||||
if item.priority == .privateEnvelope {
|
||||
// Keep the relay pending until both asynchronous writes
|
||||
// report success. `inFlightConnections` prevents duplicate
|
||||
// flushes while those callbacks are outstanding.
|
||||
item.inFlightConnections[target] = ObjectIdentifier(connection)
|
||||
} else {
|
||||
item.pendingRelays.remove(target)
|
||||
if item.pendingRelays.isEmpty {
|
||||
messageQueue.remove(at: i)
|
||||
} else {
|
||||
messageQueue[i] = item
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Flush for any relays that now have connections
|
||||
for i in (0..<messageQueue.count).reversed() {
|
||||
var item = messageQueue[i]
|
||||
for url in item.pendingRelays {
|
||||
if let conn = connectedConnection(for: url) {
|
||||
sendToRelay(event: item.event, connection: conn, relayUrl: url)
|
||||
item.pendingRelays.remove(url)
|
||||
}
|
||||
if item.pendingRelays.isEmpty {
|
||||
messageQueue.remove(at: index)
|
||||
} else {
|
||||
messageQueue[index] = item
|
||||
}
|
||||
}
|
||||
messageQueueLock.unlock()
|
||||
|
||||
// Never invoke WebSocket callbacks while holding messageQueueLock.
|
||||
for delivery in deliveries {
|
||||
if delivery.priority == .privateEnvelope {
|
||||
sendPrivateEnvelopeBatchToRelay(
|
||||
delivery.events,
|
||||
connection: delivery.connection,
|
||||
relayUrl: delivery.relayUrl
|
||||
) { [weak self] succeeded in
|
||||
self?.completePrivateEnvelopeBatchDelivery(
|
||||
itemID: delivery.itemID,
|
||||
relayUrl: delivery.relayUrl,
|
||||
connection: delivery.connection,
|
||||
succeeded: succeeded
|
||||
)
|
||||
}
|
||||
if item.pendingRelays.isEmpty {
|
||||
messageQueue.remove(at: i)
|
||||
} else {
|
||||
messageQueue[i] = item
|
||||
} else {
|
||||
for event in delivery.events {
|
||||
sendToRelay(
|
||||
event: event,
|
||||
connection: delivery.connection,
|
||||
relayUrl: delivery.relayUrl
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func flushMessageQueueWhenTorIsReady() {
|
||||
guard !awaitingTorForQueueFlush else { return }
|
||||
awaitingTorForQueueFlush = true
|
||||
let generation = connectionGeneration
|
||||
dependencies.awaitTorReady { [weak self] ready in
|
||||
guard let self else { return }
|
||||
guard generation == self.connectionGeneration else { return }
|
||||
self.awaitingTorForQueueFlush = false
|
||||
guard ready else { return }
|
||||
self.flushMessageQueue(for: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Send both copies in order and report one result for the complete pair.
|
||||
/// Stop on the first failure; the still-pending queue item retries both
|
||||
/// after reconnection, which is safe because receivers deduplicate twins.
|
||||
private func sendPrivateEnvelopeBatchToRelay(
|
||||
_ events: [NostrEvent],
|
||||
connection: NostrRelayConnectionProtocol,
|
||||
relayUrl: String,
|
||||
index: Int = 0,
|
||||
completion: @escaping (Bool) -> Void
|
||||
) {
|
||||
guard index < events.count else {
|
||||
completion(true)
|
||||
return
|
||||
}
|
||||
sendToRelay(
|
||||
event: events[index],
|
||||
connection: connection,
|
||||
relayUrl: relayUrl
|
||||
) { [weak self] succeeded in
|
||||
guard succeeded,
|
||||
let self,
|
||||
self.connections[relayUrl] === connection else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
self.sendPrivateEnvelopeBatchToRelay(
|
||||
events,
|
||||
connection: connection,
|
||||
relayUrl: relayUrl,
|
||||
index: index + 1,
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func completePrivateEnvelopeBatchDelivery(
|
||||
itemID: UUID,
|
||||
relayUrl: String,
|
||||
connection: NostrRelayConnectionProtocol,
|
||||
succeeded: Bool
|
||||
) {
|
||||
messageQueueLock.lock()
|
||||
if let index = messageQueue.firstIndex(where: { $0.id == itemID }) {
|
||||
var item = messageQueue[index]
|
||||
guard item.inFlightConnections[relayUrl] == ObjectIdentifier(connection) else {
|
||||
messageQueueLock.unlock()
|
||||
return
|
||||
}
|
||||
item.inFlightConnections.removeValue(forKey: relayUrl)
|
||||
if succeeded {
|
||||
item.hasSuccessfulDelivery = true
|
||||
item.pendingRelays.remove(relayUrl)
|
||||
}
|
||||
if item.pendingRelays.isEmpty {
|
||||
messageQueue.remove(at: index)
|
||||
} else {
|
||||
messageQueue[index] = item
|
||||
}
|
||||
}
|
||||
messageQueueLock.unlock()
|
||||
|
||||
guard !succeeded else { return }
|
||||
// A failed WebSocket write means this connection cannot advance the
|
||||
// durable queue. Keep the original pair pending and reconnect with the
|
||||
// manager's bounded backoff; the next successful ping flushes it.
|
||||
handleDisconnection(
|
||||
relayUrl: relayUrl,
|
||||
error: NSError(
|
||||
domain: "NostrRelayPrivateEnvelopeBatch",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "private-envelope batch write failed"]
|
||||
),
|
||||
connection: connection
|
||||
)
|
||||
}
|
||||
|
||||
private func connectedConnection(for relayUrl: String) -> NostrRelayConnectionProtocol? {
|
||||
guard let connection = connections[relayUrl],
|
||||
relays.first(where: { $0.url == relayUrl })?.isConnected == true else {
|
||||
@@ -598,9 +891,28 @@ final class NostrRelayManager: ObservableObject {
|
||||
relayUrls: [String]? = nil,
|
||||
handler: @escaping (NostrEvent) -> Void,
|
||||
onEOSE: (() -> Void)? = nil
|
||||
) {
|
||||
subscribe(
|
||||
filters: [filter],
|
||||
id: id,
|
||||
relayUrls: relayUrls,
|
||||
handler: handler,
|
||||
onEOSE: onEOSE
|
||||
)
|
||||
}
|
||||
|
||||
/// Subscribe with independent Nostr filters in one REQ. Each filter keeps
|
||||
/// its own relay-side limit; this is required for mixed-version mailbox
|
||||
/// recovery so kind 1402 cannot starve kind 1059 (or the reverse).
|
||||
func subscribe(
|
||||
filters: [NostrFilter],
|
||||
id: String = UUID().uuidString,
|
||||
relayUrls: [String]? = nil,
|
||||
handler: @escaping (NostrEvent) -> Void,
|
||||
onEOSE: (() -> Void)? = nil
|
||||
) {
|
||||
// Global network policy gate
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
guard dependencies.activationAllowed(), !filters.isEmpty else { return }
|
||||
// Coalesce rapid duplicate subscribe requests even while Tor readiness is pending.
|
||||
let now = dependencies.now()
|
||||
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < subscribeCoalesceInterval {
|
||||
@@ -609,7 +921,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
subscribeCoalesce[id] = now
|
||||
messageHandlers[id] = handler
|
||||
|
||||
let req = NostrRequest.subscribe(id: id, filters: [filter])
|
||||
let req = NostrRequest.subscribe(id: id, filters: filters)
|
||||
|
||||
do {
|
||||
let message = try encoder.encode(req)
|
||||
@@ -676,6 +988,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
ensureConnections(to: Self.defaultRelays)
|
||||
}
|
||||
} else {
|
||||
var terminalFailures: [() -> Void] = []
|
||||
for url in Self.defaultRelays {
|
||||
if let connection = connections[url] {
|
||||
connection.cancel(with: .goingAway, reason: nil)
|
||||
@@ -683,18 +996,9 @@ final class NostrRelayManager: ObservableObject {
|
||||
connections.removeValue(forKey: url)
|
||||
subscriptions.removeValue(forKey: url)
|
||||
pendingSubscriptions.removeValue(forKey: url)
|
||||
terminalFailures.append(contentsOf: retirePendingSendRelay(url))
|
||||
}
|
||||
messageQueueLock.lock()
|
||||
for index in (0..<messageQueue.count).reversed() {
|
||||
var item = messageQueue[index]
|
||||
item.pendingRelays.subtract(Self.defaultRelaySet)
|
||||
if item.pendingRelays.isEmpty {
|
||||
messageQueue.remove(at: index)
|
||||
} else {
|
||||
messageQueue[index] = item
|
||||
}
|
||||
}
|
||||
messageQueueLock.unlock()
|
||||
terminalFailures.forEach { $0() }
|
||||
relays.removeAll { Self.defaultRelaySet.contains($0.url) }
|
||||
updateConnectionStatus()
|
||||
}
|
||||
@@ -1139,7 +1443,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else {
|
||||
return
|
||||
}
|
||||
if event.kind != 1059 {
|
||||
if !NostrProtocol.acceptedPrivateEnvelopeKinds.contains(event.kind) {
|
||||
// Per-event logging floods dev builds in busy geohashes; sample it.
|
||||
inboundEventLogCount += 1
|
||||
if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
|
||||
@@ -1168,11 +1472,11 @@ final class NostrRelayManager: ObservableObject {
|
||||
case .ok(let eventId, let success, let reason):
|
||||
resolveConfirmedSend(eventID: eventId, relayURL: relayUrl, accepted: success)
|
||||
if success {
|
||||
_ = Self.pendingGiftWrapIDs.remove(eventId)
|
||||
_ = Self.pendingPrivateEnvelopeIDs.remove(eventId)
|
||||
SecureLogger.debug("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", category: .session)
|
||||
} else {
|
||||
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
|
||||
if isGiftWrap {
|
||||
let isPrivateEnvelope = Self.pendingPrivateEnvelopeIDs.remove(eventId) != nil
|
||||
if isPrivateEnvelope {
|
||||
SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16))… relay=\(relayUrl) reason=\(reason)", category: .session)
|
||||
} else {
|
||||
SecureLogger.error("📮 Rejected id=\(eventId.prefix(16))… relay=\(relayUrl) reason=\(reason)", category: .session)
|
||||
@@ -1284,6 +1588,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
connection: NostrRelayConnectionProtocol? = nil
|
||||
) {
|
||||
if let connection, connections[relayUrl] !== connection { return }
|
||||
clearPendingSendInFlight(relayUrl: relayUrl, connection: connection)
|
||||
connections.removeValue(forKey: relayUrl)
|
||||
subscriptions.removeValue(forKey: relayUrl)
|
||||
let awaitingConfirmation = confirmedSends.compactMap { eventID, state in
|
||||
@@ -1304,7 +1609,10 @@ final class NostrRelayManager: ObservableObject {
|
||||
let ns = error as NSError
|
||||
if errorDescription.contains("hostname could not be found") ||
|
||||
errorDescription.contains("dns") ||
|
||||
(ns.domain == NSURLErrorDomain && ns.code == NSURLErrorBadServerResponse) {
|
||||
(ns.domain == NSURLErrorDomain && (
|
||||
ns.code == NSURLErrorBadServerResponse ||
|
||||
ns.code == NSURLErrorCannotFindHost
|
||||
)) {
|
||||
if relays.first(where: { $0.url == relayUrl })?.lastError == nil {
|
||||
SecureLogger.warning("Nostr relay permanent failure for \(relayUrl) - not retrying (code=\(ns.code))", category: .session)
|
||||
}
|
||||
@@ -1314,6 +1622,8 @@ final class NostrRelayManager: ObservableObject {
|
||||
relays[index].nextReconnectTime = nil
|
||||
}
|
||||
pendingSubscriptions[relayUrl] = nil
|
||||
let terminalFailures = retirePendingSendRelay(relayUrl)
|
||||
terminalFailures.forEach { $0() }
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1325,6 +1635,8 @@ final class NostrRelayManager: ObservableObject {
|
||||
// Stop attempting after max attempts
|
||||
if relays[index].reconnectAttempts >= maxReconnectAttempts {
|
||||
SecureLogger.warning("Max reconnection attempts (\(maxReconnectAttempts)) reached for \(relayUrl)", category: .session)
|
||||
let terminalFailures = retirePendingSendRelay(relayUrl)
|
||||
terminalFailures.forEach { $0() }
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1362,6 +1674,54 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A relay in terminal cooldown cannot make progress on queued sends. Drop
|
||||
/// it from every target set so one unavailable default relay cannot pin
|
||||
/// otherwise-delivered protected batches forever. If this was the last
|
||||
/// target and no relay completed the pair, report whole-batch failure to
|
||||
/// the originating transport for visible failure or retry.
|
||||
private func retirePendingSendRelay(_ relayUrl: String) -> [() -> Void] {
|
||||
var terminalFailures: [() -> Void] = []
|
||||
messageQueueLock.lock()
|
||||
for index in (0..<messageQueue.count).reversed() {
|
||||
var item = messageQueue[index]
|
||||
guard item.pendingRelays.remove(relayUrl) != nil else { continue }
|
||||
item.inFlightConnections.removeValue(forKey: relayUrl)
|
||||
if item.pendingRelays.isEmpty {
|
||||
if item.priority == .privateEnvelope,
|
||||
!item.hasSuccessfulDelivery,
|
||||
let terminalFailure = item.terminalFailure {
|
||||
terminalFailures.append(terminalFailure)
|
||||
}
|
||||
messageQueue.remove(at: index)
|
||||
} else {
|
||||
messageQueue[index] = item
|
||||
}
|
||||
}
|
||||
messageQueueLock.unlock()
|
||||
return terminalFailures
|
||||
}
|
||||
|
||||
/// Release the in-flight marker owned by a dead socket without removing
|
||||
/// the relay from the durable pending set. A stale completion from that
|
||||
/// socket is ignored by its ObjectIdentifier check.
|
||||
private func clearPendingSendInFlight(
|
||||
relayUrl: String,
|
||||
connection: NostrRelayConnectionProtocol?
|
||||
) {
|
||||
let expectedIdentifier = connection.map(ObjectIdentifier.init)
|
||||
messageQueueLock.lock()
|
||||
for index in messageQueue.indices {
|
||||
var item = messageQueue[index]
|
||||
guard let inFlightIdentifier = item.inFlightConnections[relayUrl],
|
||||
expectedIdentifier == nil || expectedIdentifier == inFlightIdentifier else {
|
||||
continue
|
||||
}
|
||||
item.inFlightConnections.removeValue(forKey: relayUrl)
|
||||
messageQueue[index] = item
|
||||
}
|
||||
messageQueueLock.unlock()
|
||||
}
|
||||
|
||||
// MARK: - Public Utility Methods
|
||||
|
||||
@@ -1377,6 +1737,10 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
// Disconnect if connected
|
||||
if let connection = connections[normalizedRelayUrl] {
|
||||
clearPendingSendInFlight(
|
||||
relayUrl: normalizedRelayUrl,
|
||||
connection: connection
|
||||
)
|
||||
connection.cancel(with: .goingAway, reason: nil)
|
||||
connections.removeValue(forKey: normalizedRelayUrl)
|
||||
}
|
||||
@@ -1398,7 +1762,13 @@ final class NostrRelayManager: ObservableObject {
|
||||
var debugPendingMessageQueueCount: Int {
|
||||
messageQueueLock.lock()
|
||||
defer { messageQueueLock.unlock() }
|
||||
return messageQueue.count
|
||||
return messageQueue.reduce(0) { $0 + $1.events.count }
|
||||
}
|
||||
|
||||
var debugPendingMessageQueueEventIDsByBatch: [[String]] {
|
||||
messageQueueLock.lock()
|
||||
defer { messageQueueLock.unlock() }
|
||||
return messageQueue.map { $0.events.map(\.id) }
|
||||
}
|
||||
|
||||
func debugPendingSubscriptionCount(for relayUrl: String) -> Int {
|
||||
@@ -1610,14 +1980,18 @@ struct NostrFilter: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
// For NIP-17 gift wraps
|
||||
static func giftWrapsFor(pubkey: String, since: Date? = nil) -> NostrFilter {
|
||||
var filter = NostrFilter()
|
||||
filter.kinds = [1059] // Gift wrap kind
|
||||
filter.since = since?.timeIntervalSince1970.toInt()
|
||||
filter.tagFilters = ["p": [pubkey]]
|
||||
filter.limit = TransportConfig.nostrRelayDefaultFetchLimit // reasonable limit
|
||||
return filter
|
||||
// BitChat private envelopes, plus compatibility legacy envelopes emitted
|
||||
// throughout the coordinated migration and stored by older releases as
|
||||
// kind 1059.
|
||||
static func privateEnvelopeFiltersFor(pubkey: String, since: Date? = nil) -> [NostrFilter] {
|
||||
NostrProtocol.acceptedPrivateEnvelopeKinds.map { kind in
|
||||
var filter = NostrFilter()
|
||||
filter.kinds = [kind]
|
||||
filter.since = since?.timeIntervalSince1970.toInt()
|
||||
filter.tagFilters = ["p": [pubkey]]
|
||||
filter.limit = TransportConfig.nostrPrivateEnvelopeFetchLimitPerKind
|
||||
return filter
|
||||
}
|
||||
}
|
||||
|
||||
// For location channels: geohash-scoped ephemeral events (kind 20000) and presence (kind 20001)
|
||||
|
||||
@@ -40,6 +40,9 @@ struct BLEFileTransferHandlerEnvironment {
|
||||
let commitPrivateMediaFile: (_ messageID: String, _ storedURL: URL) -> Bool
|
||||
/// Rolls back a saved payload when its durable receipt commit fails.
|
||||
let removeIncomingFile: (_ storedURL: URL) -> Void
|
||||
/// Releases the allocator's save-to-UI ownership guard after synchronous
|
||||
/// conversation insertion has completed.
|
||||
let finishIncomingFileDelivery: (_ storedURL: URL) -> Void
|
||||
/// Checks the authenticated sender before any private-media disk work.
|
||||
let isPrivateMediaSenderBlocked: (PeerID) -> Bool
|
||||
/// Updates the registry last-seen timestamp for the peer (async barrier write).
|
||||
@@ -49,11 +52,14 @@ struct BLEFileTransferHandlerEnvironment {
|
||||
let acknowledgePrivateMedia: (_ messageID: String, _ peerID: PeerID) -> Void
|
||||
/// Delivers `.messageReceived` as one main-actor hop while
|
||||
/// `shouldDeliver` remains true before and after the synchronous sink.
|
||||
/// The completion authorizes the stable-media ACK.
|
||||
/// The completion authorizes the stable-media ACK. Finalization runs after
|
||||
/// every delivery attempt, including rejection, so allocator ownership
|
||||
/// cannot leak indefinitely.
|
||||
let deliverMessage: (
|
||||
_ message: BitchatMessage,
|
||||
_ shouldDeliver: @escaping () -> Bool,
|
||||
_ completion: @escaping () -> Void
|
||||
_ completion: @escaping () -> Void,
|
||||
_ finalization: @escaping (TransportEventDeliveryOutcome) -> Void
|
||||
) -> Void
|
||||
}
|
||||
|
||||
@@ -357,7 +363,24 @@ final class BLEFileTransferHandler {
|
||||
env: env
|
||||
)
|
||||
} 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
|
||||
}
|
||||
@@ -381,6 +404,9 @@ final class BLEFileTransferHandler {
|
||||
},
|
||||
{
|
||||
env.acknowledgePrivateMedia(messageID, peerID)
|
||||
},
|
||||
{ _ in
|
||||
env.finishIncomingFileDelivery(expectedURL)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ struct PanicRecoveryOperations {
|
||||
}
|
||||
}
|
||||
|
||||
struct BLEIncomingFileStore {
|
||||
struct BLEIncomingFileStore: @unchecked Sendable {
|
||||
enum PanicRecoveryError: Error {
|
||||
case externalMarkerCommitFailed
|
||||
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
|
||||
/// fail-closed startup decision before the full panic has committed.
|
||||
private static let panicRecoveryPendingMarkerFileName =
|
||||
@@ -120,7 +130,6 @@ struct BLEIncomingFileStore {
|
||||
"files/incoming",
|
||||
"files/outgoing"
|
||||
]
|
||||
|
||||
/// Name prefix of in-flight live voice captures (progressively written by
|
||||
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern —
|
||||
/// deleting one mid-stream unlinks the inode under an open `FileHandle`
|
||||
@@ -134,7 +143,9 @@ struct BLEIncomingFileStore {
|
||||
private let baseDirectory: URL?
|
||||
private let dateProvider: () -> Date
|
||||
private let panicMarkerWriter: (Data, URL) throws -> Void
|
||||
private let quotaBytes: Int64
|
||||
private let privateMediaReceipts: BLEPrivateMediaReceiptStore
|
||||
private let payloadCoordination: PayloadCoordination
|
||||
|
||||
init(
|
||||
fileManager: FileManager = .default,
|
||||
@@ -142,17 +153,20 @@ struct BLEIncomingFileStore {
|
||||
dateProvider: @escaping () -> Date = Date.init,
|
||||
panicMarkerWriter: @escaping (Data, URL) throws -> Void = {
|
||||
try $0.write(to: $1, options: .atomic)
|
||||
}
|
||||
},
|
||||
quotaBytes: Int64 = Self.defaultQuotaBytes
|
||||
) {
|
||||
self.fileManager = fileManager
|
||||
self.baseDirectory = baseDirectory
|
||||
self.dateProvider = dateProvider
|
||||
self.panicMarkerWriter = panicMarkerWriter
|
||||
self.quotaBytes = max(0, quotaBytes)
|
||||
self.privateMediaReceipts = BLEPrivateMediaReceiptStore(
|
||||
fileManager: fileManager,
|
||||
baseDirectory: baseDirectory,
|
||||
now: dateProvider
|
||||
)
|
||||
self.payloadCoordination = PayloadCoordination()
|
||||
}
|
||||
|
||||
/// Panic-wipe every managed incoming and outgoing media artifact before
|
||||
@@ -165,10 +179,21 @@ struct BLEIncomingFileStore {
|
||||
func panicWipe(
|
||||
hasDurablePendingMarker: Bool = false
|
||||
) throws {
|
||||
// The receipt index caches tombstones as well as accepted payloads.
|
||||
// Always invalidate it on return, including partial-failure paths, so
|
||||
// no pre-panic receiver decision survives after identity reset.
|
||||
defer { privateMediaReceipts.resetForPanic() }
|
||||
// The receipt index caches tombstones as well as accepted payloads,
|
||||
// while payload coordination retains save/delete reservations. Always
|
||||
// invalidate both on return, including partial-failure paths, so no
|
||||
// pre-panic receiver decision survives after identity reset.
|
||||
defer {
|
||||
privateMediaReceipts.resetForPanic()
|
||||
payloadCoordination.lock.lock()
|
||||
payloadCoordination.pendingDeliveryPaths.removeAll(
|
||||
keepingCapacity: false
|
||||
)
|
||||
payloadCoordination.deletionReservations.removeAll(
|
||||
keepingCapacity: false
|
||||
)
|
||||
payloadCoordination.lock.unlock()
|
||||
}
|
||||
|
||||
let markerError: Error?
|
||||
do {
|
||||
@@ -251,6 +276,9 @@ struct BLEIncomingFileStore {
|
||||
fallbackExtension: String?,
|
||||
defaultPrefix: String
|
||||
) -> URL? {
|
||||
payloadCoordination.lock.lock()
|
||||
defer { payloadCoordination.lock.unlock() }
|
||||
|
||||
do {
|
||||
let base = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
|
||||
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
|
||||
@@ -259,8 +287,26 @@ struct BLEIncomingFileStore {
|
||||
defaultName: "\(defaultPrefix)_\(Self.timestampString(from: dateProvider()))",
|
||||
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)
|
||||
payloadCoordination.pendingDeliveryPaths.insert(
|
||||
destination.standardizedFileURL.path
|
||||
)
|
||||
return destination
|
||||
} catch {
|
||||
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.
|
||||
func removeIncomingFile(at storedURL: URL) {
|
||||
payloadCoordination.lock.lock()
|
||||
defer { payloadCoordination.lock.unlock() }
|
||||
payloadCoordination.pendingDeliveryPaths.remove(
|
||||
storedURL.standardizedFileURL.path
|
||||
)
|
||||
guard isURLInsideFilesDirectory(storedURL) else { return }
|
||||
do {
|
||||
try fileManager.removeItem(at: storedURL)
|
||||
@@ -303,6 +416,9 @@ struct BLEIncomingFileStore {
|
||||
/// a finalized transfer can arrive at quota while a burst is still
|
||||
/// streaming — but they still count toward usage.
|
||||
func enforceQuota(reservingBytes: Int) {
|
||||
payloadCoordination.lock.lock()
|
||||
defer { payloadCoordination.lock.unlock() }
|
||||
|
||||
do {
|
||||
let base = try filesDirectory()
|
||||
let incomingDirs = [
|
||||
@@ -328,14 +444,26 @@ struct BLEIncomingFileStore {
|
||||
}
|
||||
|
||||
let currentUsage = allFiles.reduce(0) { $0 + $1.size }
|
||||
let targetUsage = Self.quotaBytes - Int64(reservingBytes)
|
||||
let targetUsage = quotaBytes - Int64(reservingBytes)
|
||||
guard currentUsage > targetUsage else { return }
|
||||
|
||||
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
|
||||
for file in allFiles.sorted(by: { $0.modified < $1.modified }) {
|
||||
guard freedSpace < needToFree else { break }
|
||||
guard !file.url.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue }
|
||||
guard !protectedPaths.contains(
|
||||
file.url.standardizedFileURL.path
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
do {
|
||||
try fileManager.removeItem(at: file.url)
|
||||
freedSpace += file.size
|
||||
@@ -423,11 +551,20 @@ struct BLEIncomingFileStore {
|
||||
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
|
||||
func isInsideDirectory(_ url: URL) -> Bool {
|
||||
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)
|
||||
guard isInsideDirectory(candidate) else {
|
||||
@@ -435,19 +572,27 @@ struct BLEIncomingFileStore {
|
||||
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
|
||||
}
|
||||
|
||||
let baseName = (fileName as NSString).deletingPathExtension
|
||||
let ext = (fileName as NSString).pathExtension
|
||||
for counter in 1..<100 {
|
||||
let newName = ext.isEmpty ? "\(baseName) (\(counter))" : "\(baseName) (\(counter)).\(ext)"
|
||||
candidate = directory.appendingPathComponent(newName)
|
||||
guard isInsideDirectory(candidate) else {
|
||||
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
||||
}
|
||||
if !fileManager.fileExists(atPath: candidate.path) {
|
||||
if isAvailable(candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,15 @@ enum BLEPrivateMediaReceiptState: Equatable {
|
||||
final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
typealias DirectoryReader = (_ directory: URL) throws -> [URL]
|
||||
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 deletionJournalFileName = ".deletion-journal.json"
|
||||
private static let maximumDeletionPathsPerMessage = 2
|
||||
|
||||
private struct ReceiptRecord: Codable, Equatable {
|
||||
enum Kind: String, Codable {
|
||||
@@ -38,10 +46,23 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
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 {
|
||||
let lock = NSLock()
|
||||
var records: [String: ReceiptRecord]?
|
||||
var volatileTombstones: [String: Date] = [:]
|
||||
var deletionJournal: [String: DeletionJournalEntry]?
|
||||
}
|
||||
|
||||
private let fileManager: FileManager
|
||||
@@ -51,6 +72,8 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
private let now: () -> Date
|
||||
private let directoryReader: DirectoryReader?
|
||||
private let dataReader: DataReader?
|
||||
private let dataWriter: DataWriter
|
||||
private let payloadRemover: PayloadRemover
|
||||
private let runtime = Runtime()
|
||||
|
||||
init(
|
||||
@@ -60,7 +83,11 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
ttl: TimeInterval = TransportConfig.privateMediaReceivedLedgerTTLSeconds,
|
||||
now: @escaping () -> Date = Date.init,
|
||||
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.baseDirectory = baseDirectory
|
||||
@@ -69,6 +96,10 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
self.now = now
|
||||
self.directoryReader = directoryReader
|
||||
self.dataReader = dataReader
|
||||
self.dataWriter = dataWriter
|
||||
self.payloadRemover = payloadRemover ?? {
|
||||
try fileManager.removeItem(at: $0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops process-lifetime decisions after the enclosing media directory
|
||||
@@ -78,7 +109,7 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
func resetForPanic() {
|
||||
runtime.lock.lock()
|
||||
runtime.records = nil
|
||||
runtime.volatileTombstones.removeAll(keepingCapacity: false)
|
||||
runtime.deletionJournal = nil
|
||||
runtime.lock.unlock()
|
||||
}
|
||||
|
||||
@@ -91,29 +122,52 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
defer { runtime.lock.unlock() }
|
||||
|
||||
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(),
|
||||
var records = loadIndexIfNeeded(from: directory, at: date) else {
|
||||
loadDurableStateIfNeeded(from: directory, at: date) else {
|
||||
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)
|
||||
runtime.records = records
|
||||
removeRecord(messageID: messageID, from: directory)
|
||||
return .absent
|
||||
}
|
||||
|
||||
switch record.kind {
|
||||
case .tombstone:
|
||||
removePayloadRecordedByTombstone(record)
|
||||
return .tombstoned
|
||||
|
||||
case .accepted:
|
||||
@@ -121,9 +175,14 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
let existingURL = existingPayload(relativePath: relativePath) else {
|
||||
// Quota cleanup is not explicit deletion. Remove the stale
|
||||
// receipt so a sender retry can restore the payload and bubble.
|
||||
guard removeRecord(
|
||||
messageID: messageID,
|
||||
from: directory
|
||||
) else {
|
||||
return .unavailable
|
||||
}
|
||||
records.removeValue(forKey: messageID)
|
||||
runtime.records = records
|
||||
removeRecord(messageID: messageID, from: directory)
|
||||
return .absent
|
||||
}
|
||||
return .accepted(existingURL)
|
||||
@@ -144,13 +203,24 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
defer { runtime.lock.unlock() }
|
||||
|
||||
let date = now()
|
||||
if let tombstonedAt = runtime.volatileTombstones[messageID],
|
||||
!isExpired(tombstonedAt, at: date) {
|
||||
guard let directory = resolvedReceiptDirectory(),
|
||||
loadDurableStateIfNeeded(from: directory, at: date) else {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let directory = resolvedReceiptDirectory(),
|
||||
var records = loadIndexIfNeeded(from: directory, at: date) else {
|
||||
_ = recoverDeletionJournal(in: directory)
|
||||
guard runtime.deletionJournal?[messageID] == nil,
|
||||
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
|
||||
}
|
||||
if let existing = records[messageID],
|
||||
@@ -188,73 +258,232 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
return true
|
||||
}
|
||||
|
||||
/// Foundation for explicit media deletion. This branch does not wire the
|
||||
/// chat-clear UI; it only makes a tombstone durable and fail closed.
|
||||
func recordDeleted(messageID: String) -> Bool {
|
||||
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
|
||||
return false
|
||||
}
|
||||
/// Atomically commits explicit deletion for every stable ID in `messageIDs`.
|
||||
///
|
||||
/// The journal is the single batch commit point: a failed write changes
|
||||
/// neither durable nor in-memory receiver state, so callers must preserve
|
||||
/// 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()
|
||||
defer { runtime.lock.unlock() }
|
||||
|
||||
let date = now()
|
||||
addVolatileTombstone(messageID, at: date)
|
||||
|
||||
guard let directory = resolvedReceiptDirectory(),
|
||||
var records = loadIndexIfNeeded(from: directory, at: date) else {
|
||||
runtime.volatileTombstones.removeValue(forKey: messageID)
|
||||
loadDurableStateIfNeeded(from: directory, at: date) else {
|
||||
return false
|
||||
}
|
||||
if let existing = records[messageID],
|
||||
existing.kind == .tombstone,
|
||||
!isExpired(existing.recordedAt, at: date) {
|
||||
runtime.volatileTombstones.removeValue(forKey: messageID)
|
||||
removePayloadRecordedByTombstone(existing)
|
||||
_ = 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 true
|
||||
}
|
||||
|
||||
let victim = capacityVictim(
|
||||
for: .tombstone,
|
||||
replacing: messageID,
|
||||
in: records
|
||||
)
|
||||
if records[messageID]?.kind != .tombstone,
|
||||
records.values.lazy.filter({ $0.kind == .tombstone }).count >= capacity,
|
||||
victim == nil {
|
||||
runtime.volatileTombstones.removeValue(forKey: messageID)
|
||||
guard !newIDs.isEmpty else {
|
||||
return true
|
||||
}
|
||||
guard Set(journal.keys).union(newIDs).count <= capacity else {
|
||||
return false
|
||||
}
|
||||
|
||||
let tombstone = ReceiptRecord(
|
||||
kind: .tombstone,
|
||||
// Retain the accepted path so a crash between the atomic record
|
||||
// write and payload unlink can finish cleanup after relaunch.
|
||||
relativePath: records[messageID]?.relativePath,
|
||||
recordedAt: date
|
||||
)
|
||||
guard persist(tombstone, messageID: messageID, to: directory) else {
|
||||
runtime.volatileTombstones.removeValue(forKey: messageID)
|
||||
var newEntries: [String: DeletionJournalEntry] = [:]
|
||||
for messageID in newIDs {
|
||||
// Accepted receipts normally supply the exact stored path. The UI
|
||||
// fallback is required when that receipt aged or was capacity
|
||||
// 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
|
||||
)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
records[messageID] = tombstone
|
||||
if let victim, victim != messageID {
|
||||
records.removeValue(forKey: victim)
|
||||
removeRecord(messageID: victim, from: directory)
|
||||
}
|
||||
runtime.records = records
|
||||
runtime.volatileTombstones.removeValue(forKey: messageID)
|
||||
removePayloadRecordedByTombstone(tombstone)
|
||||
runtime.deletionJournal = journal
|
||||
_ = recoverDeletionJournal(in: directory)
|
||||
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,
|
||||
at date: Date
|
||||
) -> [String: ReceiptRecord]? {
|
||||
if let records = runtime.records {
|
||||
return records
|
||||
) -> Bool {
|
||||
if runtime.records != nil, runtime.deletionJournal != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
do {
|
||||
@@ -268,7 +497,7 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
"❌ Failed to create private-media receipt directory: \(error)",
|
||||
category: .session
|
||||
)
|
||||
return nil
|
||||
return false
|
||||
}
|
||||
|
||||
let urls: [URL]
|
||||
@@ -287,12 +516,13 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
"❌ Failed to enumerate private-media receipts: \(error)",
|
||||
category: .session
|
||||
)
|
||||
return nil
|
||||
return false
|
||||
}
|
||||
|
||||
var records: [String: ReceiptRecord] = [:]
|
||||
var scannedRecords: [String: ReceiptRecord] = [:]
|
||||
var expired: [String] = []
|
||||
var tombstones: [ReceiptRecord] = []
|
||||
var tombstones: [(messageID: String, record: ReceiptRecord)] = []
|
||||
for url in urls {
|
||||
guard url.pathExtension == "json" else { continue }
|
||||
let messageID = url.deletingPathExtension().lastPathComponent
|
||||
@@ -311,7 +541,7 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
"❌ Failed to read private-media receipt \(messageID.prefix(12))…: \(error)",
|
||||
category: .session
|
||||
)
|
||||
return nil
|
||||
return false
|
||||
}
|
||||
|
||||
guard isStructurallyValid(record) else {
|
||||
@@ -319,16 +549,21 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
"❌ Invalid private-media receipt \(messageID.prefix(12))…",
|
||||
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)
|
||||
continue
|
||||
}
|
||||
records[messageID] = record
|
||||
if record.kind == .tombstone {
|
||||
tombstones.append(record)
|
||||
}
|
||||
}
|
||||
|
||||
let overflow = overflowVictims(in: records)
|
||||
@@ -336,17 +571,229 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
records.removeValue(forKey: messageID)
|
||||
}
|
||||
|
||||
// Install the index only after every stable-ID record was read and
|
||||
// validated successfully. Cleanup cannot influence a failed scan.
|
||||
runtime.records = records
|
||||
let journal: [String: DeletionJournalEntry]
|
||||
let journalURL = deletionJournalURL(in: directory)
|
||||
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 {
|
||||
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)
|
||||
|
||||
// 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
|
||||
}
|
||||
return records
|
||||
|
||||
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 {
|
||||
@@ -369,10 +816,14 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
) -> [String] {
|
||||
var victims: [String] = []
|
||||
for kind in [ReceiptRecord.Kind.accepted, .tombstone] {
|
||||
let matching = records.filter { $0.value.kind == kind }
|
||||
let overflow = matching.count - capacity
|
||||
let allMatching = records.filter { $0.value.kind == kind }
|
||||
let overflow = allMatching.count - capacity
|
||||
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 {
|
||||
return lhs.key < rhs.key
|
||||
}
|
||||
@@ -389,13 +840,24 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
private func capacityVictim(
|
||||
for incomingKind: ReceiptRecord.Kind,
|
||||
replacing messageID: String,
|
||||
in records: [String: ReceiptRecord]
|
||||
in records: [String: ReceiptRecord],
|
||||
preserving preservedMessageIDs: Set<String> = []
|
||||
) -> String? {
|
||||
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 {
|
||||
$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
|
||||
if lhs.value.recordedAt == rhs.value.recordedAt {
|
||||
return lhs.key < rhs.key
|
||||
@@ -421,7 +883,7 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
|
||||
#endif
|
||||
let url = recordURL(messageID: messageID, in: directory)
|
||||
try data.write(to: url, options: options)
|
||||
try dataWriter(data, url, options)
|
||||
return true
|
||||
} catch {
|
||||
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)
|
||||
guard fileManager.fileExists(atPath: url.path) else { return }
|
||||
guard fileManager.fileExists(atPath: url.path) else { return true }
|
||||
do {
|
||||
try fileManager.removeItem(at: url)
|
||||
return true
|
||||
} catch {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Failed to prune private-media receipt \(messageID.prefix(12))…: \(error)",
|
||||
category: .session
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,39 +919,55 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
.appendingPathExtension("json")
|
||||
}
|
||||
|
||||
private func removePayloadRecordedByTombstone(_ record: ReceiptRecord) {
|
||||
@discardableResult
|
||||
private func removePayloadRecordedByTombstone(
|
||||
_ record: ReceiptRecord
|
||||
) -> Bool {
|
||||
guard record.kind == .tombstone,
|
||||
let relativePath = record.relativePath,
|
||||
let payload = candidatePayload(relativePath: relativePath),
|
||||
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 {
|
||||
try fileManager.removeItem(at: payload)
|
||||
try payloadRemover(payload)
|
||||
return !fileManager.fileExists(atPath: payload.path)
|
||||
} catch {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Failed to remove explicitly deleted private media: \(error)",
|
||||
category: .session
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func addVolatileTombstone(_ messageID: String, at date: Date) {
|
||||
runtime.volatileTombstones[messageID] = date
|
||||
let overflow = runtime.volatileTombstones.count - capacity
|
||||
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
|
||||
private func isSafeDeletionTarget(relativePath: String) -> Bool {
|
||||
guard let payload = candidatePayload(relativePath: relativePath) else {
|
||||
return false
|
||||
}
|
||||
for (oldMessageID, _) in oldest.prefix(overflow) {
|
||||
runtime.volatileTombstones.removeValue(forKey: oldMessageID)
|
||||
guard fileManager.fileExists(atPath: payload.path) else {
|
||||
return true
|
||||
}
|
||||
return (try? payload.resourceValues(
|
||||
forKeys: [.isRegularFileKey]
|
||||
).isRegularFile) == true
|
||||
}
|
||||
|
||||
private func validExistingPayload(_ url: URL) -> URL? {
|
||||
let standardized = url.standardizedFileURL
|
||||
guard isInsideFilesDirectory(standardized) else { return nil }
|
||||
guard isInsideIncomingMediaDirectory(standardized) else {
|
||||
return nil
|
||||
}
|
||||
var isDirectory: ObjCBool = false
|
||||
guard fileManager.fileExists(
|
||||
atPath: standardized.path,
|
||||
@@ -520,15 +1004,27 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
let candidate = filesRoot
|
||||
.appendingPathComponent(relativePath, isDirectory: false)
|
||||
.standardizedFileURL
|
||||
guard candidate.path.hasPrefix(filesRoot.path + "/") else { return nil }
|
||||
guard isInsideIncomingMediaDirectory(candidate) else { return nil }
|
||||
return candidate
|
||||
}
|
||||
|
||||
private func isInsideFilesDirectory(_ url: URL) -> Bool {
|
||||
private func isInsideIncomingMediaDirectory(_ url: URL) -> Bool {
|
||||
guard let filesRoot = try? filesDirectory().standardizedFileURL else {
|
||||
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? {
|
||||
|
||||
@@ -2660,6 +2660,18 @@ final class BLEService: NSObject {
|
||||
removeIncomingFile: { [weak self] storedURL in
|
||||
self?.incomingFileStore.removeIncomingFile(at: storedURL)
|
||||
},
|
||||
finishIncomingFileDelivery: { [weak self] storedURL in
|
||||
// Serialize pending-owner release behind deletion barriers.
|
||||
// If /clear snapshots before this UI insertion, its already
|
||||
// queued barrier must still observe the path as pending. If
|
||||
// insertion wins first, the next MainActor snapshot sees the
|
||||
// new bubble and protects the path explicitly.
|
||||
self?.messageQueue.async(flags: .barrier) {
|
||||
self?.incomingFileStore.finishIncomingFileDelivery(
|
||||
at: storedURL
|
||||
)
|
||||
}
|
||||
},
|
||||
isPrivateMediaSenderBlocked: { [weak self] peerID in
|
||||
guard let self else { return false }
|
||||
let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID)
|
||||
@@ -2684,11 +2696,12 @@ final class BLEService: NSObject {
|
||||
}
|
||||
self.sendDeliveryAck(for: messageID, to: peerID)
|
||||
},
|
||||
deliverMessage: { [weak self] message, shouldDeliver, completion in
|
||||
deliverMessage: { [weak self] message, shouldDeliver, completion, finalization in
|
||||
self?.emitTransportEvent(
|
||||
.messageReceived(message),
|
||||
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) {
|
||||
if preseedPeer {
|
||||
// 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.
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
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 {
|
||||
|
||||
/// Notify UI on the MainActor to satisfy Swift concurrency isolation
|
||||
@@ -4409,52 +4513,71 @@ extension BLEService {
|
||||
private func emitTransportEvent(
|
||||
_ event: TransportEvent,
|
||||
shouldDeliver: (() -> Bool)? = nil,
|
||||
completion: (() -> Void)? = nil
|
||||
completion: (() -> Void)? = nil,
|
||||
finalization: ((TransportEventDeliveryOutcome) -> Void)? = nil
|
||||
) {
|
||||
notifyUI { [weak self] in
|
||||
guard let generation = capturePanicLifecycleGeneration() else {
|
||||
Task { @MainActor in
|
||||
finalization?(.rejected)
|
||||
}
|
||||
return
|
||||
}
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self,
|
||||
shouldDeliver?() ?? true,
|
||||
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 {
|
||||
self.isCurrentPanicLifecycleGeneration(generation) else {
|
||||
finalization?(.rejected)
|
||||
return
|
||||
}
|
||||
completion?()
|
||||
TransportEventDeliveryGate.attempt(
|
||||
shouldDeliver: {
|
||||
self.isCurrentPanicLifecycleGeneration(generation)
|
||||
&& (shouldDeliver?() ?? true)
|
||||
},
|
||||
deliver: {
|
||||
return self.deliverTransportEvent(event)
|
||||
},
|
||||
completion: { completion?() },
|
||||
finalization: { finalization?($0) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@discardableResult
|
||||
private func deliverTransportEvent(_ event: TransportEvent) -> Bool {
|
||||
private func deliverTransportEvent(
|
||||
_ event: TransportEvent
|
||||
) -> TransportEventDeliveryOutcome {
|
||||
if case .messageReceived(let message) = event {
|
||||
if let synchronousDelegate =
|
||||
eventDelegate as? SynchronousMessageTransportEventDelegate {
|
||||
return synchronousDelegate
|
||||
.didReceiveTransportMessageSynchronously(message)
|
||||
? .accepted
|
||||
: .rejected
|
||||
}
|
||||
if let eventDelegate {
|
||||
eventDelegate.didReceiveTransportEvent(event)
|
||||
return false
|
||||
return .invokedUnconfirmed
|
||||
}
|
||||
if let synchronousDelegate =
|
||||
delegate as? SynchronousMessageTransportEventDelegate {
|
||||
return synchronousDelegate
|
||||
.didReceiveTransportMessageSynchronously(message)
|
||||
? .accepted
|
||||
: .rejected
|
||||
}
|
||||
}
|
||||
|
||||
if let eventDelegate {
|
||||
eventDelegate.didReceiveTransportEvent(event)
|
||||
return true
|
||||
return .accepted
|
||||
} else {
|
||||
guard let delegate else { return false }
|
||||
guard let delegate else { return .rejected }
|
||||
delegate.receiveTransportEvent(event)
|
||||
if case .messageReceived = event {
|
||||
return false
|
||||
return .invokedUnconfirmed
|
||||
}
|
||||
return true
|
||||
return .accepted
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -322,7 +322,7 @@ final class CourierStore {
|
||||
|
||||
/// Envelopes eligible to park on relays as bridge courier drops. Merely
|
||||
/// 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] {
|
||||
let date = now()
|
||||
return queue.sync {
|
||||
|
||||
@@ -110,6 +110,10 @@ final class MessageOutboxStore {
|
||||
/// Delivery/read acknowledgments received before a deferred cold-load
|
||||
/// reveals the durable queue. Applied to every merge before persistence.
|
||||
private var pendingRemovalMessageIDs = Set<String>()
|
||||
/// Peer-scoped acknowledgments received before a deferred cold-load
|
||||
/// reveals the durable queue. Unlike the legacy global tombstones above,
|
||||
/// these must not remove a colliding message ID queued for another peer.
|
||||
private var pendingScopedRemovalMessageIDs: [PeerID: Set<String>] = [:]
|
||||
private var recoveryHandler: (@MainActor (Snapshot) -> Void)?
|
||||
/// Recovery loaded durable state that MessageRouter has not merged yet.
|
||||
/// While true, router saves must union with `cachedSnapshot` instead of
|
||||
@@ -195,7 +199,7 @@ final class MessageOutboxStore {
|
||||
? (pendingSnapshot ?? [:])
|
||||
: Self.merge(durable, pendingSnapshot ?? [:]))
|
||||
diskState = .loaded
|
||||
if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty {
|
||||
if pendingSnapshot != nil || hasPendingRemovalsLocked {
|
||||
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
|
||||
pendingSnapshot = nil
|
||||
pendingSnapshotIsAuthoritative = false
|
||||
@@ -212,7 +216,7 @@ final class MessageOutboxStore {
|
||||
case .missing:
|
||||
cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshot ?? [:])
|
||||
diskState = .loaded
|
||||
if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty {
|
||||
if pendingSnapshot != nil || hasPendingRemovalsLocked {
|
||||
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
|
||||
pendingSnapshot = nil
|
||||
pendingSnapshotIsAuthoritative = false
|
||||
@@ -235,7 +239,7 @@ final class MessageOutboxStore {
|
||||
diskState = .loaded
|
||||
cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshot ?? [:])
|
||||
SecureLogger.error("Failed to decode encrypted outbox: \(error)", category: .session)
|
||||
if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty {
|
||||
if pendingSnapshot != nil || hasPendingRemovalsLocked {
|
||||
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
|
||||
pendingSnapshot = nil
|
||||
pendingSnapshotIsAuthoritative = false
|
||||
@@ -442,6 +446,25 @@ final class MessageOutboxStore {
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Records an ack for only the supplied peer aliases. This preserves
|
||||
/// another recipient's queued entry when message IDs happen to collide,
|
||||
/// including while the durable snapshot is hidden by protected data.
|
||||
func recordRemoval(messageID: String, for peerIDs: Set<PeerID>) {
|
||||
guard !peerIDs.isEmpty else { return }
|
||||
|
||||
lock.lock()
|
||||
for peerID in peerIDs {
|
||||
pendingScopedRemovalMessageIDs[peerID, default: []].insert(messageID)
|
||||
}
|
||||
cachedSnapshot = Self.removing(pendingScopedRemovalMessageIDs, from: cachedSnapshot)
|
||||
unseenRecoveredSnapshot = Self.removing(pendingScopedRemovalMessageIDs, from: unseenRecoveredSnapshot)
|
||||
recoveryRouterSnapshot = Self.removing(pendingScopedRemovalMessageIDs, from: recoveryRouterSnapshot)
|
||||
if let pendingSnapshot {
|
||||
self.pendingSnapshot = Self.removing(pendingScopedRemovalMessageIDs, from: pendingSnapshot)
|
||||
}
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Retries a deferred protected-data load. The returned snapshot includes
|
||||
/// both durable messages and any messages queued during the locked wake.
|
||||
@discardableResult
|
||||
@@ -478,7 +501,7 @@ final class MessageOutboxStore {
|
||||
: (pendingSnapshotIsAuthoritative ? known : Self.merge(durable, known)))
|
||||
cachedSnapshot = merged
|
||||
diskState = .loaded
|
||||
if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) ||
|
||||
if (pendingSnapshot == nil && !hasPendingRemovalsLocked) ||
|
||||
persistSnapshotAndClearRemovalsLocked(merged) {
|
||||
pendingSnapshot = nil
|
||||
pendingSnapshotIsAuthoritative = false
|
||||
@@ -507,7 +530,7 @@ final class MessageOutboxStore {
|
||||
: known)
|
||||
cachedSnapshot = merged
|
||||
diskState = .loaded
|
||||
if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) ||
|
||||
if (pendingSnapshot == nil && !hasPendingRemovalsLocked) ||
|
||||
persistSnapshotAndClearRemovalsLocked(merged) {
|
||||
pendingSnapshot = nil
|
||||
pendingSnapshotIsAuthoritative = false
|
||||
@@ -539,7 +562,7 @@ final class MessageOutboxStore {
|
||||
: known)
|
||||
cachedSnapshot = merged
|
||||
diskState = .loaded
|
||||
if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) ||
|
||||
if (pendingSnapshot == nil && !hasPendingRemovalsLocked) ||
|
||||
persistSnapshotAndClearRemovalsLocked(merged) {
|
||||
pendingSnapshot = nil
|
||||
pendingSnapshotIsAuthoritative = false
|
||||
@@ -578,6 +601,7 @@ final class MessageOutboxStore {
|
||||
pendingSnapshot = nil
|
||||
pendingSnapshotIsAuthoritative = false
|
||||
pendingRemovalMessageIDs.removeAll()
|
||||
pendingScopedRemovalMessageIDs.removeAll()
|
||||
recoveryDeliveryPending = false
|
||||
unseenRecoveryPendingPersistence = false
|
||||
unseenRecoveredSnapshot = [:]
|
||||
@@ -742,12 +766,21 @@ final class MessageOutboxStore {
|
||||
private func persistSnapshotAndClearRemovalsLocked(_ snapshot: Snapshot) -> Bool {
|
||||
guard persistSnapshotLocked(snapshot) else { return false }
|
||||
pendingRemovalMessageIDs.removeAll()
|
||||
pendingScopedRemovalMessageIDs.removeAll()
|
||||
return true
|
||||
}
|
||||
|
||||
/// Must be called with `lock` held.
|
||||
private func applyingPendingRemovalsLocked(_ snapshot: Snapshot) -> Snapshot {
|
||||
Self.removing(pendingRemovalMessageIDs, from: snapshot)
|
||||
Self.removing(
|
||||
pendingScopedRemovalMessageIDs,
|
||||
from: Self.removing(pendingRemovalMessageIDs, from: snapshot)
|
||||
)
|
||||
}
|
||||
|
||||
/// Must be read with `lock` held.
|
||||
private var hasPendingRemovalsLocked: Bool {
|
||||
!pendingRemovalMessageIDs.isEmpty || !pendingScopedRemovalMessageIDs.isEmpty
|
||||
}
|
||||
|
||||
private static func removing(_ messageIDs: Set<String>, from snapshot: Snapshot) -> Snapshot {
|
||||
@@ -760,9 +793,28 @@ final class MessageOutboxStore {
|
||||
return filtered
|
||||
}
|
||||
|
||||
private static func removing(
|
||||
_ messageIDsByPeer: [PeerID: Set<String>],
|
||||
from snapshot: Snapshot
|
||||
) -> Snapshot {
|
||||
guard !messageIDsByPeer.isEmpty else { return snapshot }
|
||||
var filtered = snapshot
|
||||
for (peerID, messageIDs) in messageIDsByPeer {
|
||||
guard !messageIDs.isEmpty, let queue = filtered[peerID] else { continue }
|
||||
let remaining = queue.filter { !messageIDs.contains($0.messageID) }
|
||||
filtered[peerID] = remaining.isEmpty ? nil : remaining
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
private static func excludingKnownMessages(from durable: Snapshot, known: Snapshot) -> Snapshot {
|
||||
let knownIDs = Set(known.values.flatMap { $0.map(\.messageID) })
|
||||
return removing(knownIDs, from: durable)
|
||||
var unseen: Snapshot = [:]
|
||||
for (peerID, durableQueue) in durable {
|
||||
let knownIDs = Set(known[peerID]?.map(\.messageID) ?? [])
|
||||
let remaining = durableQueue.filter { !knownIDs.contains($0.messageID) }
|
||||
if !remaining.isEmpty { unseen[peerID] = remaining }
|
||||
}
|
||||
return unseen
|
||||
}
|
||||
|
||||
private static func merge(_ durable: Snapshot, _ pending: Snapshot) -> Snapshot {
|
||||
|
||||
@@ -67,7 +67,7 @@ final class BridgeCourierService: ObservableObject {
|
||||
var relaysConnected: (@MainActor () -> Bool)?
|
||||
/// Publishes a signed drop event directly to connected default (DM)
|
||||
/// 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".
|
||||
var publishEvent: (@MainActor (NostrEvent, @escaping @MainActor (Bool) -> Void) -> Void)?
|
||||
/// (Re)opens the drop subscription for the given hex tags.
|
||||
@@ -106,13 +106,14 @@ final class BridgeCourierService: ObservableObject {
|
||||
dedupKey: String?,
|
||||
operationID: UUID?
|
||||
)] = []
|
||||
/// Message IDs already published as drops (sender-side dedup) and drop
|
||||
/// event IDs already handled (multi-relay dedup). Both persist across
|
||||
/// relaunches: relays hold drops for the full 24h NIP-40 window and the
|
||||
/// persisted outbox keeps re-depositing, so in-memory-only dedup meant
|
||||
/// every relaunch republished the same message as a fresh drop and every
|
||||
/// gateway relaunch re-delivered the whole backlog (field-verified
|
||||
/// amplification storm). Entries age out with the 24h drop window.
|
||||
/// Opaque recipient/message keys already published as drops (sender-side
|
||||
/// dedup) and drop event IDs already handled (multi-relay dedup). Both
|
||||
/// persist across relaunches: relays hold drops for the full 24h NIP-40
|
||||
/// window and the persisted outbox keeps re-depositing, so in-memory-only
|
||||
/// dedup meant every relaunch republished the same message as a fresh drop
|
||||
/// and every gateway relaunch re-delivered the whole backlog
|
||||
/// (field-verified amplification storm). Entries age out with the 24h drop
|
||||
/// window.
|
||||
private var publishedDropKeys: ExpiringIDSet
|
||||
private var seenDropEventIDs: ExpiringIDSet
|
||||
private var subscriptionOpen = false
|
||||
@@ -126,10 +127,10 @@ final class BridgeCourierService: ObservableObject {
|
||||
}
|
||||
/// Sender operations queued locally or awaiting relay confirmation.
|
||||
/// The per-attempt ID prevents a stale pre-wipe callback from completing
|
||||
/// a newer attempt for the same message.
|
||||
/// a newer attempt for the same recipient/message pair.
|
||||
private var activeDropOperations: [String: ActiveDropOperation] = [:]
|
||||
/// 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.
|
||||
private var heldDropOperations: [Data: UUID] = [:]
|
||||
/// Deterministically invalid envelopes are suppressed for this process,
|
||||
@@ -214,11 +215,46 @@ final class BridgeCourierService: ObservableObject {
|
||||
|
||||
// MARK: - Sender role
|
||||
|
||||
/// Stable, opaque sender-side dedup key. Recipient scope prevents one
|
||||
/// conversation's colliding message ID from suppressing another's drop,
|
||||
/// while hashing keeps recipient keys out of the persisted snapshot.
|
||||
private static func senderDropKey(
|
||||
messageID: String,
|
||||
recipientNoiseKey: Data
|
||||
) -> String {
|
||||
var material = Data("bitchat-bridge-drop-dedup-v2".utf8)
|
||||
appendLengthPrefixed(Data(messageID.utf8), to: &material)
|
||||
appendLengthPrefixed(recipientNoiseKey, to: &material)
|
||||
return "v2:\(material.sha256Hex())"
|
||||
}
|
||||
|
||||
private static func appendLengthPrefixed(_ value: Data, to output: inout Data) {
|
||||
let length = UInt32(value.count)
|
||||
output.append(UInt8((length >> 24) & 0xFF))
|
||||
output.append(UInt8((length >> 16) & 0xFF))
|
||||
output.append(UInt8((length >> 8) & 0xFF))
|
||||
output.append(UInt8(length & 0xFF))
|
||||
output.append(value)
|
||||
}
|
||||
|
||||
/// Previous releases persisted raw message IDs without recipient scope.
|
||||
/// They remain conservative wildcards for their original 24-hour
|
||||
/// lifetime: assigning one to a recipient would be guesswork and could
|
||||
/// republish the original drop. New acceptances persist only v2 keys.
|
||||
private func wasPublished(
|
||||
legacyMessageID: String,
|
||||
dedupKey: String,
|
||||
now date: Date
|
||||
) -> Bool {
|
||||
publishedDropKeys.contains(dedupKey, now: date)
|
||||
|| publishedDropKeys.contains(legacyMessageID, now: date)
|
||||
}
|
||||
|
||||
/// Parallel-deposit a sealed copy of an outbound private message as a
|
||||
/// relay drop. Called by the message router alongside physical courier
|
||||
/// deposits; idempotent per message ID. Completion becomes true only
|
||||
/// after a real relay acceptance arrives, which is when the router may
|
||||
/// show "carried".
|
||||
/// deposits; idempotent per recipient/message pair. Completion becomes
|
||||
/// true only after a real relay acceptance arrives, which is when the
|
||||
/// router may show "carried".
|
||||
func depositDrop(
|
||||
content: String,
|
||||
messageID: String,
|
||||
@@ -229,9 +265,18 @@ final class BridgeCourierService: ObservableObject {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
guard !publishedDropKeys.contains(messageID, now: now()),
|
||||
activeDropOperations[messageID] == nil,
|
||||
!rejectedDropKeys.contains(messageID, now: now()) else {
|
||||
let date = now()
|
||||
let dedupKey = Self.senderDropKey(
|
||||
messageID: messageID,
|
||||
recipientNoiseKey: recipientNoiseKey
|
||||
)
|
||||
guard !wasPublished(
|
||||
legacyMessageID: messageID,
|
||||
dedupKey: dedupKey,
|
||||
now: date
|
||||
),
|
||||
activeDropOperations[dedupKey] == nil,
|
||||
!rejectedDropKeys.contains(dedupKey, now: date) else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
@@ -244,13 +289,13 @@ final class BridgeCourierService: ObservableObject {
|
||||
// of the sealing); suppress it in-memory so the retry sweep does not
|
||||
// churn, but never persist it as a published drop.
|
||||
guard let encoded = envelope.encode(), encoded.count <= Limits.maxDropEnvelopeBytes else {
|
||||
rejectedDropKeys.insert(messageID, now: now())
|
||||
rejectedDropKeys.insert(dedupKey, now: date)
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
let operationID = UUID()
|
||||
activeDropOperations[messageID] = ActiveDropOperation(id: operationID, completion: completion)
|
||||
publishDrop(envelope, messageID: messageID, operationID: operationID)
|
||||
activeDropOperations[dedupKey] = ActiveDropOperation(id: operationID, completion: completion)
|
||||
publishDrop(envelope, dedupKey: dedupKey, operationID: operationID)
|
||||
}
|
||||
|
||||
/// Publishes held envelopes (mail we carry for others) as drops,
|
||||
@@ -272,13 +317,14 @@ final class BridgeCourierService: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Publishes a drop, or queues it when relays are down. `messageID` is the
|
||||
/// 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
|
||||
/// in-flight slot. Completion reports actual NIP-20 relay acceptance.
|
||||
/// Publishes a drop, or queues it when relays are down. `dedupKey` is the
|
||||
/// opaque sender-side recipient/message key (nil for held/relayed
|
||||
/// envelopes we don't track); it rides the pending queue so an evicted or
|
||||
/// failed drop can release its in-flight slot. Completion reports actual
|
||||
/// NIP-01 relay acceptance.
|
||||
private func publishDrop(
|
||||
_ envelope: CourierEnvelope,
|
||||
messageID: String? = nil,
|
||||
dedupKey: String? = nil,
|
||||
operationID: UUID? = nil,
|
||||
untrackedCompletion: (@MainActor (Bool) -> Void)? = nil
|
||||
) {
|
||||
@@ -286,7 +332,7 @@ final class BridgeCourierService: ObservableObject {
|
||||
encoded.count <= Limits.maxDropEnvelopeBytes,
|
||||
!envelope.isExpired else {
|
||||
finishPublish(
|
||||
messageID: messageID,
|
||||
dedupKey: dedupKey,
|
||||
operationID: operationID,
|
||||
succeeded: false,
|
||||
untrackedCompletion: untrackedCompletion
|
||||
@@ -297,15 +343,15 @@ final class BridgeCourierService: ObservableObject {
|
||||
// Held mail remains in CourierStore and has no sender operation to
|
||||
// recover after an in-memory queue loss. Leave its cooldown unset
|
||||
// and let the next connected refresh offer it again.
|
||||
guard messageID != nil else {
|
||||
guard dedupKey != nil else {
|
||||
untrackedCompletion?(false)
|
||||
return
|
||||
}
|
||||
pendingDrops.append((envelope, messageID, operationID))
|
||||
pendingDrops.append((envelope, dedupKey, operationID))
|
||||
while pendingDrops.count > Limits.maxPendingDrops {
|
||||
let evicted = pendingDrops.removeFirst()
|
||||
finishPublish(
|
||||
messageID: evicted.dedupKey,
|
||||
dedupKey: evicted.dedupKey,
|
||||
operationID: evicted.operationID,
|
||||
succeeded: false
|
||||
)
|
||||
@@ -321,7 +367,7 @@ final class BridgeCourierService: ObservableObject {
|
||||
) else {
|
||||
SecureLogger.error("📦🌉 Failed to compose courier drop", category: .encryption)
|
||||
finishPublish(
|
||||
messageID: messageID,
|
||||
dedupKey: dedupKey,
|
||||
operationID: operationID,
|
||||
succeeded: false,
|
||||
untrackedCompletion: untrackedCompletion
|
||||
@@ -331,7 +377,7 @@ final class BridgeCourierService: ObservableObject {
|
||||
guard let publishEvent else {
|
||||
SecureLogger.error("📦🌉 Courier drop publisher is not configured", category: .session)
|
||||
finishPublish(
|
||||
messageID: messageID,
|
||||
dedupKey: dedupKey,
|
||||
operationID: operationID,
|
||||
succeeded: false,
|
||||
untrackedCompletion: untrackedCompletion
|
||||
@@ -341,7 +387,7 @@ final class BridgeCourierService: ObservableObject {
|
||||
publishEvent(event) { [weak self] succeeded in
|
||||
guard let self else { return }
|
||||
guard self.finishPublish(
|
||||
messageID: messageID,
|
||||
dedupKey: dedupKey,
|
||||
operationID: operationID,
|
||||
succeeded: succeeded,
|
||||
untrackedCompletion: untrackedCompletion
|
||||
@@ -356,23 +402,23 @@ final class BridgeCourierService: ObservableObject {
|
||||
|
||||
@discardableResult
|
||||
private func finishPublish(
|
||||
messageID: String?,
|
||||
dedupKey: String?,
|
||||
operationID: UUID?,
|
||||
succeeded: Bool,
|
||||
untrackedCompletion: (@MainActor (Bool) -> Void)? = nil
|
||||
) -> Bool {
|
||||
guard let messageID else {
|
||||
guard let dedupKey else {
|
||||
untrackedCompletion?(succeeded)
|
||||
return true
|
||||
}
|
||||
// Missing/mismatched means this callback was duplicated, invalidated
|
||||
// by panic wipe, or belongs to an older attempt for the same key.
|
||||
guard let operationID,
|
||||
let operation = activeDropOperations[messageID],
|
||||
let operation = activeDropOperations[dedupKey],
|
||||
operation.id == operationID else { return false }
|
||||
activeDropOperations.removeValue(forKey: messageID)
|
||||
activeDropOperations.removeValue(forKey: dedupKey)
|
||||
if succeeded {
|
||||
publishedDropKeys.insert(messageID, now: now())
|
||||
publishedDropKeys.insert(dedupKey, now: now())
|
||||
persistDedup()
|
||||
}
|
||||
operation.completion(succeeded)
|
||||
@@ -387,7 +433,7 @@ final class BridgeCourierService: ObservableObject {
|
||||
for item in queued {
|
||||
publishDrop(
|
||||
item.envelope,
|
||||
messageID: item.dedupKey,
|
||||
dedupKey: item.dedupKey,
|
||||
operationID: item.operationID
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,11 +64,11 @@ struct ExpiringIDSet {
|
||||
/// fresh drop (fresh throwaway seal, undeduplicatable downstream) and every
|
||||
/// gateway relaunch re-delivered the whole backlog. Field-verified: ~20
|
||||
/// copies of one DM delivered in 40ms fed the storm behind a permanent
|
||||
/// device freeze. Persisting both sides caps this at one drop per message
|
||||
/// ID per 24h regardless of relaunch count.
|
||||
/// device freeze. Persisting both sides caps this at one drop per
|
||||
/// recipient/message pair per 24h regardless of relaunch count.
|
||||
///
|
||||
/// Contents are opaque IDs (message UUIDs, relay event IDs) — no plaintext,
|
||||
/// no peer identities — so until-first-unlock protection matches
|
||||
/// Contents are opaque hashes and relay event IDs — no plaintext or peer
|
||||
/// identities — so until-first-unlock protection matches
|
||||
/// `NostrProcessedEventStore`, and the file must load during a
|
||||
/// locked-background restoration relaunch. Wiped on panic with the rest of
|
||||
/// the courier state.
|
||||
|
||||
@@ -172,11 +172,11 @@ final class MessageDeduplicationService {
|
||||
/// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format)
|
||||
private let nostrAckCache: LRUDeduplicationCache<Bool>
|
||||
|
||||
/// Optional cross-launch persistence for the Nostr event cache. NIP-59
|
||||
/// randomizes gift-wrap timestamps, so DM subscriptions look back 24h and
|
||||
/// relays redeliver the same events on every launch; without this record
|
||||
/// each relaunch reprocesses old PMs and acks. Nil (tests, macOS callers
|
||||
/// that don't opt in) keeps the cache purely in-memory.
|
||||
/// Optional cross-launch persistence for the Nostr event cache. BitChat
|
||||
/// randomizes private-envelope timestamps, so DM subscriptions look back
|
||||
/// 24h and relays redeliver the same events on every launch; without this
|
||||
/// record each relaunch reprocesses old PMs and acks. Nil (tests, macOS
|
||||
/// callers that don't opt in) keeps the cache purely in-memory.
|
||||
private let nostrEventStore: NostrProcessedEventStore?
|
||||
private let nostrEventCapacity: Int
|
||||
private var persistScheduled = false
|
||||
@@ -314,7 +314,7 @@ final class MessageDeduplicationService {
|
||||
// MARK: - Clear
|
||||
|
||||
/// 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() {
|
||||
contentCache.clear()
|
||||
nostrEventCache.clear()
|
||||
@@ -325,7 +325,7 @@ final class MessageDeduplicationService {
|
||||
|
||||
/// Clears only the in-memory Nostr caches (events and ACKs). Runs on
|
||||
/// 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).
|
||||
func clearNostrCaches() {
|
||||
nostrEventCache.clear()
|
||||
|
||||
@@ -34,6 +34,11 @@ struct CourierDirectory {
|
||||
final class MessageRouter {
|
||||
typealias QueuedMessage = MessageOutboxStore.QueuedMessage
|
||||
|
||||
private struct PeerMessageKey: Hashable {
|
||||
let peerID: PeerID
|
||||
let messageID: String
|
||||
}
|
||||
|
||||
private let transports: [Transport]
|
||||
private let now: () -> Date
|
||||
private let courierDirectory: CourierDirectory
|
||||
@@ -104,15 +109,25 @@ final class MessageRouter {
|
||||
}
|
||||
|
||||
private var bridgeSweepTask: Task<Void, Never>?
|
||||
private var bridgeDepositsInFlight = Set<String>()
|
||||
private var bridgeDepositsInFlight = Set<PeerMessageKey>()
|
||||
|
||||
private var outbox: [PeerID: [QueuedMessage]] = [:]
|
||||
/// Peer/message pairs whose latest router-owned transmission used an
|
||||
/// already-established secure session and still await an ack. Peer scope
|
||||
/// is required because message IDs are not globally unique across direct
|
||||
/// conversations. This deliberately excludes messages handed to BLE while
|
||||
/// a handshake is pending: BLE owns those sends and drains its queue after
|
||||
/// authentication, so retrying them here would duplicate every normal
|
||||
/// first-handshake DM.
|
||||
private var secureTransmissions = Set<PeerMessageKey>()
|
||||
|
||||
// Outbox limits to prevent unbounded memory growth
|
||||
private static let maxMessagesPerPeer = 100
|
||||
private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours
|
||||
// Bound resends of messages sent on a weak reachability signal that never
|
||||
// get a delivery ack (e.g. peer on an old client that doesn't ack).
|
||||
// Bound actual sends that never receive an ack, whether they used weak
|
||||
// reachability or an apparently secure session that keeps being replaced.
|
||||
// Connected pre-handshake sends are transport-owned and do not burn this
|
||||
// cap because BLE queues/drains them itself.
|
||||
private static let maxSendAttempts = 8
|
||||
// Redundant couriers improve delivery odds; receivers dedup by message ID.
|
||||
private static let maxCouriersPerMessage = 3
|
||||
@@ -158,6 +173,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
|
||||
|
||||
private func reachableTransport(for peerID: PeerID) -> Transport? {
|
||||
@@ -171,15 +196,28 @@ final class MessageRouter {
|
||||
// MARK: - Message Sending
|
||||
|
||||
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||
let message = QueuedMessage(
|
||||
content: content,
|
||||
nickname: recipientNickname,
|
||||
messageID: messageID,
|
||||
timestamp: now(),
|
||||
sendAttempts: 1
|
||||
)
|
||||
|
||||
if let transport = connectedTransport(for: peerID), transport.canDeliverSecurely(to: peerID) {
|
||||
// A live link that can complete an encrypted delivery is a
|
||||
// strong delivery signal; trust it outright.
|
||||
// Even an established Noise session can be stale after the peer
|
||||
// restarts or replaces its app. Persist before handing the packet
|
||||
// to the transport so a fast ack cannot race ahead of retention,
|
||||
// then keep the copy until a delivery/read ack clears it. A
|
||||
// replacement handshake will retry this same message ID, which
|
||||
// receivers deduplicate.
|
||||
enqueue(message, for: peerID)
|
||||
secureTransmissions.insert(PeerMessageKey(peerID: peerID, messageID: messageID))
|
||||
SecureLogger.debug("Routing PM via \(type(of: transport)) (connected) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
return
|
||||
}
|
||||
|
||||
let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: now(), sendAttempts: 1)
|
||||
if let transport = connectedTransport(for: peerID) {
|
||||
// "Connected" without an established secure session is forgeable:
|
||||
// link bindings heal on signature-verified "direct" announces, but
|
||||
@@ -197,8 +235,9 @@ final class MessageRouter {
|
||||
// deposit is cleared on ack. Don't "optimize" the courier call
|
||||
// away.
|
||||
SecureLogger.debug("Routing PM via \(type(of: transport)) (connected, no secure session) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
enqueue(message, for: peerID)
|
||||
secureTransmissions.remove(PeerMessageKey(peerID: peerID, messageID: messageID))
|
||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
attemptCourierDeposit(messageID: messageID, for: peerID)
|
||||
return
|
||||
}
|
||||
@@ -209,8 +248,8 @@ final class MessageRouter {
|
||||
// Send now, but retain a copy until a delivery/read ack clears it;
|
||||
// receivers dedup resends by message ID.
|
||||
SecureLogger.debug("Routing PM via \(type(of: transport)) (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
enqueue(message, for: peerID)
|
||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
// "Reachable" without prompt delivery means the send only joined
|
||||
// a queue (Nostr with relays down): also hand a sealed copy to
|
||||
// any connected couriers rather than waiting for internet that
|
||||
@@ -333,11 +372,12 @@ final class MessageRouter {
|
||||
for peerID: PeerID,
|
||||
recipientKey: Data
|
||||
) {
|
||||
let inFlightKey = PeerMessageKey(peerID: peerID, messageID: message.messageID)
|
||||
guard let bridgeCourierDeposit,
|
||||
bridgeDepositsInFlight.insert(message.messageID).inserted else { return }
|
||||
bridgeDepositsInFlight.insert(inFlightKey).inserted else { return }
|
||||
bridgeCourierDeposit(message.content, message.messageID, recipientKey) { [weak self] succeeded in
|
||||
guard let self else { return }
|
||||
self.bridgeDepositsInFlight.remove(message.messageID)
|
||||
self.bridgeDepositsInFlight.remove(inFlightKey)
|
||||
// A direct delivery may have cleared the outbox while the relay
|
||||
// relay confirmation was in flight; do not regress its UI state.
|
||||
guard succeeded, self.queuedMessage(message.messageID, for: peerID) != nil else { return }
|
||||
@@ -356,15 +396,40 @@ final class MessageRouter {
|
||||
|
||||
// MARK: - Outbox Management
|
||||
|
||||
/// A delivery or read ack confirms receipt; stop retaining the message.
|
||||
/// A locally trusted delivery transition confirms receipt; stop retaining
|
||||
/// every copy of the message. Authenticated remote receipts must use the
|
||||
/// peer-bound overload below instead.
|
||||
func markDelivered(_ messageID: String) {
|
||||
clearRetainedMessage(messageID, allowedPeerIDs: nil)
|
||||
}
|
||||
|
||||
/// Stops retaining a message only for the authenticated conversation
|
||||
/// aliases that produced the accepted receipt. A peer that learns another
|
||||
/// conversation's message ID cannot use it to clear that conversation's
|
||||
/// retry state.
|
||||
func markDelivered(_ messageID: String, from peerIDs: Set<PeerID>) {
|
||||
guard !peerIDs.isEmpty else { return }
|
||||
_ = markDelivered(messageID, for: Array(peerIDs))
|
||||
}
|
||||
|
||||
private func clearRetainedMessage(
|
||||
_ messageID: String,
|
||||
allowedPeerIDs: Set<PeerID>?
|
||||
) {
|
||||
var cleared = false
|
||||
for (peerID, queue) in outbox {
|
||||
if let allowedPeerIDs, !allowedPeerIDs.contains(peerID) {
|
||||
continue
|
||||
}
|
||||
let filtered = queue.filter { $0.messageID != messageID }
|
||||
guard filtered.count != queue.count else { continue }
|
||||
outbox[peerID] = filtered.isEmpty ? nil : filtered
|
||||
cleared = true
|
||||
}
|
||||
let matchingSecureTransmissions = secureTransmissions.filter {
|
||||
$0.messageID == messageID
|
||||
}
|
||||
secureTransmissions.subtract(matchingSecureTransmissions)
|
||||
// The durable snapshot may still be hidden by protected data. Record
|
||||
// the ack even when this cold-load view cannot find the message, then
|
||||
// persist the current view so the store retains a removal tombstone.
|
||||
@@ -375,6 +440,35 @@ final class MessageRouter {
|
||||
persistOutbox()
|
||||
}
|
||||
|
||||
/// A delivery or read ack authenticated to one account confirms receipt
|
||||
/// only for that account's transport aliases. A colliding message ID
|
||||
/// queued for another peer must remain retained.
|
||||
@discardableResult
|
||||
func markDelivered(_ messageID: String, for peerAliases: [PeerID]) -> Bool {
|
||||
let peerIDs = Set(peerAliases)
|
||||
guard !peerIDs.isEmpty else { return false }
|
||||
|
||||
var cleared = false
|
||||
for peerID in peerIDs {
|
||||
guard let queue = outbox[peerID] else { continue }
|
||||
let filtered = queue.filter { $0.messageID != messageID }
|
||||
guard filtered.count != queue.count else { continue }
|
||||
outbox[peerID] = filtered.isEmpty ? nil : filtered
|
||||
cleared = true
|
||||
}
|
||||
for peerID in peerIDs {
|
||||
secureTransmissions.remove(PeerMessageKey(peerID: peerID, messageID: messageID))
|
||||
}
|
||||
// Preserve the scoped ack even when protected data hides the durable
|
||||
// queue during a cold launch.
|
||||
outboxStore?.recordRemoval(messageID: messageID, for: peerIDs)
|
||||
if cleared {
|
||||
metrics?.record(.outboxDelivered)
|
||||
}
|
||||
persistOutbox()
|
||||
return cleared
|
||||
}
|
||||
|
||||
private func enqueue(_ message: QueuedMessage, for peerID: PeerID) {
|
||||
var message = message
|
||||
var queue = outbox[peerID] ?? []
|
||||
@@ -398,7 +492,34 @@ final class MessageRouter {
|
||||
persistOutbox()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func removeQueuedMessage(_ messageID: String, for peerID: PeerID) -> Bool {
|
||||
guard let queue = outbox[peerID],
|
||||
let index = queue.firstIndex(where: { $0.messageID == messageID }) else {
|
||||
return false
|
||||
}
|
||||
var updated = queue
|
||||
updated.remove(at: index)
|
||||
outbox[peerID] = updated.isEmpty ? nil : updated
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func incrementSendAttemptsIfQueued(_ messageID: String, for peerID: PeerID) -> Bool {
|
||||
guard var queue = outbox[peerID],
|
||||
let index = queue.firstIndex(where: { $0.messageID == messageID }) else {
|
||||
// A synchronous delivery/read ack may have cleared the retained
|
||||
// copy while `sendPrivateMessage` was on the stack. Never
|
||||
// resurrect it from the flush snapshot.
|
||||
return false
|
||||
}
|
||||
queue[index].sendAttempts += 1
|
||||
outbox[peerID] = queue
|
||||
return true
|
||||
}
|
||||
|
||||
private func dropMessage(_ messageID: String, for peerID: PeerID) {
|
||||
secureTransmissions.remove(PeerMessageKey(peerID: peerID, messageID: messageID))
|
||||
metrics?.record(.outboxDropped)
|
||||
onMessageDropped?(messageID, peerID)
|
||||
}
|
||||
@@ -442,6 +563,7 @@ final class MessageRouter {
|
||||
/// Panic wipe: forget queued mail on disk and in memory.
|
||||
func wipeOutbox() {
|
||||
outbox.removeAll()
|
||||
secureTransmissions.removeAll()
|
||||
outboxStore?.wipe()
|
||||
}
|
||||
|
||||
@@ -469,26 +591,160 @@ final class MessageRouter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Retries only messages that the router previously transmitted through
|
||||
/// an already-established secure session and that still await an ack.
|
||||
///
|
||||
/// A peer restart can leave that local session looking usable until the
|
||||
/// replacement handshake arrives; the first ciphertext is then
|
||||
/// undecryptable remotely. Normal pre-handshake sends are intentionally
|
||||
/// absent from `secureTransmissions` because BLE already queues
|
||||
/// and drains them when authentication completes.
|
||||
func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID]) {
|
||||
typealias Candidate = (
|
||||
peerID: PeerID,
|
||||
message: QueuedMessage,
|
||||
aliasOrder: Int,
|
||||
queueOrder: Int
|
||||
)
|
||||
|
||||
var visitedPeerIDs = Set<PeerID>()
|
||||
var retriedMessageIDs = Set<String>()
|
||||
var outboxChanged = false
|
||||
let currentDate = now()
|
||||
var candidates: [Candidate] = []
|
||||
|
||||
for (aliasOrder, peerID) in peerIDAliases.enumerated() {
|
||||
guard visitedPeerIDs.insert(peerID).inserted else { continue }
|
||||
guard let queued = outbox[peerID], !queued.isEmpty,
|
||||
let transport = connectedTransport(for: peerID),
|
||||
transport.canDeliverSecurely(to: peerID) else {
|
||||
continue
|
||||
}
|
||||
|
||||
for (queueOrder, message) in queued.enumerated() {
|
||||
let key = PeerMessageKey(peerID: peerID, messageID: message.messageID)
|
||||
guard secureTransmissions.contains(key) else { continue }
|
||||
candidates.append((
|
||||
peerID: peerID,
|
||||
message: message,
|
||||
aliasOrder: aliasOrder,
|
||||
queueOrder: queueOrder
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Conversation migration can leave retained messages split across the
|
||||
// ephemeral and stable outbox keys. Merge both queues into one
|
||||
// chronological stream so callback alias order cannot send newer mail
|
||||
// ahead of older mail.
|
||||
candidates.sort { lhs, rhs in
|
||||
if lhs.message.timestamp != rhs.message.timestamp {
|
||||
return lhs.message.timestamp < rhs.message.timestamp
|
||||
}
|
||||
if lhs.aliasOrder != rhs.aliasOrder {
|
||||
return lhs.aliasOrder < rhs.aliasOrder
|
||||
}
|
||||
if lhs.queueOrder != rhs.queueOrder {
|
||||
return lhs.queueOrder < rhs.queueOrder
|
||||
}
|
||||
return lhs.message.messageID < rhs.message.messageID
|
||||
}
|
||||
|
||||
for candidate in candidates {
|
||||
let peerID = candidate.peerID
|
||||
let message = candidate.message
|
||||
let key = PeerMessageKey(peerID: peerID, messageID: message.messageID)
|
||||
guard retriedMessageIDs.insert(message.messageID).inserted,
|
||||
secureTransmissions.contains(key),
|
||||
queuedMessage(message.messageID, for: peerID) != nil,
|
||||
let transport = connectedTransport(for: peerID),
|
||||
transport.canDeliverSecurely(to: peerID) else {
|
||||
continue
|
||||
}
|
||||
|
||||
if currentDate.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
|
||||
if removeQueuedMessage(message.messageID, for: peerID) {
|
||||
dropMessage(message.messageID, for: peerID)
|
||||
outboxChanged = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
guard message.sendAttempts < Self.maxSendAttempts else {
|
||||
SecureLogger.warning(
|
||||
"📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) secure attempts",
|
||||
category: .session
|
||||
)
|
||||
if removeQueuedMessage(message.messageID, for: peerID) {
|
||||
dropMessage(message.messageID, for: peerID)
|
||||
outboxChanged = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
SecureLogger.debug(
|
||||
"Auth retry -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…",
|
||||
category: .session
|
||||
)
|
||||
transport.sendPrivateMessage(
|
||||
message.content,
|
||||
to: peerID,
|
||||
recipientNickname: message.nickname,
|
||||
messageID: message.messageID
|
||||
)
|
||||
metrics?.record(.outboxResent)
|
||||
outboxChanged = incrementSendAttemptsIfQueued(message.messageID, for: peerID) || outboxChanged
|
||||
}
|
||||
|
||||
if outboxChanged {
|
||||
persistOutbox()
|
||||
}
|
||||
}
|
||||
|
||||
func flushOutbox(for peerID: PeerID) {
|
||||
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
||||
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
|
||||
|
||||
let now = now()
|
||||
var remaining: [QueuedMessage] = []
|
||||
var outboxChanged = false
|
||||
|
||||
for message in queued {
|
||||
// A synchronous ack from an earlier send in this flush may have
|
||||
// removed an entry from the live outbox. The snapshot is only an
|
||||
// iteration order; never use it to recreate removed messages.
|
||||
guard queuedMessage(message.messageID, for: peerID) != nil else { continue }
|
||||
|
||||
// Skip expired messages (TTL exceeded)
|
||||
if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
|
||||
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session)
|
||||
dropMessage(message.messageID, for: peerID)
|
||||
if removeQueuedMessage(message.messageID, for: peerID) {
|
||||
dropMessage(message.messageID, for: peerID)
|
||||
outboxChanged = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if let transport = connectedTransport(for: peerID), transport.canDeliverSecurely(to: peerID) {
|
||||
// Live link with a secure session: send and stop retaining.
|
||||
// A secure session is meaningful enough to retry, but not
|
||||
// proof that this particular ciphertext reached the peer: the
|
||||
// remote app may have restarted while our old session still
|
||||
// looked established. Retain until an ack, while bounding
|
||||
// actual secure transmissions for peers that never ack.
|
||||
guard message.sendAttempts < Self.maxSendAttempts else {
|
||||
SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session)
|
||||
if removeQueuedMessage(message.messageID, for: peerID) {
|
||||
dropMessage(message.messageID, for: peerID)
|
||||
outboxChanged = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
SecureLogger.debug("Outbox -> \(type(of: transport)) (connected) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
||||
secureTransmissions.insert(
|
||||
PeerMessageKey(peerID: peerID, messageID: message.messageID)
|
||||
)
|
||||
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
||||
metrics?.record(.outboxResent)
|
||||
outboxChanged = incrementSendAttemptsIfQueued(message.messageID, for: peerID) || outboxChanged
|
||||
} else if let transport = connectedTransport(for: peerID) {
|
||||
// "Connected" without a secure session — possibly a stolen
|
||||
// binding from a replayed announce: send (a genuine link
|
||||
@@ -501,9 +757,11 @@ final class MessageRouter {
|
||||
// preserve. Retention stays bounded by the 24h outbox TTL
|
||||
// and the per-peer FIFO cap.
|
||||
SecureLogger.debug("Outbox -> \(type(of: transport)) (connected, no secure session) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
||||
secureTransmissions.remove(
|
||||
PeerMessageKey(peerID: peerID, messageID: message.messageID)
|
||||
)
|
||||
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
||||
metrics?.record(.outboxResent)
|
||||
remaining.append(message)
|
||||
} else if let transport = reachableTransport(for: peerID) {
|
||||
// Reachability without a connection is a freshness heuristic,
|
||||
// so the send can silently go nowhere: send but keep retaining
|
||||
@@ -511,26 +769,22 @@ final class MessageRouter {
|
||||
// that never ack.
|
||||
guard message.sendAttempts < Self.maxSendAttempts else {
|
||||
SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session)
|
||||
dropMessage(message.messageID, for: peerID)
|
||||
if removeQueuedMessage(message.messageID, for: peerID) {
|
||||
dropMessage(message.messageID, for: peerID)
|
||||
outboxChanged = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
SecureLogger.debug("Outbox -> \(type(of: transport)) (reachable) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
||||
metrics?.record(.outboxResent)
|
||||
var retained = message
|
||||
retained.sendAttempts += 1
|
||||
remaining.append(retained)
|
||||
} else {
|
||||
remaining.append(message)
|
||||
outboxChanged = incrementSendAttemptsIfQueued(message.messageID, for: peerID) || outboxChanged
|
||||
}
|
||||
}
|
||||
|
||||
if remaining.isEmpty {
|
||||
outbox.removeValue(forKey: peerID)
|
||||
} else {
|
||||
outbox[peerID] = remaining
|
||||
if outboxChanged {
|
||||
persistOutbox()
|
||||
}
|
||||
persistOutbox()
|
||||
}
|
||||
|
||||
func flushAllOutbox() {
|
||||
|
||||
@@ -97,7 +97,7 @@ enum EncryptionStatus: Equatable {
|
||||
case noiseHandshaking // Currently establishing
|
||||
case noiseSecured // Established but not verified
|
||||
case noiseVerified // Established and verified
|
||||
|
||||
|
||||
var icon: String? { // Made optional to hide icon when no handshake
|
||||
switch self {
|
||||
case .none:
|
||||
@@ -660,7 +660,7 @@ final class NoiseEncryptionService {
|
||||
guard let packetData = packet.toBinaryDataForSigning() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// Sign with the noise private key (converted to Ed25519 for signing)
|
||||
guard let signature = signData(packetData) else {
|
||||
return nil
|
||||
@@ -907,7 +907,7 @@ final class NoiseEncryptionService {
|
||||
}
|
||||
return try sessionManager.encrypt(data, for: peerID)
|
||||
}
|
||||
|
||||
|
||||
/// Decrypt data from a specific peer
|
||||
func decrypt(_ data: Data, from peerID: PeerID) throws -> Data {
|
||||
try decryptWithSessionGeneration(data, from: peerID).plaintext
|
||||
@@ -924,7 +924,7 @@ final class NoiseEncryptionService {
|
||||
let isStandardCiphertext = NoiseSecurityValidator.validateCiphertextSize(data)
|
||||
let isAdmittedCiphertext = isStandardCiphertext
|
||||
|| NoiseSecurityValidator.validatePrivateFileCiphertextSize(data)
|
||||
|
||||
|
||||
// A quarantined transport is deliberately unavailable for outbound
|
||||
// state, but remains receive-only until the responder proves identity
|
||||
// or the bounded rollback restores it.
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// Disk persistence for processed gift-wrap event IDs. NIP-59 randomizes
|
||||
/// gift-wrap timestamps, so DM subscriptions must look back generously (24h)
|
||||
/// and relays redeliver the same events on every launch — without a
|
||||
/// cross-launch record, each relaunch reprocesses old PMs and acks
|
||||
/// Disk persistence for processed private-envelope event IDs. BitChat
|
||||
/// randomizes envelope timestamps, so DM subscriptions must look back
|
||||
/// generously (24h) and relays redeliver the same events on every launch —
|
||||
/// without a cross-launch record, each relaunch reprocesses old PMs and acks
|
||||
/// (re-sent DELIVERED bursts, "delivered ack for unknown mid" noise).
|
||||
///
|
||||
/// Contents are event IDs already visible to every relay, so
|
||||
|
||||
@@ -11,8 +11,12 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
let favoriteStatusForNoiseKey: @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||
let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||
let currentIdentity: @MainActor () throws -> NostrIdentity?
|
||||
let registerPendingGiftWrap: @MainActor (String) -> Void
|
||||
let sendEvent: @MainActor (NostrEvent) -> Void
|
||||
let registerPendingPrivateEnvelope: @MainActor (String) -> Void
|
||||
let sendPrivateEnvelopeBatch: @MainActor (
|
||||
[NostrEvent],
|
||||
@escaping @MainActor () -> Void
|
||||
) -> Bool
|
||||
let envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue
|
||||
/// Emits whether a relay that carries private messages is up
|
||||
/// (fail-closed behind Tor). A connected geohash/custom relay alone
|
||||
/// doesn't count: DM sends target the default relay set and would
|
||||
@@ -22,25 +26,35 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
/// serialize behind each other; `live` passes the process-wide one.
|
||||
let ackPacer: AckPacer
|
||||
|
||||
@MainActor
|
||||
init(
|
||||
notificationCenter: NotificationCenter,
|
||||
loadFavorites: @escaping @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship],
|
||||
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?,
|
||||
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?,
|
||||
currentIdentity: @escaping @MainActor () throws -> NostrIdentity?,
|
||||
registerPendingGiftWrap: @escaping @MainActor (String) -> Void,
|
||||
sendEvent: @escaping @MainActor (NostrEvent) -> Void,
|
||||
registerPendingPrivateEnvelope: @escaping @MainActor (String) -> Void,
|
||||
sendPrivateEnvelopeBatch: @escaping @MainActor (
|
||||
[NostrEvent],
|
||||
@escaping @MainActor () -> Void
|
||||
) -> Bool,
|
||||
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void,
|
||||
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never>,
|
||||
ackPacer: AckPacer? = nil
|
||||
ackPacer: AckPacer? = nil,
|
||||
envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue? = nil
|
||||
) {
|
||||
self.notificationCenter = notificationCenter
|
||||
self.loadFavorites = loadFavorites
|
||||
self.favoriteStatusForNoiseKey = favoriteStatusForNoiseKey
|
||||
self.favoriteStatusForPeerID = favoriteStatusForPeerID
|
||||
self.currentIdentity = currentIdentity
|
||||
self.registerPendingGiftWrap = registerPendingGiftWrap
|
||||
self.sendEvent = sendEvent
|
||||
self.registerPendingPrivateEnvelope = registerPendingPrivateEnvelope
|
||||
self.sendPrivateEnvelopeBatch = sendPrivateEnvelopeBatch
|
||||
self.envelopeRetryQueue = envelopeRetryQueue ?? NostrPrivateEnvelopeRetryQueue(
|
||||
sendPrivateEnvelopeBatch: sendPrivateEnvelopeBatch,
|
||||
registerPendingPrivateEnvelope: registerPendingPrivateEnvelope,
|
||||
scheduleAfter: scheduleAfter
|
||||
)
|
||||
self.relayConnectivity = relayConnectivity
|
||||
// Default pacer drives its throttle through the same injected
|
||||
// scheduler, so tests that step scheduleAfter manually keep
|
||||
@@ -56,13 +70,19 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
favoriteStatusForNoiseKey: { FavoritesPersistenceService.shared.getFavoriteStatus(for: $0) },
|
||||
favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) },
|
||||
currentIdentity: { try idBridge.getCurrentNostrIdentity() },
|
||||
registerPendingGiftWrap: { NostrRelayManager.registerPendingGiftWrap(id: $0) },
|
||||
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
|
||||
registerPendingPrivateEnvelope: { NostrRelayManager.registerPendingPrivateEnvelope(id: $0) },
|
||||
sendPrivateEnvelopeBatch: { events, terminalFailure in
|
||||
NostrRelayManager.shared.sendPrivateEnvelopeBatch(
|
||||
events,
|
||||
terminalFailure: terminalFailure
|
||||
)
|
||||
},
|
||||
scheduleAfter: { delay, action in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
||||
},
|
||||
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()
|
||||
// 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 envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue
|
||||
private var favoriteStatusObserver: NSObjectProtocol?
|
||||
|
||||
// Reachability Cache (thread-safe)
|
||||
@@ -145,7 +195,9 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
idBridge: NostrIdentityBridge,
|
||||
dependencies: Dependencies? = nil
|
||||
) {
|
||||
self.dependencies = dependencies ?? .live(idBridge: idBridge)
|
||||
let resolvedDependencies = dependencies ?? .live(idBridge: idBridge)
|
||||
self.dependencies = resolvedDependencies
|
||||
self.envelopeRetryQueue = resolvedDependencies.envelopeRetryQueue
|
||||
|
||||
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() {
|
||||
favoriteStatusObserver = dependencies.notificationCenter.addObserver(
|
||||
forName: .favoriteStatusChanged,
|
||||
@@ -261,7 +325,12 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
||||
return
|
||||
}
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
sendPrivateEnvelope(
|
||||
content: embedded,
|
||||
recipientHex: recipientHex,
|
||||
senderIdentity: senderIdentity,
|
||||
failurePolicy: .userMessage(messageID: messageID)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,7 +356,14 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
||||
return
|
||||
}
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
sendPrivateEnvelope(
|
||||
content: embedded,
|
||||
recipientHex: recipientHex,
|
||||
senderIdentity: senderIdentity,
|
||||
failurePolicy: .retry(
|
||||
retryKey: privateEnvelopeRetryKey(content: embedded, recipientHex: recipientHex)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,7 +395,13 @@ extension NostrTransport {
|
||||
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
||||
return
|
||||
}
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||
sendPrivateEnvelope(
|
||||
content: embedded,
|
||||
recipientHex: recipientHex,
|
||||
senderIdentity: identity,
|
||||
registerPending: true,
|
||||
failurePolicy: .userMessage(messageID: messageID)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -340,17 +422,92 @@ extension NostrTransport {
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates and sends a gift-wrapped private message event
|
||||
/// Creates and sends a BitChat private-envelope event over Nostr.
|
||||
@MainActor
|
||||
private func sendWrappedMessage(content: String, recipientHex: String, senderIdentity: NostrIdentity, registerPending: Bool = false) {
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: content, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event", category: .session)
|
||||
private func sendPrivateEnvelope(
|
||||
content: String,
|
||||
recipientHex: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
registerPending: Bool = false,
|
||||
failurePolicy: PrivateEnvelopeFailurePolicy
|
||||
) {
|
||||
guard let events = try? NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||
content: content,
|
||||
recipientPubkey: recipientHex,
|
||||
senderIdentity: senderIdentity
|
||||
) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr private-envelope batch", category: .session)
|
||||
return
|
||||
}
|
||||
if registerPending {
|
||||
dependencies.registerPendingGiftWrap(event.id)
|
||||
let accepted = dependencies.sendPrivateEnvelopeBatch(events) { [self] in
|
||||
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
|
||||
}
|
||||
registerPendingPrivateEnvelopesIfNeeded(events, registerPending: registerPending)
|
||||
}
|
||||
|
||||
@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):
|
||||
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 +524,14 @@ extension NostrTransport {
|
||||
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
||||
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):
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||
@@ -378,17 +542,40 @@ extension NostrTransport {
|
||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||
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):
|
||||
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))…", category: .session)
|
||||
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):
|
||||
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))…", category: .session)
|
||||
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 +595,122 @@ extension NostrTransport {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded retry owner for non-user private control payloads. It is separate
|
||||
/// from `NostrTransport` so a scheduled retry from a short-lived geohash
|
||||
/// transport remains valid after that transport deinitializes. Scheduler
|
||||
/// callbacks retain this queue, never the transport; an evicted key simply
|
||||
/// becomes a harmless no-op when its already-scheduled callback fires.
|
||||
@MainActor
|
||||
final class NostrPrivateEnvelopeRetryQueue {
|
||||
private struct PendingRetry {
|
||||
let events: [NostrEvent]
|
||||
let registerPending: Bool
|
||||
var attempt: Int
|
||||
var isScheduled: Bool
|
||||
}
|
||||
|
||||
private let sendPrivateEnvelopeBatch: @MainActor (
|
||||
[NostrEvent],
|
||||
@escaping @MainActor () -> Void
|
||||
) -> Bool
|
||||
private let registerPendingPrivateEnvelope: @MainActor (String) -> Void
|
||||
private let scheduleAfter: @Sendable (
|
||||
TimeInterval,
|
||||
@escaping @Sendable () -> Void
|
||||
) -> Void
|
||||
private var pending: [String: PendingRetry] = [:]
|
||||
private var insertionOrder: [String] = []
|
||||
|
||||
init(
|
||||
sendPrivateEnvelopeBatch: @escaping @MainActor (
|
||||
[NostrEvent],
|
||||
@escaping @MainActor () -> Void
|
||||
) -> Bool,
|
||||
registerPendingPrivateEnvelope: @escaping @MainActor (String) -> Void,
|
||||
scheduleAfter: @escaping @Sendable (
|
||||
TimeInterval,
|
||||
@escaping @Sendable () -> Void
|
||||
) -> Void
|
||||
) {
|
||||
self.sendPrivateEnvelopeBatch = sendPrivateEnvelopeBatch
|
||||
self.registerPendingPrivateEnvelope = registerPendingPrivateEnvelope
|
||||
self.scheduleAfter = scheduleAfter
|
||||
}
|
||||
|
||||
func enqueue(key: String, events: [NostrEvent], registerPending: Bool) {
|
||||
guard pending[key] == nil else { return }
|
||||
if pending.count >= TransportConfig.nostrPrivateEnvelopeRetryQueueCap,
|
||||
let evictedKey = insertionOrder.first {
|
||||
insertionOrder.removeFirst()
|
||||
pending.removeValue(forKey: evictedKey)
|
||||
// These are control payloads, never user-authored messages. Keep
|
||||
// the bounded-loss decision explicit rather than silently growing
|
||||
// memory during a prolonged outage.
|
||||
SecureLogger.warning(
|
||||
"📮 Private control retry queue full — evicted oldest whole migration pair",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
pending[key] = PendingRetry(
|
||||
events: events,
|
||||
registerPending: registerPending,
|
||||
attempt: 0,
|
||||
isScheduled: false
|
||||
)
|
||||
insertionOrder.append(key)
|
||||
schedule(key: key)
|
||||
}
|
||||
|
||||
private func schedule(key: String) {
|
||||
guard var item = pending[key], !item.isScheduled else { return }
|
||||
item.isScheduled = true
|
||||
pending[key] = item
|
||||
let exponent = min(item.attempt, 5)
|
||||
let delay = min(2.0 * pow(2.0, Double(exponent)), 60.0)
|
||||
scheduleAfter(delay) { [self] in
|
||||
Task { @MainActor [self] in
|
||||
self.retry(key: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func retry(key: String) {
|
||||
guard var item = pending[key] else { return }
|
||||
item.isScheduled = false
|
||||
pending[key] = item
|
||||
|
||||
let accepted = sendPrivateEnvelopeBatch(item.events) { [self] in
|
||||
self.enqueue(
|
||||
key: key,
|
||||
events: item.events,
|
||||
registerPending: item.registerPending
|
||||
)
|
||||
}
|
||||
if accepted {
|
||||
remove(key: key)
|
||||
if item.registerPending {
|
||||
for event in item.events {
|
||||
registerPendingPrivateEnvelope(event.id)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
item.attempt += 1
|
||||
pending[key] = item
|
||||
schedule(key: key)
|
||||
}
|
||||
}
|
||||
|
||||
private func remove(key: String) {
|
||||
pending.removeValue(forKey: key)
|
||||
insertionOrder.removeAll { $0 == key }
|
||||
}
|
||||
|
||||
func removeAll() {
|
||||
pending.removeAll()
|
||||
insertionOrder.removeAll()
|
||||
}
|
||||
|
||||
var debugPendingCount: Int { pending.count }
|
||||
func debugContains(key: String) -> Bool { pending[key] != nil }
|
||||
}
|
||||
|
||||
@@ -97,6 +97,19 @@ enum PrivateMediaSendPolicy: Equatable {
|
||||
case blockedDowngrade
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@MainActor func didReceiveTransportEvent(_ event: TransportEvent)
|
||||
}
|
||||
|
||||
@@ -196,7 +196,22 @@ enum TransportConfig {
|
||||
static let nostrGeoRelayCount: Int = 5
|
||||
static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300
|
||||
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
|
||||
// there" for the empty-timeline nearby-activity hint.
|
||||
static let uiGeohashChatActivityWindowSeconds: TimeInterval = 900
|
||||
@@ -224,11 +239,20 @@ enum TransportConfig {
|
||||
// Reconnect delays get ±20% random jitter so relays that dropped together
|
||||
// (e.g. a network blip) don't thundering-herd the same reconnect instant.
|
||||
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
|
||||
// bootstrap deadline) to attempt before unblocking pending EOSE callers.
|
||||
static let nostrTorReadyMaxWaitAttempts: Int = 3
|
||||
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
|
||||
// dropped event). Drops are ephemeral presence/geo traffic — log-only.
|
||||
static let nostrPendingSendDropLogInterval: Int = 10
|
||||
@@ -240,7 +264,7 @@ enum TransportConfig {
|
||||
// Fallback deadline for treating a subscription's initial fetch as complete
|
||||
// when a relay never sends EOSE (generous to cover Tor circuit setup).
|
||||
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.
|
||||
static let nostrConfirmedSendAckTimeoutSeconds: TimeInterval = 10.0
|
||||
// After this long, a relay marked permanently failed gets another chance.
|
||||
|
||||
@@ -21,6 +21,14 @@ protocol ChatDeliveryContext: AnyObject {
|
||||
/// message is unknown or no copy changed.
|
||||
@discardableResult
|
||||
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool
|
||||
/// Applies an authenticated receipt only to the direct conversations
|
||||
/// represented by the supplied peer aliases.
|
||||
@discardableResult
|
||||
func setDeliveryStatus(
|
||||
_ status: DeliveryStatus,
|
||||
forMessageID messageID: String,
|
||||
inDirectPeerAliases peerIDs: Set<PeerID>
|
||||
) -> Bool
|
||||
/// Current delivery status of the message in whichever conversation holds it.
|
||||
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus?
|
||||
/// Message IDs across all direct conversations (read-receipt pruning).
|
||||
@@ -33,6 +41,12 @@ protocol ChatDeliveryContext: AnyObject {
|
||||
func notifyUIChanged()
|
||||
/// Confirms receipt so the message router stops retaining the message for resend.
|
||||
func markMessageDelivered(_ messageID: String)
|
||||
/// Peer-bound form for authenticated remote receipts. Only the supplied
|
||||
/// conversation aliases may have retained state terminalized.
|
||||
func markMessageDelivered(_ messageID: String, from peerIDs: Set<PeerID>)
|
||||
/// Returns true only when `messageID` is one of our outgoing messages in
|
||||
/// at least one of the authenticated peer's direct-conversation aliases.
|
||||
func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set<PeerID>) -> Bool
|
||||
}
|
||||
|
||||
extension ChatViewModel: ChatDeliveryContext {
|
||||
@@ -41,6 +55,19 @@ extension ChatViewModel: ChatDeliveryContext {
|
||||
conversations.setDeliveryStatus(status, forMessageID: messageID)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setDeliveryStatus(
|
||||
_ status: DeliveryStatus,
|
||||
forMessageID messageID: String,
|
||||
inDirectPeerAliases peerIDs: Set<PeerID>
|
||||
) -> Bool {
|
||||
conversations.setDeliveryStatus(
|
||||
status,
|
||||
forMessageID: messageID,
|
||||
inDirectPeerAliases: peerIDs
|
||||
)
|
||||
}
|
||||
|
||||
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
|
||||
conversations.deliveryStatus(forMessageID: messageID)
|
||||
}
|
||||
@@ -59,6 +86,24 @@ extension ChatViewModel: ChatDeliveryContext {
|
||||
messageID: messageID
|
||||
)
|
||||
}
|
||||
|
||||
func markMessageDelivered(_ messageID: String, from peerIDs: Set<PeerID>) {
|
||||
messageRouter.markDelivered(messageID, from: peerIDs)
|
||||
// The caller has already bound this receipt to one of our outgoing
|
||||
// conversations. Release the matching media retry only after that
|
||||
// peer-scoped validation and accepted delivery-status transition.
|
||||
mediaTransferCoordinator.confirmPrivateMediaDelivery(
|
||||
messageID: messageID
|
||||
)
|
||||
}
|
||||
|
||||
func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set<PeerID>) -> Bool {
|
||||
peerIDs.contains { peerID in
|
||||
privateMessages(for: peerID).contains { message in
|
||||
message.id == messageID && message.senderPeerID == myPeerID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thin mapper from delivery events (read receipts, transport delivery
|
||||
@@ -86,9 +131,10 @@ final class ChatDeliveryCoordinator {
|
||||
|
||||
@MainActor
|
||||
func didReceiveReadReceipt(_ receipt: ReadReceipt) {
|
||||
updateMessageDeliveryStatus(
|
||||
updateAcknowledgedMessageDeliveryStatus(
|
||||
receipt.originalMessageID,
|
||||
status: .read(by: receipt.readerNickname, at: receipt.timestamp)
|
||||
status: .read(by: receipt.readerNickname, at: receipt.timestamp),
|
||||
from: [receipt.readerID]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -105,17 +151,47 @@ final class ChatDeliveryCoordinator {
|
||||
@MainActor
|
||||
@discardableResult
|
||||
func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool {
|
||||
guard context.setDeliveryStatus(status, forMessageID: messageID) else {
|
||||
return false
|
||||
}
|
||||
switch status {
|
||||
case .delivered, .read:
|
||||
// Confirmed receipt — stop retaining the message for resend.
|
||||
// Terminalize only after the store accepted the transition.
|
||||
context.markMessageDelivered(messageID)
|
||||
default:
|
||||
break
|
||||
}
|
||||
context.notifyUIChanged()
|
||||
return true
|
||||
}
|
||||
|
||||
guard context.setDeliveryStatus(status, forMessageID: messageID) else {
|
||||
/// Applies an authenticated remote delivery/read receipt only when it
|
||||
/// belongs to one of our outgoing messages in that peer's conversation.
|
||||
/// Retry state is cleared after, never before, the status transition is
|
||||
/// accepted by the store.
|
||||
@MainActor
|
||||
@discardableResult
|
||||
func updateAcknowledgedMessageDeliveryStatus(
|
||||
_ messageID: String,
|
||||
status: DeliveryStatus,
|
||||
from peerIDAliases: Set<PeerID>
|
||||
) -> Bool {
|
||||
switch status {
|
||||
case .delivered, .read:
|
||||
break
|
||||
default:
|
||||
return false
|
||||
}
|
||||
guard !peerIDAliases.isEmpty,
|
||||
context.isOutgoingPrivateMessage(messageID, toAny: peerIDAliases),
|
||||
context.setDeliveryStatus(
|
||||
status,
|
||||
forMessageID: messageID,
|
||||
inDirectPeerAliases: peerIDAliases
|
||||
) else {
|
||||
return false
|
||||
}
|
||||
context.markMessageDelivered(messageID, from: peerIDAliases)
|
||||
context.notifyUIChanged()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -86,6 +86,10 @@ protocol ChatMediaTransferContext: AnyObject {
|
||||
@discardableResult
|
||||
func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> 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)
|
||||
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
|
||||
func notifyUIChanged()
|
||||
@@ -122,6 +126,15 @@ protocol ChatMediaTransferContext: AnyObject {
|
||||
)
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, 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 {
|
||||
@@ -208,6 +221,130 @@ extension ChatViewModel: ChatMediaTransferContext {
|
||||
func cancelTransfer(_ transferId: String) {
|
||||
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)"
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -222,6 +359,7 @@ final class ChatMediaTransferCoordinator {
|
||||
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
|
||||
private(set) var messageIDToTransferId: [String: String] = [:]
|
||||
private var preparationGeneration: UInt64 = 0
|
||||
private var deletionGeneration: UInt64 = 0
|
||||
private var reconnectRetryRecords: [
|
||||
String: PrivateMediaReconnectRetryRecord
|
||||
] = [:]
|
||||
@@ -751,8 +889,7 @@ final class ChatMediaTransferCoordinator {
|
||||
cancelActiveTransfer: false
|
||||
)
|
||||
clearTransferMapping(for: messageID)
|
||||
context.removeMessage(withID: messageID, cleanupFile: true)
|
||||
|
||||
context.removeOutgoingMediaMessage(withID: messageID)
|
||||
case .rejected(let id, let reason):
|
||||
guard let messageID = currentMessageID(forTransferID: id) else {
|
||||
return
|
||||
@@ -771,6 +908,40 @@ final class ChatMediaTransferCoordinator {
|
||||
}
|
||||
|
||||
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]
|
||||
guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }),
|
||||
let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty,
|
||||
@@ -781,11 +952,27 @@ final class ChatMediaTransferCoordinator {
|
||||
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 {
|
||||
let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename)
|
||||
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 {
|
||||
try FileManager.default.removeItem(at: target)
|
||||
} catch CocoaError.fileNoSuchFile {
|
||||
@@ -797,33 +984,83 @@ final class ChatMediaTransferCoordinator {
|
||||
}
|
||||
|
||||
func cancelMediaSend(messageID: String) {
|
||||
discardReconnectRetry(
|
||||
messageID: 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)
|
||||
cancelAllMediaSendOwners(messageID: messageID)
|
||||
context.removeOutgoingMediaMessage(withID: messageID)
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
// 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(
|
||||
messageID: messageID,
|
||||
cancelActiveTransfer: false
|
||||
cancelActiveTransfer: true
|
||||
)
|
||||
// Delete is also a send cancellation. In particular, an approved
|
||||
// legacy-clear send may still be waiting on BLEService.messageQueue;
|
||||
// removing only the UI mapping would let that deferred work transmit.
|
||||
// In particular, an approved legacy send may still be waiting on
|
||||
// BLEService.messageQueue. Its admission must be canceled before the
|
||||
// mapping/consent owner is released.
|
||||
if let transferId = messageIDToTransferId[messageID],
|
||||
transferIdToMessageIDs[transferId]?.first == messageID {
|
||||
context.cancelTransfer(transferId)
|
||||
}
|
||||
clearTransferMapping(for: messageID)
|
||||
context.removeMessage(withID: messageID, cleanupFile: true)
|
||||
}
|
||||
|
||||
/// A raw link callback can arrive before the replacement Noise session
|
||||
@@ -868,6 +1105,7 @@ final class ChatMediaTransferCoordinator {
|
||||
/// can recreate a message or send after a panic wipe.
|
||||
func resetForPanic() {
|
||||
preparationGeneration &+= 1
|
||||
deletionGeneration &+= 1
|
||||
peersResolvingReconnectRetry.removeAll(keepingCapacity: false)
|
||||
for task in reconnectRetryExpiryTasks.values {
|
||||
task.cancel()
|
||||
|
||||
@@ -85,6 +85,11 @@ protocol ChatPrivateConversationContext: AnyObject {
|
||||
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
|
||||
@discardableResult
|
||||
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool
|
||||
/// Confirms an authenticated delivery/read acknowledgement so the router
|
||||
/// stops retaining the original private message for resend. The peer
|
||||
/// aliases scope the removal to the authenticated sender.
|
||||
@discardableResult
|
||||
func markMessageDelivered(_ messageID: String, for peerIDs: [PeerID]) -> Bool
|
||||
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
||||
func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String)
|
||||
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
||||
@@ -166,6 +171,11 @@ extension ChatViewModel: ChatPrivateConversationContext {
|
||||
messageRouter.sendReadReceipt(receipt, to: peerID)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func markMessageDelivered(_ messageID: String, for peerIDs: [PeerID]) -> Bool {
|
||||
messageRouter.markDelivered(messageID, for: peerIDs)
|
||||
}
|
||||
|
||||
func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||
messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
}
|
||||
@@ -227,7 +237,7 @@ extension ChatViewModel: ChatPrivateConversationContext {
|
||||
final class ChatPrivateConversationCoordinator {
|
||||
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
|
||||
// message IDs so each copy past the first costs one (already-deduped)
|
||||
// ack check and nothing else.
|
||||
@@ -250,6 +260,60 @@ final class ChatPrivateConversationCoordinator {
|
||||
return true
|
||||
}
|
||||
|
||||
/// Account DMs can arrive under the authenticated peer's full Noise key
|
||||
/// while an existing mesh conversation is keyed by its derived short ID.
|
||||
/// These are the only aliases we may safely join: the short ID is derived
|
||||
/// directly from the authenticated key, rather than guessed from a
|
||||
/// nickname or found by scanning unrelated chats.
|
||||
private func accountConversationAliases(for peerID: PeerID) -> [PeerID] {
|
||||
guard peerID.noiseKey != nil else { return [peerID] }
|
||||
let shortPeerID = peerID.toShort()
|
||||
return shortPeerID == peerID ? [peerID] : [peerID, shortPeerID]
|
||||
}
|
||||
|
||||
/// Keeps a connected account DM on its short routing ID and an offline DM
|
||||
/// on its stable Noise-key ID, folding the other authenticated alias into
|
||||
/// it and handing an open sheet across without closing it.
|
||||
private func consolidateAccountConversationAliases(for peerID: PeerID) -> PeerID {
|
||||
let aliases = accountConversationAliases(for: peerID)
|
||||
guard aliases.count > 1 else { return peerID }
|
||||
|
||||
let shortPeerID = peerID.toShort()
|
||||
let targetPeerID = context.isPeerConnected(shortPeerID) ? shortPeerID : peerID
|
||||
let sourcePeerIDs = aliases.filter { $0 != targetPeerID }
|
||||
|
||||
for sourcePeerID in sourcePeerIDs where !context.privateMessages(for: sourcePeerID).isEmpty {
|
||||
context.migratePrivateChat(from: sourcePeerID, to: targetPeerID)
|
||||
// ConversationStore deliberately preserves message values during a
|
||||
// generic migration, including its destination-wins rule for
|
||||
// duplicate IDs. Rewrite the resulting canonical copies so later
|
||||
// read-receipt scans compare against the new routing key without
|
||||
// replacing a newer destination value with the source snapshot.
|
||||
let canonicalMessages = context.privateMessages(for: targetPeerID)
|
||||
for message in canonicalMessages where message.senderPeerID == sourcePeerID {
|
||||
context.upsertPrivateMessage(
|
||||
BitchatMessage(
|
||||
id: message.id,
|
||||
sender: message.sender,
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
isRelay: message.isRelay,
|
||||
originalSender: message.originalSender,
|
||||
isPrivate: message.isPrivate,
|
||||
recipientNickname: message.recipientNickname,
|
||||
senderPeerID: targetPeerID,
|
||||
mentions: message.mentions,
|
||||
deliveryStatus: message.deliveryStatus,
|
||||
isBridged: message.isBridged
|
||||
),
|
||||
in: targetPeerID
|
||||
)
|
||||
}
|
||||
}
|
||||
context.handOffSelectedPrivateChat(from: sourcePeerIDs, to: targetPeerID)
|
||||
return targetPeerID
|
||||
}
|
||||
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID) {
|
||||
guard !content.isEmpty else { return }
|
||||
|
||||
@@ -464,6 +528,8 @@ final class ChatPrivateConversationCoordinator {
|
||||
return
|
||||
}
|
||||
|
||||
let conversationPeerID = consolidateAccountConversationAliases(for: convKey)
|
||||
|
||||
if context.privateChatsContainMessage(withID: messageId) { return }
|
||||
|
||||
let message = BitchatMessage(
|
||||
@@ -474,18 +540,18 @@ final class ChatPrivateConversationCoordinator {
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: context.nickname,
|
||||
senderPeerID: convKey,
|
||||
senderPeerID: conversationPeerID,
|
||||
deliveryStatus: .delivered(to: context.nickname, at: Date())
|
||||
)
|
||||
|
||||
context.appendPrivateMessage(message, to: convKey)
|
||||
context.appendPrivateMessage(message, to: conversationPeerID)
|
||||
|
||||
let isViewing = context.selectedPrivateChatPeer == convKey
|
||||
let isViewing = context.selectedPrivateChatPeer == conversationPeerID
|
||||
let wasReadBefore = context.sentReadReceipts.contains(messageId)
|
||||
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
|
||||
let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage
|
||||
if shouldMarkUnread {
|
||||
context.markPrivateChatUnread(convKey)
|
||||
context.markPrivateChatUnread(conversationPeerID)
|
||||
}
|
||||
|
||||
if isViewing {
|
||||
@@ -493,7 +559,7 @@ final class ChatPrivateConversationCoordinator {
|
||||
}
|
||||
|
||||
if !isViewing && shouldMarkUnread {
|
||||
context.notifyPrivateMessage(from: senderName, message: pm.content, peerID: convKey)
|
||||
context.notifyPrivateMessage(from: senderName, message: pm.content, peerID: conversationPeerID)
|
||||
}
|
||||
|
||||
context.notifyUIChanged()
|
||||
@@ -502,17 +568,32 @@ final class ChatPrivateConversationCoordinator {
|
||||
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
|
||||
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
|
||||
|
||||
if context.privateChat(convKey, containsMessageWithID: messageID) {
|
||||
context.setPrivateDeliveryStatus(
|
||||
let aliases = accountConversationAliases(for: convKey)
|
||||
let clearedRetainedMessage = convKey.noiseKey != nil
|
||||
? context.markMessageDelivered(messageID, for: aliases)
|
||||
: false
|
||||
let hasConversationMessage = aliases.contains {
|
||||
context.privateChat($0, containsMessageWithID: messageID)
|
||||
}
|
||||
if hasConversationMessage {
|
||||
let conversationPeerID = consolidateAccountConversationAliases(for: convKey)
|
||||
let didChange = context.setPrivateDeliveryStatus(
|
||||
.delivered(to: context.displayNameForNostrPubkey(senderPubkey), at: Date()),
|
||||
forMessageID: messageID,
|
||||
peerID: convKey
|
||||
peerID: conversationPeerID
|
||||
)
|
||||
context.notifyUIChanged()
|
||||
if didChange {
|
||||
context.notifyUIChanged()
|
||||
}
|
||||
SecureLogger.info(
|
||||
"GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…",
|
||||
category: .session
|
||||
)
|
||||
} else if clearedRetainedMessage {
|
||||
SecureLogger.debug(
|
||||
"GeoDM: recv DELIVERED for cleared mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…",
|
||||
category: .session
|
||||
)
|
||||
} else {
|
||||
// A stale ack for a message this device no longer tracks (dropped
|
||||
// outbox entry, cleared chat, or a peer re-acking after losing our
|
||||
@@ -524,14 +605,29 @@ final class ChatPrivateConversationCoordinator {
|
||||
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
|
||||
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
|
||||
|
||||
if context.privateChat(convKey, containsMessageWithID: messageID) {
|
||||
context.setPrivateDeliveryStatus(
|
||||
let aliases = accountConversationAliases(for: convKey)
|
||||
let clearedRetainedMessage = convKey.noiseKey != nil
|
||||
? context.markMessageDelivered(messageID, for: aliases)
|
||||
: false
|
||||
let hasConversationMessage = aliases.contains {
|
||||
context.privateChat($0, containsMessageWithID: messageID)
|
||||
}
|
||||
if hasConversationMessage {
|
||||
let conversationPeerID = consolidateAccountConversationAliases(for: convKey)
|
||||
let didChange = context.setPrivateDeliveryStatus(
|
||||
.read(by: context.displayNameForNostrPubkey(senderPubkey), at: Date()),
|
||||
forMessageID: messageID,
|
||||
peerID: convKey
|
||||
peerID: conversationPeerID
|
||||
)
|
||||
context.notifyUIChanged()
|
||||
if didChange {
|
||||
context.notifyUIChanged()
|
||||
}
|
||||
SecureLogger.info("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session)
|
||||
} else if clearedRetainedMessage {
|
||||
SecureLogger.debug(
|
||||
"GeoDM: recv READ for cleared mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…",
|
||||
category: .session
|
||||
)
|
||||
} else {
|
||||
SecureLogger.warning("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
|
||||
}
|
||||
|
||||
@@ -62,10 +62,15 @@ protocol ChatTransportEventContext: AnyObject {
|
||||
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID)
|
||||
|
||||
// MARK: Delivery status
|
||||
/// Applies the status to every known location of the message.
|
||||
/// Returns `false` when no message with that ID was updated.
|
||||
/// Applies an authenticated receipt to the message only when it belongs
|
||||
/// to the supplied peer conversation aliases. Returns `false` for an
|
||||
/// unknown ID, wrong peer, or rejected status transition.
|
||||
@discardableResult
|
||||
func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool
|
||||
func applyAcknowledgedMessageDeliveryStatus(
|
||||
_ messageID: String,
|
||||
status: DeliveryStatus,
|
||||
from peerIDAliases: Set<PeerID>
|
||||
) -> Bool
|
||||
func deliveryStatus(for messageID: String) -> DeliveryStatus?
|
||||
|
||||
// MARK: Verification payloads
|
||||
@@ -122,8 +127,16 @@ extension ChatViewModel: ChatTransportEventContext {
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool {
|
||||
deliveryCoordinator.updateMessageDeliveryStatus(messageID, status: status)
|
||||
func applyAcknowledgedMessageDeliveryStatus(
|
||||
_ messageID: String,
|
||||
status: DeliveryStatus,
|
||||
from peerIDAliases: Set<PeerID>
|
||||
) -> Bool {
|
||||
deliveryCoordinator.updateAcknowledgedMessageDeliveryStatus(
|
||||
messageID,
|
||||
status: status,
|
||||
from: peerIDAliases
|
||||
)
|
||||
}
|
||||
|
||||
func deliveryStatus(for messageID: String) -> DeliveryStatus? {
|
||||
@@ -446,9 +459,10 @@ private extension ChatTransportEventCoordinator {
|
||||
guard let messageID = String(data: payload, encoding: .utf8) else { return }
|
||||
|
||||
let name = deliveryStatusName(for: peerID, in: context)
|
||||
let didUpdate = context.applyMessageDeliveryStatus(
|
||||
let didUpdate = context.applyAcknowledgedMessageDeliveryStatus(
|
||||
messageID,
|
||||
status: .delivered(to: name, at: Date())
|
||||
status: .delivered(to: name, at: Date()),
|
||||
from: receiptPeerAliases(for: peerID, in: context)
|
||||
)
|
||||
|
||||
if !didUpdate {
|
||||
@@ -463,9 +477,10 @@ private extension ChatTransportEventCoordinator {
|
||||
guard let messageID = String(data: payload, encoding: .utf8) else { return }
|
||||
|
||||
let name = deliveryStatusName(for: peerID, in: context)
|
||||
let didUpdate = context.applyMessageDeliveryStatus(
|
||||
let didUpdate = context.applyAcknowledgedMessageDeliveryStatus(
|
||||
messageID,
|
||||
status: .read(by: name, at: Date())
|
||||
status: .read(by: name, at: Date()),
|
||||
from: receiptPeerAliases(for: peerID, in: context)
|
||||
)
|
||||
|
||||
if !didUpdate {
|
||||
@@ -503,4 +518,21 @@ private extension ChatTransportEventCoordinator {
|
||||
func deliveryStatusName(for peerID: PeerID, in context: any ChatTransportEventContext) -> String {
|
||||
context.unifiedPeer(for: peerID)?.nickname ?? context.resolveNickname(for: peerID)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func receiptPeerAliases(
|
||||
for peerID: PeerID,
|
||||
in context: any ChatTransportEventContext
|
||||
) -> Set<PeerID> {
|
||||
var aliases: Set<PeerID> = [peerID]
|
||||
// The active authenticated Noise key is authoritative. A cached
|
||||
// ephemeral→stable mapping can predate an identity replacement, so
|
||||
// use it only when the live session cannot provide its static key.
|
||||
if let keyData = context.noiseSessionPublicKeyData(for: peerID) {
|
||||
aliases.insert(PeerID(hexData: keyData))
|
||||
} else if let stablePeerID = context.cachedStablePeerID(for: peerID) {
|
||||
aliases.insert(stablePeerID)
|
||||
}
|
||||
return aliases
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,10 @@ protocol ChatVerificationContext: AnyObject {
|
||||
func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool
|
||||
func triggerHandshake(with peerID: PeerID)
|
||||
func privateMediaPeerDidAuthenticate(_ peerID: PeerID)
|
||||
/// Retries only private messages previously transmitted through a secure
|
||||
/// session and still pending an ack. Both ephemeral and stable aliases
|
||||
/// are supplied because either can own the outbox entry.
|
||||
func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID])
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
|
||||
@@ -121,6 +125,10 @@ extension ChatViewModel: ChatVerificationContext {
|
||||
mediaTransferCoordinator.peerDidAuthenticate(peerID.toShort())
|
||||
}
|
||||
|
||||
func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID]) {
|
||||
messageRouter.retrySecurePrivateMessagesAfterAuthentication(for: peerIDAliases)
|
||||
}
|
||||
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||||
meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA)
|
||||
}
|
||||
@@ -216,16 +224,37 @@ final class ChatVerificationCoordinator {
|
||||
|
||||
self.context.invalidateEncryptionCache(for: peerID)
|
||||
|
||||
if self.context.cachedStablePeerID(for: peerID) == nil,
|
||||
let keyData = self.context.noiseSessionPublicKeyData(for: peerID) {
|
||||
var authenticatedStablePeerID: PeerID?
|
||||
if let keyData = self.context.noiseSessionPublicKeyData(for: peerID) {
|
||||
let stablePeerID = PeerID(hexData: keyData)
|
||||
self.context.cacheStablePeerID(stablePeerID, for: peerID)
|
||||
authenticatedStablePeerID = stablePeerID
|
||||
if self.context.cachedStablePeerID(for: peerID) != stablePeerID {
|
||||
// The freshly authenticated Noise key outranks a
|
||||
// stale announce-derived alias.
|
||||
self.context.cacheStablePeerID(stablePeerID, for: peerID)
|
||||
}
|
||||
SecureLogger.debug(
|
||||
"🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stablePeerID.id.prefix(8))…",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
|
||||
// A locally established session may have belonged to the
|
||||
// peer's previous app process. The first ciphertext sent
|
||||
// into that stale session is retained by MessageRouter;
|
||||
// retry it now that this newly authenticated/replacement
|
||||
// session can actually decrypt it.
|
||||
var peerIDAliases = [peerID]
|
||||
if let stablePeerID = authenticatedStablePeerID
|
||||
?? self.context.cachedStablePeerID(for: peerID),
|
||||
stablePeerID != peerID {
|
||||
// Conversations can migrate from the ephemeral BLE ID
|
||||
// to the authenticated Noise-key ID. Retry both aliases
|
||||
// because either may own the retained outbox entry.
|
||||
peerIDAliases.append(stablePeerID)
|
||||
}
|
||||
self.context.retrySecurePrivateMessagesAfterAuthentication(for: peerIDAliases)
|
||||
|
||||
if var pending = self.pendingQRVerifications[peerID], pending.sent == false {
|
||||
self.context.sendVerifyChallenge(
|
||||
to: peerID,
|
||||
|
||||
@@ -109,6 +109,16 @@ struct PanicNetworkLifecycle {
|
||||
}
|
||||
}
|
||||
|
||||
private struct PendingPrivateChatClear {
|
||||
let peerID: PeerID
|
||||
let sourceConversationID: ConversationID
|
||||
let messages: [BitchatMessage]
|
||||
let otherMessageIDs: Set<String>
|
||||
let localPeerID: PeerID
|
||||
let nickname: String
|
||||
let outgoingMedia: [BitchatMessage]
|
||||
}
|
||||
|
||||
/// Manages the application state and business logic for BitChat.
|
||||
/// Acts as the primary coordinator between UI components and backend services,
|
||||
/// implementing the BitchatDelegate protocol to handle network events.
|
||||
@@ -376,6 +386,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
||||
@Published var bluetoothAlertMessage = ""
|
||||
@Published var bluetoothState: CBManagerState = .unknown
|
||||
@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 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`).
|
||||
@MainActor
|
||||
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.
|
||||
@@ -1275,6 +1554,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
||||
// handles before clearing state or removing the media directory.
|
||||
mediaTransferCoordinator.resetForPanic()
|
||||
liveVoiceCoordinator.resetForPanic()
|
||||
privateChatClearGeneration &+= 1
|
||||
queuedPrivateChatClears.removeAll(keepingCapacity: false)
|
||||
privateChatClearInFlight = false
|
||||
|
||||
// Deny and release any clear-media confirmations before identities,
|
||||
// message state, and local files are wiped.
|
||||
@@ -1366,6 +1648,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
||||
// Drop relay subscriptions, handlers, pending sends, and replay state.
|
||||
// Geohash DM handlers can capture pre-wipe Nostr identities, so a plain
|
||||
// disconnect is not enough here.
|
||||
NostrTransport.resetControlRetriesForPanicWipe()
|
||||
NostrRelayManager.shared.resetForPanicWipe()
|
||||
nostrRelayManager = nil
|
||||
|
||||
|
||||
@@ -41,10 +41,11 @@ struct ChatViewModelServiceBundle {
|
||||
self.privateChatManager = privateChatManager
|
||||
self.unifiedPeerService = unifiedPeerService
|
||||
self.autocompleteService = AutocompleteService()
|
||||
// Persist processed gift-wrap event IDs: NIP-59 randomizes their
|
||||
// timestamps, so the 24h-lookback DM subscriptions redeliver the same
|
||||
// events on every launch and only a cross-launch record stops the
|
||||
// reprocessing (re-sent DELIVERED bursts, phantom-ack noise).
|
||||
// Persist processed private-envelope event IDs: legacy Android can
|
||||
// randomize timestamps across the full 72h15m mailbox lookback, so DM
|
||||
// subscriptions redeliver the same events on every launch and only a
|
||||
// cross-launch record stops the reprocessing (re-sent DELIVERED
|
||||
// bursts, phantom-ack noise).
|
||||
self.deduplicationService = MessageDeduplicationService(nostrEventStore: NostrProcessedEventStore())
|
||||
self.publicMessagePipeline = PublicMessagePipeline()
|
||||
}
|
||||
@@ -176,7 +177,7 @@ private extension ChatViewModelBootstrapper {
|
||||
|
||||
func configureTransport() {
|
||||
viewModel.meshService.delegate = viewModel
|
||||
viewModel.meshService.eventDelegate = viewModel
|
||||
viewModel.messageRouter.setEventDelegate(viewModel)
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiStartupInitialDelaySeconds) { [weak viewModel] in
|
||||
guard let viewModel else { return }
|
||||
@@ -558,7 +559,7 @@ private extension ChatViewModelBootstrapper {
|
||||
// Default (DM) relays: drops need the standing global relay set,
|
||||
// not geo relays — sender and recipient share no cell.
|
||||
// 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)
|
||||
}
|
||||
courier.openSubscription = { tagsHex in
|
||||
|
||||
@@ -21,8 +21,8 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
nostrCoordinator.inbound.subscribeGiftWrap(giftWrap, id: id)
|
||||
func subscribePrivateEnvelope(_ envelope: NostrEvent, id: NostrIdentity) {
|
||||
nostrCoordinator.inbound.subscribePrivateEnvelope(envelope, id: id)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -36,8 +36,8 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
nostrCoordinator.inbound.handleGiftWrap(giftWrap, id: id)
|
||||
func handlePrivateEnvelope(_ envelope: NostrEvent, id: NostrIdentity) {
|
||||
nostrCoordinator.inbound.handlePrivateEnvelope(envelope, id: id)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -108,7 +108,7 @@ extension ChatViewModel: GeohashSubscriptionContext {
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// forwarded to `NostrInboundPipeline` / `GeoPresenceTracker`.
|
||||
final class GeohashSubscriptionManager {
|
||||
@@ -162,13 +162,13 @@ final class GeohashSubscriptionManager {
|
||||
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
|
||||
let dmSub = "geo-dm-\(channel.geohash)"
|
||||
context.setGeoDmSubscriptionID(dmSub)
|
||||
let dmFilter = NostrFilter.giftWrapsFor(
|
||||
let dmFilters = NostrFilter.privateEnvelopeFiltersFor(
|
||||
pubkey: identity.publicKeyHex,
|
||||
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
|
||||
self?.inbound.subscribeGiftWrap(giftWrap, id: identity)
|
||||
self?.inbound.subscribePrivateEnvelope(envelope, id: identity)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -260,13 +260,13 @@ final class GeohashSubscriptionManager {
|
||||
if TorManager.shared.isReady {
|
||||
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,
|
||||
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
|
||||
self?.inbound.handleGiftWrap(giftWrap, id: identity)
|
||||
self?.inbound.handlePrivateEnvelope(envelope, id: identity)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -388,14 +388,14 @@ final class GeohashSubscriptionManager {
|
||||
category: .session
|
||||
)
|
||||
|
||||
let filter = NostrFilter.giftWrapsFor(
|
||||
let filters = NostrFilter.privateEnvelopeFiltersFor(
|
||||
pubkey: currentIdentity.publicKeyHex,
|
||||
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
|
||||
self?.inbound.handleNostrMessage(event)
|
||||
self?.inbound.handleAccountPrivateEnvelope(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,13 +84,21 @@ extension ChatViewModel: NostrInboundPipelineContext {
|
||||
/// Ordering is deliberate and performance-critical: cheap rejects (kind,
|
||||
/// dedup lookup) run BEFORE Schnorr signature verification because duplicates
|
||||
/// 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
|
||||
/// main-actor check-and-record.
|
||||
final class NostrInboundPipeline {
|
||||
private weak var context: (any NostrInboundPipelineContext)?
|
||||
private let presence: GeoPresenceTracker
|
||||
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) {
|
||||
self.context = context
|
||||
@@ -271,15 +279,16 @@ final class NostrInboundPipeline {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
func subscribePrivateEnvelope(_ envelope: NostrEvent, id: NostrIdentity) {
|
||||
guard let context else { return }
|
||||
// Dedup lookup before Schnorr verification; record only after it passes.
|
||||
guard !context.hasProcessedNostrEvent(giftWrap.id) else { return }
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
context.recordProcessedNostrEvent(giftWrap.id)
|
||||
guard !context.hasProcessedNostrEvent(envelope.id) else { return }
|
||||
guard envelope.content.utf8.count <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes else { return }
|
||||
guard envelope.isValidSignature() else { return }
|
||||
context.recordProcessedNostrEvent(envelope.id)
|
||||
|
||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
guard let (content, senderPubkey, messageTs) = try? NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: id
|
||||
),
|
||||
let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
||||
@@ -288,8 +297,14 @@ final class NostrInboundPipeline {
|
||||
else {
|
||||
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)
|
||||
context.registerNostrKeyMapping(senderPubkey, for: convKey)
|
||||
|
||||
@@ -316,25 +331,26 @@ final class NostrInboundPipeline {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
func handlePrivateEnvelope(_ envelope: NostrEvent, id: NostrIdentity) {
|
||||
guard let context else { return }
|
||||
// Dedup lookup before Schnorr verification; record only after it passes.
|
||||
if context.hasProcessedNostrEvent(giftWrap.id) {
|
||||
if context.hasProcessedNostrEvent(envelope.id) {
|
||||
return
|
||||
}
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
context.recordProcessedNostrEvent(giftWrap.id)
|
||||
guard envelope.content.utf8.count <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes else { return }
|
||||
guard envelope.isValidSignature() else { return }
|
||||
context.recordProcessedNostrEvent(envelope.id)
|
||||
|
||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
guard let (content, senderPubkey, messageTs) = try? NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: id
|
||||
) 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
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
@@ -344,13 +360,19 @@ final class NostrInboundPipeline {
|
||||
else {
|
||||
return
|
||||
}
|
||||
guard shouldProcessPrivatePayload(
|
||||
payload,
|
||||
senderPubkey: senderPubkey,
|
||||
recipientPubkey: id.publicKeyHex,
|
||||
envelopeKind: envelope.kind
|
||||
) else { return }
|
||||
|
||||
let convKey = PeerID(nostr_: senderPubkey)
|
||||
context.registerNostrKeyMapping(senderPubkey, for: convKey)
|
||||
|
||||
switch payload.type {
|
||||
case .privateMessage:
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(messageTs))
|
||||
context.handlePrivateMessage(
|
||||
payload,
|
||||
senderPubkey: senderPubkey,
|
||||
@@ -372,28 +394,29 @@ final class NostrInboundPipeline {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handleNostrMessage(_ giftWrap: NostrEvent) {
|
||||
func handleAccountPrivateEnvelope(_ envelope: NostrEvent) {
|
||||
guard let context else { return }
|
||||
// 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
|
||||
// forged-signature copy can never poison the dedup set and suppress
|
||||
// the genuine event.
|
||||
if context.hasProcessedNostrEvent(giftWrap.id) { return }
|
||||
if context.hasProcessedNostrEvent(envelope.id) { return }
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
await self?.processNostrMessage(giftWrap)
|
||||
await self?.processAccountPrivateEnvelope(envelope)
|
||||
}
|
||||
}
|
||||
|
||||
func processNostrMessage(_ giftWrap: NostrEvent) async {
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
func processAccountPrivateEnvelope(_ envelope: NostrEvent) async {
|
||||
guard envelope.content.utf8.count <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes else { return }
|
||||
guard envelope.isValidSignature() else { return }
|
||||
guard let context else { return }
|
||||
// Authoritative check-and-record, atomic on the main actor so two
|
||||
// concurrent detached tasks can't both process the same event.
|
||||
let alreadyProcessed: Bool = await MainActor.run {
|
||||
if context.hasProcessedNostrEvent(giftWrap.id) { return true }
|
||||
context.recordProcessedNostrEvent(giftWrap.id)
|
||||
if context.hasProcessedNostrEvent(envelope.id) { return true }
|
||||
context.recordProcessedNostrEvent(envelope.id)
|
||||
return false
|
||||
}
|
||||
if alreadyProcessed { return }
|
||||
@@ -403,8 +426,8 @@ final class NostrInboundPipeline {
|
||||
guard let currentIdentity else { return }
|
||||
|
||||
do {
|
||||
let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
let (content, senderPubkey, messageTimestampSeconds) = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: currentIdentity
|
||||
)
|
||||
|
||||
@@ -428,8 +451,14 @@ final class NostrInboundPipeline {
|
||||
|
||||
if packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
let payload = NoisePayload.decode(packet.payload) {
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(messageTimestampSeconds))
|
||||
await MainActor.run {
|
||||
guard self.shouldProcessPrivatePayload(
|
||||
payload,
|
||||
senderPubkey: senderPubkey,
|
||||
recipientPubkey: currentIdentity.publicKeyHex,
|
||||
envelopeKind: envelope.kind
|
||||
) else { return }
|
||||
context.registerNostrKeyMapping(senderPubkey, for: targetPeerID)
|
||||
|
||||
switch payload.type {
|
||||
@@ -503,6 +532,47 @@ final class 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
|
||||
static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {
|
||||
guard content.hasPrefix("bitchat1:") else { return nil }
|
||||
|
||||
@@ -11,6 +11,7 @@ struct ContentPeopleSheetView: View {
|
||||
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
|
||||
@EnvironmentObject private var verificationModel: VerificationModel
|
||||
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
@Binding var showSidebar: Bool
|
||||
@Binding var messageText: String
|
||||
@@ -35,6 +36,35 @@ struct ContentPeopleSheetView: View {
|
||||
@Binding var showMacImagePicker: Bool
|
||||
#endif
|
||||
|
||||
private var hasModalPresentation: Bool {
|
||||
if imagePreviewURL != nil {
|
||||
return true
|
||||
}
|
||||
#if os(iOS)
|
||||
return showImagePicker
|
||||
#else
|
||||
return showMacImagePicker
|
||||
#endif
|
||||
}
|
||||
|
||||
private var bluetoothAlertBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: {
|
||||
scenePhase == .active
|
||||
&& appChromeModel.showBluetoothAlert
|
||||
&& !hasModalPresentation
|
||||
},
|
||||
set: { isPresented in
|
||||
guard !isPresented,
|
||||
scenePhase == .active,
|
||||
!hasModalPresentation else {
|
||||
return
|
||||
}
|
||||
appChromeModel.showBluetoothAlert = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let legacyConsentRequest = conversationUIModel.legacyPrivateMediaConsentRequest
|
||||
NavigationStack {
|
||||
@@ -182,6 +212,17 @@ struct ContentPeopleSheetView: View {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.alert(
|
||||
"content.alert.bluetooth_required.title",
|
||||
isPresented: bluetoothAlertBinding
|
||||
) {
|
||||
Button("content.alert.bluetooth_required.settings") {
|
||||
SystemSettings.bluetooth.open()
|
||||
}
|
||||
Button("common.ok", role: .cancel) {}
|
||||
} message: {
|
||||
Text(appChromeModel.bluetoothAlertMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,7 +545,8 @@ private struct ContentPrivateChatSheetView: View {
|
||||
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")
|
||||
}
|
||||
// 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
|
||||
// "encrypted" claim only once the Noise handshake has secured.
|
||||
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
||||
|
||||
@@ -42,6 +42,7 @@ struct ContentView: View {
|
||||
@FocusState private var isTextFieldFocused: Bool
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Environment(\.appTheme) private var appTheme
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var showSidebar = false
|
||||
@State private var selectedMessageSender: String?
|
||||
@State private var selectedMessageSenderID: PeerID?
|
||||
@@ -71,6 +72,44 @@ struct ContentView: View {
|
||||
|
||||
private var usesGlassLayout: Bool { appTheme.usesGlassChrome }
|
||||
|
||||
private var isPeopleSheetPresented: Bool {
|
||||
showSidebar || selectedPrivatePeerID != nil
|
||||
}
|
||||
|
||||
private var hasRootModalPresentation: Bool {
|
||||
if isPeopleSheetPresented
|
||||
|| appChromeModel.isAppInfoPresented
|
||||
|| appChromeModel.showingFingerprintFor != nil
|
||||
|| imagePreviewURL != nil
|
||||
|| showVerifySheet
|
||||
|| voiceRecordingVM.showAlert {
|
||||
return true
|
||||
}
|
||||
#if os(iOS)
|
||||
return showImagePicker
|
||||
#else
|
||||
return showMacImagePicker
|
||||
#endif
|
||||
}
|
||||
|
||||
private var rootBluetoothAlertBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: {
|
||||
scenePhase == .active
|
||||
&& appChromeModel.showBluetoothAlert
|
||||
&& !hasRootModalPresentation
|
||||
},
|
||||
set: { isPresented in
|
||||
guard !isPresented,
|
||||
scenePhase == .active,
|
||||
!hasRootModalPresentation else {
|
||||
return
|
||||
}
|
||||
appChromeModel.showBluetoothAlert = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
mainContent
|
||||
.onAppear {
|
||||
@@ -107,11 +146,18 @@ struct ContentView: View {
|
||||
}
|
||||
.sheet(
|
||||
isPresented: Binding(
|
||||
get: { showSidebar || selectedPrivatePeerID != nil },
|
||||
get: { isPeopleSheetPresented },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
showSidebar = false
|
||||
privateConversationModel.endConversation()
|
||||
// Scene/background and Bluetooth-alert presentation
|
||||
// reconciliation are not user requests to leave the
|
||||
// conversation. Keep the selected DM so the sheet
|
||||
// remains live when the app returns from Settings.
|
||||
if scenePhase == .active,
|
||||
!appChromeModel.showBluetoothAlert {
|
||||
privateConversationModel.endConversation()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -222,7 +268,7 @@ struct ContentView: View {
|
||||
}, message: {
|
||||
Text(voiceRecordingVM.state.alertMessage)
|
||||
})
|
||||
.alert("content.alert.bluetooth_required.title", isPresented: $appChromeModel.showBluetoothAlert) {
|
||||
.alert("content.alert.bluetooth_required.title", isPresented: rootBluetoothAlertBinding) {
|
||||
Button("content.alert.bluetooth_required.settings") {
|
||||
SystemSettings.bluetooth.open()
|
||||
}
|
||||
|
||||
@@ -932,6 +932,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
|
||||
func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws {
|
||||
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 removedMessages: [(messageID: String, cleanupFile: Bool)] = []
|
||||
private(set) var untombstonedMediaRemovals: [String] = []
|
||||
private(set) var outgoingMediaRemovals: [String] = []
|
||||
private(set) var systemMessages: [String] = []
|
||||
private(set) var notifyUIChangedCount = 0
|
||||
|
||||
@@ -71,6 +73,16 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
||||
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 notifyUIChanged() { notifyUIChangedCount += 1 }
|
||||
|
||||
@@ -99,6 +111,13 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
||||
private(set) var broadcastFileSends: [String] = []
|
||||
private(set) var cancelledTransfers: [String] = []
|
||||
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 resolvedPrivateMediaPolicy: PrivateMediaSendPolicy?
|
||||
var resolvesPrivateMediaPolicyImmediately = true
|
||||
@@ -218,6 +237,28 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
||||
func cancelTransfer(_ transferId: String) {
|
||||
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 {
|
||||
@@ -440,7 +481,8 @@ struct ChatMediaTransferCoordinatorContextTests {
|
||||
coordinator.handleTransferEvent(.cancelled(id: "t2", sentFragments: 1, totalFragments: 5))
|
||||
#expect(context.removedMessages.count == 1)
|
||||
#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,
|
||||
// including queued post-handshake encryption failures.
|
||||
@@ -497,7 +539,172 @@ struct ChatMediaTransferCoordinatorContextTests {
|
||||
#expect(context.cancelledTransfers == ["approved-delete"])
|
||||
#expect(coordinator.messageIDToTransferId["message-delete"] == nil)
|
||||
#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
|
||||
|
||||
@@ -243,7 +243,7 @@ private func drainMainQueue() async {
|
||||
|
||||
/// Exercises `ChatNostrCoordinator` against `MockChatNostrContext` with no
|
||||
/// `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
|
||||
/// are injected through the context — the favorite-notification ingest and
|
||||
/// the sampled-geohash notification cooldown. Flows that hit live singletons
|
||||
@@ -335,7 +335,7 @@ struct ChatNostrCoordinatorContextTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleGiftWrap_routesEmbeddedPrivateMessageAndDeduplicates() async throws {
|
||||
func handlePrivateEnvelope_routesEmbeddedPrivateMessageAndDeduplicates() async throws {
|
||||
let context = MockChatNostrContext()
|
||||
let coordinator = ChatNostrCoordinator(context: context)
|
||||
|
||||
@@ -346,17 +346,21 @@ struct ChatNostrCoordinatorContextTests {
|
||||
messageID: "gm-1",
|
||||
senderPeerID: PeerID(str: "aabbccddeeff0011")
|
||||
))
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelopes = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||
content: embedded,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
|
||||
for envelope in envelopes {
|
||||
coordinator.inbound.handlePrivateEnvelope(envelope, id: recipient)
|
||||
}
|
||||
|
||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
||||
#expect(context.recordedNostrEventIDs == envelopes.map(\.id))
|
||||
#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.first?.senderPubkey == sender.publicKeyHex)
|
||||
#expect(context.handledPrivateMessages.first?.convKey == convKey)
|
||||
@@ -368,34 +372,84 @@ struct ChatNostrCoordinatorContextTests {
|
||||
#expect(pm.messageID == "gm-1")
|
||||
#expect(pm.content == "psst")
|
||||
|
||||
// The same gift wrap is dropped on replay.
|
||||
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
|
||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
||||
// The same private envelope is dropped on replay.
|
||||
coordinator.inbound.handlePrivateEnvelope(envelopes[0], id: recipient)
|
||||
#expect(context.recordedNostrEventIDs == envelopes.map(\.id))
|
||||
#expect(context.handledPrivateMessages.count == 1)
|
||||
}
|
||||
|
||||
@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 coordinator = ChatNostrCoordinator(context: context)
|
||||
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: "verify:noop",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
var invalidGiftWrap = giftWrap
|
||||
invalidGiftWrap.sig = String(repeating: "0", count: 128)
|
||||
var invalidEnvelope = envelope
|
||||
invalidEnvelope.sig = String(repeating: "0", count: 128)
|
||||
|
||||
// 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)
|
||||
|
||||
// ...so the genuine event with the same ID still processes and records.
|
||||
await coordinator.inbound.processNostrMessage(giftWrap)
|
||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
||||
await coordinator.inbound.processAccountPrivateEnvelope(envelope)
|
||||
#expect(context.recordedNostrEventIDs == [envelope.id])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
|
||||
@@ -176,6 +176,9 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
|
||||
private(set) var geoPrivateMessages: [(content: String, recipientHex: String, messageID: String)] = []
|
||||
private(set) var geoDeliveryAcks: [(messageID: String, recipientHex: String)] = []
|
||||
private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = []
|
||||
var queuedMessageIDsByPeerID: [PeerID: Set<String>] = [:]
|
||||
private(set) var deliveryAckAttempts: [(messageID: String, peerIDs: [PeerID])] = []
|
||||
private(set) var deliveredMessageIDs: [String] = []
|
||||
|
||||
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||
routedPrivateMessages.append((content, peerID, messageID))
|
||||
@@ -187,6 +190,22 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
|
||||
return routeReadReceiptResult
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func markMessageDelivered(_ messageID: String, for peerIDs: [PeerID]) -> Bool {
|
||||
deliveryAckAttempts.append((messageID, peerIDs))
|
||||
var cleared = false
|
||||
for peerID in Set(peerIDs) {
|
||||
guard var queued = queuedMessageIDsByPeerID[peerID],
|
||||
queued.remove(messageID) != nil else { continue }
|
||||
queuedMessageIDsByPeerID[peerID] = queued.isEmpty ? nil : queued
|
||||
cleared = true
|
||||
}
|
||||
if cleared {
|
||||
deliveredMessageIDs.append(messageID)
|
||||
}
|
||||
return cleared
|
||||
}
|
||||
|
||||
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||
meshReadReceipts.append((receipt.originalMessageID, peerID))
|
||||
}
|
||||
@@ -370,6 +389,66 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
||||
convKey: convKey
|
||||
)
|
||||
#expect(context.notifyUIChangedCount == 2)
|
||||
#expect(context.deliveryAckAttempts.isEmpty)
|
||||
#expect(context.deliveredMessageIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func accountDMAcks_findShortIDMessageAndHandConversationToStable() async {
|
||||
let context = MockChatPrivateConversationContext()
|
||||
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||
let noiseKey = Data(repeating: 0xD5, count: 32)
|
||||
let stablePeerID = PeerID(hexData: noiseKey)
|
||||
let shortPeerID = stablePeerID.toShort()
|
||||
let senderPubkey = "feedface00112233"
|
||||
context.displayNamesByPubkey[senderPubkey] = "alice"
|
||||
context.selectedPrivateChatPeer = shortPeerID
|
||||
context.privateChats[shortPeerID] = [
|
||||
makeIncomingMessage(id: "mine-short-1", sender: "me"),
|
||||
makeIncomingMessage(id: "mine-short-2", sender: "me")
|
||||
]
|
||||
context.queuedMessageIDsByPeerID[shortPeerID] = ["mine-short-1", "mine-short-2"]
|
||||
|
||||
coordinator.handleDelivered(
|
||||
NoisePayload(type: .delivered, data: Data("mine-short-1".utf8)),
|
||||
senderPubkey: senderPubkey,
|
||||
convKey: stablePeerID
|
||||
)
|
||||
coordinator.handleReadReceipt(
|
||||
NoisePayload(type: .readReceipt, data: Data("mine-short-2".utf8)),
|
||||
senderPubkey: senderPubkey,
|
||||
convKey: stablePeerID
|
||||
)
|
||||
|
||||
#expect(context.privateChats[shortPeerID] == nil)
|
||||
#expect(isDelivered(context.privateChats[stablePeerID]?.first?.deliveryStatus, to: "alice"))
|
||||
#expect(isRead(context.privateChats[stablePeerID]?.last?.deliveryStatus, by: "alice"))
|
||||
#expect(context.selectedPrivateChatPeer == stablePeerID)
|
||||
#expect(context.deliveredMessageIDs == ["mine-short-1", "mine-short-2"])
|
||||
#expect(context.notifyUIChangedCount == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func accountDMAck_doesNotTouchAnUnrelatedConversationWithTheSameMessageID() async {
|
||||
let context = MockChatPrivateConversationContext()
|
||||
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||
let stablePeerID = PeerID(hexData: Data(repeating: 0xC1, count: 32))
|
||||
let unrelatedPeerID = PeerID(str: "1111222233334444")
|
||||
context.privateChats[unrelatedPeerID] = [
|
||||
makeIncomingMessage(id: "collision", sender: "me")
|
||||
]
|
||||
context.queuedMessageIDsByPeerID[unrelatedPeerID] = ["collision"]
|
||||
|
||||
coordinator.handleDelivered(
|
||||
NoisePayload(type: .delivered, data: Data("collision".utf8)),
|
||||
senderPubkey: "feedface00112233",
|
||||
convKey: stablePeerID
|
||||
)
|
||||
|
||||
#expect(isDelivered(context.privateChats[unrelatedPeerID]?.first?.deliveryStatus, to: "me"))
|
||||
#expect(context.deliveredMessageIDs.isEmpty)
|
||||
#expect(context.queuedMessageIDsByPeerID[unrelatedPeerID] == ["collision"])
|
||||
#expect(context.notifyUIChangedCount == 0)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
@@ -412,6 +491,180 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
||||
#expect(context.privateChats[convKey]?.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func accountDM_handsOpenShortIDConversationToStableWhenOffline() async {
|
||||
let context = MockChatPrivateConversationContext()
|
||||
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||
let noiseKey = Data(repeating: 0xA7, count: 32)
|
||||
let stablePeerID = PeerID(hexData: noiseKey)
|
||||
let shortPeerID = stablePeerID.toShort()
|
||||
let senderPubkey = "feedface00112233"
|
||||
let now = Date()
|
||||
context.displayNamesByPubkey[senderPubkey] = "bob"
|
||||
context.selectedPrivateChatPeer = shortPeerID
|
||||
context.privateChats[shortPeerID] = [
|
||||
makeIncomingMessage(
|
||||
id: "short-history",
|
||||
sender: "me",
|
||||
timestamp: now.addingTimeInterval(-30),
|
||||
senderPeerID: context.myPeerID
|
||||
),
|
||||
makeIncomingMessage(
|
||||
id: "short-inbound",
|
||||
sender: "bob",
|
||||
timestamp: now.addingTimeInterval(-25),
|
||||
senderPeerID: shortPeerID
|
||||
)
|
||||
]
|
||||
context.privateChats[stablePeerID] = [
|
||||
makeIncomingMessage(
|
||||
id: "stable-history",
|
||||
sender: "bob",
|
||||
timestamp: now.addingTimeInterval(-20),
|
||||
senderPeerID: stablePeerID
|
||||
)
|
||||
]
|
||||
let payloadData = PrivateMessagePacket(messageID: "account-live-1", content: "live reply").encode()!
|
||||
|
||||
coordinator.handlePrivateMessage(
|
||||
NoisePayload(type: .privateMessage, data: payloadData),
|
||||
senderPubkey: senderPubkey,
|
||||
convKey: stablePeerID,
|
||||
id: MockChatPrivateConversationContext.dummyIdentity,
|
||||
messageTimestamp: now
|
||||
)
|
||||
|
||||
#expect(context.privateChats[shortPeerID] == nil)
|
||||
#expect(context.privateChats[stablePeerID]?.map(\.id) == [
|
||||
"short-history",
|
||||
"short-inbound",
|
||||
"stable-history",
|
||||
"account-live-1"
|
||||
])
|
||||
#expect(context.privateChats[stablePeerID]?[1].senderPeerID == stablePeerID)
|
||||
#expect(context.privateChats[stablePeerID]?.last?.senderPeerID == stablePeerID)
|
||||
#expect(context.migratedChats.contains(where: { $0.from == shortPeerID && $0.to == stablePeerID }))
|
||||
#expect(context.selectedPrivateChatPeer == stablePeerID)
|
||||
#expect(context.geoReadReceipts.map(\.messageID) == ["account-live-1"])
|
||||
#expect(context.unreadPrivateMessages.isEmpty)
|
||||
#expect(context.notifyUIChangedCount == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func accountDM_aliasMergePreservesTheCanonicalDestinationCopy() async {
|
||||
let context = MockChatPrivateConversationContext()
|
||||
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||
let noiseKey = Data(repeating: 0xA8, count: 32)
|
||||
let stablePeerID = PeerID(hexData: noiseKey)
|
||||
let shortPeerID = stablePeerID.toShort()
|
||||
let senderPubkey = "feedface00112233"
|
||||
let olderSource = makeIncomingMessage(
|
||||
id: "duplicate-history",
|
||||
sender: "bob",
|
||||
content: "older source copy",
|
||||
senderPeerID: shortPeerID
|
||||
)
|
||||
olderSource.deliveryStatus = .delivered(to: "me", at: Date(timeIntervalSince1970: 10))
|
||||
let newerDestination = makeIncomingMessage(
|
||||
id: "duplicate-history",
|
||||
sender: "bob",
|
||||
content: "newer destination copy",
|
||||
senderPeerID: shortPeerID
|
||||
)
|
||||
newerDestination.deliveryStatus = .read(by: "me", at: Date(timeIntervalSince1970: 20))
|
||||
context.privateChats[shortPeerID] = [olderSource]
|
||||
context.privateChats[stablePeerID] = [newerDestination]
|
||||
context.displayNamesByPubkey[senderPubkey] = "bob"
|
||||
let payloadData = PrivateMessagePacket(messageID: "after-merge", content: "new reply").encode()!
|
||||
|
||||
coordinator.handlePrivateMessage(
|
||||
NoisePayload(type: .privateMessage, data: payloadData),
|
||||
senderPubkey: senderPubkey,
|
||||
convKey: stablePeerID,
|
||||
id: MockChatPrivateConversationContext.dummyIdentity,
|
||||
messageTimestamp: Date()
|
||||
)
|
||||
|
||||
let merged = context.privateChats[stablePeerID]?.first
|
||||
#expect(context.privateChats[shortPeerID] == nil)
|
||||
#expect(merged?.content == "newer destination copy")
|
||||
#expect(isRead(merged?.deliveryStatus, by: "me"))
|
||||
#expect(merged?.senderPeerID == stablePeerID)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func accountDM_keepsTheConnectedShortIDConversationCanonical() async {
|
||||
let context = MockChatPrivateConversationContext()
|
||||
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||
let noiseKey = Data(repeating: 0xB8, count: 32)
|
||||
let stablePeerID = PeerID(hexData: noiseKey)
|
||||
let shortPeerID = stablePeerID.toShort()
|
||||
let senderPubkey = "feedface00112233"
|
||||
context.connectedPeers = [shortPeerID]
|
||||
context.selectedPrivateChatPeer = stablePeerID
|
||||
context.displayNamesByPubkey[senderPubkey] = "bob"
|
||||
context.privateChats[stablePeerID] = [
|
||||
makeIncomingMessage(id: "stable-history", senderPeerID: stablePeerID)
|
||||
]
|
||||
let payloadData = PrivateMessagePacket(messageID: "connected-live-1", content: "still nearby").encode()!
|
||||
|
||||
coordinator.handlePrivateMessage(
|
||||
NoisePayload(type: .privateMessage, data: payloadData),
|
||||
senderPubkey: senderPubkey,
|
||||
convKey: stablePeerID,
|
||||
id: MockChatPrivateConversationContext.dummyIdentity,
|
||||
messageTimestamp: Date()
|
||||
)
|
||||
|
||||
#expect(context.privateChats[stablePeerID] == nil)
|
||||
#expect(context.privateChats[shortPeerID]?.map(\.id) == ["stable-history", "connected-live-1"])
|
||||
#expect(context.privateChats[shortPeerID]?.first?.senderPeerID == shortPeerID)
|
||||
#expect(context.privateChats[shortPeerID]?.last?.senderPeerID == shortPeerID)
|
||||
#expect(context.selectedPrivateChatPeer == shortPeerID)
|
||||
#expect(context.geoReadReceipts.map(\.messageID) == ["connected-live-1"])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func accountDM_duplicateDowngradeAckStillClearsTheRetainedOutbox() async {
|
||||
let context = MockChatPrivateConversationContext()
|
||||
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||
let stablePeerID = PeerID(hexData: Data(repeating: 0xE2, count: 32))
|
||||
let message = makeIncomingMessage(id: "already-read", sender: "me")
|
||||
message.deliveryStatus = .read(by: "bob", at: Date())
|
||||
context.privateChats[stablePeerID] = [message]
|
||||
context.queuedMessageIDsByPeerID[stablePeerID] = ["already-read"]
|
||||
|
||||
coordinator.handleDelivered(
|
||||
NoisePayload(type: .delivered, data: Data("already-read".utf8)),
|
||||
senderPubkey: "feedface00112233",
|
||||
convKey: stablePeerID
|
||||
)
|
||||
|
||||
#expect(isRead(context.privateChats[stablePeerID]?.first?.deliveryStatus, by: "bob"))
|
||||
#expect(context.deliveredMessageIDs == ["already-read"])
|
||||
#expect(context.notifyUIChangedCount == 0)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func accountDMAck_clearsRetainedMessageAfterConversationWasRemoved() async {
|
||||
let context = MockChatPrivateConversationContext()
|
||||
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||
let stablePeerID = PeerID(hexData: Data(repeating: 0xE3, count: 32))
|
||||
let shortPeerID = stablePeerID.toShort()
|
||||
context.queuedMessageIDsByPeerID[shortPeerID] = ["cleared-bubble"]
|
||||
|
||||
coordinator.handleDelivered(
|
||||
NoisePayload(type: .delivered, data: Data("cleared-bubble".utf8)),
|
||||
senderPubkey: "feedface00112233",
|
||||
convKey: stablePeerID
|
||||
)
|
||||
|
||||
#expect(context.privateChats.isEmpty)
|
||||
#expect(context.queuedMessageIDsByPeerID[shortPeerID] == nil)
|
||||
#expect(context.deliveredMessageIDs == ["cleared-bubble"])
|
||||
#expect(context.notifyUIChangedCount == 0)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleViewingThisChat_clearsUnreadAndSendsRoutedReadReceiptOnce() async {
|
||||
let context = MockChatPrivateConversationContext()
|
||||
|
||||
@@ -133,11 +133,17 @@ private final class MockChatTransportEventContext: ChatTransportEventContext {
|
||||
// Delivery status
|
||||
var applyMessageDeliveryStatusResult = true
|
||||
var deliveryStatusesByMessageID: [String: DeliveryStatus] = [:]
|
||||
private(set) var appliedDeliveryStatuses: [(messageID: String, status: DeliveryStatus)] = []
|
||||
private(set) var appliedDeliveryStatuses: [
|
||||
(messageID: String, status: DeliveryStatus, peerIDAliases: Set<PeerID>)
|
||||
] = []
|
||||
|
||||
@discardableResult
|
||||
func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool {
|
||||
appliedDeliveryStatuses.append((messageID, status))
|
||||
func applyAcknowledgedMessageDeliveryStatus(
|
||||
_ messageID: String,
|
||||
status: DeliveryStatus,
|
||||
from peerIDAliases: Set<PeerID>
|
||||
) -> Bool {
|
||||
appliedDeliveryStatuses.append((messageID, status, peerIDAliases))
|
||||
return applyMessageDeliveryStatusResult
|
||||
}
|
||||
|
||||
@@ -417,6 +423,10 @@ struct ChatTransportEventCoordinatorContextTests {
|
||||
let peerID = PeerID(str: "99aabbccddeeff00")
|
||||
let noiseKey = Data(repeating: 0x44, count: 32)
|
||||
context.peersByID[peerID] = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice")
|
||||
let stablePeerID = PeerID(hexData: noiseKey)
|
||||
let staleStablePeerID = PeerID(hexData: Data(repeating: 0x55, count: 32))
|
||||
context.cacheStablePeerID(staleStablePeerID, for: peerID)
|
||||
context.noiseSessionKeysByPeerID[peerID] = noiseKey
|
||||
|
||||
// Inbound private message: decoded, handled, and delivery-acked.
|
||||
let packet = PrivateMessagePacket(messageID: "pm-1", content: "hi there")
|
||||
@@ -438,6 +448,8 @@ struct ChatTransportEventCoordinatorContextTests {
|
||||
await drainMainActorTasks()
|
||||
#expect(context.appliedDeliveryStatuses.count == 2)
|
||||
#expect(context.appliedDeliveryStatuses[0].messageID == "m-1")
|
||||
#expect(context.appliedDeliveryStatuses[0].peerIDAliases == [peerID, stablePeerID])
|
||||
#expect(!context.appliedDeliveryStatuses[0].peerIDAliases.contains(staleStablePeerID))
|
||||
if case .delivered(let to, _) = context.appliedDeliveryStatuses[0].status {
|
||||
#expect(to == "alice")
|
||||
} else {
|
||||
|
||||
@@ -98,6 +98,7 @@ private final class MockChatVerificationContext: ChatVerificationContext {
|
||||
private(set) var installedCallbacks: (onPeerAuthenticated: (PeerID, String) -> Void, onHandshakeRequired: (PeerID) -> Void)?
|
||||
private(set) var triggeredHandshakes: [PeerID] = []
|
||||
private(set) var privateMediaAuthenticatedPeers: [PeerID] = []
|
||||
private(set) var securePrivateMessageRetryAliases: [[PeerID]] = []
|
||||
private(set) var sentChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||
private(set) var sentResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||
|
||||
@@ -118,6 +119,10 @@ private final class MockChatVerificationContext: ChatVerificationContext {
|
||||
privateMediaAuthenticatedPeers.append(peerID)
|
||||
}
|
||||
|
||||
func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID]) {
|
||||
securePrivateMessageRetryAliases.append(peerIDAliases)
|
||||
}
|
||||
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||||
sentChallenges.append((peerID, noiseKeyHex, nonceA))
|
||||
}
|
||||
@@ -269,6 +274,10 @@ struct ChatVerificationCoordinatorContextTests {
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
let noiseKey = Data(repeating: 0x33, count: 32)
|
||||
context.noiseSessionKeysByPeerID[peerID] = noiseKey
|
||||
context.cacheStablePeerID(
|
||||
PeerID(hexData: Data(repeating: 0x44, count: 32)),
|
||||
for: peerID
|
||||
)
|
||||
context.verifiedFingerprints = ["fp-verified"]
|
||||
|
||||
coordinator.setupNoiseCallbacks()
|
||||
@@ -279,9 +288,11 @@ struct ChatVerificationCoordinatorContextTests {
|
||||
callbacks?.onPeerAuthenticated(peerID, "fp-verified")
|
||||
await waitForMainQueue()
|
||||
#expect(context.encryptionStatuses[peerID] == .noiseVerified)
|
||||
#expect(context.stablePeerIDCache[peerID] == PeerID(hexData: noiseKey))
|
||||
let stablePeerID = PeerID(hexData: noiseKey)
|
||||
#expect(context.stablePeerIDCache[peerID] == stablePeerID)
|
||||
#expect(context.invalidatedEncryptionCachePeers.contains(peerID))
|
||||
#expect(context.privateMediaAuthenticatedPeers == [peerID])
|
||||
#expect(context.securePrivateMessageRetryAliases == [[peerID, stablePeerID]])
|
||||
|
||||
// Handshake required -> handshaking status.
|
||||
callbacks?.onHandshakeRequired(peerID)
|
||||
|
||||
@@ -298,6 +298,137 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
}())
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func authenticatedNoiseAckCannotClearAnotherPeersRetryState() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let intendedPeer = PeerID(str: "0102030405060708")
|
||||
let otherPeer = PeerID(str: "1112131415161718")
|
||||
let messageID = "noise-peer-bound-ack"
|
||||
|
||||
viewModel.seedPrivateChat(
|
||||
[
|
||||
BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Keep retrying",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Intended",
|
||||
senderPeerID: viewModel.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
],
|
||||
for: intendedPeer
|
||||
)
|
||||
transport.reachablePeers.insert(intendedPeer)
|
||||
viewModel.messageRouter.sendPrivate(
|
||||
"Keep retrying",
|
||||
to: intendedPeer,
|
||||
recipientNickname: "Intended",
|
||||
messageID: messageID
|
||||
)
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
|
||||
// This models a decrypted Noise receipt: the transport-authenticated
|
||||
// peer is authoritative, not the attacker-controlled message ID.
|
||||
viewModel.didReceiveNoisePayload(
|
||||
from: otherPeer,
|
||||
type: .delivered,
|
||||
payload: Data(messageID.utf8),
|
||||
timestamp: Date()
|
||||
)
|
||||
for _ in 0..<10 { await Task.yield() }
|
||||
|
||||
#expect(isSent(viewModel.conversations.deliveryStatus(forMessageID: messageID)))
|
||||
viewModel.messageRouter.flushOutbox(for: intendedPeer)
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
|
||||
viewModel.didReceiveNoisePayload(
|
||||
from: intendedPeer,
|
||||
type: .delivered,
|
||||
payload: Data(messageID.utf8),
|
||||
timestamp: Date()
|
||||
)
|
||||
for _ in 0..<10 { await Task.yield() }
|
||||
|
||||
#expect(isDelivered(viewModel.conversations.deliveryStatus(forMessageID: messageID)))
|
||||
viewModel.messageRouter.flushOutbox(for: intendedPeer)
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func authenticatedNoiseAckClearsOnlyIntendedPeersPrivateMediaRetry() async throws {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let intendedPeer = PeerID(str: "0102030405060708")
|
||||
let otherPeer = PeerID(str: "1112131415161718")
|
||||
let fileName = "voice_0011223344556677.m4a"
|
||||
let content = Data("voice".utf8)
|
||||
|
||||
transport.privateMediaPolicies[intendedPeer] = .encrypted
|
||||
transport.privateMediaReceiptSessionGenerations[intendedPeer] = UUID()
|
||||
viewModel.selectedPrivateChatPeer = intendedPeer
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: viewModel,
|
||||
prepareVoiceNotePacket: { _ in
|
||||
BitchatFilePacket(
|
||||
fileName: fileName,
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "audio/mp4",
|
||||
content: content
|
||||
)
|
||||
},
|
||||
transferIDFactory: { "\($0)-receipt-ack" }
|
||||
)
|
||||
viewModel.mediaTransferCoordinator = coordinator
|
||||
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(
|
||||
"scoped-media-ack-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
try FileManager.default.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let sourceURL = directory.appendingPathComponent(fileName)
|
||||
try content.write(to: sourceURL)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
coordinator.sendVoiceNote(at: sourceURL)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{
|
||||
transport.sentPrivateFiles.count == 1
|
||||
&& coordinator.retainedReconnectRetryCount == 1
|
||||
},
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
let messageID = try #require(
|
||||
viewModel.privateChats[intendedPeer]?.first?.id
|
||||
)
|
||||
let transferID = try #require(
|
||||
transport.sentPrivateFiles.first?.transferID
|
||||
)
|
||||
|
||||
#expect(!viewModel.deliveryCoordinator
|
||||
.updateAcknowledgedMessageDeliveryStatus(
|
||||
messageID,
|
||||
status: .delivered(to: "Other", at: Date()),
|
||||
from: [otherPeer]
|
||||
))
|
||||
#expect(coordinator.retainedReconnectRetryCount == 1)
|
||||
#expect(transport.cancelledTransfers.isEmpty)
|
||||
|
||||
#expect(viewModel.deliveryCoordinator
|
||||
.updateAcknowledgedMessageDeliveryStatus(
|
||||
messageID,
|
||||
status: .delivered(to: "Intended", at: Date()),
|
||||
from: [intendedPeer]
|
||||
))
|
||||
#expect(coordinator.retainedReconnectRetryCount == 0)
|
||||
#expect(transport.cancelledTransfers == [transferID])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func cleanupOldReadReceipts_removesReceiptIDsWithoutMessages() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
@@ -577,12 +708,26 @@ private final class MockChatDeliveryContext: ChatDeliveryContext {
|
||||
var isStartupPhase = false
|
||||
private(set) var notifyUIChangedCount = 0
|
||||
private(set) var markedDeliveredMessageIDs: [String] = []
|
||||
private(set) var peerBoundDeliveredMessages: [(messageID: String, peerIDs: Set<PeerID>)] = []
|
||||
|
||||
@discardableResult
|
||||
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||
store.setDeliveryStatus(status, forMessageID: messageID)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setDeliveryStatus(
|
||||
_ status: DeliveryStatus,
|
||||
forMessageID messageID: String,
|
||||
inDirectPeerAliases peerIDs: Set<PeerID>
|
||||
) -> Bool {
|
||||
store.setDeliveryStatus(
|
||||
status,
|
||||
forMessageID: messageID,
|
||||
inDirectPeerAliases: peerIDs
|
||||
)
|
||||
}
|
||||
|
||||
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
|
||||
store.deliveryStatus(forMessageID: messageID)
|
||||
}
|
||||
@@ -604,6 +749,24 @@ private final class MockChatDeliveryContext: ChatDeliveryContext {
|
||||
func markMessageDelivered(_ messageID: String) {
|
||||
markedDeliveredMessageIDs.append(messageID)
|
||||
}
|
||||
|
||||
func markMessageDelivered(_ messageID: String, from peerIDs: Set<PeerID>) {
|
||||
peerBoundDeliveredMessages.append((messageID, peerIDs))
|
||||
}
|
||||
|
||||
func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set<PeerID>) -> Bool {
|
||||
peerIDs.contains { peerID in
|
||||
contextMessages(for: peerID).contains { message in
|
||||
message.id == messageID && message.senderPeerID == localPeerID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let localPeerID = PeerID(str: "aabbccddeeff0011")
|
||||
|
||||
private func contextMessages(for peerID: PeerID) -> [BitchatMessage] {
|
||||
store.conversationsByID[.directPeer(peerID)]?.messages ?? []
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -685,7 +848,151 @@ struct ChatDeliveryCoordinatorContextTests {
|
||||
|
||||
#expect(isRead(coordinator.deliveryStatus(for: messageID)))
|
||||
#expect(context.notifyUIChangedCount == 1)
|
||||
#expect(context.markedDeliveredMessageIDs == [messageID])
|
||||
#expect(context.markedDeliveredMessageIDs.isEmpty)
|
||||
#expect(context.peerBoundDeliveredMessages.count == 1)
|
||||
#expect(context.peerBoundDeliveredMessages[0].messageID == messageID)
|
||||
#expect(context.peerBoundDeliveredMessages[0].peerIDs == [peerID])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func authenticatedReceiptWithCollidingIDUpdatesOnlyAuthenticatedAliases() async {
|
||||
let context = MockChatDeliveryContext()
|
||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||
let ephemeralPeerID = PeerID(str: "0102030405060708")
|
||||
let stablePeerID = PeerID(hexData: Data(repeating: 0x08, count: 32))
|
||||
let otherPeerID = PeerID(str: "1112131415161718")
|
||||
let messageID = "authenticated-receipt-collision"
|
||||
let mirroredMessage = makePrivateMessage(id: messageID, status: .sent)
|
||||
|
||||
context.store.append(mirroredMessage, to: .directPeer(ephemeralPeerID))
|
||||
context.store.append(mirroredMessage, to: .directPeer(stablePeerID))
|
||||
context.store.append(
|
||||
makePrivateMessage(id: messageID, status: .sent),
|
||||
to: .directPeer(otherPeerID)
|
||||
)
|
||||
context.store.append(makePublicMessage(id: messageID, status: .sent), to: .mesh)
|
||||
|
||||
let aliases: Set<PeerID> = [ephemeralPeerID, stablePeerID]
|
||||
let didUpdate = coordinator.updateAcknowledgedMessageDeliveryStatus(
|
||||
messageID,
|
||||
status: .delivered(to: "Peer", at: Date()),
|
||||
from: aliases
|
||||
)
|
||||
|
||||
#expect(didUpdate)
|
||||
#expect(isDelivered(
|
||||
context.store.conversation(for: .directPeer(ephemeralPeerID))
|
||||
.message(withID: messageID)?.deliveryStatus
|
||||
))
|
||||
#expect(isDelivered(
|
||||
context.store.conversation(for: .directPeer(stablePeerID))
|
||||
.message(withID: messageID)?.deliveryStatus
|
||||
))
|
||||
#expect(isSent(
|
||||
context.store.conversation(for: .directPeer(otherPeerID))
|
||||
.message(withID: messageID)?.deliveryStatus
|
||||
))
|
||||
#expect(isSent(
|
||||
context.store.conversation(for: .mesh)
|
||||
.message(withID: messageID)?.deliveryStatus
|
||||
))
|
||||
#expect(context.peerBoundDeliveredMessages.count == 1)
|
||||
#expect(context.peerBoundDeliveredMessages[0].messageID == messageID)
|
||||
#expect(context.peerBoundDeliveredMessages[0].peerIDs == aliases)
|
||||
#expect(context.notifyUIChangedCount == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func authenticatedReceiptRemainsScopedAfterPeerAliasMigration() async {
|
||||
let context = MockChatDeliveryContext()
|
||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||
let ephemeralPeerID = PeerID(str: "2122232425262728")
|
||||
let stablePeerID = PeerID(hexData: Data(repeating: 0x28, count: 32))
|
||||
let otherPeerID = PeerID(str: "3132333435363738")
|
||||
let messageID = "authenticated-receipt-after-migration"
|
||||
|
||||
context.store.append(
|
||||
makePrivateMessage(id: messageID, status: .sent),
|
||||
to: .directPeer(ephemeralPeerID)
|
||||
)
|
||||
context.store.append(
|
||||
makePrivateMessage(id: messageID, status: .sent),
|
||||
to: .directPeer(otherPeerID)
|
||||
)
|
||||
context.store.migrateConversation(
|
||||
from: .directPeer(ephemeralPeerID),
|
||||
to: .directPeer(stablePeerID)
|
||||
)
|
||||
|
||||
let didUpdate = coordinator.updateAcknowledgedMessageDeliveryStatus(
|
||||
messageID,
|
||||
status: .read(by: "Peer", at: Date()),
|
||||
from: [ephemeralPeerID, stablePeerID]
|
||||
)
|
||||
|
||||
#expect(didUpdate)
|
||||
#expect(context.store.conversationsByID[.directPeer(ephemeralPeerID)] == nil)
|
||||
#expect(isRead(
|
||||
context.store.conversation(for: .directPeer(stablePeerID))
|
||||
.message(withID: messageID)?.deliveryStatus
|
||||
))
|
||||
#expect(isSent(
|
||||
context.store.conversation(for: .directPeer(otherPeerID))
|
||||
.message(withID: messageID)?.deliveryStatus
|
||||
))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func receiptFromWrongPeerDoesNotUpdateOrTerminalizeOutgoingMessage() async {
|
||||
let context = MockChatDeliveryContext()
|
||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||
let intendedPeer = PeerID(str: "0102030405060708")
|
||||
let otherPeer = PeerID(str: "1112131415161718")
|
||||
let messageID = "wrong-peer-receipt"
|
||||
context.store.append(
|
||||
makePrivateMessage(id: messageID, status: .sent),
|
||||
to: .directPeer(intendedPeer)
|
||||
)
|
||||
|
||||
coordinator.didReceiveReadReceipt(
|
||||
ReadReceipt(
|
||||
originalMessageID: messageID,
|
||||
readerID: otherPeer,
|
||||
readerNickname: "Other"
|
||||
)
|
||||
)
|
||||
|
||||
#expect(isSent(coordinator.deliveryStatus(for: messageID)))
|
||||
#expect(context.peerBoundDeliveredMessages.isEmpty)
|
||||
#expect(context.markedDeliveredMessageIDs.isEmpty)
|
||||
#expect(context.notifyUIChangedCount == 0)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func rejectedStaleReceiptDoesNotTerminalizeRetryState() async {
|
||||
let context = MockChatDeliveryContext()
|
||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
let messageID = "stale-receipt"
|
||||
context.store.append(
|
||||
makePrivateMessage(
|
||||
id: messageID,
|
||||
status: .read(by: "Peer", at: Date())
|
||||
),
|
||||
to: .directPeer(peerID)
|
||||
)
|
||||
|
||||
let didUpdate = coordinator.updateAcknowledgedMessageDeliveryStatus(
|
||||
messageID,
|
||||
status: .delivered(to: "Peer", at: Date()),
|
||||
from: [peerID]
|
||||
)
|
||||
|
||||
#expect(!didUpdate)
|
||||
#expect(isRead(coordinator.deliveryStatus(for: messageID)))
|
||||
#expect(context.peerBoundDeliveredMessages.isEmpty)
|
||||
#expect(context.markedDeliveredMessageIDs.isEmpty)
|
||||
#expect(context.notifyUIChangedCount == 0)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
|
||||
@@ -393,20 +393,25 @@ struct ChatViewModelNostrExtensionTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func subscribeGiftWrap_rejectsOversizedEmbeddedPacket() async throws {
|
||||
func privateEnvelope_rejectsOversizedEmbeddedPacketBeforePublication() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
|
||||
let oversized = Data(repeating: 0x41, count: FileTransferLimits.maxFramedFileBytes + 1)
|
||||
let content = "bitchat1:" + base64URLEncode(oversized)
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
|
||||
do {
|
||||
_ = try NostrProtocol.createPrivateEnvelope(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
Issue.record("Expected oversized private-envelope plaintext to be rejected")
|
||||
} catch NostrError.invalidCiphertext {
|
||||
// Rejected before encryption/publication, as intended.
|
||||
} catch {
|
||||
Issue.record("Expected NostrError.invalidCiphertext, got \(error)")
|
||||
}
|
||||
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
#expect(viewModel.privateChats.isEmpty)
|
||||
@@ -451,7 +456,7 @@ struct ChatViewModelNostrExtensionTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func subscribeGiftWrap_deliveredAckUpdatesExistingMessage() async throws {
|
||||
func subscribePrivateEnvelope_deliveredAckUpdatesExistingMessage() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
@@ -473,13 +478,13 @@ struct ChatViewModelNostrExtensionTests {
|
||||
], for: convKey)
|
||||
|
||||
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
|
||||
viewModel.subscribePrivateEnvelope(envelope, id: recipient)
|
||||
|
||||
let didUpdate = await TestHelpers.waitUntil(
|
||||
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||
@@ -489,7 +494,7 @@ struct ChatViewModelNostrExtensionTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func subscribeGiftWrap_readAckUpdatesExistingMessage() async throws {
|
||||
func subscribePrivateEnvelope_readAckUpdatesExistingMessage() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
@@ -511,13 +516,13 @@ struct ChatViewModelNostrExtensionTests {
|
||||
], for: convKey)
|
||||
|
||||
let content = try ackContent(type: .readReceipt, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
|
||||
viewModel.subscribePrivateEnvelope(envelope, id: recipient)
|
||||
|
||||
let didUpdate = await TestHelpers.waitUntil(
|
||||
{ isRead(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||
@@ -527,28 +532,28 @@ struct ChatViewModelNostrExtensionTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleGiftWrap_privateMessageStoresConversationAndMapping() async throws {
|
||||
func handlePrivateEnvelope_privateMessageStoresConversationAndMapping() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let messageID = "gift-private"
|
||||
let messageID = "envelope-private"
|
||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||
|
||||
let content = try privateMessageContent(
|
||||
text: "Hello from gift wrap",
|
||||
text: "Hello from private envelope",
|
||||
messageID: messageID,
|
||||
senderPeerID: PeerID(str: "0123456789abcdef")
|
||||
)
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
viewModel.handleGiftWrap(giftWrap, id: recipient)
|
||||
viewModel.handlePrivateEnvelope(envelope, id: recipient)
|
||||
|
||||
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
|
||||
)
|
||||
#expect(didStore)
|
||||
@@ -557,11 +562,11 @@ struct ChatViewModelNostrExtensionTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleGiftWrap_blockedSenderSkipsMessageStorage() async throws {
|
||||
func handlePrivateEnvelope_blockedSenderSkipsMessageStorage() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let messageID = "gift-blocked"
|
||||
let messageID = "envelope-blocked"
|
||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||
|
||||
viewModel.identityManager.setNostrBlocked(sender.publicKeyHex, isBlocked: true)
|
||||
@@ -571,13 +576,13 @@ struct ChatViewModelNostrExtensionTests {
|
||||
messageID: messageID,
|
||||
senderPeerID: PeerID(str: "0123456789abcdef")
|
||||
)
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
viewModel.handleGiftWrap(giftWrap, id: recipient)
|
||||
viewModel.handlePrivateEnvelope(envelope, id: recipient)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
#expect(viewModel.privateChats[convKey] == nil)
|
||||
@@ -585,12 +590,12 @@ struct ChatViewModelNostrExtensionTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleGiftWrap_deliveredAckUpdatesExistingMessage() async throws {
|
||||
func handlePrivateEnvelope_deliveredAckUpdatesExistingMessage() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||
let messageID = "gift-delivered"
|
||||
let messageID = "envelope-delivered"
|
||||
|
||||
viewModel.seedPrivateChat([
|
||||
BitchatMessage(
|
||||
@@ -607,13 +612,13 @@ struct ChatViewModelNostrExtensionTests {
|
||||
], for: convKey)
|
||||
|
||||
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
viewModel.handleGiftWrap(giftWrap, id: recipient)
|
||||
viewModel.handlePrivateEnvelope(envelope, id: recipient)
|
||||
|
||||
let didUpdate = await TestHelpers.waitUntil(
|
||||
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||
|
||||
@@ -1193,6 +1193,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
|
||||
|
||||
struct ChatViewModelPanicTests {
|
||||
|
||||
@@ -755,6 +755,71 @@ struct ConversationStoreTests {
|
||||
#expect(statusChangedIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test("peer-scoped receipt updates only authenticated direct aliases")
|
||||
@MainActor
|
||||
func peerScopedReceiptUpdatesOnlyAuthenticatedDirectAliases() {
|
||||
let store = ConversationStore()
|
||||
let ephemeralPeer = PeerID(str: "0102030405060708")
|
||||
let stablePeer = PeerID(hexData: Data(repeating: 0x08, count: 32))
|
||||
let otherPeer = PeerID(str: "1112131415161718")
|
||||
let ephemeral = ConversationID.directPeer(ephemeralPeer)
|
||||
let stable = ConversationID.directPeer(stablePeer)
|
||||
let other = ConversationID.directPeer(otherPeer)
|
||||
let messageID = "scoped-receipt"
|
||||
let mirrored = makeMessage(
|
||||
id: messageID,
|
||||
timestamp: 1,
|
||||
isPrivate: true,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
store.upsertByID(mirrored, in: ephemeral)
|
||||
store.upsertByID(mirrored, in: stable)
|
||||
store.upsertByID(
|
||||
makeMessage(
|
||||
id: messageID,
|
||||
timestamp: 1,
|
||||
isPrivate: true,
|
||||
deliveryStatus: .sent
|
||||
),
|
||||
in: other
|
||||
)
|
||||
store.upsertByID(
|
||||
makeMessage(id: messageID, timestamp: 1, deliveryStatus: .sent),
|
||||
in: .mesh
|
||||
)
|
||||
|
||||
var cancellables = Set<AnyCancellable>()
|
||||
var publishedIDs: [ConversationID] = []
|
||||
for id in [ephemeral, stable, other, .mesh] {
|
||||
store.conversation(for: id).objectWillChange
|
||||
.sink { publishedIDs.append(id) }
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
var statusChangedIDs: [ConversationID] = []
|
||||
store.changes
|
||||
.sink { change in
|
||||
if case .statusChanged(let id, messageID, _) = change,
|
||||
messageID == "scoped-receipt" {
|
||||
statusChangedIDs.append(id)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
let delivered = DeliveryStatus.delivered(to: "bob", at: Date())
|
||||
#expect(store.setDeliveryStatus(
|
||||
delivered,
|
||||
forMessageID: messageID,
|
||||
inDirectPeerAliases: [ephemeralPeer, stablePeer]
|
||||
))
|
||||
|
||||
#expect(Set(publishedIDs) == Set([ephemeral, stable]))
|
||||
#expect(Set(statusChangedIDs) == Set([ephemeral, stable]))
|
||||
#expect(store.conversation(for: ephemeral).message(withID: messageID)?.deliveryStatus == delivered)
|
||||
#expect(store.conversation(for: stable).message(withID: messageID)?.deliveryStatus == delivered)
|
||||
#expect(store.conversation(for: other).message(withID: messageID)?.deliveryStatus == .sent)
|
||||
#expect(store.conversation(for: .mesh).message(withID: messageID)?.deliveryStatus == .sent)
|
||||
}
|
||||
|
||||
// MARK: - Invariant audit (field observability)
|
||||
|
||||
/// A store exercised through every intent family: public + geohash +
|
||||
|
||||
@@ -14,7 +14,7 @@ import BitFoundation
|
||||
|
||||
/// Mock Transport implementation for testing ChatViewModel in isolation.
|
||||
/// Records all method calls and allows test code to verify interactions.
|
||||
final class MockTransport: Transport {
|
||||
final class MockTransport: Transport, PrivateMediaDeletionPersisting {
|
||||
|
||||
// MARK: - Protocol Properties
|
||||
|
||||
@@ -38,6 +38,11 @@ final class MockTransport: Transport {
|
||||
private(set) var sentPrivateFiles: [(packet: BitchatFilePacket, peerID: PeerID, transferID: String)] = []
|
||||
private(set) var sentPrivateFileLegacyAllowances: [Bool] = []
|
||||
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 sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||
private(set) var sentCourierMessages: [(content: String, messageID: String, recipientNoiseKey: Data, couriers: [PeerID])] = []
|
||||
@@ -60,6 +65,15 @@ final class MockTransport: Transport {
|
||||
var peerFingerprints: [PeerID: String] = [:]
|
||||
var peerNoiseStates: [PeerID: LazyHandshakeState] = [:]
|
||||
var privateMediaPolicies: [PeerID: PrivateMediaSendPolicy] = [:]
|
||||
var privateMediaReceiptSessionGenerations: [PeerID: UUID] = [:]
|
||||
var persistDeletedPrivateMediaResult = true
|
||||
var deferDeletedPrivateMediaPersistence = false
|
||||
private var pendingDeletedPrivateMediaCompletions: [
|
||||
@MainActor (Bool) -> Void
|
||||
] = []
|
||||
/// Optional synchronous hook for send-ordering tests (for example, an ack
|
||||
/// arriving before the router's send call returns).
|
||||
var onSendPrivateMessage: (@MainActor (_ messageID: String) -> Void)?
|
||||
private let mockKeychain = MockKeychain()
|
||||
|
||||
// MARK: - Transport Protocol Implementation
|
||||
@@ -164,6 +178,11 @@ final class MockTransport: Transport {
|
||||
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||
sentPrivateMessages.append((content, peerID, recipientNickname, messageID))
|
||||
if let onSendPrivateMessage {
|
||||
MainActor.assumeIsolated {
|
||||
onSendPrivateMessage(messageID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||
@@ -205,6 +224,12 @@ final class MockTransport: Transport {
|
||||
privateMediaPolicies[peerID] ?? .encrypted
|
||||
}
|
||||
|
||||
func authenticatedPrivateMediaReceiptSessionGeneration(
|
||||
to peerID: PeerID
|
||||
) -> UUID? {
|
||||
privateMediaReceiptSessionGenerations[peerID]
|
||||
}
|
||||
|
||||
func resolvePrivateMediaSendPolicy(
|
||||
to peerID: PeerID,
|
||||
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||
@@ -217,6 +242,34 @@ final class MockTransport: Transport {
|
||||
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) {
|
||||
sentVerifyChallenges.append((peerID, noiseKeyHex, nonceA))
|
||||
}
|
||||
@@ -264,6 +317,9 @@ final class MockTransport: Transport {
|
||||
sentBroadcastFiles.removeAll()
|
||||
sentPrivateFiles.removeAll()
|
||||
cancelledTransfers.removeAll()
|
||||
deletedPrivateMediaMessageIDBatches.removeAll()
|
||||
deletedPrivateMediaRelativePaths.removeAll()
|
||||
protectedPrivateMediaRelativePaths.removeAll()
|
||||
sentVerifyChallenges.removeAll()
|
||||
sentVerifyResponses.removeAll()
|
||||
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"}
|
||||
@@ -2,18 +2,17 @@
|
||||
// NostrProtocolTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for NIP-17 gift-wrapped private messages
|
||||
// Tests for BitChat's proprietary private-envelope transport over Nostr.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
struct NostrProtocolTests {
|
||||
|
||||
@Test func nip17MessageRoundTrip() throws {
|
||||
@Test func privateEnvelopeRoundTrip() throws {
|
||||
// Create sender and recipient identities
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
@@ -22,21 +21,19 @@ struct NostrProtocolTests {
|
||||
print("Recipient pubkey: \(recipient.publicKeyHex)")
|
||||
|
||||
// Create a test message
|
||||
let originalContent = "Hello from NIP-17 test!"
|
||||
let originalContent = "Hello from BitChat private-envelope test!"
|
||||
|
||||
// Create encrypted gift wrap
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: originalContent,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
print("Gift wrap created with ID: \(giftWrap.id)")
|
||||
print("Gift wrap pubkey: \(giftWrap.pubkey)")
|
||||
print("Private envelope created with ID: \(envelope.id)")
|
||||
print("Private envelope pubkey: \(envelope.pubkey)")
|
||||
|
||||
// Decrypt the gift wrap
|
||||
let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
|
||||
@@ -52,43 +49,389 @@ struct NostrProtocolTests {
|
||||
print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey) at \(messageDate)")
|
||||
}
|
||||
|
||||
@Test func giftWrapUsesUniqueEphemeralKeys() throws {
|
||||
@Test func privateEnvelopesUseUniqueEphemeralKeys() throws {
|
||||
// Create identities
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
|
||||
// Create two messages
|
||||
let message1 = try NostrProtocol.createPrivateMessage(
|
||||
let message1 = try NostrProtocol.createPrivateEnvelope(
|
||||
content: "Message 1",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
let message2 = try NostrProtocol.createPrivateMessage(
|
||||
let message2 = try NostrProtocol.createPrivateEnvelope(
|
||||
content: "Message 2",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
// Gift wrap pubkeys should be different (unique ephemeral keys)
|
||||
// Public envelope keys must be one-time use.
|
||||
#expect(message1.pubkey != message2.pubkey)
|
||||
|
||||
print("Message 1 gift wrap pubkey: \(message1.pubkey)")
|
||||
print("Message 2 gift wrap pubkey: \(message2.pubkey)")
|
||||
print("Message 1 envelope pubkey: \(message1.pubkey)")
|
||||
print("Message 2 envelope pubkey: \(message2.pubkey)")
|
||||
|
||||
// Both should decrypt successfully
|
||||
let (content1, _, _) = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: message1,
|
||||
let (content1, _, _) = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: message1,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
let (content2, _, _) = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: message2,
|
||||
let (content2, _, _) = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: message2,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
|
||||
#expect(content1 == "Message 1")
|
||||
#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 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 {
|
||||
let sender = try NostrIdentity.generate()
|
||||
@@ -96,68 +439,58 @@ struct NostrProtocolTests {
|
||||
let wrongRecipient = try NostrIdentity.generate()
|
||||
|
||||
// Create message for recipient
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: "Secret message",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
// Try to decrypt with wrong recipient
|
||||
if #available(macOS 14.4, iOS 17.4, *) {
|
||||
#expect(throws: CryptoKitError.authenticationFailure) {
|
||||
try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: wrongRecipient
|
||||
)
|
||||
}
|
||||
} else {
|
||||
#expect(throws: (any Error).self) {
|
||||
try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: wrongRecipient
|
||||
)
|
||||
}
|
||||
expectInvalidEvent {
|
||||
_ = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: wrongRecipient
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func decryptRejectsInvalidSealSignature() throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let giftWrap = try NostrProtocol.createPrivateMessageWithInvalidSealSignatureForTesting(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelopeWithInvalidSealSignatureForTesting(
|
||||
content: "forged signature",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
expectInvalidEvent {
|
||||
_ = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
_ = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func decryptRejectsSealRumorPubkeyMismatch() throws {
|
||||
@Test func decryptRejectsSealMessagePubkeyMismatch() throws {
|
||||
let claimedSender = try NostrIdentity.generate()
|
||||
let sealSigner = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let giftWrap = try NostrProtocol.createPrivateMessageWithMismatchedSealRumorPubkeyForTesting(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelopeWithMismatchedSealMessagePubkeyForTesting(
|
||||
content: "spoofed sender",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
rumorIdentity: claimedSender,
|
||||
messageIdentity: claimedSender,
|
||||
sealSignerIdentity: sealSigner
|
||||
)
|
||||
|
||||
expectInvalidEvent {
|
||||
_ = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
_ = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func testAckRoundTripNIP44V2_Delivered() throws {
|
||||
func deliveredAckRoundTripsInsidePrivateEnvelope() throws {
|
||||
// Identities
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
@@ -171,19 +504,17 @@ struct NostrProtocolTests {
|
||||
"Failed to embed delivered ack"
|
||||
)
|
||||
|
||||
// Create NIP-17 gift wrap to recipient (uses NIP-44 v2 internally)
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: embedded,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
// Ensure v2 format was used for ciphertext
|
||||
#expect(giftWrap.content.hasPrefix("v2:"))
|
||||
#expect(envelope.content.hasPrefix(NostrProtocol.privateEnvelopeContentPrefix))
|
||||
|
||||
// Decrypt as recipient
|
||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
|
||||
@@ -208,7 +539,7 @@ struct NostrProtocolTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test func ackRoundTripNIP44V2_ReadReceipt() throws {
|
||||
@Test func readReceiptRoundTripsInsidePrivateEnvelope() throws {
|
||||
// Identities
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
@@ -220,16 +551,16 @@ struct NostrProtocolTests {
|
||||
"Failed to embed read ack"
|
||||
)
|
||||
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: embedded,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
#expect(giftWrap.content.hasPrefix("v2:"))
|
||||
#expect(envelope.content.hasPrefix(NostrProtocol.privateEnvelopeContentPrefix))
|
||||
|
||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
#expect(senderPubkey == sender.publicKeyHex)
|
||||
@@ -290,7 +621,6 @@ struct NostrProtocolTests {
|
||||
#expect(object["limit"] as? Int == 42)
|
||||
}
|
||||
|
||||
|
||||
@Test func inboundNostrEventRejectsTooManyTags() throws {
|
||||
var eventDict = Self.validInboundEventDict()
|
||||
eventDict["tags"] = Array(
|
||||
@@ -336,6 +666,33 @@ struct NostrProtocolTests {
|
||||
#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
|
||||
private static func validInboundEventDict() -> [String: Any] {
|
||||
[
|
||||
@@ -349,6 +706,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? {
|
||||
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||
let rem = str.count % 4
|
||||
@@ -366,4 +762,15 @@ struct NostrProtocolTests {
|
||||
Issue.record("Expected NostrError.invalidEvent, got \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func expectInvalidCiphertext(_ operation: () throws -> Void) {
|
||||
do {
|
||||
try operation()
|
||||
Issue.record("Expected NostrError.invalidCiphertext")
|
||||
} catch NostrError.invalidCiphertext {
|
||||
return
|
||||
} catch {
|
||||
Issue.record("Expected NostrError.invalidCiphertext, got \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -690,12 +690,29 @@ private final class PerfDeliveryContext: ChatDeliveryContext {
|
||||
|
||||
func notifyUIChanged() {}
|
||||
func markMessageDelivered(_ messageID: String) {}
|
||||
func markMessageDelivered(_ messageID: String, from peerIDs: Set<PeerID>) {}
|
||||
func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set<PeerID>) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||
store.setDeliveryStatus(status, forMessageID: messageID)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setDeliveryStatus(
|
||||
_ status: DeliveryStatus,
|
||||
forMessageID messageID: String,
|
||||
inDirectPeerAliases peerIDs: Set<PeerID>
|
||||
) -> Bool {
|
||||
store.setDeliveryStatus(
|
||||
status,
|
||||
forMessageID: messageID,
|
||||
inDirectPeerAliases: peerIDs
|
||||
)
|
||||
}
|
||||
|
||||
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
|
||||
store.deliveryStatus(forMessageID: messageID)
|
||||
}
|
||||
|
||||
@@ -21,9 +21,12 @@ struct BLEFileTransferHandlerTests {
|
||||
var receiptCommits: [(messageID: String, storedURL: URL)] = []
|
||||
var receiptCommitSucceeds = true
|
||||
var removedIncomingFiles: [URL] = []
|
||||
var finishedIncomingFileDeliveries: [URL] = []
|
||||
var lastSeenUpdates: [PeerID] = []
|
||||
var deliveryAcks: [(messageID: String, peerID: PeerID)] = []
|
||||
var deliveredMessages: [BitchatMessage] = []
|
||||
var shouldAcceptDelivery = true
|
||||
var deliveryOutcome = TransportEventDeliveryOutcome.accepted
|
||||
var saveOverride: ((
|
||||
_ data: Data,
|
||||
_ preferredName: String?,
|
||||
@@ -34,12 +37,85 @@ struct BLEFileTransferHandlerTests {
|
||||
var receiptStateOverride: ((String) -> BLEPrivateMediaReceiptState)?
|
||||
var receiptCommitOverride: ((String, URL) -> Bool)?
|
||||
var removeIncomingFileOverride: ((URL) -> Void)?
|
||||
var finishIncomingFileDeliveryOverride: ((URL) -> Void)?
|
||||
}
|
||||
|
||||
private let localPeerID = PeerID(str: "0102030405060708")
|
||||
private let remotePeerID = PeerID(str: "1122334455667788")
|
||||
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 {
|
||||
let environment = BLEFileTransferHandlerEnvironment(
|
||||
localPeerID: { [localPeerID] in localPeerID },
|
||||
@@ -86,6 +162,10 @@ struct BLEFileTransferHandlerTests {
|
||||
recorder.removedIncomingFiles.append(storedURL)
|
||||
recorder.removeIncomingFileOverride?(storedURL)
|
||||
},
|
||||
finishIncomingFileDelivery: { storedURL in
|
||||
recorder.finishedIncomingFileDeliveries.append(storedURL)
|
||||
recorder.finishIncomingFileDeliveryOverride?(storedURL)
|
||||
},
|
||||
isPrivateMediaSenderBlocked: { peerID in
|
||||
recorder.blockedPeers.contains(peerID)
|
||||
},
|
||||
@@ -95,10 +175,18 @@ struct BLEFileTransferHandlerTests {
|
||||
acknowledgePrivateMedia: { messageID, peerID in
|
||||
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 }
|
||||
recorder.deliveredMessages.append(message)
|
||||
guard shouldDeliver() else { return }
|
||||
if recorder.deliveryOutcome == .invokedUnconfirmed {
|
||||
outcome = .invokedUnconfirmed
|
||||
return
|
||||
}
|
||||
outcome = .accepted
|
||||
completion()
|
||||
}
|
||||
)
|
||||
@@ -375,6 +463,73 @@ struct BLEFileTransferHandlerTests {
|
||||
#expect(recorder.deliveryAcks.first?.messageID == recorder.deliveredMessages.first?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
func rejectedStableDeliveryReleasesPendingPayloadOwnership() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||
"private-media-handler-rejected-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let store = BLEIncomingFileStore(baseDirectory: root)
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(
|
||||
remotePeerID,
|
||||
nickname: "Alice",
|
||||
isVerified: true
|
||||
)]
|
||||
recorder.shouldAcceptDelivery = false
|
||||
recorder.saveOverride = {
|
||||
store.save(
|
||||
data: $0,
|
||||
preferredName: $1,
|
||||
subdirectory: $2,
|
||||
fallbackExtension: $3,
|
||||
defaultPrefix: $4
|
||||
)
|
||||
}
|
||||
recorder.receiptStateOverride = {
|
||||
store.privateMediaReceiptState(messageID: $0)
|
||||
}
|
||||
recorder.receiptCommitOverride = {
|
||||
store.commitPrivateMediaFile(messageID: $0, storedURL: $1)
|
||||
}
|
||||
recorder.removeIncomingFileOverride = {
|
||||
store.removeIncomingFile(at: $0)
|
||||
}
|
||||
recorder.finishIncomingFileDeliveryOverride = {
|
||||
store.finishIncomingFileDelivery(at: $0)
|
||||
}
|
||||
|
||||
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||
let fileName =
|
||||
"img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||
let file = BitchatFilePacket(
|
||||
fileName: fileName,
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "image/jpeg",
|
||||
content: content
|
||||
)
|
||||
let payload = try #require(file.encode())
|
||||
let stableID = try #require(PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: remotePeerID,
|
||||
recipientPeerID: localPeerID,
|
||||
fileName: fileName
|
||||
))
|
||||
|
||||
#expect(makeHandler(recorder: recorder).handlePrivatePayload(
|
||||
payload,
|
||||
from: remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||
))
|
||||
#expect(recorder.deliveredMessages.isEmpty)
|
||||
#expect(recorder.deliveryAcks.isEmpty)
|
||||
#expect(recorder.finishedIncomingFileDeliveries.count == 1)
|
||||
#expect(store.reservePrivateMediaDeletion(
|
||||
messageIDs: [stableID],
|
||||
payloadRelativePaths: [:]
|
||||
) != nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func rawLegacyPrivateFileWithRetryShapedNameNeverUsesReceiptLedger() throws {
|
||||
let recorder = Recorder()
|
||||
@@ -402,6 +557,64 @@ struct BLEFileTransferHandlerTests {
|
||||
#expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func rejectedRawDeliveryRemovesUIUnownedPayload() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(
|
||||
remotePeerID,
|
||||
nickname: "Alice",
|
||||
isVerified: true,
|
||||
signingPublicKey: sampleSigningKey
|
||||
)]
|
||||
recorder.signatureVerifies = true
|
||||
recorder.shouldAcceptDelivery = false
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = try makeFileTransferPacket(
|
||||
sender: remotePeerID,
|
||||
mimeType: "image/jpeg",
|
||||
content: Data([0xFF, 0xD8, 0xFF, 0xD9]),
|
||||
recipientID: Data(hexString: localPeerID.id),
|
||||
fileName: "raw-rejected.jpg"
|
||||
)
|
||||
|
||||
#expect(handler.handle(packet, from: remotePeerID))
|
||||
#expect(recorder.deliveredMessages.isEmpty)
|
||||
#expect(recorder.removedIncomingFiles.count == 1)
|
||||
#expect(recorder.removedIncomingFiles.first == recorder.saveResult)
|
||||
#expect(recorder.finishedIncomingFileDeliveries.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func plainDelegateRawDeliveryPreservesPayloadWithoutSynchronousAck() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(
|
||||
remotePeerID,
|
||||
nickname: "Alice",
|
||||
isVerified: true,
|
||||
signingPublicKey: sampleSigningKey
|
||||
)]
|
||||
recorder.signatureVerifies = true
|
||||
recorder.deliveryOutcome = .invokedUnconfirmed
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = try makeFileTransferPacket(
|
||||
sender: remotePeerID,
|
||||
mimeType: "image/jpeg",
|
||||
content: Data([0xFF, 0xD8, 0xFF, 0xD9]),
|
||||
recipientID: Data(hexString: localPeerID.id),
|
||||
fileName: "plain-delegate.jpg"
|
||||
)
|
||||
|
||||
#expect(handler.handle(packet, from: remotePeerID))
|
||||
#expect(recorder.deliveredMessages.count == 1)
|
||||
#expect(recorder.deliveryAcks.isEmpty)
|
||||
#expect(recorder.removedIncomingFiles.isEmpty)
|
||||
#expect(recorder.finishedIncomingFileDeliveries.count == 1)
|
||||
#expect(
|
||||
recorder.finishedIncomingFileDeliveries.first
|
||||
== recorder.saveResult
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func repeatedLegacyPrivateImageNamesKeepDistinctRandomMessageIDs() throws {
|
||||
let recorder = Recorder()
|
||||
@@ -502,12 +715,7 @@ struct BLEFileTransferHandlerTests {
|
||||
nickname: "Alice",
|
||||
isVerified: true
|
||||
)]
|
||||
recorder.saveOverride = {
|
||||
data,
|
||||
preferredName,
|
||||
subdirectory,
|
||||
fallbackExtension,
|
||||
defaultPrefix in
|
||||
recorder.saveOverride = { data, preferredName, subdirectory, fallbackExtension, defaultPrefix in
|
||||
store.save(
|
||||
data: data,
|
||||
preferredName: preferredName,
|
||||
@@ -525,6 +733,9 @@ struct BLEFileTransferHandlerTests {
|
||||
recorder.removeIncomingFileOverride = {
|
||||
store.removeIncomingFile(at: $0)
|
||||
}
|
||||
recorder.finishIncomingFileDeliveryOverride = {
|
||||
store.finishIncomingFileDelivery(at: $0)
|
||||
}
|
||||
}
|
||||
|
||||
let first = Recorder()
|
||||
@@ -880,6 +1091,21 @@ struct BLEFileTransferHandlerTests {
|
||||
let messageID = "media-00112233445566778899aabbccddeeff"
|
||||
|
||||
let seed = BLEPrivateMediaReceiptStore(baseDirectory: base)
|
||||
let payload = base
|
||||
.appendingPathComponent(
|
||||
"files/images/incoming",
|
||||
isDirectory: true
|
||||
)
|
||||
.appendingPathComponent("panic-receipt.jpg")
|
||||
try FileManager.default.createDirectory(
|
||||
at: payload.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try Data("secret".utf8).write(to: payload, options: .atomic)
|
||||
#expect(seed.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: payload
|
||||
))
|
||||
#expect(seed.recordDeleted(messageID: messageID))
|
||||
|
||||
let store = BLEIncomingFileStore(baseDirectory: base)
|
||||
@@ -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
|
||||
func panicWipeAttemptsDeletionWhenMarkerPersistenceFails() throws {
|
||||
enum MarkerFailure: Error { case unavailable }
|
||||
|
||||
@@ -4,8 +4,22 @@ import Testing
|
||||
|
||||
struct BLEPrivateMediaReceiptStoreTests {
|
||||
private struct TestError: Error {}
|
||||
private struct ReceiptFixture: Codable {
|
||||
let kind: String
|
||||
let relativePath: String?
|
||||
let recordedAt: Date
|
||||
}
|
||||
private struct JournalEntryFixture: Codable {
|
||||
let relativePaths: [String]
|
||||
let recordedAt: Date
|
||||
}
|
||||
private struct JournalFixture: Codable {
|
||||
let version: Int
|
||||
let entries: [String: JournalEntryFixture]
|
||||
}
|
||||
|
||||
private let messageID = "media-00112233445566778899aabbccddeeff"
|
||||
private let secondMessageID = "media-ffeeddccbbaa99887766554433221100"
|
||||
|
||||
@Test
|
||||
func acceptedReceiptPersistsAcrossStoreInstances() throws {
|
||||
@@ -21,6 +35,215 @@ struct BLEPrivateMediaReceiptStoreTests {
|
||||
#expect(relaunched.state(for: messageID) == .accepted(payload))
|
||||
}
|
||||
|
||||
@Test
|
||||
func acceptedReceiptRejectsOutgoingPayloadAndCrossIDPathReuse() throws {
|
||||
let root = makeRoot("accepted-ownership")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let incoming = try makePayload(in: root)
|
||||
let outgoingDirectory = root.appendingPathComponent(
|
||||
"files/images/outgoing",
|
||||
isDirectory: true
|
||||
)
|
||||
try FileManager.default.createDirectory(
|
||||
at: outgoingDirectory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let outgoing = outgoingDirectory.appendingPathComponent("image.jpg")
|
||||
try Data([0x01]).write(to: outgoing)
|
||||
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
|
||||
#expect(!store.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: outgoing
|
||||
))
|
||||
#expect(store.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: incoming
|
||||
))
|
||||
#expect(!store.commitAccepted(
|
||||
messageID: secondMessageID,
|
||||
storedURL: incoming
|
||||
))
|
||||
#expect(FileManager.default.fileExists(atPath: incoming.path))
|
||||
#expect(FileManager.default.fileExists(atPath: outgoing.path))
|
||||
}
|
||||
|
||||
@Test
|
||||
func liveAcceptedPathSurvivesTTLAndCapacityPressure() throws {
|
||||
let root = makeRoot("accepted-retention")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let firstPayload = try makePayload(in: root, name: "first.jpg")
|
||||
let secondPayload = try makePayload(in: root, name: "second.jpg")
|
||||
let recordedAt = Date(timeIntervalSince1970: 2_000)
|
||||
let seed = BLEPrivateMediaReceiptStore(
|
||||
baseDirectory: root,
|
||||
capacity: 1,
|
||||
ttl: 1,
|
||||
now: { recordedAt }
|
||||
)
|
||||
#expect(seed.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: firstPayload
|
||||
))
|
||||
|
||||
let relaunched = BLEPrivateMediaReceiptStore(
|
||||
baseDirectory: root,
|
||||
capacity: 1,
|
||||
ttl: 1,
|
||||
now: { recordedAt.addingTimeInterval(2) }
|
||||
)
|
||||
#expect(relaunched.state(for: messageID) == .accepted(firstPayload))
|
||||
#expect(!relaunched.commitAccepted(
|
||||
messageID: secondMessageID,
|
||||
storedURL: secondPayload
|
||||
))
|
||||
#expect(relaunched.state(for: messageID) == .accepted(firstPayload))
|
||||
}
|
||||
|
||||
@Test
|
||||
func incomingAllocationDoesNotReuseReceiptOwnedMissingPath() throws {
|
||||
let root = makeRoot("accepted-reservation")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let original = try makePayload(in: root)
|
||||
#expect(BLEPrivateMediaReceiptStore(
|
||||
baseDirectory: root
|
||||
).commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: original
|
||||
))
|
||||
try FileManager.default.removeItem(at: original)
|
||||
|
||||
let stored = BLEIncomingFileStore(baseDirectory: root).save(
|
||||
data: Data([0x03]),
|
||||
preferredName: "image.jpg",
|
||||
subdirectory: "images/incoming",
|
||||
fallbackExtension: "jpg",
|
||||
defaultPrefix: "image"
|
||||
)
|
||||
|
||||
#expect(stored?.lastPathComponent != "image.jpg")
|
||||
#expect(stored.map {
|
||||
FileManager.default.fileExists(atPath: $0.path)
|
||||
} == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func pendingRawArrivalBlocksDeletionFallbackPathReuse() throws {
|
||||
let root = makeRoot("pending-raw-arrival")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let incoming = BLEIncomingFileStore(baseDirectory: root)
|
||||
|
||||
// The old stable bubble names image.jpg, but its receipt and payload
|
||||
// have already been pruned. A raw arrival saves that basename before
|
||||
// its main-actor bubble is inserted.
|
||||
let rawArrival = incoming.save(
|
||||
data: Data([0x03]),
|
||||
preferredName: "image.jpg",
|
||||
subdirectory: "images/incoming",
|
||||
fallbackExtension: "jpg",
|
||||
defaultPrefix: "image"
|
||||
)
|
||||
#expect(rawArrival?.lastPathComponent == "image.jpg")
|
||||
|
||||
let reservation = incoming.reservePrivateMediaDeletion(
|
||||
messageIDs: [messageID],
|
||||
payloadRelativePaths: [
|
||||
messageID: "images/incoming/image.jpg"
|
||||
]
|
||||
)
|
||||
#expect(reservation == nil)
|
||||
#expect(rawArrival.map {
|
||||
FileManager.default.fileExists(atPath: $0.path)
|
||||
} == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func quotaDoesNotEvictOrReusePendingDeliveryPath() throws {
|
||||
let root = makeRoot("pending-quota")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let incoming = BLEIncomingFileStore(
|
||||
baseDirectory: root,
|
||||
quotaBytes: 1
|
||||
)
|
||||
let first = try #require(incoming.save(
|
||||
data: Data([0x01, 0x02]),
|
||||
preferredName: "image.jpg",
|
||||
subdirectory: "images/incoming",
|
||||
fallbackExtension: "jpg",
|
||||
defaultPrefix: "image"
|
||||
))
|
||||
|
||||
incoming.enforceQuota(reservingBytes: 1)
|
||||
#expect(FileManager.default.fileExists(atPath: first.path))
|
||||
|
||||
let second = try #require(incoming.save(
|
||||
data: Data([0x03]),
|
||||
preferredName: "image.jpg",
|
||||
subdirectory: "images/incoming",
|
||||
fallbackExtension: "jpg",
|
||||
defaultPrefix: "image"
|
||||
))
|
||||
#expect(second != first)
|
||||
#expect(second.lastPathComponent == "image (1).jpg")
|
||||
#expect(FileManager.default.fileExists(atPath: first.path))
|
||||
#expect(FileManager.default.fileExists(atPath: second.path))
|
||||
}
|
||||
|
||||
@Test
|
||||
func invalidReceiptAndJournalCannotTargetOutgoingPayload() throws {
|
||||
let root = makeRoot("invalid-owned-path")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let outgoingDirectory = root.appendingPathComponent(
|
||||
"files/images/outgoing",
|
||||
isDirectory: true
|
||||
)
|
||||
try FileManager.default.createDirectory(
|
||||
at: outgoingDirectory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let victim = outgoingDirectory.appendingPathComponent("victim.jpg")
|
||||
try Data([0x02]).write(to: victim)
|
||||
let receiptURL = receiptRecord(in: root)
|
||||
try FileManager.default.createDirectory(
|
||||
at: receiptURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let fixture = ReceiptFixture(
|
||||
kind: "tombstone",
|
||||
relativePath: "images/outgoing/victim.jpg",
|
||||
recordedAt: Date()
|
||||
)
|
||||
try JSONEncoder().encode(fixture).write(
|
||||
to: receiptURL,
|
||||
options: .atomic
|
||||
)
|
||||
|
||||
#expect(
|
||||
BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
.state(for: messageID) == .unavailable
|
||||
)
|
||||
#expect(FileManager.default.fileExists(atPath: victim.path))
|
||||
|
||||
try FileManager.default.removeItem(at: receiptURL)
|
||||
let journal = JournalFixture(
|
||||
version: 1,
|
||||
entries: [messageID: JournalEntryFixture(
|
||||
relativePaths: ["images/outgoing/victim.jpg"],
|
||||
recordedAt: fixture.recordedAt
|
||||
)]
|
||||
)
|
||||
try JSONEncoder().encode(journal).write(
|
||||
to: deletionJournal(in: root),
|
||||
options: .atomic
|
||||
)
|
||||
|
||||
#expect(
|
||||
BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
.state(for: messageID) == .unavailable
|
||||
)
|
||||
#expect(FileManager.default.fileExists(atPath: victim.path))
|
||||
}
|
||||
|
||||
@Test
|
||||
func directoryEnumerationFailureIsUnavailableAndRetriesWithoutCachingEmpty() throws {
|
||||
let root = makeRoot("list-failure")
|
||||
@@ -128,25 +351,447 @@ struct BLEPrivateMediaReceiptStoreTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func failedTombstonePersistenceDoesNotPoisonVolatileState() throws {
|
||||
let root = makeRoot("failed-tombstone-write")
|
||||
func deletionBatchCommitsEveryIDBeforeRemovingPayloads() throws {
|
||||
let root = makeRoot("batch")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let firstPayload = try makePayload(in: root, name: "first.jpg")
|
||||
let secondPayload = try makePayload(in: root, name: "second.jpg")
|
||||
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(store.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: firstPayload
|
||||
))
|
||||
#expect(store.commitAccepted(
|
||||
messageID: secondMessageID,
|
||||
storedURL: secondPayload
|
||||
))
|
||||
|
||||
#expect(store.recordDeleted(
|
||||
messageIDs: [secondMessageID, messageID]
|
||||
))
|
||||
|
||||
#expect(store.state(for: messageID) == .tombstoned)
|
||||
#expect(store.state(for: secondMessageID) == .tombstoned)
|
||||
#expect(!FileManager.default.fileExists(atPath: firstPayload.path))
|
||||
#expect(!FileManager.default.fileExists(atPath: secondPayload.path))
|
||||
#expect(!FileManager.default.fileExists(
|
||||
atPath: deletionJournal(in: root).path
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
func failedJournalCommitPreservesAcceptedStateAndPayloads() throws {
|
||||
let root = makeRoot("journal-failure")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let firstPayload = try makePayload(in: root, name: "first.jpg")
|
||||
let secondPayload = try makePayload(in: root, name: "second.jpg")
|
||||
let seed = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(seed.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: firstPayload
|
||||
))
|
||||
#expect(seed.commitAccepted(
|
||||
messageID: secondMessageID,
|
||||
storedURL: secondPayload
|
||||
))
|
||||
|
||||
let failing = BLEPrivateMediaReceiptStore(
|
||||
baseDirectory: root,
|
||||
dataWriter: { _, _, _ in throw TestError() }
|
||||
)
|
||||
#expect(!failing.recordDeleted(
|
||||
messageIDs: [messageID, secondMessageID]
|
||||
))
|
||||
|
||||
// A failed commit must not install a volatile tombstone. Otherwise a
|
||||
// sender retry could be ACKed although the caller kept both bubbles.
|
||||
#expect(failing.state(for: messageID) == .accepted(firstPayload))
|
||||
#expect(
|
||||
failing.state(for: secondMessageID) == .accepted(secondPayload)
|
||||
)
|
||||
#expect(FileManager.default.fileExists(atPath: firstPayload.path))
|
||||
#expect(FileManager.default.fileExists(atPath: secondPayload.path))
|
||||
#expect(!FileManager.default.fileExists(
|
||||
atPath: deletionJournal(in: root).path
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
func journalRecoversBatchAfterCrashDuringMaterialization() throws {
|
||||
let root = makeRoot("crash-recovery")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let firstPayload = try makePayload(in: root, name: "first.jpg")
|
||||
let secondPayload = try makePayload(in: root, name: "second.jpg")
|
||||
let seed = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(seed.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: firstPayload
|
||||
))
|
||||
#expect(seed.commitAccepted(
|
||||
messageID: secondMessageID,
|
||||
storedURL: secondPayload
|
||||
))
|
||||
|
||||
let interrupted = BLEPrivateMediaReceiptStore(
|
||||
baseDirectory: root,
|
||||
dataWriter: { data, url, options in
|
||||
let isJournal =
|
||||
url.lastPathComponent == ".deletion-journal.json"
|
||||
let isFirstRecord =
|
||||
url.deletingPathExtension().lastPathComponent == messageID
|
||||
guard isJournal || isFirstRecord else {
|
||||
throw TestError()
|
||||
}
|
||||
try data.write(to: url, options: options)
|
||||
}
|
||||
)
|
||||
#expect(interrupted.recordDeleted(
|
||||
messageIDs: [messageID, secondMessageID]
|
||||
))
|
||||
#expect(FileManager.default.fileExists(
|
||||
atPath: deletionJournal(in: root).path
|
||||
))
|
||||
#expect(!FileManager.default.fileExists(atPath: firstPayload.path))
|
||||
#expect(FileManager.default.fileExists(atPath: secondPayload.path))
|
||||
#expect(interrupted.state(for: messageID) == .tombstoned)
|
||||
#expect(interrupted.state(for: secondMessageID) == .tombstoned)
|
||||
|
||||
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(relaunched.state(for: messageID) == .tombstoned)
|
||||
#expect(relaunched.state(for: secondMessageID) == .tombstoned)
|
||||
#expect(!FileManager.default.fileExists(atPath: firstPayload.path))
|
||||
#expect(!FileManager.default.fileExists(atPath: secondPayload.path))
|
||||
#expect(!FileManager.default.fileExists(
|
||||
atPath: deletionJournal(in: root).path
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
func journalRetriesPayloadUnlinkAfterRelaunch() throws {
|
||||
let root = makeRoot("unlink-recovery")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let payload = try makePayload(in: root)
|
||||
let seed = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(seed.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: payload
|
||||
))
|
||||
|
||||
let unlinkFailure = BLEPrivateMediaReceiptStore(
|
||||
baseDirectory: root,
|
||||
payloadRemover: { _ in throw TestError() }
|
||||
)
|
||||
#expect(unlinkFailure.recordDeleted(messageID: messageID))
|
||||
#expect(unlinkFailure.state(for: messageID) == .tombstoned)
|
||||
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||
#expect(FileManager.default.fileExists(
|
||||
atPath: deletionJournal(in: root).path
|
||||
))
|
||||
|
||||
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(relaunched.state(for: messageID) == .tombstoned)
|
||||
#expect(!FileManager.default.fileExists(atPath: payload.path))
|
||||
#expect(!FileManager.default.fileExists(
|
||||
atPath: deletionJournal(in: root).path
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
func expiredReceiptUsesFallbackPathForCrashSafeCleanup() throws {
|
||||
let root = makeRoot("expired-fallback")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let payload = try makePayload(in: root)
|
||||
let recordedAt = Date(timeIntervalSince1970: 1_000)
|
||||
let seed = BLEPrivateMediaReceiptStore(
|
||||
baseDirectory: root,
|
||||
ttl: 10,
|
||||
now: { recordedAt }
|
||||
)
|
||||
#expect(seed.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: payload
|
||||
))
|
||||
// Simulate a receipt pruned by an older app version while its bubble
|
||||
// and payload remain. Current code retains live accepted-path owners.
|
||||
try FileManager.default.removeItem(at: receiptRecord(in: root))
|
||||
|
||||
let afterExpiry = recordedAt.addingTimeInterval(11)
|
||||
let interrupted = BLEPrivateMediaReceiptStore(
|
||||
baseDirectory: root,
|
||||
ttl: 10,
|
||||
now: { afterExpiry },
|
||||
payloadRemover: { _ in throw TestError() }
|
||||
)
|
||||
#expect(interrupted.recordDeleted(
|
||||
messageIDs: [messageID],
|
||||
payloadRelativePaths: [
|
||||
messageID: "images/incoming/image.jpg"
|
||||
]
|
||||
))
|
||||
#expect(interrupted.state(for: messageID) == .tombstoned)
|
||||
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||
#expect(FileManager.default.fileExists(
|
||||
atPath: deletionJournal(in: root).path
|
||||
))
|
||||
|
||||
let relaunched = BLEPrivateMediaReceiptStore(
|
||||
baseDirectory: root,
|
||||
ttl: 10,
|
||||
now: { afterExpiry }
|
||||
)
|
||||
#expect(relaunched.state(for: messageID) == .tombstoned)
|
||||
#expect(!FileManager.default.fileExists(atPath: payload.path))
|
||||
#expect(!FileManager.default.fileExists(
|
||||
atPath: deletionJournal(in: root).path
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
func retryAtSuffixedPathDeletesReceiptAndBubblePayloads() throws {
|
||||
let root = makeRoot("retry-suffix")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let original = try makePayload(in: root)
|
||||
let seed = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(seed.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: original
|
||||
))
|
||||
|
||||
// Simulate an older build pruning only the receipt. A retry must use a
|
||||
// suffixed filename while the old bubble still references image.jpg.
|
||||
try FileManager.default.removeItem(at: receiptRecord(in: root))
|
||||
let retry = try makePayload(in: root, name: "image (1).jpg")
|
||||
let retried = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(retried.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: retry
|
||||
))
|
||||
|
||||
#expect(retried.recordDeleted(
|
||||
messageIDs: [messageID],
|
||||
payloadRelativePaths: [
|
||||
messageID: "images/incoming/image.jpg"
|
||||
]
|
||||
))
|
||||
#expect(retried.state(for: messageID) == .tombstoned)
|
||||
#expect(!FileManager.default.fileExists(atPath: original.path))
|
||||
#expect(!FileManager.default.fileExists(atPath: retry.path))
|
||||
#expect(!FileManager.default.fileExists(
|
||||
atPath: deletionJournal(in: root).path
|
||||
))
|
||||
|
||||
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(relaunched.state(for: messageID) == .tombstoned)
|
||||
#expect(!FileManager.default.fileExists(atPath: original.path))
|
||||
#expect(!FileManager.default.fileExists(atPath: retry.path))
|
||||
}
|
||||
|
||||
@Test
|
||||
func protectedUIPathRejectsAcceptedReceiptDeletion() throws {
|
||||
let root = makeRoot("protected-ui-owner")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let payload = try makePayload(in: root)
|
||||
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(store.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: payload
|
||||
))
|
||||
|
||||
#expect(!store.recordDeleted(
|
||||
messageIDs: [messageID],
|
||||
protectedPayloadRelativePaths: [
|
||||
"images/incoming/image.jpg"
|
||||
]
|
||||
))
|
||||
#expect(store.state(for: messageID) == .accepted(payload))
|
||||
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||
}
|
||||
|
||||
@Test
|
||||
func pathlessNewDeletionFailsWithoutChangingReceiverState() {
|
||||
let root = makeRoot("pathless-delete")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(store.state(for: messageID) == .absent)
|
||||
|
||||
// Force the atomic record write itself to fail after the store has
|
||||
// successfully loaded its empty index.
|
||||
let record = receiptRecord(in: root)
|
||||
#expect(!store.recordDeleted(messageID: messageID))
|
||||
#expect(store.state(for: messageID) == .absent)
|
||||
#expect(!FileManager.default.fileExists(
|
||||
atPath: deletionJournal(in: root).path
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
func completedTombstoneNeverDeletesReusedPayloadPath() throws {
|
||||
let root = makeRoot("path-reuse")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let original = try makePayload(in: root)
|
||||
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(store.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: original
|
||||
))
|
||||
#expect(store.recordDeleted(messageID: messageID))
|
||||
#expect(!FileManager.default.fileExists(atPath: original.path))
|
||||
|
||||
let reused = try makePayload(in: root)
|
||||
#expect(store.commitAccepted(
|
||||
messageID: secondMessageID,
|
||||
storedURL: reused
|
||||
))
|
||||
#expect(store.state(for: messageID) == .tombstoned)
|
||||
#expect(FileManager.default.fileExists(atPath: reused.path))
|
||||
|
||||
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(relaunched.state(for: messageID) == .tombstoned)
|
||||
#expect(FileManager.default.fileExists(atPath: reused.path))
|
||||
#expect(
|
||||
relaunched.state(for: secondMessageID) == .accepted(reused)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func expiredLegacyPathfulTombstoneDoesNotDeleteAcceptedOwner() throws {
|
||||
let root = makeRoot("legacy-path-conflict")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let payload = try makePayload(in: root)
|
||||
let receiptDirectory = receiptRecord(in: root)
|
||||
.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(
|
||||
at: record,
|
||||
at: receiptDirectory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
#expect(!store.recordDeleted(messageID: messageID))
|
||||
try FileManager.default.removeItem(at: record)
|
||||
let recordedAt = Date(timeIntervalSince1970: 3_000)
|
||||
try JSONEncoder().encode(ReceiptFixture(
|
||||
kind: "tombstone",
|
||||
relativePath: "images/incoming/image.jpg",
|
||||
recordedAt: recordedAt
|
||||
)).write(
|
||||
to: receiptRecord(in: root),
|
||||
options: .atomic
|
||||
)
|
||||
try JSONEncoder().encode(ReceiptFixture(
|
||||
kind: "accepted",
|
||||
relativePath: "images/incoming/image.jpg",
|
||||
recordedAt: recordedAt
|
||||
)).write(
|
||||
to: receiptRecord(
|
||||
in: root,
|
||||
messageID: secondMessageID
|
||||
),
|
||||
options: .atomic
|
||||
)
|
||||
|
||||
// The UI must be able to report the deletion failure without a
|
||||
// process-lifetime tombstone silently hiding a later retry.
|
||||
let store = BLEPrivateMediaReceiptStore(
|
||||
baseDirectory: root,
|
||||
ttl: 1,
|
||||
now: { recordedAt.addingTimeInterval(2) }
|
||||
)
|
||||
#expect(store.state(for: messageID) == .absent)
|
||||
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||
#expect(
|
||||
store.state(for: secondMessageID) == .accepted(payload)
|
||||
)
|
||||
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||
}
|
||||
|
||||
@Test
|
||||
func expiredLegacyPathfulTombstonePreservesAmbiguousPayload() throws {
|
||||
let root = makeRoot("legacy-expired-cleanup")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let payload = try makePayload(in: root)
|
||||
let receiptURL = receiptRecord(in: root)
|
||||
try FileManager.default.createDirectory(
|
||||
at: receiptURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let recordedAt = Date(timeIntervalSince1970: 3_000)
|
||||
try JSONEncoder().encode(ReceiptFixture(
|
||||
kind: "tombstone",
|
||||
relativePath: "images/incoming/image.jpg",
|
||||
recordedAt: recordedAt
|
||||
)).write(to: receiptURL, options: .atomic)
|
||||
|
||||
let store = BLEPrivateMediaReceiptStore(
|
||||
baseDirectory: root,
|
||||
ttl: 1,
|
||||
now: { recordedAt.addingTimeInterval(2) }
|
||||
)
|
||||
|
||||
#expect(store.state(for: messageID) == .absent)
|
||||
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||
#expect(!FileManager.default.fileExists(atPath: receiptURL.path))
|
||||
}
|
||||
|
||||
@Test
|
||||
func deletionJournalNeverRecursivelyRemovesDirectoryTarget() throws {
|
||||
let root = makeRoot("journal-directory")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let directory = root.appendingPathComponent(
|
||||
"files/images/incoming/archive",
|
||||
isDirectory: true
|
||||
)
|
||||
try FileManager.default.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let child = directory.appendingPathComponent("child.jpg")
|
||||
try Data([0x01]).write(to: child)
|
||||
let receiptDirectory = receiptRecord(in: root)
|
||||
.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(
|
||||
at: receiptDirectory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
#expect(!BLEPrivateMediaReceiptStore(
|
||||
baseDirectory: root
|
||||
).recordDeleted(
|
||||
messageIDs: [messageID],
|
||||
payloadRelativePaths: [
|
||||
messageID: "images/incoming/archive"
|
||||
]
|
||||
))
|
||||
#expect(!FileManager.default.fileExists(
|
||||
atPath: deletionJournal(in: root).path
|
||||
))
|
||||
try JSONEncoder().encode(JournalFixture(
|
||||
version: 1,
|
||||
entries: [messageID: JournalEntryFixture(
|
||||
relativePaths: ["images/incoming/archive"],
|
||||
recordedAt: Date()
|
||||
)]
|
||||
)).write(to: deletionJournal(in: root), options: .atomic)
|
||||
|
||||
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(store.state(for: messageID) == .tombstoned)
|
||||
#expect(FileManager.default.fileExists(atPath: directory.path))
|
||||
#expect(FileManager.default.fileExists(atPath: child.path))
|
||||
#expect(FileManager.default.fileExists(
|
||||
atPath: deletionJournal(in: root).path
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
func corruptDeletionJournalFailsClosedWithoutRemovingPayload() throws {
|
||||
let root = makeRoot("corrupt-journal")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let payload = try makePayload(in: root)
|
||||
#expect(BLEPrivateMediaReceiptStore(
|
||||
baseDirectory: root
|
||||
).commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: payload
|
||||
))
|
||||
let journal = deletionJournal(in: root)
|
||||
try Data("{not-json".utf8).write(to: journal, options: .atomic)
|
||||
|
||||
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(relaunched.state(for: messageID) == .unavailable)
|
||||
#expect(!relaunched.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: payload
|
||||
))
|
||||
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||
#expect(try Data(contentsOf: journal) == Data("{not-json".utf8))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -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(
|
||||
"files/images/incoming",
|
||||
isDirectory: true
|
||||
@@ -189,18 +837,30 @@ struct BLEPrivateMediaReceiptStoreTests {
|
||||
at: directory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let payload = directory.appendingPathComponent("image.jpg")
|
||||
let payload = directory.appendingPathComponent(name)
|
||||
try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: payload)
|
||||
return payload
|
||||
}
|
||||
|
||||
private func receiptRecord(in root: URL) -> URL {
|
||||
private func receiptRecord(
|
||||
in root: URL,
|
||||
messageID requestedMessageID: String? = nil
|
||||
) -> URL {
|
||||
root
|
||||
.appendingPathComponent(
|
||||
"files/.private-media-receipts",
|
||||
isDirectory: true
|
||||
)
|
||||
.appendingPathComponent(messageID)
|
||||
.appendingPathComponent(requestedMessageID ?? messageID)
|
||||
.appendingPathExtension("json")
|
||||
}
|
||||
|
||||
private func deletionJournal(in root: URL) -> URL {
|
||||
root
|
||||
.appendingPathComponent(
|
||||
"files/.private-media-receipts",
|
||||
isDirectory: true
|
||||
)
|
||||
.appendingPathComponent(".deletion-journal.json")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +203,134 @@ struct BridgeCourierServiceTests {
|
||||
#expect(confirmed.sealRequests.isEmpty)
|
||||
}
|
||||
|
||||
@Test func sameMessageIDIsScopedByRecipientAcrossRejectedActiveAndPersistedState() {
|
||||
let fileURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("bridge-dedup-\(UUID().uuidString).json")
|
||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||
let rejectedKey = Fixture.randomKey()
|
||||
let firstKey = Fixture.randomKey()
|
||||
let secondKey = Fixture.randomKey()
|
||||
let thirdKey = Fixture.randomKey()
|
||||
let messageID = "recipient-scoped-collision"
|
||||
|
||||
let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
|
||||
fixture.sealResult = makeEnvelope(
|
||||
recipientKey: rejectedKey,
|
||||
ciphertext: Data(
|
||||
repeating: 7,
|
||||
count: BridgeCourierService.Limits.maxDropEnvelopeBytes + 1
|
||||
)
|
||||
)
|
||||
var rejectedResults: [Bool] = []
|
||||
fixture.service.depositDrop(
|
||||
content: "rejected",
|
||||
messageID: messageID,
|
||||
recipientNoiseKey: rejectedKey
|
||||
) { rejectedResults.append($0) }
|
||||
#expect(rejectedResults == [false])
|
||||
|
||||
fixture.sealResult = makeEnvelope(recipientKey: firstKey)
|
||||
fixture.automaticPublishResult = nil
|
||||
var firstResults: [Bool] = []
|
||||
var secondResults: [Bool] = []
|
||||
var duplicateFirstResults: [Bool] = []
|
||||
fixture.service.depositDrop(
|
||||
content: "first",
|
||||
messageID: messageID,
|
||||
recipientNoiseKey: firstKey
|
||||
) { firstResults.append($0) }
|
||||
fixture.service.depositDrop(
|
||||
content: "second",
|
||||
messageID: messageID,
|
||||
recipientNoiseKey: secondKey
|
||||
) { secondResults.append($0) }
|
||||
fixture.service.depositDrop(
|
||||
content: "first duplicate",
|
||||
messageID: messageID,
|
||||
recipientNoiseKey: firstKey
|
||||
) { duplicateFirstResults.append($0) }
|
||||
|
||||
#expect(fixture.publishedEvents.count == 2)
|
||||
#expect(fixture.pendingPublishCompletions.count == 2)
|
||||
#expect(duplicateFirstResults == [false])
|
||||
#expect(firstResults.isEmpty)
|
||||
#expect(secondResults.isEmpty)
|
||||
|
||||
fixture.resolveNextPublish(true)
|
||||
fixture.resolveNextPublish(true)
|
||||
#expect(firstResults == [true])
|
||||
#expect(secondResults == [true])
|
||||
fixture.service.flushDedupSnapshot()
|
||||
|
||||
let relaunched = Fixture(
|
||||
dedupStore: BridgeDropDedupStore(fileURL: fileURL)
|
||||
)
|
||||
relaunched.sealResult = makeEnvelope(recipientKey: thirdKey)
|
||||
var relaunchResults: [Bool] = []
|
||||
relaunched.service.depositDrop(
|
||||
content: "first",
|
||||
messageID: messageID,
|
||||
recipientNoiseKey: firstKey
|
||||
) { relaunchResults.append($0) }
|
||||
relaunched.service.depositDrop(
|
||||
content: "second",
|
||||
messageID: messageID,
|
||||
recipientNoiseKey: secondKey
|
||||
) { relaunchResults.append($0) }
|
||||
relaunched.service.depositDrop(
|
||||
content: "third",
|
||||
messageID: messageID,
|
||||
recipientNoiseKey: thirdKey
|
||||
) { relaunchResults.append($0) }
|
||||
|
||||
#expect(relaunchResults == [false, false, true])
|
||||
#expect(relaunched.sealRequests.count == 1)
|
||||
#expect(relaunched.sealRequests.first?.key == thirdKey)
|
||||
#expect(relaunched.publishedEvents.count == 1)
|
||||
}
|
||||
|
||||
@Test func legacyPublishedMessageIDIsWildcardUntilItsOriginalExpiry() {
|
||||
let fileURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("bridge-dedup-\(UUID().uuidString).json")
|
||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||
var date = Date()
|
||||
let messageID = "legacy-wildcard"
|
||||
let recipientKey = Fixture.randomKey()
|
||||
let store = BridgeDropDedupStore(fileURL: fileURL)
|
||||
store.save(BridgeDropDedupStore.Snapshot(
|
||||
publishedDropKeys: [messageID: date],
|
||||
seenDropEventIDs: [:]
|
||||
))
|
||||
|
||||
let fixture = Fixture(
|
||||
now: { date },
|
||||
dedupStore: BridgeDropDedupStore(fileURL: fileURL)
|
||||
)
|
||||
fixture.sealResult = makeEnvelope(recipientKey: recipientKey)
|
||||
var results: [Bool] = []
|
||||
fixture.service.depositDrop(
|
||||
content: "legacy",
|
||||
messageID: messageID,
|
||||
recipientNoiseKey: recipientKey
|
||||
) { results.append($0) }
|
||||
#expect(results == [false])
|
||||
#expect(fixture.sealRequests.isEmpty)
|
||||
|
||||
date = date.addingTimeInterval(CourierEnvelope.maxLifetimeSeconds + 1)
|
||||
fixture.service.depositDrop(
|
||||
content: "after expiry",
|
||||
messageID: messageID,
|
||||
recipientNoiseKey: recipientKey
|
||||
) { results.append($0) }
|
||||
#expect(results == [false, true])
|
||||
#expect(fixture.publishedEvents.count == 1)
|
||||
fixture.service.flushDedupSnapshot()
|
||||
|
||||
let snapshot = BridgeDropDedupStore(fileURL: fileURL).load()
|
||||
#expect(snapshot.publishedDropKeys[messageID] == nil)
|
||||
#expect(snapshot.publishedDropKeys.count == 1)
|
||||
}
|
||||
|
||||
@Test func panicWipeInvalidatesInFlightPublishCompletion() throws {
|
||||
let fileURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("bridge-dedup-\(UUID().uuidString).json")
|
||||
@@ -297,8 +425,10 @@ struct BridgeCourierServiceTests {
|
||||
#expect(firstResults == [false])
|
||||
|
||||
// The evicted first drop is deposit-able again (slot released).
|
||||
let sealCountBeforeRetry = fixture.sealRequests.count
|
||||
fixture.service.depositDrop(content: "0-retry", messageID: firstID, recipientNoiseKey: key)
|
||||
#expect(fixture.service.pendingDrops.last?.dedupKey == firstID)
|
||||
#expect(fixture.sealRequests.count == sealCountBeforeRetry + 1)
|
||||
#expect(fixture.service.pendingDrops.count == BridgeCourierService.Limits.maxPendingDrops)
|
||||
}
|
||||
|
||||
@Test func oversizeDropConsumesSlotInsteadOfChurning() {
|
||||
|
||||
@@ -207,6 +207,42 @@ struct MessageOutboxStoreTests {
|
||||
#expect(MessageOutboxStore(keychain: keychain, fileURL: fileURL).load().isEmpty)
|
||||
}
|
||||
|
||||
@Test func deferredScopedRemovalTombstoneFiltersOnlySelectedPeer() {
|
||||
let fileURL = makeTempURL()
|
||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||
let keychain = MockKeychain()
|
||||
let acknowledgedPeer = PeerID(str: "0000000000000001")
|
||||
let otherPeer = PeerID(str: "0000000000000002")
|
||||
MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([
|
||||
acknowledgedPeer: [makeMessage("shared-id", content: "for acknowledged peer")],
|
||||
otherPeer: [makeMessage("shared-id", content: "for other peer")]
|
||||
])
|
||||
|
||||
var protectedDataUnavailable = true
|
||||
let restored = MessageOutboxStore(
|
||||
keychain: keychain,
|
||||
fileURL: fileURL,
|
||||
readData: { url in
|
||||
if protectedDataUnavailable {
|
||||
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
|
||||
}
|
||||
return try Data(contentsOf: url)
|
||||
}
|
||||
)
|
||||
#expect(restored.load().isEmpty)
|
||||
restored.recordRemoval(messageID: "shared-id", for: [acknowledgedPeer])
|
||||
restored.save([:])
|
||||
|
||||
protectedDataUnavailable = false
|
||||
let recovered = restored.retryDeferredLoad()
|
||||
#expect(recovered?[acknowledgedPeer] == nil)
|
||||
#expect(recovered?[otherPeer]?.map(\.messageID) == ["shared-id"])
|
||||
|
||||
let relaunched = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load()
|
||||
#expect(relaunched[acknowledgedPeer] == nil)
|
||||
#expect(relaunched[otherPeer]?.map(\.messageID) == ["shared-id"])
|
||||
}
|
||||
|
||||
@Test func wipeRemovesFileAndKey() {
|
||||
let fileURL = makeTempURL()
|
||||
let keychain = MockKeychain()
|
||||
|
||||
@@ -79,18 +79,231 @@ struct MessageRouterTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendPrivate_connectedSendIsNotRetained() async {
|
||||
func peerBoundDeliveryAckCannotClearAnotherPeersRetainedMessage() async {
|
||||
let intendedPeer = PeerID(str: "0000000000000023")
|
||||
let otherPeer = PeerID(str: "0000000000000024")
|
||||
let transport = MockTransport()
|
||||
transport.reachablePeers = [intendedPeer, otherPeer]
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
router.sendPrivate(
|
||||
"Secret",
|
||||
to: intendedPeer,
|
||||
recipientNickname: "Intended",
|
||||
messageID: "peer-bound-ack"
|
||||
)
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
|
||||
// Even a receipt arriving over another authenticated conversation
|
||||
// must not terminalize the intended peer's retained retry.
|
||||
router.markDelivered("peer-bound-ack", from: [otherPeer])
|
||||
router.flushOutbox(for: intendedPeer)
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
|
||||
router.markDelivered("peer-bound-ack", from: [intendedPeer])
|
||||
router.flushOutbox(for: intendedPeer)
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendPrivate_connectedSecureSendRetainsUntilDeliveryAck() async {
|
||||
let peerID = PeerID(str: "0000000000000007")
|
||||
let transport = MockTransport()
|
||||
transport.connectedPeers.insert(peerID)
|
||||
transport.reachablePeers.insert(peerID)
|
||||
transport.securePeers = [peerID]
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m7")
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
|
||||
router.flushOutbox(for: peerID)
|
||||
// A newly authenticated/replacement session retries the retained
|
||||
// message instead of losing the first ciphertext to a stale session.
|
||||
router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
|
||||
router.markDelivered("m7")
|
||||
router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func authenticationRetry_matchesStableOutboxAliasWithoutDoubleSending() async {
|
||||
let shortPeerID = PeerID(str: "0000000000000019")
|
||||
let stablePeerID = PeerID(hexData: Data(repeating: 0x19, count: 32))
|
||||
let transport = MockTransport()
|
||||
transport.connectedPeers.insert(stablePeerID)
|
||||
transport.securePeers = [stablePeerID]
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
router.sendPrivate("Hello", to: stablePeerID, recipientNickname: "Peer", messageID: "alias-retry")
|
||||
|
||||
router.retrySecurePrivateMessagesAfterAuthentication(for: [shortPeerID, stablePeerID, stablePeerID])
|
||||
|
||||
#expect(transport.sentPrivateMessages.map(\.messageID) == ["alias-retry", "alias-retry"])
|
||||
#expect(transport.sentPrivateMessages.allSatisfy { $0.peerID == stablePeerID })
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func authenticationRetry_preservesFIFOAcrossSplitAliases() async {
|
||||
let shortPeerID = PeerID(str: "0000000000000022")
|
||||
let stablePeerID = PeerID(hexData: Data(repeating: 0x22, count: 32))
|
||||
let transport = MockTransport()
|
||||
transport.connectedPeers = [shortPeerID, stablePeerID]
|
||||
transport.securePeers = [shortPeerID, stablePeerID]
|
||||
let clock = MutableTestClock()
|
||||
let router = MessageRouter(transports: [transport], now: { clock.now })
|
||||
|
||||
// The older message lives under the stable key, even though the auth
|
||||
// callback supplies the ephemeral alias first.
|
||||
router.sendPrivate("Older", to: stablePeerID, recipientNickname: "Peer", messageID: "fifo-old")
|
||||
clock.now = clock.now.addingTimeInterval(1)
|
||||
router.sendPrivate("Newer", to: shortPeerID, recipientNickname: "Peer", messageID: "fifo-new")
|
||||
transport.resetRecordings()
|
||||
|
||||
router.retrySecurePrivateMessagesAfterAuthentication(for: [shortPeerID, stablePeerID])
|
||||
|
||||
#expect(transport.sentPrivateMessages.map(\.messageID) == ["fifo-old", "fifo-new"])
|
||||
#expect(transport.sentPrivateMessages.map(\.peerID) == [stablePeerID, shortPeerID])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func authenticationRetry_doesNotDuplicateNormalPendingHandshakeSend() async {
|
||||
let peerID = PeerID(str: "0000000000000020")
|
||||
let transport = MockTransport()
|
||||
transport.connectedPeers.insert(peerID)
|
||||
transport.securePeers = []
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "normal-handshake")
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
|
||||
// BLE owns this pending send and drains it after authentication. Once
|
||||
// the session becomes secure, the router's targeted auth retry must
|
||||
// stay silent instead of producing a second copy.
|
||||
transport.securePeers = [peerID]
|
||||
router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
|
||||
router.markDelivered("normal-handshake")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func authenticationRetry_scopesCollidingMessageIDsByPeer() async {
|
||||
let securePeer = PeerID(str: "0000000000000025")
|
||||
let pendingPeer = PeerID(str: "0000000000000026")
|
||||
let transport = MockTransport()
|
||||
transport.connectedPeers = [securePeer, pendingPeer]
|
||||
transport.securePeers = [securePeer]
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
let promotedID = "collision-promoted"
|
||||
let clearedID = "collision-cleared"
|
||||
|
||||
// Pending B then secure A: an ID-global marker falsely promotes B.
|
||||
router.sendPrivate(
|
||||
"pending promoted",
|
||||
to: pendingPeer,
|
||||
recipientNickname: "Pending",
|
||||
messageID: promotedID
|
||||
)
|
||||
router.sendPrivate(
|
||||
"secure promoted",
|
||||
to: securePeer,
|
||||
recipientNickname: "Secure",
|
||||
messageID: promotedID
|
||||
)
|
||||
|
||||
// Secure A then pending B: an ID-global removal falsely clears A.
|
||||
router.sendPrivate(
|
||||
"secure cleared",
|
||||
to: securePeer,
|
||||
recipientNickname: "Secure",
|
||||
messageID: clearedID
|
||||
)
|
||||
router.sendPrivate(
|
||||
"pending cleared",
|
||||
to: pendingPeer,
|
||||
recipientNickname: "Pending",
|
||||
messageID: clearedID
|
||||
)
|
||||
|
||||
transport.resetRecordings()
|
||||
transport.securePeers = [securePeer, pendingPeer]
|
||||
|
||||
router.retrySecurePrivateMessagesAfterAuthentication(for: [pendingPeer])
|
||||
#expect(transport.sentPrivateMessages.isEmpty)
|
||||
|
||||
router.retrySecurePrivateMessagesAfterAuthentication(for: [securePeer])
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
#expect(Set(transport.sentPrivateMessages.map(\.messageID)) == [promotedID, clearedID])
|
||||
#expect(transport.sentPrivateMessages.allSatisfy { $0.peerID == securePeer })
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func authenticationRetry_doesNotDuplicateMessageRequeuedByBLEForHandshake() async {
|
||||
let peerID = PeerID(str: "0000000000000021")
|
||||
let transport = MockTransport()
|
||||
transport.connectedPeers.insert(peerID)
|
||||
transport.securePeers = [peerID]
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "session-lost")
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
|
||||
// The session disappears before a normal outbox flush. That send is
|
||||
// now owned by BLE's pending-handshake queue, so it clears the
|
||||
// router's secure-auth retry marker.
|
||||
transport.securePeers = []
|
||||
router.flushOutbox(for: peerID)
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
|
||||
transport.securePeers = [peerID]
|
||||
router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
|
||||
router.markDelivered("session-lost")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendPrivate_fastDeliveryAckCannotRaceAheadOfRetention() async {
|
||||
let peerID = PeerID(str: "0000000000000017")
|
||||
let transport = MockTransport()
|
||||
transport.connectedPeers.insert(peerID)
|
||||
transport.securePeers = [peerID]
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
transport.onSendPrivateMessage = { messageID in
|
||||
router.markDelivered(messageID)
|
||||
}
|
||||
|
||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "fast-ack")
|
||||
#expect(transport.sentPrivateMessages.map(\.messageID) == ["fast-ack"])
|
||||
|
||||
transport.onSendPrivateMessage = nil
|
||||
router.flushOutbox(for: peerID)
|
||||
#expect(transport.sentPrivateMessages.map(\.messageID) == ["fast-ack"])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func flushOutbox_synchronousAckDoesNotResurrectSnapshotEntry() async {
|
||||
let peerID = PeerID(str: "0000000000000018")
|
||||
let transport = MockTransport()
|
||||
transport.connectedPeers.insert(peerID)
|
||||
transport.securePeers = [peerID]
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "flush-fast-ack")
|
||||
transport.onSendPrivateMessage = { messageID in
|
||||
router.markDelivered(messageID)
|
||||
}
|
||||
|
||||
router.flushOutbox(for: peerID)
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
|
||||
transport.onSendPrivateMessage = nil
|
||||
router.flushOutbox(for: peerID)
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
@@ -392,10 +605,31 @@ struct MessageRouterTests {
|
||||
#expect(transport.sentPrivateMessages.count == 11)
|
||||
}
|
||||
|
||||
/// With an established secure session the connected fast-path stays
|
||||
/// exactly as before: trusted outright, no retained copy, no courier.
|
||||
@Test @MainActor
|
||||
func sendPrivate_connectedWithSecureSessionIsTrustedOutright() async {
|
||||
func authenticationRetry_capsActualSecureTransmissions() async {
|
||||
let peerID = PeerID(str: "00000000000000ad")
|
||||
let transport = MockTransport()
|
||||
transport.connectedPeers.insert(peerID)
|
||||
transport.securePeers = [peerID]
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
var dropped: [String] = []
|
||||
router.onMessageDropped = { messageID, _ in dropped.append(messageID) }
|
||||
|
||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "secure-retry")
|
||||
for _ in 0..<10 {
|
||||
router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
|
||||
}
|
||||
|
||||
#expect(dropped == ["secure-retry"])
|
||||
#expect(transport.sentPrivateMessages.count == 8)
|
||||
}
|
||||
|
||||
/// With an established secure session the connected fast-path sends
|
||||
/// immediately and never leaks to couriers, but retains a local encrypted
|
||||
/// outbox copy until the peer confirms receipt.
|
||||
@Test @MainActor
|
||||
func sendPrivate_connectedWithSecureSessionRetainsLocallyWithoutCourier() async {
|
||||
let peerID = PeerID(str: "00000000000000ab")
|
||||
let peerKey = Data(repeating: 0xAB, count: 32)
|
||||
let courier = PeerID(str: "00000000000000cc")
|
||||
@@ -415,7 +649,11 @@ struct MessageRouterTests {
|
||||
#expect(transport.sentPrivateMessages.map(\.messageID) == ["cs2"])
|
||||
#expect(transport.sentCourierMessages.isEmpty)
|
||||
router.flushOutbox(for: peerID)
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
#expect(transport.sentCourierMessages.isEmpty)
|
||||
router.markDelivered("cs2")
|
||||
router.flushOutbox(for: peerID)
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
@@ -527,6 +765,52 @@ struct MessageRouterTests {
|
||||
#expect(carried == ["bridge-ack"])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func bridgeDepositsScopeCollidingMessageIDsByRecipient() async {
|
||||
let firstRecipient = PeerID(str: "00000000000000b1")
|
||||
let secondRecipient = PeerID(str: "00000000000000b2")
|
||||
let firstKey = Data(repeating: 0xB1, count: 32)
|
||||
let secondKey = Data(repeating: 0xB2, count: 32)
|
||||
let recipientKeys = [
|
||||
firstRecipient: firstKey,
|
||||
secondRecipient: secondKey
|
||||
]
|
||||
let router = MessageRouter(
|
||||
transports: [MockTransport()],
|
||||
courierDirectory: CourierDirectory(
|
||||
noiseKey: { recipientKeys[$0] },
|
||||
isTrustedCourier: { _ in false }
|
||||
)
|
||||
)
|
||||
var requestedKeys: [Data] = []
|
||||
var completions: [@MainActor (Bool) -> Void] = []
|
||||
router.bridgeCourierDeposit = { _, _, recipientKey, completion in
|
||||
requestedKeys.append(recipientKey)
|
||||
completions.append(completion)
|
||||
}
|
||||
var carriedPeers: [PeerID] = []
|
||||
router.onMessageCarried = { _, peerID in carriedPeers.append(peerID) }
|
||||
|
||||
router.sendPrivate(
|
||||
"First",
|
||||
to: firstRecipient,
|
||||
recipientNickname: "First",
|
||||
messageID: "bridge-collision"
|
||||
)
|
||||
router.sendPrivate(
|
||||
"Second",
|
||||
to: secondRecipient,
|
||||
recipientNickname: "Second",
|
||||
messageID: "bridge-collision"
|
||||
)
|
||||
|
||||
#expect(completions.count == 2)
|
||||
#expect(Set(requestedKeys) == [firstKey, secondKey])
|
||||
|
||||
completions.forEach { $0(true) }
|
||||
#expect(Set(carriedPeers) == [firstRecipient, secondRecipient])
|
||||
}
|
||||
|
||||
// MARK: - Outbox persistence
|
||||
|
||||
@Test @MainActor
|
||||
@@ -582,6 +866,82 @@ struct MessageRouterTests {
|
||||
#expect(transport2.sentPrivateMessages.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func scopedDeliveryAckClearsOnlySelectedPeerWhenMessageIDsCollide() async {
|
||||
let fileURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("router-outbox-scoped-ack-\(UUID().uuidString).sealed")
|
||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||
let keychain = MockKeychain()
|
||||
let acknowledgedPeer = PeerID(str: "00000000000000d1")
|
||||
let otherPeer = PeerID(str: "00000000000000d2")
|
||||
let transport = MockTransport()
|
||||
let router = MessageRouter(
|
||||
transports: [transport],
|
||||
outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL)
|
||||
)
|
||||
router.sendPrivate("For acknowledged peer", to: acknowledgedPeer, recipientNickname: "One", messageID: "shared-id")
|
||||
router.sendPrivate("For other peer", to: otherPeer, recipientNickname: "Two", messageID: "shared-id")
|
||||
|
||||
#expect(router.markDelivered("shared-id", for: [acknowledgedPeer]))
|
||||
transport.reachablePeers.formUnion([acknowledgedPeer, otherPeer])
|
||||
router.flushOutbox(for: acknowledgedPeer)
|
||||
router.flushOutbox(for: otherPeer)
|
||||
|
||||
#expect(transport.sentPrivateMessages.map(\.peerID) == [otherPeer])
|
||||
let persisted = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load()
|
||||
#expect(persisted[acknowledgedPeer] == nil)
|
||||
#expect(persisted[otherPeer]?.map(\.messageID) == ["shared-id"])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func scopedAckWhileColdLoadIsLockedPreventsOnlyTargetPeerResurrection() async {
|
||||
let fileURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("router-locked-scoped-ack-\(UUID().uuidString).sealed")
|
||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||
let keychain = MockKeychain()
|
||||
let acknowledgedPeer = PeerID(str: "00000000000000d3")
|
||||
let otherPeer = PeerID(str: "00000000000000d4")
|
||||
let durable = MessageOutboxStore.QueuedMessage(
|
||||
content: "Queued before reboot",
|
||||
nickname: "Peer",
|
||||
messageID: "shared-locked-id",
|
||||
timestamp: Date()
|
||||
)
|
||||
MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([
|
||||
acknowledgedPeer: [durable],
|
||||
otherPeer: [durable]
|
||||
])
|
||||
|
||||
var protectedDataUnavailable = true
|
||||
let restoredStore = MessageOutboxStore(
|
||||
keychain: keychain,
|
||||
fileURL: fileURL,
|
||||
readData: { url in
|
||||
if protectedDataUnavailable {
|
||||
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError)
|
||||
}
|
||||
return try Data(contentsOf: url)
|
||||
}
|
||||
)
|
||||
let transport = MockTransport()
|
||||
transport.reachablePeers.formUnion([acknowledgedPeer, otherPeer])
|
||||
let router = MessageRouter(transports: [transport], outboxStore: restoredStore)
|
||||
|
||||
router.markDelivered(
|
||||
"shared-locked-id",
|
||||
from: [acknowledgedPeer]
|
||||
)
|
||||
protectedDataUnavailable = false
|
||||
restoredStore.retryDeferredLoad()
|
||||
await Task.yield()
|
||||
await Task.yield()
|
||||
|
||||
#expect(transport.sentPrivateMessages.map(\.peerID) == [otherPeer])
|
||||
let persisted = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load()
|
||||
#expect(persisted[acknowledgedPeer] == nil)
|
||||
#expect(persisted[otherPeer]?.map(\.messageID) == ["shared-locked-id"])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func protectedDataRecoveryMergesDurableAndLockedWakeMessagesIntoRouter() async {
|
||||
let fileURL = FileManager.default.temporaryDirectory
|
||||
@@ -780,12 +1140,14 @@ struct MessageRouterTests {
|
||||
protectedDataUnavailable = false
|
||||
restoredStore.retryDeferredLoad() // captures unseen durable + known wake
|
||||
|
||||
// Secure direct flush removes the wake message before recovery's
|
||||
// MainActor merge. It must remain removed, while the unseen durable
|
||||
// message still arrives through the pending recovery claim.
|
||||
// A secure direct retry followed by its delivery ack removes the wake
|
||||
// message before recovery's MainActor merge. It must remain removed,
|
||||
// while the unseen durable message still arrives through the pending
|
||||
// recovery claim.
|
||||
transport.connectedPeers.insert(peerID)
|
||||
transport.securePeers = [peerID]
|
||||
router.flushOutbox(for: peerID)
|
||||
router.markDelivered("recovery-gap-known")
|
||||
await Task.yield()
|
||||
await Task.yield()
|
||||
|
||||
@@ -856,7 +1218,8 @@ struct MessageRouterTests {
|
||||
restoredStore.retryDeferredLoad() // persists D+W and queues recovery
|
||||
transport.connectedPeers.insert(peerID)
|
||||
transport.securePeers = [peerID]
|
||||
router.flushOutbox(for: peerID) // removes W before queued callback
|
||||
router.flushOutbox(for: peerID)
|
||||
router.markDelivered("recovery-write-failure-known") // removes W before queued callback
|
||||
|
||||
// The gap save may remove W, but it must leave unseen D durable until
|
||||
// MessageRouter receives the pending recovery callback.
|
||||
|
||||
@@ -267,6 +267,312 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
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 &&
|
||||
context.manager.debugPendingMessageQueueCount == 0
|
||||
}
|
||||
XCTAssertTrue(flushed)
|
||||
}
|
||||
|
||||
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 drained = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: healthyURL)?.sentStrings.count == 2 &&
|
||||
context.manager.debugPendingMessageQueueCount == 0
|
||||
}
|
||||
XCTAssertTrue(drained)
|
||||
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 {
|
||||
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch(
|
||||
[primary, legacy],
|
||||
to: [healthyURL, deadURL]
|
||||
))
|
||||
let sent = await waitUntil {
|
||||
context.manager.debugPendingMessageQueueCount == 0
|
||||
}
|
||||
XCTAssertTrue(sent)
|
||||
}
|
||||
}
|
||||
|
||||
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_privateEnvelopeBatchRemainsPendingUntilBothWritesSucceed() 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()
|
||||
let committed = await waitUntil {
|
||||
context.manager.debugPendingMessageQueueCount == 0
|
||||
}
|
||||
XCTAssertTrue(committed)
|
||||
}
|
||||
|
||||
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 replayed = await waitUntil {
|
||||
let connections = context.sessionFactory.connectionsByURL[relayURL] ?? []
|
||||
guard connections.count == 2, let replacement = connections.last else {
|
||||
return false
|
||||
}
|
||||
return replacement.sentStrings.count == 2 &&
|
||||
context.manager.debugPendingMessageQueueCount == 0
|
||||
}
|
||||
XCTAssertTrue(replayed)
|
||||
|
||||
// A late success callback from the canceled socket must neither send
|
||||
// the legacy half on that stale socket nor disturb the completed pair.
|
||||
firstConnection.flushDeferredSendCompletions()
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
XCTAssertEqual(firstConnection.sentStrings.count, 1)
|
||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 0)
|
||||
}
|
||||
|
||||
private func assertPrivateEnvelopeBatchWriteFailure(
|
||||
failureSequence: [Error?],
|
||||
expectedFirstConnectionWrites: Int
|
||||
) async throws {
|
||||
let relayURL = "wss://private-batch-write-failure.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
||||
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
||||
|
||||
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch([primary, legacy], to: [relayURL]))
|
||||
let firstConnection = try XCTUnwrap(context.sessionFactory.latestConnection(for: relayURL))
|
||||
firstConnection.sendErrorSequence = failureSequence
|
||||
|
||||
let stayedQueued = await waitUntil {
|
||||
firstConnection.sentStrings.count == expectedFirstConnectionWrites &&
|
||||
context.manager.debugPendingMessageQueueEventIDsByBatch.contains([primary.id, legacy.id])
|
||||
}
|
||||
XCTAssertTrue(stayedQueued)
|
||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
|
||||
|
||||
// The failed socket is disconnected with bounded backoff. Its queue
|
||||
// item is reused—not re-admitted—and the replacement retries both
|
||||
// copies before the pair is finally removed.
|
||||
XCTAssertFalse(context.scheduler.scheduled.isEmpty)
|
||||
context.scheduler.runNext()
|
||||
let retried = await waitUntil {
|
||||
guard let replacement = context.sessionFactory.latestConnection(for: relayURL),
|
||||
replacement !== firstConnection else { return false }
|
||||
return replacement.sentStrings.count == 2 &&
|
||||
context.manager.debugPendingMessageQueueCount == 0
|
||||
}
|
||||
XCTAssertTrue(retried)
|
||||
}
|
||||
|
||||
func test_sendEvent_waitsForTorReadinessBeforeSending() async throws {
|
||||
let relayURL = "wss://tor-ready.example"
|
||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
@@ -548,6 +854,52 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
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 {
|
||||
let relayURL = "wss://tor-subscribe-coalesce.example"
|
||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
@@ -893,11 +1245,11 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
XCTAssertTrue(secondDelivered)
|
||||
}
|
||||
|
||||
func test_okMessages_clearPendingGiftWrapIDs() async throws {
|
||||
func test_okMessages_clearPendingPrivateEnvelopeIDs() async throws {
|
||||
let relayURL = "wss://ok.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
let successID = "gift-wrap-success"
|
||||
let failureID = "gift-wrap-failure"
|
||||
let successID = "private-envelope-success"
|
||||
let failureID = "private-envelope-failure"
|
||||
|
||||
context.manager.ensureConnections(to: [relayURL])
|
||||
let connected = await waitUntil {
|
||||
@@ -906,17 +1258,17 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
|
||||
NostrRelayManager.registerPendingGiftWrap(id: successID)
|
||||
NostrRelayManager.registerPendingPrivateEnvelope(id: successID)
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: successID, success: true, reason: "ok")
|
||||
let successCleared = await waitUntil {
|
||||
!NostrRelayManager.pendingGiftWrapIDs.contains(successID)
|
||||
!NostrRelayManager.pendingPrivateEnvelopeIDs.contains(successID)
|
||||
}
|
||||
XCTAssertTrue(successCleared)
|
||||
|
||||
NostrRelayManager.registerPendingGiftWrap(id: failureID)
|
||||
NostrRelayManager.registerPendingPrivateEnvelope(id: failureID)
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: failureID, success: false, reason: "rejected")
|
||||
let failureCleared = await waitUntil {
|
||||
!NostrRelayManager.pendingGiftWrapIDs.contains(failureID)
|
||||
!NostrRelayManager.pendingPrivateEnvelopeIDs.contains(failureID)
|
||||
}
|
||||
XCTAssertTrue(failureCleared)
|
||||
}
|
||||
@@ -1646,12 +1998,15 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
return filter
|
||||
}
|
||||
|
||||
private func makeSignedEvent(content: String) throws -> NostrEvent {
|
||||
private func makeSignedEvent(
|
||||
content: String,
|
||||
kind: NostrProtocol.EventKind = .textNote
|
||||
) throws -> NostrEvent {
|
||||
let identity = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .textNote,
|
||||
kind: kind,
|
||||
tags: [],
|
||||
content: content
|
||||
)
|
||||
@@ -1791,6 +2146,7 @@ private final class MockRelaySessionFactory: NostrRelaySessionProtocol {
|
||||
private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
||||
private let pingError: Error?
|
||||
var sendError: Error?
|
||||
var sendErrorSequence: [Error?] = []
|
||||
private var receiveHandler: ((Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
|
||||
private(set) var resumeCallCount = 0
|
||||
private(set) var cancelCallCount = 0
|
||||
@@ -1820,14 +2176,15 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
||||
}
|
||||
|
||||
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) {
|
||||
sentMessages.append(message)
|
||||
let error = sendErrorSequence.isEmpty ? sendError : sendErrorSequence.removeFirst()
|
||||
if deferSendCompletions {
|
||||
deferredSendCompletions.append(completionHandler)
|
||||
deferredSendCompletions.append((error, completionHandler))
|
||||
} else {
|
||||
completionHandler(sendError)
|
||||
completionHandler(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1835,7 +2192,7 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
||||
let pending = deferredSendCompletions
|
||||
deferredSendCompletions = []
|
||||
pending.forEach {
|
||||
$0(sendError)
|
||||
$0.completion($0.error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ struct NostrTransportTests {
|
||||
#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
|
||||
func sendPrivateMessageResolvesShortPeerID() async throws {
|
||||
let keychain = MockKeychain()
|
||||
@@ -153,8 +153,8 @@ struct NostrTransportTests {
|
||||
favoriteStatusForNoiseKey: { _ in nil },
|
||||
favoriteStatusForPeerID: { $0 == shortPeerID ? relationship : nil },
|
||||
currentIdentity: { sender },
|
||||
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
||||
sendEvent: probe.record(event:),
|
||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
}
|
||||
@@ -164,8 +164,12 @@ struct NostrTransportTests {
|
||||
|
||||
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(probe.sentEvents.map(\.kind) == [
|
||||
NostrProtocol.EventKind.privateEnvelope.rawValue,
|
||||
NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue
|
||||
])
|
||||
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
||||
let privateMessage = try decodePrivateMessage(from: result.payload)
|
||||
|
||||
@@ -173,7 +177,332 @@ struct NostrTransportTests {
|
||||
#expect(privateMessage.messageID == "pm-1")
|
||||
#expect(privateMessage.content == "hello over nostr")
|
||||
#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("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")
|
||||
@@ -198,8 +527,8 @@ struct NostrTransportTests {
|
||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||
favoriteStatusForPeerID: { _ in nil },
|
||||
currentIdentity: { sender },
|
||||
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
||||
sendEvent: probe.record(event:),
|
||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
}
|
||||
@@ -209,7 +538,7 @@ struct NostrTransportTests {
|
||||
|
||||
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)
|
||||
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
||||
let privateMessage = try decodePrivateMessage(from: result.payload)
|
||||
@@ -239,8 +568,8 @@ struct NostrTransportTests {
|
||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||
favoriteStatusForPeerID: { _ in nil },
|
||||
currentIdentity: { sender },
|
||||
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
||||
sendEvent: probe.record(event:),
|
||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
}
|
||||
@@ -250,7 +579,7 @@ struct NostrTransportTests {
|
||||
|
||||
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)
|
||||
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
||||
|
||||
@@ -259,9 +588,9 @@ struct NostrTransportTests {
|
||||
#expect(result.packet.recipientID == fullPeerID.toShort().routingData)
|
||||
}
|
||||
|
||||
@Test("Geohash private message registers pending gift wrap")
|
||||
@Test("Geohash private message registers pending private envelope")
|
||||
@MainActor
|
||||
func sendPrivateMessageGeohashRegistersPendingGiftWrap() async throws {
|
||||
func sendPrivateMessageGeohashRegistersPendingPrivateEnvelope() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let sender = try NostrIdentity.generate()
|
||||
@@ -272,8 +601,8 @@ struct NostrTransportTests {
|
||||
idBridge: idBridge,
|
||||
dependencies: makeDependencies(
|
||||
currentIdentity: { sender },
|
||||
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
||||
sendEvent: probe.record(event:),
|
||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
}
|
||||
@@ -288,7 +617,7 @@ struct NostrTransportTests {
|
||||
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)
|
||||
let event = probe.sentEvents[0]
|
||||
let result = try decodeEmbeddedPayload(from: event, recipient: recipient)
|
||||
@@ -297,7 +626,7 @@ struct NostrTransportTests {
|
||||
#expect(privateMessage.messageID == "geo-1")
|
||||
#expect(privateMessage.content == "geo hello")
|
||||
#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")
|
||||
@@ -322,8 +651,8 @@ struct NostrTransportTests {
|
||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||
favoriteStatusForPeerID: { _ in nil },
|
||||
currentIdentity: { sender },
|
||||
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
||||
sendEvent: probe.record(event:),
|
||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
}
|
||||
@@ -338,20 +667,20 @@ struct NostrTransportTests {
|
||||
transport.sendReadReceipt(second, to: fullPeerID)
|
||||
|
||||
let readReceiptTimeout: TimeInterval = 5.0
|
||||
let sentFirst = await TestHelpers.waitUntil({ probe.sentEvents.count >= 1 }, timeout: readReceiptTimeout)
|
||||
try #require(sentFirst, "Expected first queued read receipt event")
|
||||
let sentFirst = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: readReceiptTimeout)
|
||||
try #require(sentFirst, "Expected first queued read receipt pair")
|
||||
let scheduledThrottle = await TestHelpers.waitUntil({ probe.scheduledActionCount == 1 }, timeout: readReceiptTimeout)
|
||||
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
|
||||
#expect(firstPayload.type == .readReceipt)
|
||||
#expect(String(data: firstPayload.data, encoding: .utf8) == "read-1")
|
||||
|
||||
try #require(probe.runNextScheduledAction(), "Expected queued throttle action after first read receipt")
|
||||
|
||||
let sentSecond = await TestHelpers.waitUntil({ probe.sentEvents.count >= 2 }, timeout: readReceiptTimeout)
|
||||
try #require(sentSecond, "Expected second read receipt after running throttle action")
|
||||
let secondEvent = try #require(probe.sentEvents.last, "Expected second queued read receipt event")
|
||||
let sentSecond = await TestHelpers.waitUntil({ probe.sentEvents.count == 4 }, timeout: readReceiptTimeout)
|
||||
try #require(sentSecond, "Expected second read receipt pair after running throttle action")
|
||||
let secondEvent = probe.sentEvents[2]
|
||||
let secondPayload = try decodeEmbeddedPayload(from: secondEvent, recipient: recipient).payload
|
||||
#expect(secondPayload.type == .readReceipt)
|
||||
#expect(String(data: secondPayload.data, encoding: .utf8) == "read-2")
|
||||
@@ -419,10 +748,14 @@ struct NostrTransportTests {
|
||||
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoriteRelationship? = { _ in nil },
|
||||
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoriteRelationship? = { _ in nil },
|
||||
currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil },
|
||||
registerPendingGiftWrap: @escaping @MainActor (String) -> Void = { _ in },
|
||||
sendEvent: @escaping @MainActor (NostrEvent) -> Void = { _ in },
|
||||
registerPendingPrivateEnvelope: @escaping @MainActor (String) -> Void = { _ in },
|
||||
sendPrivateEnvelopeBatch: @escaping @MainActor (
|
||||
[NostrEvent],
|
||||
@escaping @MainActor () -> Void
|
||||
) -> Bool = { _, _ in true },
|
||||
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(
|
||||
notificationCenter: notificationCenter,
|
||||
@@ -430,10 +763,11 @@ struct NostrTransportTests {
|
||||
favoriteStatusForNoiseKey: favoriteStatusForNoiseKey,
|
||||
favoriteStatusForPeerID: favoriteStatusForPeerID,
|
||||
currentIdentity: currentIdentity,
|
||||
registerPendingGiftWrap: registerPendingGiftWrap,
|
||||
sendEvent: sendEvent,
|
||||
registerPendingPrivateEnvelope: registerPendingPrivateEnvelope,
|
||||
sendPrivateEnvelopeBatch: sendPrivateEnvelopeBatch,
|
||||
scheduleAfter: scheduleAfter,
|
||||
relayConnectivity: relayConnectivity
|
||||
relayConnectivity: relayConnectivity,
|
||||
envelopeRetryQueue: envelopeRetryQueue
|
||||
)
|
||||
}
|
||||
|
||||
@@ -457,8 +791,8 @@ struct NostrTransportTests {
|
||||
from event: NostrEvent,
|
||||
recipient: NostrIdentity
|
||||
) throws -> (packet: BitchatPacket, payload: NoisePayload, senderPubkey: String) {
|
||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: event,
|
||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: event,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
guard content.hasPrefix("bitchat1:") else {
|
||||
@@ -488,6 +822,17 @@ private enum NostrTransportTestError: Error {
|
||||
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? {
|
||||
var candidate = string
|
||||
let padding = (4 - (candidate.count % 4)) % 4
|
||||
@@ -503,7 +848,7 @@ private func base64URLDecode(_ string: String) -> Data? {
|
||||
private final class NostrTransportProbe: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var sentEventsStorage: [NostrEvent] = []
|
||||
private var pendingGiftWrapIDsStorage: [String] = []
|
||||
private var pendingPrivateEnvelopeIDsStorage: [String] = []
|
||||
private var scheduledActionsStorage: [(@Sendable () -> Void)] = []
|
||||
|
||||
var sentEvents: [NostrEvent] {
|
||||
@@ -512,10 +857,10 @@ private final class NostrTransportProbe: @unchecked Sendable {
|
||||
return sentEventsStorage
|
||||
}
|
||||
|
||||
var pendingGiftWrapIDs: [String] {
|
||||
var pendingPrivateEnvelopeIDs: [String] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return pendingGiftWrapIDsStorage
|
||||
return pendingPrivateEnvelopeIDsStorage
|
||||
}
|
||||
|
||||
var scheduledActionCount: Int {
|
||||
@@ -524,15 +869,16 @@ private final class NostrTransportProbe: @unchecked Sendable {
|
||||
return scheduledActionsStorage.count
|
||||
}
|
||||
|
||||
func record(event: NostrEvent) {
|
||||
func record(batch: [NostrEvent]) -> Bool {
|
||||
lock.lock()
|
||||
sentEventsStorage.append(event)
|
||||
sentEventsStorage.append(contentsOf: batch)
|
||||
lock.unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
func recordPendingGiftWrap(id: String) {
|
||||
func recordPendingPrivateEnvelope(id: String) {
|
||||
lock.lock()
|
||||
pendingGiftWrapIDsStorage.append(id)
|
||||
pendingPrivateEnvelopeIDsStorage.append(id)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
|
||||
@@ -54,9 +54,9 @@ Public archives contain content already intended for public mesh/board distribut
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user