mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 11:05:19 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce7532c02d | ||
|
|
8b8ad2893e | ||
|
|
79b921aeb9 |
+2
-2
@@ -73,7 +73,7 @@ Private group members receive the group's name, roster, key epoch, and encrypted
|
|||||||
|
|
||||||
Internet-backed features are optional. When enabled or used:
|
Internet-backed features are optional. When enabled or used:
|
||||||
|
|
||||||
- Private fallback messages use 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.
|
- Private fallback messages use encrypted NIP-17 gift wraps. Relays can observe event and network metadata but not the message plaintext.
|
||||||
- Public location-channel messages, notes, notices, and presence include a geohash tag, event kind, timestamp, and a public key. A geohash reveals an approximate area; finer precision reveals a smaller area.
|
- Public location-channel messages, notes, notices, and presence include a geohash tag, event kind, timestamp, and a public key. A geohash reveals an approximate area; finer precision reveals a smaller area.
|
||||||
- The optional mesh bridge publishes bridge-enabled public mesh messages and presence to a neighborhood rendezvous cell. Those messages are public to participants and relays for that cell. A per-message “nearby only” choice prevents that message from crossing the bridge.
|
- The optional mesh bridge publishes bridge-enabled public mesh messages and presence to a neighborhood rendezvous cell. Those messages are public to participants and relays for that cell. A per-message “nearby only” choice prevents that message from crossing the bridge.
|
||||||
- Bridge courier drops contain opaque end-to-end encrypted envelopes and a rotating recipient tag. Relays still observe timing and network metadata.
|
- Bridge courier drops contain opaque end-to-end encrypted envelopes and a rotating recipient tag. Relays still observe timing and network metadata.
|
||||||
@@ -103,7 +103,7 @@ Private and public features use different protections:
|
|||||||
|
|
||||||
- Mesh private sessions use Noise XX with X25519, ChaCha20-Poly1305, and SHA-256.
|
- Mesh private sessions use Noise XX with X25519, ChaCha20-Poly1305, and SHA-256.
|
||||||
- Private group messages use ChaCha20-Poly1305; group state and relevant mesh packets use Ed25519 signatures.
|
- Private group messages use ChaCha20-Poly1305; group state and relevant mesh packets use Ed25519 signatures.
|
||||||
- Nostr events use secp256k1 Schnorr signatures. 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.
|
- Nostr events use secp256k1 Schnorr signatures. NIP-44 v2 private payloads use secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305.
|
||||||
- The persistent private-message outbox uses ChaCha20-Poly1305 with a key held in the keychain. Some other protected local identity state uses AES-GCM.
|
- The persistent private-message outbox uses ChaCha20-Poly1305 with a key held in the keychain. Some other protected local identity state uses AES-GCM.
|
||||||
- Public mesh, bridge, geohash, and board content is signed or authenticated as appropriate but is intentionally not confidential.
|
- Public mesh, bridge, geohash, and board content is signed or authenticated as appropriate but is intentionally not confidential.
|
||||||
|
|
||||||
|
|||||||
+1
-5
@@ -63,11 +63,7 @@ let package = Package(
|
|||||||
// Only the vector fixture: declaring the whole "Noise"
|
// Only the vector fixture: declaring the whole "Noise"
|
||||||
// directory would claim its .swift test files as resources
|
// directory would claim its .swift test files as resources
|
||||||
// and silently drop them from compilation.
|
// and silently drop them from compilation.
|
||||||
.process("Noise/NoiseTestVectors.json"),
|
.process("Noise/NoiseTestVectors.json")
|
||||||
// Frozen output produced by the released 733098bb private-DM
|
|
||||||
// implementation; proves receive compatibility independently
|
|
||||||
// of the refactored legacy generator.
|
|
||||||
.process("Nostr/Fixtures")
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file
|
|||||||
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
|
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
|
||||||
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
|
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
|
||||||
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
|
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
|
||||||
- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, BitChat private envelopes for Nostr fallback
|
- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, NIP-17 for Nostr
|
||||||
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
|
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
|
||||||
- **Universal App**: Native support for iOS and macOS
|
- **Universal App**: Native support for iOS and macOS
|
||||||
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
||||||
@@ -44,50 +44,9 @@ BitChat uses a **hybrid messaging architecture** with two complementary transpor
|
|||||||
- **Global Reach**: Connect with users worldwide via internet relays
|
- **Global Reach**: Connect with users worldwide via internet relays
|
||||||
- **Location Channels**: Geographic chat rooms using geohash coordinates
|
- **Location Channels**: Geographic chat rooms using geohash coordinates
|
||||||
- **290+ Relay Network**: Distributed across the globe for reliability
|
- **290+ Relay Network**: Distributed across the globe for reliability
|
||||||
- **BitChat Private Envelopes**: App-specific encrypted private messages over Nostr relays
|
- **NIP-17 Encryption**: Gift-wrapped private messages for internet privacy
|
||||||
- **Ephemeral Keys**: Fresh cryptographic identity per geohash area
|
- **Ephemeral Keys**: Fresh cryptographic identity per geohash area
|
||||||
|
|
||||||
BitChat's private-envelope format is proprietary and is **not** NIP-17,
|
|
||||||
NIP-44, or NIP-59 compatible. It uses Nostr as a relay transport but only
|
|
||||||
interoperates with BitChat clients. New envelopes use provisional,
|
|
||||||
BitChat-specific public kind 1402 (not a formally reserved Nostr kind),
|
|
||||||
encrypted inner kinds 1403/1404, and the `bitchat-pm-v1:` content prefix.
|
|
||||||
For mixed-version delivery, clients publish both the primary kind-1402
|
|
||||||
envelope and a compatibility kind-1059 copy. There is no date-based cutoff:
|
|
||||||
kind 1059 must remain enabled until a coordinated iOS/Android release confirms
|
|
||||||
that supported older clients have migrated. Receivers subscribe to both kinds
|
|
||||||
and deduplicate the authenticated embedded BitChat payload.
|
|
||||||
|
|
||||||
Private-envelope migration compatibility:
|
|
||||||
|
|
||||||
| Sender | Receiver | Delivery path |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| New iOS | New iOS | Kind 1402 is primary; the kind-1059 twin is deduplicated |
|
|
||||||
| New iOS | Released iOS | Compatibility kind 1059 |
|
|
||||||
| New iOS | Current Android | Compatibility kind 1059 |
|
|
||||||
| Released iOS | New iOS | Kind 1059 with the released empty inner-tag shape |
|
|
||||||
| Current Android | New iOS | Kind 1059 with exactly the authenticated recipient `p` tag |
|
|
||||||
|
|
||||||
New kind-1402 envelopes require an empty inner tag list. The Android recipient
|
|
||||||
tag exception is intentionally confined to legacy kind 1059 and accepts only
|
|
||||||
the exact addressed recipient. Mailbox subscriptions cover the 24-hour
|
|
||||||
delivery window plus Android's full 48-hour timestamp randomization and 15
|
|
||||||
minutes of clock skew. Recovery uses
|
|
||||||
one independent 500-event relay filter per wire kind so either format cannot
|
|
||||||
consume the other's result budget.
|
|
||||||
|
|
||||||
The two outbound migration copies are admitted to the relay queue as one
|
|
||||||
protected batch. Queue pressure evicts ephemeral traffic first, never one copy
|
|
||||||
of a private pair; if protected capacity is exhausted, the entire new pair is
|
|
||||||
rejected as a whole. User-message rejection becomes a visible failed delivery;
|
|
||||||
acknowledgements and favorite notifications retain the exact pair in a
|
|
||||||
process-wide 256-entry bounded retry queue. A sustained outage beyond that
|
|
||||||
bound evicts the oldest whole control pair with an explicit warning, never half
|
|
||||||
a pair.
|
|
||||||
If either socket write fails, the same queued pair remains pending and both
|
|
||||||
copies are replayed on the replacement connection. A terminal relay target is
|
|
||||||
pruned after bounded retries so one dead relay cannot wedge healthy delivery.
|
|
||||||
|
|
||||||
### Channel Types
|
### Channel Types
|
||||||
|
|
||||||
#### `mesh #bluetooth`
|
#### `mesh #bluetooth`
|
||||||
@@ -121,7 +80,7 @@ Private messages use **intelligent transport selection**:
|
|||||||
2. **Nostr Fallback** (when Bluetooth unavailable)
|
2. **Nostr Fallback** (when Bluetooth unavailable)
|
||||||
|
|
||||||
- Uses recipient's Nostr public key
|
- Uses recipient's Nostr public key
|
||||||
- BitChat's app-specific private-envelope encryption
|
- NIP-17 gift-wrapping for privacy
|
||||||
- Routes through global relay network
|
- Routes through global relay network
|
||||||
|
|
||||||
3. **Smart Queuing** (when neither available)
|
3. **Smart Queuing** (when neither available)
|
||||||
|
|||||||
+4
-10
@@ -25,7 +25,7 @@ bitchat is a decentralized, peer-to-peer messaging application for secure, priva
|
|||||||
Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`:
|
Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`:
|
||||||
|
|
||||||
* **BLE mesh** — every device is simultaneously a GATT central and peripheral, relaying packets in a controlled flood. No infrastructure, pairing, or accounts.
|
* **BLE mesh** — every device is simultaneously a GATT central and peripheral, relaying packets in a controlled flood. No infrastructure, pairing, or accounts.
|
||||||
* **Nostr** — private messages to mutual favorites travel in BitChat's app-specific encrypted envelopes over public relays (over Tor where enabled), bridging separate meshes through the internet.
|
* **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.
|
||||||
|
|
||||||
The router prefers a live mesh link, falls back to Nostr, and engages the courier system when neither can deliver promptly.
|
The router prefers a live mesh link, falls back to Nostr, and engages the courier system when neither can deliver promptly.
|
||||||
|
|
||||||
@@ -78,9 +78,7 @@ Courier envelopes are sealed to the recipient's *static* key with the one-way No
|
|||||||
|
|
||||||
### 5.3 Nostr Path
|
### 5.3 Nostr Path
|
||||||
|
|
||||||
Private messages to mutual favorites 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.
|
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.
|
||||||
|
|
||||||
This format is **not NIP-17, NIP-44, or NIP-59 compatible** and interoperates only with BitChat clients. The outer `p` tag exposes the recipient's Nostr public key to relays; the stable sender identity and plaintext remain inside authenticated ciphertext. New-format seal and envelope timestamps are randomized up to 15 minutes into the past, while the actual message timestamp is encrypted. Legacy Android envelopes can carry public-layer timestamps randomized across the preceding 48 hours. The protocol does not provide forward secrecy: compromise of the recipient's static Nostr private key can expose stored envelopes.
|
|
||||||
|
|
||||||
## 6. Store and Forward
|
## 6. Store and Forward
|
||||||
|
|
||||||
@@ -107,11 +105,7 @@ Public broadcast messages are cached (1000 packets) and reconciled between peers
|
|||||||
|
|
||||||
### 6.4 Nostr Mailboxes
|
### 6.4 Nostr Mailboxes
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
The relay send queue treats those two events as one protected batch. Capacity pressure removes ephemeral events before regular traffic and never evicts only one private-envelope copy. A queue containing only protected batches rejects a new pair as a whole: user messages surface a failed delivery, while acknowledgements and favorite notifications retain the exact pair in one process-wide 256-entry bounded-backoff retry queue shared by account and short-lived geohash transports. Sustained control-payload overflow evicts the oldest whole pair with an explicit warning rather than growing memory or splitting formats. After either socket write fails, the same pair remains pending and both copies are replayed on the replacement connection. Terminal relay targets are pruned after bounded connection retries; a batch that succeeded elsewhere retires normally, while an all-target failure returns to the transport's failure/retry policy. Receive-side dedup suppresses the replay if one copy had already reached the relay.
|
|
||||||
|
|
||||||
Released iOS legacy envelopes use an empty inner tag list. Current Android legacy envelopes use exactly one inner `p` tag naming the recipient. The kind-1059 decoder accepts only those two shapes after authenticating the outer recipient and sender-signed seal; alternate recipients, duplicate tags, and extra tags are rejected. Kind 1402 remains strict and permits no inner tags.
|
|
||||||
|
|
||||||
### 6.5 Delivery Metrics
|
### 6.5 Delivery Metrics
|
||||||
|
|
||||||
@@ -133,7 +127,7 @@ Bare local counters (deposits, handovers, sprays, opens, outbox flushes and drop
|
|||||||
* **Flooding abuse** is bounded by TTL clamps, deduplication, per-depositor quotas, connect-rate limits, and announce-rate limiting.
|
* **Flooding abuse** is bounded by TTL clamps, deduplication, per-depositor quotas, connect-rate limits, and announce-rate limiting.
|
||||||
* **Replay** of public broadcasts is bounded by the 6-hour acceptance window plus deduplication; private payloads are protected by Noise nonces.
|
* **Replay** of public broadcasts is bounded by the 6-hour acceptance window plus deduplication; private payloads are protected by Noise nonces.
|
||||||
* **Metadata.** BLE proximity is inherently observable; ephemeral IDs and daily-rotating courier tags limit long-term correlation. Nostr traffic can ride Tor.
|
* **Metadata.** BLE proximity is inherently observable; ephemeral IDs and daily-rotating courier tags limit long-term correlation. Nostr traffic can ride Tor.
|
||||||
* **No forward secrecy for sealed mail or Nostr envelopes** (§5.2–5.3) means compromise of a recipient's static key can expose retained ciphertext addressed to that key.
|
* **No forward secrecy for sealed mail** (§5.2) is the main cryptographic trade-off of the offline path.
|
||||||
|
|
||||||
## 9. Future Work
|
## 9. Future Work
|
||||||
|
|
||||||
|
|||||||
@@ -39,15 +39,17 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
@Published private(set) var messages: [BitchatMessage] = []
|
@Published private(set) var messages: [BitchatMessage] = []
|
||||||
@Published private(set) var isUnread: Bool = false
|
@Published private(set) var isUnread: Bool = false
|
||||||
|
|
||||||
/// Incrementally-maintained message-ID → index map for O(1) dedup and
|
/// Incrementally-maintained message-ID → logical-index map for O(1)
|
||||||
/// delivery-status lookup. Kept in sync on every mutation:
|
/// dedup and delivery-status lookup. Logical indexes are physical array
|
||||||
/// - tail append: single insert
|
/// indexes plus `indexOffset`; trimming from the head advances the offset
|
||||||
/// - out-of-order insert: suffix reindex from the insertion point
|
/// instead of rewriting every surviving dictionary entry. This matters
|
||||||
/// - trim: full rebuild — `removeFirst(k)` is already O(n), so the
|
/// after the 1337-message cap is reached, when every steady-state tail
|
||||||
/// rebuild does not change the asymptotics, and trim only happens once
|
/// append evicts one old row.
|
||||||
/// the cap (1337) is reached. Simple and correct beats the
|
///
|
||||||
/// offset-tracking alternative here.
|
/// Out-of-order inserts and middle removals still reindex only the
|
||||||
|
/// affected suffix. Full filtering resets the offset while rebuilding.
|
||||||
private var indexByMessageID: [String: Int] = [:]
|
private var indexByMessageID: [String: Int] = [:]
|
||||||
|
private var indexOffset = 0
|
||||||
|
|
||||||
fileprivate init(id: ConversationID, cap: Int) {
|
fileprivate init(id: ConversationID, cap: Int) {
|
||||||
self.id = id
|
self.id = id
|
||||||
@@ -61,7 +63,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func message(withID messageID: String) -> BitchatMessage? {
|
func message(withID messageID: String) -> BitchatMessage? {
|
||||||
guard let index = indexByMessageID[messageID] else { return nil }
|
guard let index = physicalIndex(forMessageID: messageID) else { return nil }
|
||||||
return messages[index]
|
return messages[index]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +103,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
reindex(from: index)
|
reindex(from: index)
|
||||||
} else {
|
} else {
|
||||||
messages.append(message)
|
messages.append(message)
|
||||||
indexByMessageID[message.id] = messages.count - 1
|
indexByMessageID[message.id] = indexOffset + messages.count - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
|
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
|
||||||
@@ -111,7 +113,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
/// timeline position (in-place updates like media progress reuse the
|
/// timeline position (in-place updates like media progress reuse the
|
||||||
/// original timestamp); a new message goes through ordered insertion.
|
/// original timestamp); a new message goes through ordered insertion.
|
||||||
fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome {
|
fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome {
|
||||||
if let index = indexByMessageID[message.id] {
|
if let index = physicalIndex(forMessageID: message.id) {
|
||||||
messages[index] = message
|
messages[index] = message
|
||||||
return .updated
|
return .updated
|
||||||
}
|
}
|
||||||
@@ -125,7 +127,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
/// `.read` is never downgraded to `.delivered` or `.sent`.
|
/// `.read` is never downgraded to `.delivered` or `.sent`.
|
||||||
/// Returns `true` when the status was applied.
|
/// Returns `true` when the status was applied.
|
||||||
fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||||
guard let index = indexByMessageID[messageID] else { return false }
|
guard let index = physicalIndex(forMessageID: messageID) else { return false }
|
||||||
let message = messages[index]
|
let message = messages[index]
|
||||||
guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false }
|
guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false }
|
||||||
|
|
||||||
@@ -142,7 +144,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
/// observers still need an @Published emission to re-render.
|
/// observers still need an @Published emission to re-render.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
fileprivate func republishMessage(withID messageID: String) -> Bool {
|
fileprivate func republishMessage(withID messageID: String) -> Bool {
|
||||||
guard let index = indexByMessageID[messageID] else { return false }
|
guard let index = physicalIndex(forMessageID: messageID) else { return false }
|
||||||
messages[index] = messages[index]
|
messages[index] = messages[index]
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -157,10 +159,14 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
/// Removes a single message by ID. Returns the removed message, or
|
/// Removes a single message by ID. Returns the removed message, or
|
||||||
/// `nil` when no message with that ID exists.
|
/// `nil` when no message with that ID exists.
|
||||||
fileprivate func remove(messageID: String) -> BitchatMessage? {
|
fileprivate func remove(messageID: String) -> BitchatMessage? {
|
||||||
guard let index = indexByMessageID[messageID] else { return nil }
|
guard let index = physicalIndex(forMessageID: messageID) else { return nil }
|
||||||
let removed = messages.remove(at: index)
|
let removed = messages.remove(at: index)
|
||||||
indexByMessageID.removeValue(forKey: messageID)
|
indexByMessageID.removeValue(forKey: messageID)
|
||||||
reindex(from: index)
|
if index == 0 {
|
||||||
|
indexOffset += 1
|
||||||
|
} else {
|
||||||
|
reindex(from: index)
|
||||||
|
}
|
||||||
return removed
|
return removed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +183,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
for id in removedIDs {
|
for id in removedIDs {
|
||||||
indexByMessageID.removeValue(forKey: id)
|
indexByMessageID.removeValue(forKey: id)
|
||||||
}
|
}
|
||||||
|
indexOffset = 0
|
||||||
reindex(from: 0)
|
reindex(from: 0)
|
||||||
return removedIDs
|
return removedIDs
|
||||||
}
|
}
|
||||||
@@ -184,6 +191,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
fileprivate func clearMessages() {
|
fileprivate func clearMessages() {
|
||||||
messages.removeAll()
|
messages.removeAll()
|
||||||
indexByMessageID.removeAll()
|
indexByMessageID.removeAll()
|
||||||
|
indexOffset = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Diagnostics
|
// MARK: Diagnostics
|
||||||
@@ -205,9 +213,10 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
let message = messages[position]
|
let message = messages[position]
|
||||||
// Count equality + every message resolving to its own position
|
// Count equality + every message resolving to its own position
|
||||||
// proves the index is exactly the inverse map (no stale extras).
|
// proves the index is exactly the inverse map (no stale extras).
|
||||||
if let index = indexByMessageID[message.id] {
|
if let logicalIndex = indexByMessageID[message.id] {
|
||||||
if index != position {
|
let expectedIndex = indexOffset + position
|
||||||
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(index)")
|
if logicalIndex != expectedIndex {
|
||||||
|
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(logicalIndex - indexOffset)")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
violations.append("\(label): message \(message.id.prefix(8))… at \(position) missing from index")
|
violations.append("\(label): message \(message.id.prefix(8))… at \(position) missing from index")
|
||||||
@@ -269,10 +278,17 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
|
|
||||||
private func reindex(from start: Int) {
|
private func reindex(from start: Int) {
|
||||||
for index in start..<messages.count {
|
for index in start..<messages.count {
|
||||||
indexByMessageID[messages[index].id] = index
|
indexByMessageID[messages[index].id] = indexOffset + index
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func physicalIndex(forMessageID messageID: String) -> Int? {
|
||||||
|
guard let logicalIndex = indexByMessageID[messageID] else { return nil }
|
||||||
|
let index = logicalIndex - indexOffset
|
||||||
|
guard messages.indices.contains(index) else { return nil }
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
/// Trims oldest messages over the cap; returns the trimmed message IDs.
|
/// Trims oldest messages over the cap; returns the trimmed message IDs.
|
||||||
private func trimIfNeeded() -> [String] {
|
private func trimIfNeeded() -> [String] {
|
||||||
guard messages.count > cap else { return [] }
|
guard messages.count > cap else { return [] }
|
||||||
@@ -282,7 +298,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
indexByMessageID.removeValue(forKey: id)
|
indexByMessageID.removeValue(forKey: id)
|
||||||
}
|
}
|
||||||
messages.removeFirst(overflow)
|
messages.removeFirst(overflow)
|
||||||
reindex(from: 0)
|
indexOffset += overflow
|
||||||
return trimmedIDs
|
return trimmedIDs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -844,8 +860,8 @@ extension Conversation {
|
|||||||
/// (positions 0 and 1 swap their index entries). Requires >= 2 messages.
|
/// (positions 0 and 1 swap their index entries). Requires >= 2 messages.
|
||||||
func _testCorruptIndexEntries() {
|
func _testCorruptIndexEntries() {
|
||||||
guard messages.count >= 2 else { return }
|
guard messages.count >= 2 else { return }
|
||||||
indexByMessageID[messages[0].id] = 1
|
indexByMessageID[messages[0].id] = indexOffset + 1
|
||||||
indexByMessageID[messages[1].id] = 0
|
indexByMessageID[messages[1].id] = indexOffset
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drops a message's index entry entirely (count mismatch + missing).
|
/// Drops a message's index entry entirely (count mismatch + missing).
|
||||||
@@ -859,8 +875,8 @@ extension Conversation {
|
|||||||
func _testCorruptOrderingPreservingIndex() {
|
func _testCorruptOrderingPreservingIndex() {
|
||||||
guard messages.count >= 2 else { return }
|
guard messages.count >= 2 else { return }
|
||||||
messages.swapAt(0, messages.count - 1)
|
messages.swapAt(0, messages.count - 1)
|
||||||
indexByMessageID[messages[0].id] = 0
|
indexByMessageID[messages[0].id] = indexOffset
|
||||||
indexByMessageID[messages[messages.count - 1].id] = messages.count - 1
|
indexByMessageID[messages[messages.count - 1].id] = indexOffset + messages.count - 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -900,7 +916,7 @@ extension ConversationStore {
|
|||||||
extension Conversation {
|
extension Conversation {
|
||||||
fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) {
|
fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) {
|
||||||
messages.append(message)
|
messages.append(message)
|
||||||
indexByMessageID[message.id] = messages.count - 1
|
indexByMessageID[message.id] = indexOffset + messages.count - 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -265,10 +265,10 @@ final class PrivateConversationModel: ObservableObject {
|
|||||||
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
||||||
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
||||||
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
||||||
// Geo DMs are always routed through BitChat private envelopes over
|
// Geo DMs are always routed over Nostr (NIP-17); their nostr_ keys
|
||||||
// Nostr; their nostr_ keys never resolve to a reachable mesh peer, so
|
// never resolve to a reachable mesh peer, so resolveAvailability would
|
||||||
// resolveAvailability would report .offline. Report .nostrAvailable
|
// report .offline. Report .nostrAvailable so the header shows the
|
||||||
// so the header shows the globe instead of a misleading "offline" tag.
|
// globe instead of a misleading "offline" tag.
|
||||||
let availability = conversationPeerID.isGeoDM
|
let availability = conversationPeerID.isGeoDM
|
||||||
? .nostrAvailable
|
? .nostrAvailable
|
||||||
: resolveAvailability(for: headerPeerID, peer: peer)
|
: resolveAvailability(for: headerPeerID, peer: peer)
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import P256K
|
import P256K
|
||||||
|
|
||||||
/// Manages the secp256k1 identity used by BitChat's Nostr relay features,
|
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
|
||||||
/// including the proprietary private-envelope transport.
|
|
||||||
struct NostrIdentity: Codable {
|
struct NostrIdentity: Codable {
|
||||||
let privateKey: Data
|
let privateKey: Data
|
||||||
let publicKey: Data
|
let publicKey: Data
|
||||||
|
|||||||
+213
-423
@@ -1,3 +1,4 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import P256K
|
import P256K
|
||||||
@@ -6,31 +7,16 @@ import Security
|
|||||||
// Note: This file depends on Data extension from BinaryEncodingUtils.swift
|
// Note: This file depends on Data extension from BinaryEncodingUtils.swift
|
||||||
// Make sure BinaryEncodingUtils.swift is included in the target
|
// Make sure BinaryEncodingUtils.swift is included in the target
|
||||||
|
|
||||||
/// BitChat's private-envelope protocol transported over Nostr relays.
|
/// NIP-17 Protocol Implementation for Private Direct Messages
|
||||||
///
|
|
||||||
/// This is deliberately BitChat-specific and is not NIP-17, NIP-44, or NIP-59.
|
|
||||||
/// It uses Nostr events and secp256k1 identities, but its XChaCha20-Poly1305
|
|
||||||
/// payload layout is proprietary and interoperates only with BitChat clients.
|
|
||||||
struct NostrProtocol {
|
struct NostrProtocol {
|
||||||
|
|
||||||
/// Nostr event kinds
|
/// Nostr event kinds
|
||||||
enum EventKind: Int {
|
enum EventKind: Int {
|
||||||
case metadata = 0
|
case metadata = 0
|
||||||
case textNote = 1
|
case textNote = 1
|
||||||
// Compatibility for BitChat releases that incorrectly emitted the
|
case dm = 14 // NIP-17 DM rumor kind
|
||||||
// proprietary payload under standard NIP kinds. Kind 1059 continues
|
case seal = 13 // NIP-17 sealed event
|
||||||
// to be published and read until a coordinated cross-platform release
|
case giftWrap = 1059 // NIP-59 gift wrap
|
||||||
// explicitly removes it; all three legacy layers remain readable.
|
|
||||||
case legacyNIP59Seal = 13
|
|
||||||
case legacyNIP17DirectMessage = 14
|
|
||||||
case legacyNIP59GiftWrap = 1059
|
|
||||||
// Provisional BitChat-specific regular event kinds. These are not
|
|
||||||
// formally reserved by the Nostr kind registry. Only
|
|
||||||
// `privateEnvelope` is published; message and seal exist solely
|
|
||||||
// inside ciphertext.
|
|
||||||
case privateEnvelope = 1402
|
|
||||||
case privateSeal = 1403
|
|
||||||
case privateMessage = 1404
|
|
||||||
case ephemeralEvent = 20000
|
case ephemeralEvent = 20000
|
||||||
case geohashPresence = 20001
|
case geohashPresence = 20001
|
||||||
case deletion = 5 // NIP-09 event deletion request
|
case deletion = 5 // NIP-09 event deletion request
|
||||||
@@ -40,285 +26,144 @@ struct NostrProtocol {
|
|||||||
case courierDrop = 1401
|
case courierDrop = 1401
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prefix for BitChat private-envelope ciphertext. The suffix is
|
/// Create a NIP-17 private message
|
||||||
/// base64url(nonce24 || ciphertext || poly1305Tag).
|
static func createPrivateMessage(
|
||||||
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 BitChat private envelope for relay transport.
|
|
||||||
static func createPrivateEnvelope(
|
|
||||||
content: String,
|
content: String,
|
||||||
recipientPubkey: String,
|
recipientPubkey: String,
|
||||||
senderIdentity: NostrIdentity
|
senderIdentity: NostrIdentity
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
try createPrivateEnvelope(
|
|
||||||
content: content,
|
|
||||||
recipientPubkey: recipientPubkey,
|
|
||||||
senderIdentity: senderIdentity,
|
|
||||||
format: .bitchatV1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Events to publish for one logical private payload. The primary
|
// Creating private message
|
||||||
/// 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(
|
// 1. Create the rumor (unsigned event)
|
||||||
content: String,
|
let rumor = NostrEvent(
|
||||||
recipientPubkey: String,
|
|
||||||
senderIdentity: NostrIdentity,
|
|
||||||
format: PrivateEnvelopeWireFormat,
|
|
||||||
messageTags: [[String]] = []
|
|
||||||
) throws -> NostrEvent {
|
|
||||||
// 1. Create the unsigned inner BitChat message.
|
|
||||||
let message = NostrEvent(
|
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
kind: format.messageKind,
|
kind: .dm, // NIP-17: DM rumor kind 14
|
||||||
tags: messageTags,
|
tags: [],
|
||||||
content: content
|
content: content
|
||||||
)
|
)
|
||||||
|
|
||||||
// 2. Encrypt the message to the recipient and sign the private seal
|
// 2. Seal the rumor (encrypt to recipient) and sign it with the SENDER'S
|
||||||
// with the sender's stable Nostr identity for sender authentication.
|
// 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.
|
||||||
let senderKey = try senderIdentity.schnorrSigningKey()
|
let senderKey = try senderIdentity.schnorrSigningKey()
|
||||||
let sealedEvent = try createPrivateSeal(
|
let sealedEvent = try createSeal(
|
||||||
message: message,
|
rumor: rumor,
|
||||||
recipientPubkey: recipientPubkey,
|
recipientPubkey: recipientPubkey,
|
||||||
senderKey: senderKey,
|
senderKey: senderKey
|
||||||
format: format
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 3. Encrypt the seal under a one-time key so the public envelope does
|
// 3. Gift wrap the sealed event with a throwaway ephemeral key (the wrap
|
||||||
// not reveal the stable sender identity.
|
// layer hides the sender's identity from relays; createGiftWrap mints
|
||||||
return try createPrivateEnvelopeEvent(
|
// its own ephemeral key internally).
|
||||||
|
let giftWrap = try createGiftWrap(
|
||||||
seal: sealedEvent,
|
seal: sealedEvent,
|
||||||
recipientPubkey: recipientPubkey,
|
recipientPubkey: recipientPubkey
|
||||||
format: format
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Created gift wrap
|
||||||
|
|
||||||
|
return giftWrap
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt a BitChat private envelope. Legacy proprietary envelopes that
|
/// Decrypt a received NIP-17 message
|
||||||
/// older BitChat releases placed under kinds 1059/13/14 are accepted only
|
/// Returns the content, sender pubkey, and the actual message timestamp (not the randomized gift wrap timestamp)
|
||||||
/// through the format-isolated receive path.
|
static func decryptPrivateMessage(
|
||||||
static func decryptPrivateEnvelope(
|
giftWrap: NostrEvent,
|
||||||
envelope: NostrEvent,
|
|
||||||
recipientIdentity: NostrIdentity
|
recipientIdentity: NostrIdentity
|
||||||
) throws -> (content: String, senderPubkey: String, timestamp: Int) {
|
) throws -> (content: String, senderPubkey: String, timestamp: Int) {
|
||||||
let layers = try decodePrivateEnvelopeLayers(
|
|
||||||
envelope: envelope,
|
// Starting decryption
|
||||||
recipientIdentity: recipientIdentity
|
|
||||||
)
|
// 1. Unwrap the gift wrap
|
||||||
return (
|
let seal: NostrEvent
|
||||||
content: layers.message.content,
|
do {
|
||||||
senderPubkey: layers.seal.pubkey,
|
seal = try unwrapGiftWrap(
|
||||||
timestamp: layers.message.created_at
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
static func createPrivateEnvelopeWithInvalidSealSignatureForTesting(
|
static func createPrivateMessageWithInvalidSealSignatureForTesting(
|
||||||
content: String,
|
content: String,
|
||||||
recipientPubkey: String,
|
recipientPubkey: String,
|
||||||
senderIdentity: NostrIdentity
|
senderIdentity: NostrIdentity
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
let format = PrivateEnvelopeWireFormat.bitchatV1
|
let rumor = NostrEvent(
|
||||||
let message = NostrEvent(
|
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
kind: format.messageKind,
|
kind: .dm,
|
||||||
tags: [],
|
tags: [],
|
||||||
content: content
|
content: content
|
||||||
)
|
)
|
||||||
var seal = try createPrivateSeal(
|
var seal = try createSeal(
|
||||||
message: message,
|
rumor: rumor,
|
||||||
recipientPubkey: recipientPubkey,
|
recipientPubkey: recipientPubkey,
|
||||||
senderKey: senderIdentity.schnorrSigningKey(),
|
senderKey: senderIdentity.schnorrSigningKey()
|
||||||
format: format
|
|
||||||
)
|
)
|
||||||
seal.sig = String(repeating: "0", count: 128)
|
seal.sig = String(repeating: "0", count: 128)
|
||||||
return try createPrivateEnvelopeEvent(
|
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
|
||||||
seal: seal,
|
|
||||||
recipientPubkey: recipientPubkey,
|
|
||||||
format: format
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func createPrivateEnvelopeWithMismatchedSealMessagePubkeyForTesting(
|
static func createPrivateMessageWithMismatchedSealRumorPubkeyForTesting(
|
||||||
content: String,
|
content: String,
|
||||||
recipientPubkey: String,
|
recipientPubkey: String,
|
||||||
messageIdentity: NostrIdentity,
|
rumorIdentity: NostrIdentity,
|
||||||
sealSignerIdentity: NostrIdentity
|
sealSignerIdentity: NostrIdentity
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
let format = PrivateEnvelopeWireFormat.bitchatV1
|
let rumor = NostrEvent(
|
||||||
let message = NostrEvent(
|
pubkey: rumorIdentity.publicKeyHex,
|
||||||
pubkey: messageIdentity.publicKeyHex,
|
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
kind: format.messageKind,
|
kind: .dm,
|
||||||
tags: [],
|
tags: [],
|
||||||
content: content
|
content: content
|
||||||
)
|
)
|
||||||
let seal = try createPrivateSeal(
|
let seal = try createSeal(
|
||||||
message: message,
|
rumor: rumor,
|
||||||
recipientPubkey: recipientPubkey,
|
recipientPubkey: recipientPubkey,
|
||||||
senderKey: sealSignerIdentity.schnorrSigningKey(),
|
senderKey: sealSignerIdentity.schnorrSigningKey()
|
||||||
format: format
|
|
||||||
)
|
)
|
||||||
return try createPrivateEnvelopeEvent(
|
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
|
||||||
seal: seal,
|
|
||||||
recipientPubkey: recipientPubkey,
|
|
||||||
format: format
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func createPrivateEnvelopeWithInnerTagsForTesting(
|
|
||||||
content: String,
|
|
||||||
recipientPubkey: String,
|
|
||||||
senderIdentity: NostrIdentity,
|
|
||||||
innerMessageTags: [[String]]
|
|
||||||
) throws -> NostrEvent {
|
|
||||||
try createPrivateEnvelope(
|
|
||||||
content: content,
|
|
||||||
recipientPubkey: recipientPubkey,
|
|
||||||
senderIdentity: senderIdentity,
|
|
||||||
format: .bitchatV1,
|
|
||||||
messageTags: innerMessageTags
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func createLegacyPrivateEnvelopeForTesting(
|
|
||||||
content: String,
|
|
||||||
recipientPubkey: String,
|
|
||||||
senderIdentity: NostrIdentity,
|
|
||||||
innerMessageTags: [[String]] = []
|
|
||||||
) throws -> NostrEvent {
|
|
||||||
// Current Android legacy envelopes use exactly one recipient `p` tag
|
|
||||||
// on the unsigned inner kind-14 event; released iOS envelopes use no
|
|
||||||
// inner tags. Tests pass the Android shape explicitly so this helper
|
|
||||||
// cannot silently make the production encoder depend on that quirk.
|
|
||||||
try createPrivateEnvelope(
|
|
||||||
content: content,
|
|
||||||
recipientPubkey: recipientPubkey,
|
|
||||||
senderIdentity: senderIdentity,
|
|
||||||
format: .legacyMislabelledV2,
|
|
||||||
messageTags: innerMessageTags
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func decodePrivateEnvelopeLayersForTesting(
|
|
||||||
envelope: NostrEvent,
|
|
||||||
recipientIdentity: NostrIdentity
|
|
||||||
) throws -> (seal: NostrEvent, message: NostrEvent) {
|
|
||||||
try decodePrivateEnvelopeLayers(
|
|
||||||
envelope: envelope,
|
|
||||||
recipientIdentity: recipientIdentity
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func decodePrivateEnvelopeEventJSONForTesting(_ json: String) throws -> NostrEvent {
|
|
||||||
try decodePrivateEnvelopeEventJSON(json)
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -555,207 +400,151 @@ struct NostrProtocol {
|
|||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
private static func createPrivateSeal(
|
private static func createSeal(
|
||||||
message: NostrEvent,
|
rumor: NostrEvent,
|
||||||
recipientPubkey: String,
|
recipientPubkey: String,
|
||||||
senderKey: P256K.Schnorr.PrivateKey,
|
senderKey: P256K.Schnorr.PrivateKey
|
||||||
format: PrivateEnvelopeWireFormat
|
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
|
|
||||||
|
let rumorJSON = try rumor.jsonString()
|
||||||
let encrypted = try encrypt(
|
let encrypted = try encrypt(
|
||||||
plaintext: message.jsonString(),
|
plaintext: rumorJSON,
|
||||||
recipientPubkey: recipientPubkey,
|
recipientPubkey: recipientPubkey,
|
||||||
senderKey: senderKey,
|
senderKey: senderKey
|
||||||
format: format,
|
|
||||||
maximumPlaintextBytes: maximumPrivateEnvelopePlaintextBytes
|
|
||||||
)
|
)
|
||||||
|
|
||||||
let seal = NostrEvent(
|
let seal = NostrEvent(
|
||||||
pubkey: Data(senderKey.xonly.bytes).hexEncodedString(),
|
pubkey: Data(senderKey.xonly.bytes).hexEncodedString(),
|
||||||
createdAt: randomizedPastTimestamp(),
|
createdAt: randomizedTimestamp(),
|
||||||
kind: format.sealKind,
|
kind: .seal,
|
||||||
tags: [],
|
tags: [],
|
||||||
content: encrypted
|
content: encrypted
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Sign the seal with the sender's Schnorr private key
|
||||||
return try seal.sign(with: senderKey)
|
return try seal.sign(with: senderKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func createPrivateEnvelopeEvent(
|
private static func createGiftWrap(
|
||||||
seal: NostrEvent,
|
seal: NostrEvent,
|
||||||
recipientPubkey: String,
|
recipientPubkey: String
|
||||||
format: PrivateEnvelopeWireFormat
|
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
// A fresh signing/encryption key for every public envelope keeps the
|
|
||||||
// stable sender identity inside ciphertext.
|
let sealJSON = try seal.jsonString()
|
||||||
let envelopeKey = try P256K.Schnorr.PrivateKey()
|
|
||||||
|
// Create new ephemeral key for gift wrap
|
||||||
|
let wrapKey = try P256K.Schnorr.PrivateKey()
|
||||||
|
// Creating gift wrap with ephemeral key
|
||||||
|
|
||||||
|
// Encrypt the seal with the new ephemeral key (not the seal's key)
|
||||||
let encrypted = try encrypt(
|
let encrypted = try encrypt(
|
||||||
plaintext: seal.jsonString(),
|
plaintext: sealJSON,
|
||||||
recipientPubkey: recipientPubkey,
|
recipientPubkey: recipientPubkey,
|
||||||
senderKey: envelopeKey,
|
senderKey: wrapKey // Use the gift wrap ephemeral key
|
||||||
format: format,
|
|
||||||
maximumPlaintextBytes: maximumPrivateEnvelopeSealPlaintextBytes
|
|
||||||
)
|
)
|
||||||
|
|
||||||
let envelope = NostrEvent(
|
let giftWrap = NostrEvent(
|
||||||
pubkey: Data(envelopeKey.xonly.bytes).hexEncodedString(),
|
pubkey: Data(wrapKey.xonly.bytes).hexEncodedString(),
|
||||||
createdAt: randomizedPastTimestamp(),
|
createdAt: randomizedTimestamp(),
|
||||||
kind: format.envelopeKind,
|
kind: .giftWrap,
|
||||||
tags: [["p", recipientPubkey]],
|
tags: [["p", recipientPubkey]], // Tag recipient
|
||||||
content: encrypted
|
content: encrypted
|
||||||
)
|
)
|
||||||
return try envelope.sign(with: envelopeKey)
|
|
||||||
|
// Sign the gift wrap with the wrap Schnorr private key
|
||||||
|
return try giftWrap.sign(with: wrapKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func decodePrivateEnvelopeLayers(
|
private static func unwrapGiftWrap(
|
||||||
envelope: NostrEvent,
|
giftWrap: NostrEvent,
|
||||||
recipientIdentity: NostrIdentity
|
recipientKey: P256K.Schnorr.PrivateKey
|
||||||
) throws -> (seal: NostrEvent, message: NostrEvent) {
|
) throws -> NostrEvent {
|
||||||
guard envelope.content.utf8.count <= maximumPrivateEnvelopeCiphertextBytes else {
|
|
||||||
throw NostrError.invalidCiphertext
|
// Unwrapping gift wrap
|
||||||
}
|
|
||||||
guard let format = PrivateEnvelopeWireFormat(outerKind: envelope.kind),
|
let decrypted = try decrypt(
|
||||||
envelope.tags == [["p", recipientIdentity.publicKeyHex]],
|
ciphertext: giftWrap.content,
|
||||||
envelope.isValidSignature() else {
|
senderPubkey: giftWrap.pubkey,
|
||||||
|
recipientKey: recipientKey
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let data = decrypted.data(using: .utf8),
|
||||||
|
let sealDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
throw NostrError.invalidEvent
|
throw NostrError.invalidEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
let recipientKey = try recipientIdentity.schnorrSigningKey()
|
let seal = try NostrEvent(from: sealDict)
|
||||||
let sealJSON = try decrypt(
|
// Unwrapped seal
|
||||||
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(
|
return seal
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func openSeal(
|
||||||
|
seal: NostrEvent,
|
||||||
|
recipientKey: P256K.Schnorr.PrivateKey
|
||||||
|
) throws -> NostrEvent {
|
||||||
|
|
||||||
|
let decrypted = try decrypt(
|
||||||
ciphertext: seal.content,
|
ciphertext: seal.content,
|
||||||
senderPubkey: seal.pubkey,
|
senderPubkey: seal.pubkey,
|
||||||
recipientKey: recipientKey,
|
recipientKey: recipientKey
|
||||||
format: format,
|
|
||||||
maximumPlaintextBytes: maximumPrivateEnvelopePlaintextBytes
|
|
||||||
)
|
|
||||||
let message = try decodePrivateEnvelopeEventJSON(
|
|
||||||
messageJSON,
|
|
||||||
maximumBytes: maximumPrivateEnvelopePlaintextBytes
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// The inner message is intentionally unsigned; sender authentication
|
guard let data = decrypted.data(using: .utf8),
|
||||||
// comes from the seal. Bind its claimed sender and custom kind to that
|
let rumorDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
// authenticated layer before exposing content.
|
|
||||||
guard message.kind == format.messageKind.rawValue,
|
|
||||||
validInnerMessageTags(
|
|
||||||
message.tags,
|
|
||||||
format: format,
|
|
||||||
recipientPubkey: recipientIdentity.publicKeyHex
|
|
||||||
),
|
|
||||||
message.sig == nil,
|
|
||||||
seal.pubkey == message.pubkey else {
|
|
||||||
throw NostrError.invalidEvent
|
throw NostrError.invalidEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
return (seal, message)
|
return try NostrEvent(from: rumorDict)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Released iOS legacy envelopes used no inner tags, while current
|
// MARK: - Encryption (NIP-44 v2)
|
||||||
/// Android legacy envelopes use exactly the authenticated recipient tag.
|
|
||||||
/// Accept only those two historical shapes for kind 1059. The new kind
|
|
||||||
/// 1402 format remains strict and rejects every inner tag.
|
|
||||||
private static func validInnerMessageTags(
|
|
||||||
_ tags: [[String]],
|
|
||||||
format: PrivateEnvelopeWireFormat,
|
|
||||||
recipientPubkey: String
|
|
||||||
) -> Bool {
|
|
||||||
switch format {
|
|
||||||
case .bitchatV1:
|
|
||||||
return tags.isEmpty
|
|
||||||
case .legacyMislabelledV2:
|
|
||||||
return tags.isEmpty || tags == [["p", recipientPubkey]]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func decodePrivateEnvelopeEventJSON(
|
|
||||||
_ json: String,
|
|
||||||
maximumBytes: Int = maximumPrivateEnvelopePlaintextBytes
|
|
||||||
) throws -> NostrEvent {
|
|
||||||
// Check UTF-8 size before allocating Data or invoking the general JSON
|
|
||||||
// parser. `decrypt` enforces the same cap on authenticated bytes; this
|
|
||||||
// local guard keeps the parser boundary explicit and independently
|
|
||||||
// testable.
|
|
||||||
guard json.utf8.count <= maximumBytes else {
|
|
||||||
throw NostrError.invalidCiphertext
|
|
||||||
}
|
|
||||||
guard let data = json.data(using: .utf8),
|
|
||||||
let dictionary = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
||||||
throw NostrError.invalidEvent
|
|
||||||
}
|
|
||||||
return try NostrEvent(from: dictionary)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - BitChat private-envelope encryption
|
|
||||||
|
|
||||||
private static func encrypt(
|
private static func encrypt(
|
||||||
plaintext: String,
|
plaintext: String,
|
||||||
recipientPubkey: String,
|
recipientPubkey: String,
|
||||||
senderKey: P256K.Schnorr.PrivateKey,
|
senderKey: P256K.Schnorr.PrivateKey
|
||||||
format: PrivateEnvelopeWireFormat,
|
|
||||||
maximumPlaintextBytes: Int
|
|
||||||
) throws -> String {
|
) throws -> String {
|
||||||
|
|
||||||
guard let recipientPubkeyData = Data(hexString: recipientPubkey) else {
|
guard let recipientPubkeyData = Data(hexString: recipientPubkey) else {
|
||||||
throw NostrError.invalidPublicKey
|
throw NostrError.invalidPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
|
||||||
|
|
||||||
|
// Derive shared secret
|
||||||
let sharedSecret = try deriveSharedSecret(
|
let sharedSecret = try deriveSharedSecret(
|
||||||
privateKey: senderKey,
|
privateKey: senderKey,
|
||||||
publicKey: recipientPubkeyData
|
publicKey: recipientPubkeyData
|
||||||
)
|
)
|
||||||
let key = derivePrivateEnvelopeKey(from: sharedSecret, format: format)
|
// 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
|
||||||
var nonce24 = Data(count: 24)
|
var nonce24 = Data(count: 24)
|
||||||
let randomStatus = nonce24.withUnsafeMutableBytes { ptr in
|
_ = nonce24.withUnsafeMutableBytes { ptr in
|
||||||
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
|
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
|
||||||
}
|
}
|
||||||
guard randomStatus == errSecSuccess else {
|
|
||||||
throw NostrError.cryptographicFailure
|
|
||||||
}
|
|
||||||
|
|
||||||
let plaintextData = Data(plaintext.utf8)
|
let pt = Data(plaintext.utf8)
|
||||||
guard plaintextData.count <= maximumPlaintextBytes else {
|
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
|
||||||
throw NostrError.invalidCiphertext
|
|
||||||
}
|
|
||||||
let sealed = try XChaCha20Poly1305Compat.seal(
|
|
||||||
plaintext: plaintextData,
|
|
||||||
key: key,
|
|
||||||
nonce24: nonce24
|
|
||||||
)
|
|
||||||
|
|
||||||
|
// v2: base64url(nonce24 || ciphertext || tag)
|
||||||
var combined = Data()
|
var combined = Data()
|
||||||
combined.append(nonce24)
|
combined.append(nonce24)
|
||||||
combined.append(sealed.ciphertext)
|
combined.append(sealed.ciphertext)
|
||||||
combined.append(sealed.tag)
|
combined.append(sealed.tag)
|
||||||
return format.contentPrefix + Base64URLCoding.encode(combined)
|
return "v2:" + Base64URLCoding.encode(combined)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func decrypt(
|
private static func decrypt(
|
||||||
ciphertext: String,
|
ciphertext: String,
|
||||||
senderPubkey: String,
|
senderPubkey: String,
|
||||||
recipientKey: P256K.Schnorr.PrivateKey,
|
recipientKey: P256K.Schnorr.PrivateKey
|
||||||
format: PrivateEnvelopeWireFormat,
|
|
||||||
maximumPlaintextBytes: Int
|
|
||||||
) throws -> String {
|
) throws -> String {
|
||||||
guard ciphertext.utf8.count <= maximumPrivateEnvelopeCiphertextBytes,
|
// Expect NIP-44 v2 format
|
||||||
ciphertext.hasPrefix(format.contentPrefix) else {
|
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
|
||||||
throw NostrError.invalidCiphertext
|
let encoded = String(ciphertext.dropFirst(3))
|
||||||
}
|
|
||||||
let encoded = String(ciphertext.dropFirst(format.contentPrefix.count))
|
|
||||||
guard let data = Base64URLCoding.decode(encoded),
|
guard let data = Base64URLCoding.decode(encoded),
|
||||||
data.count > (24 + 16),
|
data.count > (24 + 16),
|
||||||
let senderPubkeyData = Data(hexString: senderPubkey) else {
|
let senderPubkeyData = Data(hexString: senderPubkey) else {
|
||||||
@@ -765,40 +554,33 @@ struct NostrProtocol {
|
|||||||
let nonce24 = data.prefix(24)
|
let nonce24 = data.prefix(24)
|
||||||
let rest = data.dropFirst(24)
|
let rest = data.dropFirst(24)
|
||||||
let tag = rest.suffix(16)
|
let tag = rest.suffix(16)
|
||||||
let ciphertextBytes = rest.dropLast(16)
|
let ct = rest.dropLast(16)
|
||||||
|
|
||||||
func attemptDecrypt(using publicKeyData: Data) throws -> Data {
|
// Try decryption with even-Y then odd-Y when sender pubkey is x-only
|
||||||
let sharedSecret = try deriveSharedSecret(
|
func attemptDecrypt(using pubKeyData: Data) throws -> Data {
|
||||||
privateKey: recipientKey,
|
let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData)
|
||||||
publicKey: publicKeyData
|
let key = try deriveNIP44V2Key(from: ss)
|
||||||
)
|
|
||||||
let key = derivePrivateEnvelopeKey(from: sharedSecret, format: format)
|
|
||||||
return try XChaCha20Poly1305Compat.open(
|
return try XChaCha20Poly1305Compat.open(
|
||||||
ciphertext: Data(ciphertextBytes),
|
ciphertext: Data(ct),
|
||||||
tag: Data(tag),
|
tag: Data(tag),
|
||||||
key: key,
|
key: key,
|
||||||
nonce24: Data(nonce24)
|
nonce24: Data(nonce24)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let plaintext: Data
|
// If 32 bytes (x-only) try both parities, otherwise single try
|
||||||
if senderPubkeyData.count == 32 {
|
if senderPubkeyData.count == 32 {
|
||||||
let evenKey = Data([0x02]) + senderPubkeyData
|
let even = Data([0x02]) + senderPubkeyData
|
||||||
if let opened = try? attemptDecrypt(using: evenKey) {
|
if let pt = try? attemptDecrypt(using: even) {
|
||||||
plaintext = opened
|
return String(data: pt, encoding: .utf8) ?? ""
|
||||||
} else {
|
|
||||||
let oddKey = Data([0x03]) + senderPubkeyData
|
|
||||||
plaintext = try attemptDecrypt(using: oddKey)
|
|
||||||
}
|
}
|
||||||
|
let odd = Data([0x03]) + senderPubkeyData
|
||||||
|
let pt = try attemptDecrypt(using: odd)
|
||||||
|
return String(data: pt, encoding: .utf8) ?? ""
|
||||||
} else {
|
} else {
|
||||||
plaintext = try attemptDecrypt(using: senderPubkeyData)
|
let pt = try attemptDecrypt(using: senderPubkeyData)
|
||||||
|
return String(data: pt, encoding: .utf8) ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
guard plaintext.count <= maximumPlaintextBytes,
|
|
||||||
let decoded = String(data: plaintext, encoding: .utf8) else {
|
|
||||||
throw NostrError.invalidCiphertext
|
|
||||||
}
|
|
||||||
return decoded
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func deriveSharedSecret(
|
private static func deriveSharedSecret(
|
||||||
@@ -858,17 +640,29 @@ struct NostrProtocol {
|
|||||||
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
||||||
// ECDH shared secret derived
|
// ECDH shared secret derived
|
||||||
|
|
||||||
// Return raw ECDH shared secret; the wire-format-specific HKDF is
|
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
|
||||||
// applied by derivePrivateEnvelopeKey.
|
|
||||||
return sharedSecretData
|
return sharedSecretData
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func randomizedPastTimestamp() -> Date {
|
private static func randomizedTimestamp() -> Date {
|
||||||
// Keep public timestamps in the past: future-dated events are rejected
|
// Add random offset to current time for privacy
|
||||||
// by some relays. The actual message timestamp remains encrypted.
|
// This prevents timing correlation attacks while the actual message timestamp
|
||||||
Date().addingTimeInterval(
|
// is preserved in the encrypted rumor
|
||||||
-TimeInterval.random(in: 0...TransportConfig.nostrPrivateEnvelopeTimestampFuzzSeconds)
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -981,20 +775,16 @@ enum NostrError: Error {
|
|||||||
case invalidPublicKey
|
case invalidPublicKey
|
||||||
case invalidEvent
|
case invalidEvent
|
||||||
case invalidCiphertext
|
case invalidCiphertext
|
||||||
case cryptographicFailure
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - BitChat private-envelope key derivation
|
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
|
||||||
|
|
||||||
private extension NostrProtocol {
|
private extension NostrProtocol {
|
||||||
private static func derivePrivateEnvelopeKey(
|
static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {
|
||||||
from sharedSecretData: Data,
|
|
||||||
format: PrivateEnvelopeWireFormat
|
|
||||||
) -> Data {
|
|
||||||
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
||||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
||||||
salt: format.hkdfSalt,
|
salt: Data(),
|
||||||
info: format.hkdfInfo,
|
info: Data("nip44-v2".utf8),
|
||||||
outputByteCount: 32
|
outputByteCount: 32
|
||||||
)
|
)
|
||||||
return derivedKey.withUnsafeBytes { Data($0) }
|
return derivedKey.withUnsafeBytes { Data($0) }
|
||||||
|
|||||||
@@ -106,11 +106,10 @@ private extension NostrRelayManagerDependencies {
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class NostrRelayManager: ObservableObject {
|
final class NostrRelayManager: ObservableObject {
|
||||||
static let shared = NostrRelayManager()
|
static let shared = NostrRelayManager()
|
||||||
// Track BitChat private envelopes we initiated so relay rejections can be
|
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info
|
||||||
// reported without misclassifying ordinary public-event failures.
|
private(set) static var pendingGiftWrapIDs = Set<String>()
|
||||||
private(set) static var pendingPrivateEnvelopeIDs = Set<String>()
|
static func registerPendingGiftWrap(id: String) {
|
||||||
static func registerPendingPrivateEnvelope(id: String) {
|
pendingGiftWrapIDs.insert(id)
|
||||||
pendingPrivateEnvelopeIDs.insert(id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Relay: Identifiable {
|
struct Relay: Identifiable {
|
||||||
@@ -125,7 +124,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
var nextReconnectTime: Date?
|
var nextReconnectTime: Date?
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default relays carry persisted BitChat private-envelope events.
|
// Default relays carry NIP-17 gift wraps, so avoid relays known to reject kind 1059.
|
||||||
private static let defaultRelays = [
|
private static let defaultRelays = [
|
||||||
"wss://relay.damus.io",
|
"wss://relay.damus.io",
|
||||||
"wss://nos.lol",
|
"wss://nos.lol",
|
||||||
@@ -138,7 +137,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
@Published private(set) var relays: [Relay] = []
|
@Published private(set) var relays: [Relay] = []
|
||||||
@Published private(set) var isConnected = false
|
@Published private(set) var isConnected = false
|
||||||
/// Whether a relay that carries private messages is connected. DMs
|
/// Whether a relay that carries private messages is connected. DMs
|
||||||
/// target the default private-envelope relay set, so a connected
|
/// target the default (gift-wrap-capable) relay set, so a connected
|
||||||
/// geohash/custom relay alone must not count — sends would still queue.
|
/// geohash/custom relay alone must not count — sends would still queue.
|
||||||
@Published private(set) var isDMRelayConnected = false
|
@Published private(set) var isDMRelayConnected = false
|
||||||
|
|
||||||
@@ -175,7 +174,6 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private var subscribeCoalesce: [String: Date] = [:]
|
private var subscribeCoalesce: [String: Date] = [:]
|
||||||
private var pendingTorConnectionURLs = Set<String>()
|
private var pendingTorConnectionURLs = Set<String>()
|
||||||
private var awaitingTorForConnections = false
|
private var awaitingTorForConnections = false
|
||||||
private var awaitingTorForQueueFlush = false
|
|
||||||
private var torReadyWaitAttempts = 0
|
private var torReadyWaitAttempts = 0
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
@@ -210,37 +208,16 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private var eoseTrackerEpoch = 0
|
private var eoseTrackerEpoch = 0
|
||||||
private var pendingEOSECallbacks: [String: () -> Void] = [:]
|
private var pendingEOSECallbacks: [String: () -> Void] = [:]
|
||||||
|
|
||||||
// Message queue for reliability. Pending sends are grouped so a private
|
// Message queue for reliability
|
||||||
// envelope's primary and legacy migration copies can never be split by
|
// Pending sends held only for relays that are not yet connected.
|
||||||
// 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 {
|
private struct PendingSend {
|
||||||
let id: UUID
|
var event: NostrEvent
|
||||||
let events: [NostrEvent]
|
|
||||||
var pendingRelays: Set<String>
|
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 var messageQueue: [PendingSend] = []
|
||||||
private let messageQueueLock = NSLock()
|
private let messageQueueLock = NSLock()
|
||||||
/// Non-queued sends whose callers require relay durability. A WebSocket
|
/// Non-queued sends whose callers require relay durability. A WebSocket
|
||||||
/// write only proves bytes left this process; NIP-01 `OK` is the relay's
|
/// write only proves bytes left this process; NIP-20 OK is the relay's
|
||||||
/// accept/reject acknowledgment.
|
/// accept/reject acknowledgment.
|
||||||
private struct ConfirmedSendState {
|
private struct ConfirmedSendState {
|
||||||
let token: UUID
|
let token: UUID
|
||||||
@@ -327,8 +304,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
/// Disconnect from all relays
|
/// Disconnect from all relays
|
||||||
func disconnect() {
|
func disconnect() {
|
||||||
connectionGeneration &+= 1
|
connectionGeneration &+= 1
|
||||||
for (relayURL, task) in connections {
|
for (_, task) in connections {
|
||||||
clearPendingSendInFlight(relayUrl: relayURL, connection: task)
|
|
||||||
task.cancel(with: .goingAway, reason: nil)
|
task.cancel(with: .goingAway, reason: nil)
|
||||||
}
|
}
|
||||||
connections.removeAll()
|
connections.removeAll()
|
||||||
@@ -350,7 +326,6 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
confirmed.forEach { $0(false) }
|
confirmed.forEach { $0(false) }
|
||||||
pendingTorConnectionURLs.removeAll()
|
pendingTorConnectionURLs.removeAll()
|
||||||
awaitingTorForConnections = false
|
awaitingTorForConnections = false
|
||||||
awaitingTorForQueueFlush = false
|
|
||||||
torReadyWaitAttempts = 0
|
torReadyWaitAttempts = 0
|
||||||
updateConnectionStatus()
|
updateConnectionStatus()
|
||||||
}
|
}
|
||||||
@@ -375,14 +350,13 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
pendingEOSECallbacks.removeAll()
|
pendingEOSECallbacks.removeAll()
|
||||||
pendingTorConnectionURLs.removeAll()
|
pendingTorConnectionURLs.removeAll()
|
||||||
awaitingTorForConnections = false
|
awaitingTorForConnections = false
|
||||||
awaitingTorForQueueFlush = false
|
|
||||||
torReadyWaitAttempts = 0
|
torReadyWaitAttempts = 0
|
||||||
recentInboundEventKeys.removeAll()
|
recentInboundEventKeys.removeAll()
|
||||||
recentInboundEventKeyOrder.removeAll()
|
recentInboundEventKeyOrder.removeAll()
|
||||||
duplicateInboundEventDropCount = 0
|
duplicateInboundEventDropCount = 0
|
||||||
duplicateInboundEventDropCountBySubscription.removeAll()
|
duplicateInboundEventDropCountBySubscription.removeAll()
|
||||||
inboundEventLogCount = 0
|
inboundEventLogCount = 0
|
||||||
Self.pendingPrivateEnvelopeIDs.removeAll()
|
Self.pendingGiftWrapIDs.removeAll()
|
||||||
confirmedSends.removeAll()
|
confirmedSends.removeAll()
|
||||||
|
|
||||||
messageQueueLock.lock()
|
messageQueueLock.lock()
|
||||||
@@ -424,132 +398,43 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
connectToRelays(targets)
|
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) {
|
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
||||||
_ = sendEvents(
|
// Global network policy gate
|
||||||
[event],
|
guard dependencies.activationAllowed() else { return }
|
||||||
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() {
|
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
||||||
// Fail-closed: nothing touches the network until Tor is up. Queue the
|
// Fail-closed: nothing touches the network until Tor is up. Queue the
|
||||||
// complete item locally so it survives a slow bootstrap, then kick
|
// event locally so it survives a slow bootstrap (queued sends flush
|
||||||
// off connection setup, which itself waits for Tor readiness.
|
// when relays connect), then kick off connection setup, which itself
|
||||||
guard enqueuePendingSendBatch(
|
// waits for Tor readiness.
|
||||||
events,
|
let targetRelays = allowedRelayList(from: relayUrls ?? Self.defaultRelays)
|
||||||
pendingRelays: Set(targetRelays),
|
guard !targetRelays.isEmpty else { return }
|
||||||
priority: priority,
|
enqueuePendingSend(event, pendingRelays: Set(targetRelays))
|
||||||
terminalFailure: nil
|
|
||||||
) != nil else { return false }
|
|
||||||
ensureConnections(to: targetRelays)
|
ensureConnections(to: targetRelays)
|
||||||
return true
|
return
|
||||||
}
|
}
|
||||||
|
let requestedRelays = relayUrls ?? Self.defaultRelays
|
||||||
|
let targetRelays = allowedRelayList(from: requestedRelays)
|
||||||
|
guard !targetRelays.isEmpty else { return }
|
||||||
|
ensureConnections(to: targetRelays)
|
||||||
|
|
||||||
// Ordinary single events retain their existing immediate-send path.
|
// Attempt immediate send to relays with active connections; queue the rest
|
||||||
var connectedTargets: [(String, NostrRelayConnectionProtocol)] = []
|
|
||||||
var stillPending = Set<String>()
|
var stillPending = Set<String>()
|
||||||
for relayUrl in targetRelays {
|
for relayUrl in targetRelays {
|
||||||
if let connection = connectedConnection(for: relayUrl) {
|
if let connection = connectedConnection(for: relayUrl) {
|
||||||
connectedTargets.append((relayUrl, connection))
|
sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
|
||||||
} else {
|
} else {
|
||||||
stillPending.insert(relayUrl)
|
stillPending.insert(relayUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !stillPending.isEmpty {
|
if !stillPending.isEmpty {
|
||||||
guard enqueuePendingSendBatch(
|
enqueuePendingSend(event, pendingRelays: stillPending)
|
||||||
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
|
/// Attempts an event only on currently connected target relays and
|
||||||
/// reports whether at least one relay explicitly accepted it via NIP-01
|
/// reports whether at least one relay explicitly accepted it via NIP-20
|
||||||
/// `OK`. A successful WebSocket write alone is not durable acceptance.
|
/// OK. A successful WebSocket write alone is not durable acceptance.
|
||||||
/// Unlike `sendEvent`, this never enters the process-local pending queue;
|
/// Unlike `sendEvent`, this never enters the process-local pending queue;
|
||||||
/// callers use it when success unlocks durable state or user-visible
|
/// callers use it when success unlocks durable state or user-visible
|
||||||
/// delivery progress.
|
/// delivery progress.
|
||||||
@@ -638,244 +523,66 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
state.completion(false)
|
state.completion(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func pendingSendPriority(for event: NostrEvent) -> PendingSendPriority {
|
private func enqueuePendingSend(_ event: NostrEvent, pendingRelays: Set<String>) {
|
||||||
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()
|
messageQueueLock.lock()
|
||||||
let queuedEventCount = messageQueue.reduce(0) { $0 + $1.events.count }
|
messageQueue.append(PendingSend(event: event, pendingRelays: pendingRelays))
|
||||||
let overflow = queuedEventCount + events.count - TransportConfig.nostrPendingSendQueueCap
|
let overflow = messageQueue.count - TransportConfig.nostrPendingSendQueueCap
|
||||||
var removalIndexes: [Int] = []
|
|
||||||
var evictedEventCount = 0
|
|
||||||
if overflow > 0 {
|
if overflow > 0 {
|
||||||
var eventsToFree = overflow
|
messageQueue.removeFirst(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()
|
messageQueueLock.unlock()
|
||||||
guard evictedEventCount > 0 else { return itemID }
|
guard overflow > 0 else { return }
|
||||||
let isFirstEviction = pendingSendDropCount == 0
|
// Dropped events are ephemeral (presence/geo), so no status surfacing
|
||||||
pendingSendDropCount += evictedEventCount
|
// is needed — but the drops should be visible. Sampled so a sustained
|
||||||
if isFirstEviction ||
|
// relay stall can't flood the log.
|
||||||
|
pendingSendDropCount += overflow
|
||||||
|
if pendingSendDropCount == 1 ||
|
||||||
pendingSendDropCount.isMultiple(of: TransportConfig.nostrPendingSendDropLogInterval) {
|
pendingSendDropCount.isMultiple(of: TransportConfig.nostrPendingSendDropLogInterval) {
|
||||||
SecureLogger.warning(
|
SecureLogger.warning(
|
||||||
"📤 Relay send queue full — evicted \(pendingSendDropCount) lower-priority event(s)",
|
"📤 Relay send queue full — dropped \(pendingSendDropCount) oldest event(s)",
|
||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return itemID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Try to flush any queued messages for relays that are now connected.
|
/// Try to flush any queued messages for relays that are now connected.
|
||||||
private func flushMessageQueue(for relayUrl: String? = nil) {
|
private func flushMessageQueue(for relayUrl: String? = nil) {
|
||||||
guard !(shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady()) else {
|
|
||||||
flushMessageQueueWhenTorIsReady()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var deliveries: [PendingDelivery] = []
|
|
||||||
messageQueueLock.lock()
|
messageQueueLock.lock()
|
||||||
for index in (0..<messageQueue.count).reversed() {
|
defer { messageQueueLock.unlock() }
|
||||||
var item = messageQueue[index]
|
guard !messageQueue.isEmpty else { return }
|
||||||
let targets = relayUrl.map { [$0] } ?? Array(item.pendingRelays)
|
if let target = relayUrl {
|
||||||
for target in targets {
|
// Flush only for a specific relay
|
||||||
guard item.pendingRelays.contains(target),
|
for i in (0..<messageQueue.count).reversed() {
|
||||||
item.inFlightConnections[target] == nil,
|
var item = messageQueue[i]
|
||||||
let connection = connectedConnection(for: target) else { continue }
|
if item.pendingRelays.contains(target), let conn = connectedConnection(for: target) {
|
||||||
deliveries.append(PendingDelivery(
|
sendToRelay(event: item.event, connection: conn, relayUrl: target)
|
||||||
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)
|
item.pendingRelays.remove(target)
|
||||||
|
if item.pendingRelays.isEmpty {
|
||||||
|
messageQueue.remove(at: i)
|
||||||
|
} else {
|
||||||
|
messageQueue[i] = item
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if item.pendingRelays.isEmpty {
|
} else {
|
||||||
messageQueue.remove(at: index)
|
// Flush for any relays that now have connections
|
||||||
} else {
|
for i in (0..<messageQueue.count).reversed() {
|
||||||
messageQueue[index] = item
|
var item = messageQueue[i]
|
||||||
}
|
for url in item.pendingRelays {
|
||||||
}
|
if let conn = connectedConnection(for: url) {
|
||||||
messageQueueLock.unlock()
|
sendToRelay(event: item.event, connection: conn, relayUrl: url)
|
||||||
|
item.pendingRelays.remove(url)
|
||||||
// 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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else {
|
if item.pendingRelays.isEmpty {
|
||||||
for event in delivery.events {
|
messageQueue.remove(at: i)
|
||||||
sendToRelay(
|
} else {
|
||||||
event: event,
|
messageQueue[i] = item
|
||||||
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? {
|
private func connectedConnection(for relayUrl: String) -> NostrRelayConnectionProtocol? {
|
||||||
guard let connection = connections[relayUrl],
|
guard let connection = connections[relayUrl],
|
||||||
relays.first(where: { $0.url == relayUrl })?.isConnected == true else {
|
relays.first(where: { $0.url == relayUrl })?.isConnected == true else {
|
||||||
@@ -891,28 +598,9 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
relayUrls: [String]? = nil,
|
relayUrls: [String]? = nil,
|
||||||
handler: @escaping (NostrEvent) -> Void,
|
handler: @escaping (NostrEvent) -> Void,
|
||||||
onEOSE: (() -> Void)? = nil
|
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
|
// Global network policy gate
|
||||||
guard dependencies.activationAllowed(), !filters.isEmpty else { return }
|
guard dependencies.activationAllowed() else { return }
|
||||||
// Coalesce rapid duplicate subscribe requests even while Tor readiness is pending.
|
// Coalesce rapid duplicate subscribe requests even while Tor readiness is pending.
|
||||||
let now = dependencies.now()
|
let now = dependencies.now()
|
||||||
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < subscribeCoalesceInterval {
|
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < subscribeCoalesceInterval {
|
||||||
@@ -921,7 +609,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
subscribeCoalesce[id] = now
|
subscribeCoalesce[id] = now
|
||||||
messageHandlers[id] = handler
|
messageHandlers[id] = handler
|
||||||
|
|
||||||
let req = NostrRequest.subscribe(id: id, filters: filters)
|
let req = NostrRequest.subscribe(id: id, filters: [filter])
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let message = try encoder.encode(req)
|
let message = try encoder.encode(req)
|
||||||
@@ -988,7 +676,6 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
ensureConnections(to: Self.defaultRelays)
|
ensureConnections(to: Self.defaultRelays)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
var terminalFailures: [() -> Void] = []
|
|
||||||
for url in Self.defaultRelays {
|
for url in Self.defaultRelays {
|
||||||
if let connection = connections[url] {
|
if let connection = connections[url] {
|
||||||
connection.cancel(with: .goingAway, reason: nil)
|
connection.cancel(with: .goingAway, reason: nil)
|
||||||
@@ -996,9 +683,18 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
connections.removeValue(forKey: url)
|
connections.removeValue(forKey: url)
|
||||||
subscriptions.removeValue(forKey: url)
|
subscriptions.removeValue(forKey: url)
|
||||||
pendingSubscriptions.removeValue(forKey: url)
|
pendingSubscriptions.removeValue(forKey: url)
|
||||||
terminalFailures.append(contentsOf: retirePendingSendRelay(url))
|
|
||||||
}
|
}
|
||||||
terminalFailures.forEach { $0() }
|
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()
|
||||||
relays.removeAll { Self.defaultRelaySet.contains($0.url) }
|
relays.removeAll { Self.defaultRelaySet.contains($0.url) }
|
||||||
updateConnectionStatus()
|
updateConnectionStatus()
|
||||||
}
|
}
|
||||||
@@ -1443,7 +1139,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else {
|
guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !NostrProtocol.acceptedPrivateEnvelopeKinds.contains(event.kind) {
|
if event.kind != 1059 {
|
||||||
// Per-event logging floods dev builds in busy geohashes; sample it.
|
// Per-event logging floods dev builds in busy geohashes; sample it.
|
||||||
inboundEventLogCount += 1
|
inboundEventLogCount += 1
|
||||||
if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
|
if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
|
||||||
@@ -1472,11 +1168,11 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
case .ok(let eventId, let success, let reason):
|
case .ok(let eventId, let success, let reason):
|
||||||
resolveConfirmedSend(eventID: eventId, relayURL: relayUrl, accepted: success)
|
resolveConfirmedSend(eventID: eventId, relayURL: relayUrl, accepted: success)
|
||||||
if success {
|
if success {
|
||||||
_ = Self.pendingPrivateEnvelopeIDs.remove(eventId)
|
_ = Self.pendingGiftWrapIDs.remove(eventId)
|
||||||
SecureLogger.debug("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", category: .session)
|
SecureLogger.debug("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", category: .session)
|
||||||
} else {
|
} else {
|
||||||
let isPrivateEnvelope = Self.pendingPrivateEnvelopeIDs.remove(eventId) != nil
|
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
|
||||||
if isPrivateEnvelope {
|
if isGiftWrap {
|
||||||
SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16))… relay=\(relayUrl) reason=\(reason)", category: .session)
|
SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16))… relay=\(relayUrl) reason=\(reason)", category: .session)
|
||||||
} else {
|
} else {
|
||||||
SecureLogger.error("📮 Rejected id=\(eventId.prefix(16))… relay=\(relayUrl) reason=\(reason)", category: .session)
|
SecureLogger.error("📮 Rejected id=\(eventId.prefix(16))… relay=\(relayUrl) reason=\(reason)", category: .session)
|
||||||
@@ -1588,7 +1284,6 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
connection: NostrRelayConnectionProtocol? = nil
|
connection: NostrRelayConnectionProtocol? = nil
|
||||||
) {
|
) {
|
||||||
if let connection, connections[relayUrl] !== connection { return }
|
if let connection, connections[relayUrl] !== connection { return }
|
||||||
clearPendingSendInFlight(relayUrl: relayUrl, connection: connection)
|
|
||||||
connections.removeValue(forKey: relayUrl)
|
connections.removeValue(forKey: relayUrl)
|
||||||
subscriptions.removeValue(forKey: relayUrl)
|
subscriptions.removeValue(forKey: relayUrl)
|
||||||
let awaitingConfirmation = confirmedSends.compactMap { eventID, state in
|
let awaitingConfirmation = confirmedSends.compactMap { eventID, state in
|
||||||
@@ -1609,10 +1304,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
let ns = error as NSError
|
let ns = error as NSError
|
||||||
if errorDescription.contains("hostname could not be found") ||
|
if errorDescription.contains("hostname could not be found") ||
|
||||||
errorDescription.contains("dns") ||
|
errorDescription.contains("dns") ||
|
||||||
(ns.domain == NSURLErrorDomain && (
|
(ns.domain == NSURLErrorDomain && ns.code == NSURLErrorBadServerResponse) {
|
||||||
ns.code == NSURLErrorBadServerResponse ||
|
|
||||||
ns.code == NSURLErrorCannotFindHost
|
|
||||||
)) {
|
|
||||||
if relays.first(where: { $0.url == relayUrl })?.lastError == nil {
|
if relays.first(where: { $0.url == relayUrl })?.lastError == nil {
|
||||||
SecureLogger.warning("Nostr relay permanent failure for \(relayUrl) - not retrying (code=\(ns.code))", category: .session)
|
SecureLogger.warning("Nostr relay permanent failure for \(relayUrl) - not retrying (code=\(ns.code))", category: .session)
|
||||||
}
|
}
|
||||||
@@ -1622,8 +1314,6 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
relays[index].nextReconnectTime = nil
|
relays[index].nextReconnectTime = nil
|
||||||
}
|
}
|
||||||
pendingSubscriptions[relayUrl] = nil
|
pendingSubscriptions[relayUrl] = nil
|
||||||
let terminalFailures = retirePendingSendRelay(relayUrl)
|
|
||||||
terminalFailures.forEach { $0() }
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1635,8 +1325,6 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
// Stop attempting after max attempts
|
// Stop attempting after max attempts
|
||||||
if relays[index].reconnectAttempts >= maxReconnectAttempts {
|
if relays[index].reconnectAttempts >= maxReconnectAttempts {
|
||||||
SecureLogger.warning("Max reconnection attempts (\(maxReconnectAttempts)) reached for \(relayUrl)", category: .session)
|
SecureLogger.warning("Max reconnection attempts (\(maxReconnectAttempts)) reached for \(relayUrl)", category: .session)
|
||||||
let terminalFailures = retirePendingSendRelay(relayUrl)
|
|
||||||
terminalFailures.forEach { $0() }
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1675,54 +1363,6 @@ 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
|
// MARK: - Public Utility Methods
|
||||||
|
|
||||||
/// Manually retry connection to a specific relay
|
/// Manually retry connection to a specific relay
|
||||||
@@ -1737,10 +1377,6 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
// Disconnect if connected
|
// Disconnect if connected
|
||||||
if let connection = connections[normalizedRelayUrl] {
|
if let connection = connections[normalizedRelayUrl] {
|
||||||
clearPendingSendInFlight(
|
|
||||||
relayUrl: normalizedRelayUrl,
|
|
||||||
connection: connection
|
|
||||||
)
|
|
||||||
connection.cancel(with: .goingAway, reason: nil)
|
connection.cancel(with: .goingAway, reason: nil)
|
||||||
connections.removeValue(forKey: normalizedRelayUrl)
|
connections.removeValue(forKey: normalizedRelayUrl)
|
||||||
}
|
}
|
||||||
@@ -1762,13 +1398,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
var debugPendingMessageQueueCount: Int {
|
var debugPendingMessageQueueCount: Int {
|
||||||
messageQueueLock.lock()
|
messageQueueLock.lock()
|
||||||
defer { messageQueueLock.unlock() }
|
defer { messageQueueLock.unlock() }
|
||||||
return messageQueue.reduce(0) { $0 + $1.events.count }
|
return messageQueue.count
|
||||||
}
|
|
||||||
|
|
||||||
var debugPendingMessageQueueEventIDsByBatch: [[String]] {
|
|
||||||
messageQueueLock.lock()
|
|
||||||
defer { messageQueueLock.unlock() }
|
|
||||||
return messageQueue.map { $0.events.map(\.id) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func debugPendingSubscriptionCount(for relayUrl: String) -> Int {
|
func debugPendingSubscriptionCount(for relayUrl: String) -> Int {
|
||||||
@@ -1972,18 +1602,14 @@ struct NostrFilter: Encodable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// BitChat private envelopes, plus compatibility legacy envelopes emitted
|
// For NIP-17 gift wraps
|
||||||
// throughout the coordinated migration and stored by older releases as
|
static func giftWrapsFor(pubkey: String, since: Date? = nil) -> NostrFilter {
|
||||||
// kind 1059.
|
var filter = NostrFilter()
|
||||||
static func privateEnvelopeFiltersFor(pubkey: String, since: Date? = nil) -> [NostrFilter] {
|
filter.kinds = [1059] // Gift wrap kind
|
||||||
NostrProtocol.acceptedPrivateEnvelopeKinds.map { kind in
|
filter.since = since?.timeIntervalSince1970.toInt()
|
||||||
var filter = NostrFilter()
|
filter.tagFilters = ["p": [pubkey]]
|
||||||
filter.kinds = [kind]
|
filter.limit = TransportConfig.nostrRelayDefaultFetchLimit // reasonable limit
|
||||||
filter.since = since?.timeIntervalSince1970.toInt()
|
return filter
|
||||||
filter.tagFilters = ["p": [pubkey]]
|
|
||||||
filter.limit = TransportConfig.nostrPrivateEnvelopeFetchLimitPerKind
|
|
||||||
return filter
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// For location channels: geohash-scoped ephemeral events (kind 20000) and presence (kind 20001)
|
// For location channels: geohash-scoped ephemeral events (kind 20000) and presence (kind 20001)
|
||||||
|
|||||||
@@ -322,7 +322,7 @@ final class CourierStore {
|
|||||||
|
|
||||||
/// Envelopes eligible to park on relays as bridge courier drops. Merely
|
/// Envelopes eligible to park on relays as bridge courier drops. Merely
|
||||||
/// offering one does not start its cooldown: the caller commits that only
|
/// offering one does not start its cooldown: the caller commits that only
|
||||||
/// after a relay explicitly accepts the event via NIP-01 `OK`.
|
/// after a relay explicitly accepts the event via NIP-20 OK.
|
||||||
func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] {
|
func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] {
|
||||||
let date = now()
|
let date = now()
|
||||||
return queue.sync {
|
return queue.sync {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
var relaysConnected: (@MainActor () -> Bool)?
|
var relaysConnected: (@MainActor () -> Bool)?
|
||||||
/// Publishes a signed drop event directly to connected default (DM)
|
/// Publishes a signed drop event directly to connected default (DM)
|
||||||
/// relays. Completion is true only after at least one relay explicitly
|
/// relays. Completion is true only after at least one relay explicitly
|
||||||
/// accepts the event via NIP-01 `OK`; this must never mean "queued in RAM"
|
/// accepts the event via NIP-20 OK; this must never mean "queued in RAM"
|
||||||
/// or merely "written to a socket".
|
/// or merely "written to a socket".
|
||||||
var publishEvent: (@MainActor (NostrEvent, @escaping @MainActor (Bool) -> Void) -> Void)?
|
var publishEvent: (@MainActor (NostrEvent, @escaping @MainActor (Bool) -> Void) -> Void)?
|
||||||
/// (Re)opens the drop subscription for the given hex tags.
|
/// (Re)opens the drop subscription for the given hex tags.
|
||||||
@@ -129,7 +129,7 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
/// a newer attempt for the same message.
|
/// a newer attempt for the same message.
|
||||||
private var activeDropOperations: [String: ActiveDropOperation] = [:]
|
private var activeDropOperations: [String: ActiveDropOperation] = [:]
|
||||||
/// Held-envelope publishes have no sender message ID, but still need an
|
/// Held-envelope publishes have no sender message ID, but still need an
|
||||||
/// in-flight identity: repeated refreshes inside the relay-OK wait window
|
/// in-flight identity: repeated refreshes inside the NIP-20 wait window
|
||||||
/// must not mint duplicate relay events for the same opaque envelope.
|
/// must not mint duplicate relay events for the same opaque envelope.
|
||||||
private var heldDropOperations: [Data: UUID] = [:]
|
private var heldDropOperations: [Data: UUID] = [:]
|
||||||
/// Deterministically invalid envelopes are suppressed for this process,
|
/// Deterministically invalid envelopes are suppressed for this process,
|
||||||
@@ -275,7 +275,7 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
/// Publishes a drop, or queues it when relays are down. `messageID` is the
|
/// Publishes a drop, or queues it when relays are down. `messageID` is the
|
||||||
/// sender-side dedup key (nil for held/relayed envelopes we don't track);
|
/// sender-side dedup key (nil for held/relayed envelopes we don't track);
|
||||||
/// it rides the pending queue so an evicted or failed drop can release its
|
/// it rides the pending queue so an evicted or failed drop can release its
|
||||||
/// in-flight slot. Completion reports actual NIP-01 relay acceptance.
|
/// in-flight slot. Completion reports actual NIP-20 relay acceptance.
|
||||||
private func publishDrop(
|
private func publishDrop(
|
||||||
_ envelope: CourierEnvelope,
|
_ envelope: CourierEnvelope,
|
||||||
messageID: String? = nil,
|
messageID: String? = nil,
|
||||||
|
|||||||
@@ -172,11 +172,11 @@ final class MessageDeduplicationService {
|
|||||||
/// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format)
|
/// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format)
|
||||||
private let nostrAckCache: LRUDeduplicationCache<Bool>
|
private let nostrAckCache: LRUDeduplicationCache<Bool>
|
||||||
|
|
||||||
/// Optional cross-launch persistence for the Nostr event cache. BitChat
|
/// Optional cross-launch persistence for the Nostr event cache. NIP-59
|
||||||
/// randomizes private-envelope timestamps, so DM subscriptions look back
|
/// randomizes gift-wrap timestamps, so DM subscriptions look back 24h and
|
||||||
/// 24h and relays redeliver the same events on every launch; without this
|
/// relays redeliver the same events on every launch; without this record
|
||||||
/// record each relaunch reprocesses old PMs and acks. Nil (tests, macOS
|
/// each relaunch reprocesses old PMs and acks. Nil (tests, macOS callers
|
||||||
/// callers that don't opt in) keeps the cache purely in-memory.
|
/// that don't opt in) keeps the cache purely in-memory.
|
||||||
private let nostrEventStore: NostrProcessedEventStore?
|
private let nostrEventStore: NostrProcessedEventStore?
|
||||||
private let nostrEventCapacity: Int
|
private let nostrEventCapacity: Int
|
||||||
private var persistScheduled = false
|
private var persistScheduled = false
|
||||||
@@ -314,7 +314,7 @@ final class MessageDeduplicationService {
|
|||||||
// MARK: - Clear
|
// MARK: - Clear
|
||||||
|
|
||||||
/// Clears all caches. This is the wipe/panic path: the persisted
|
/// Clears all caches. This is the wipe/panic path: the persisted
|
||||||
/// private-envelope record goes with everything else.
|
/// gift-wrap record goes with everything else.
|
||||||
func clearAll() {
|
func clearAll() {
|
||||||
contentCache.clear()
|
contentCache.clear()
|
||||||
nostrEventCache.clear()
|
nostrEventCache.clear()
|
||||||
@@ -325,7 +325,7 @@ final class MessageDeduplicationService {
|
|||||||
|
|
||||||
/// Clears only the in-memory Nostr caches (events and ACKs). Runs on
|
/// Clears only the in-memory Nostr caches (events and ACKs). Runs on
|
||||||
/// every geohash channel switch, so the disk record deliberately
|
/// every geohash channel switch, so the disk record deliberately
|
||||||
/// survives — wiping it here would forfeit cross-launch private-envelope dedup
|
/// survives — wiping it here would forfeit cross-launch gift-wrap dedup
|
||||||
/// each time the user changes channels (flagged by Codex on #1398).
|
/// each time the user changes channels (flagged by Codex on #1398).
|
||||||
func clearNostrCaches() {
|
func clearNostrCaches() {
|
||||||
nostrEventCache.clear()
|
nostrEventCache.clear()
|
||||||
|
|||||||
@@ -158,16 +158,6 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wire one typed event sink to every route, including the queue-backed
|
|
||||||
/// Nostr transport. Keeping this at the router boundary prevents a relay
|
|
||||||
/// admission failure from disappearing merely because only the primary
|
|
||||||
/// mesh transport was assigned a UI delegate.
|
|
||||||
func setEventDelegate(_ delegate: TransportEventDelegate?) {
|
|
||||||
for transport in transports {
|
|
||||||
transport.eventDelegate = delegate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Transport Selection
|
// MARK: - Transport Selection
|
||||||
|
|
||||||
private func reachableTransport(for peerID: PeerID) -> Transport? {
|
private func reachableTransport(for peerID: PeerID) -> Transport? {
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Disk persistence for processed private-envelope event IDs. BitChat
|
/// Disk persistence for processed gift-wrap event IDs. NIP-59 randomizes
|
||||||
/// randomizes envelope timestamps, so DM subscriptions must look back
|
/// gift-wrap timestamps, so DM subscriptions must look back generously (24h)
|
||||||
/// generously (24h) and relays redeliver the same events on every launch —
|
/// and relays redeliver the same events on every launch — without a
|
||||||
/// without a cross-launch record, each relaunch reprocesses old PMs and acks
|
/// cross-launch record, each relaunch reprocesses old PMs and acks
|
||||||
/// (re-sent DELIVERED bursts, "delivered ack for unknown mid" noise).
|
/// (re-sent DELIVERED bursts, "delivered ack for unknown mid" noise).
|
||||||
///
|
///
|
||||||
/// Contents are event IDs already visible to every relay, so
|
/// Contents are event IDs already visible to every relay, so
|
||||||
|
|||||||
@@ -11,12 +11,8 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
let favoriteStatusForNoiseKey: @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?
|
let favoriteStatusForNoiseKey: @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||||
let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
|
let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||||
let currentIdentity: @MainActor () throws -> NostrIdentity?
|
let currentIdentity: @MainActor () throws -> NostrIdentity?
|
||||||
let registerPendingPrivateEnvelope: @MainActor (String) -> Void
|
let registerPendingGiftWrap: @MainActor (String) -> Void
|
||||||
let sendPrivateEnvelopeBatch: @MainActor (
|
let sendEvent: @MainActor (NostrEvent) -> Void
|
||||||
[NostrEvent],
|
|
||||||
@escaping @MainActor () -> Void
|
|
||||||
) -> Bool
|
|
||||||
let envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue
|
|
||||||
/// Emits whether a relay that carries private messages is up
|
/// Emits whether a relay that carries private messages is up
|
||||||
/// (fail-closed behind Tor). A connected geohash/custom relay alone
|
/// (fail-closed behind Tor). A connected geohash/custom relay alone
|
||||||
/// doesn't count: DM sends target the default relay set and would
|
/// doesn't count: DM sends target the default relay set and would
|
||||||
@@ -26,35 +22,25 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
/// serialize behind each other; `live` passes the process-wide one.
|
/// serialize behind each other; `live` passes the process-wide one.
|
||||||
let ackPacer: AckPacer
|
let ackPacer: AckPacer
|
||||||
|
|
||||||
@MainActor
|
|
||||||
init(
|
init(
|
||||||
notificationCenter: NotificationCenter,
|
notificationCenter: NotificationCenter,
|
||||||
loadFavorites: @escaping @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship],
|
loadFavorites: @escaping @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship],
|
||||||
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?,
|
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?,
|
||||||
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?,
|
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?,
|
||||||
currentIdentity: @escaping @MainActor () throws -> NostrIdentity?,
|
currentIdentity: @escaping @MainActor () throws -> NostrIdentity?,
|
||||||
registerPendingPrivateEnvelope: @escaping @MainActor (String) -> Void,
|
registerPendingGiftWrap: @escaping @MainActor (String) -> Void,
|
||||||
sendPrivateEnvelopeBatch: @escaping @MainActor (
|
sendEvent: @escaping @MainActor (NostrEvent) -> Void,
|
||||||
[NostrEvent],
|
|
||||||
@escaping @MainActor () -> Void
|
|
||||||
) -> Bool,
|
|
||||||
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void,
|
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void,
|
||||||
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never>,
|
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never>,
|
||||||
ackPacer: AckPacer? = nil,
|
ackPacer: AckPacer? = nil
|
||||||
envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue? = nil
|
|
||||||
) {
|
) {
|
||||||
self.notificationCenter = notificationCenter
|
self.notificationCenter = notificationCenter
|
||||||
self.loadFavorites = loadFavorites
|
self.loadFavorites = loadFavorites
|
||||||
self.favoriteStatusForNoiseKey = favoriteStatusForNoiseKey
|
self.favoriteStatusForNoiseKey = favoriteStatusForNoiseKey
|
||||||
self.favoriteStatusForPeerID = favoriteStatusForPeerID
|
self.favoriteStatusForPeerID = favoriteStatusForPeerID
|
||||||
self.currentIdentity = currentIdentity
|
self.currentIdentity = currentIdentity
|
||||||
self.registerPendingPrivateEnvelope = registerPendingPrivateEnvelope
|
self.registerPendingGiftWrap = registerPendingGiftWrap
|
||||||
self.sendPrivateEnvelopeBatch = sendPrivateEnvelopeBatch
|
self.sendEvent = sendEvent
|
||||||
self.envelopeRetryQueue = envelopeRetryQueue ?? NostrPrivateEnvelopeRetryQueue(
|
|
||||||
sendPrivateEnvelopeBatch: sendPrivateEnvelopeBatch,
|
|
||||||
registerPendingPrivateEnvelope: registerPendingPrivateEnvelope,
|
|
||||||
scheduleAfter: scheduleAfter
|
|
||||||
)
|
|
||||||
self.relayConnectivity = relayConnectivity
|
self.relayConnectivity = relayConnectivity
|
||||||
// Default pacer drives its throttle through the same injected
|
// Default pacer drives its throttle through the same injected
|
||||||
// scheduler, so tests that step scheduleAfter manually keep
|
// scheduler, so tests that step scheduleAfter manually keep
|
||||||
@@ -70,19 +56,13 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
favoriteStatusForNoiseKey: { FavoritesPersistenceService.shared.getFavoriteStatus(for: $0) },
|
favoriteStatusForNoiseKey: { FavoritesPersistenceService.shared.getFavoriteStatus(for: $0) },
|
||||||
favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) },
|
favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) },
|
||||||
currentIdentity: { try idBridge.getCurrentNostrIdentity() },
|
currentIdentity: { try idBridge.getCurrentNostrIdentity() },
|
||||||
registerPendingPrivateEnvelope: { NostrRelayManager.registerPendingPrivateEnvelope(id: $0) },
|
registerPendingGiftWrap: { NostrRelayManager.registerPendingGiftWrap(id: $0) },
|
||||||
sendPrivateEnvelopeBatch: { events, terminalFailure in
|
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
|
||||||
NostrRelayManager.shared.sendPrivateEnvelopeBatch(
|
|
||||||
events,
|
|
||||||
terminalFailure: terminalFailure
|
|
||||||
)
|
|
||||||
},
|
|
||||||
scheduleAfter: { delay, action in
|
scheduleAfter: { delay, action in
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
||||||
},
|
},
|
||||||
relayConnectivity: { NostrRelayManager.shared.$isDMRelayConnected.eraseToAnyPublisher() },
|
relayConnectivity: { NostrRelayManager.shared.$isDMRelayConnected.eraseToAnyPublisher() },
|
||||||
ackPacer: NostrTransport.sharedAckPacer,
|
ackPacer: NostrTransport.sharedAckPacer
|
||||||
envelopeRetryQueue: NostrTransport.sharedEnvelopeRetryQueue
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,37 +128,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
static let sharedAckPacer = AckPacer()
|
static let sharedAckPacer = AckPacer()
|
||||||
// Geohash acknowledgements use short-lived NostrTransport instances, so
|
|
||||||
// the retry owner must be process-wide. A per-transport cap would still be
|
|
||||||
// globally unbounded under outage as throwaway instances accumulated.
|
|
||||||
@MainActor
|
|
||||||
private static let sharedEnvelopeRetryQueue = NostrPrivateEnvelopeRetryQueue(
|
|
||||||
sendPrivateEnvelopeBatch: { events, terminalFailure in
|
|
||||||
NostrRelayManager.shared.sendPrivateEnvelopeBatch(
|
|
||||||
events,
|
|
||||||
terminalFailure: terminalFailure
|
|
||||||
)
|
|
||||||
},
|
|
||||||
registerPendingPrivateEnvelope: {
|
|
||||||
NostrRelayManager.registerPendingPrivateEnvelope(id: $0)
|
|
||||||
},
|
|
||||||
scheduleAfter: { delay, action in
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
static func resetControlRetriesForPanicWipe() {
|
|
||||||
sharedEnvelopeRetryQueue.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum PrivateEnvelopeFailurePolicy {
|
|
||||||
case userMessage(messageID: String)
|
|
||||||
case retry(retryKey: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
private let dependencies: Dependencies
|
private let dependencies: Dependencies
|
||||||
private let envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue
|
|
||||||
private var favoriteStatusObserver: NSObjectProtocol?
|
private var favoriteStatusObserver: NSObjectProtocol?
|
||||||
|
|
||||||
// Reachability Cache (thread-safe)
|
// Reachability Cache (thread-safe)
|
||||||
@@ -195,9 +145,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
idBridge: NostrIdentityBridge,
|
idBridge: NostrIdentityBridge,
|
||||||
dependencies: Dependencies? = nil
|
dependencies: Dependencies? = nil
|
||||||
) {
|
) {
|
||||||
let resolvedDependencies = dependencies ?? .live(idBridge: idBridge)
|
self.dependencies = dependencies ?? .live(idBridge: idBridge)
|
||||||
self.dependencies = resolvedDependencies
|
|
||||||
self.envelopeRetryQueue = resolvedDependencies.envelopeRetryQueue
|
|
||||||
|
|
||||||
setupObservers()
|
setupObservers()
|
||||||
|
|
||||||
@@ -224,18 +172,6 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
@MainActor
|
|
||||||
func debugEnqueueControlRetry(key: String, events: [NostrEvent]) {
|
|
||||||
envelopeRetryQueue.enqueue(key: key, events: events, registerPending: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
var debugControlRetryCount: Int {
|
|
||||||
envelopeRetryQueue.debugPendingCount
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
private func setupObservers() {
|
private func setupObservers() {
|
||||||
favoriteStatusObserver = dependencies.notificationCenter.addObserver(
|
favoriteStatusObserver = dependencies.notificationCenter.addObserver(
|
||||||
forName: .favoriteStatusChanged,
|
forName: .favoriteStatusChanged,
|
||||||
@@ -325,12 +261,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendPrivateEnvelope(
|
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||||
content: embedded,
|
|
||||||
recipientHex: recipientHex,
|
|
||||||
senderIdentity: senderIdentity,
|
|
||||||
failurePolicy: .userMessage(messageID: messageID)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,14 +287,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendPrivateEnvelope(
|
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||||
content: embedded,
|
|
||||||
recipientHex: recipientHex,
|
|
||||||
senderIdentity: senderIdentity,
|
|
||||||
failurePolicy: .retry(
|
|
||||||
retryKey: privateEnvelopeRetryKey(content: embedded, recipientHex: recipientHex)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,13 +319,7 @@ extension NostrTransport {
|
|||||||
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendPrivateEnvelope(
|
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||||
content: embedded,
|
|
||||||
recipientHex: recipientHex,
|
|
||||||
senderIdentity: identity,
|
|
||||||
registerPending: true,
|
|
||||||
failurePolicy: .userMessage(messageID: messageID)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -422,92 +340,17 @@ extension NostrTransport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates and sends a BitChat private-envelope event over Nostr.
|
/// Creates and sends a gift-wrapped private message event
|
||||||
@MainActor
|
@MainActor
|
||||||
private func sendPrivateEnvelope(
|
private func sendWrappedMessage(content: String, recipientHex: String, senderIdentity: NostrIdentity, registerPending: Bool = false) {
|
||||||
content: String,
|
guard let event = try? NostrProtocol.createPrivateMessage(content: content, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||||
recipientHex: String,
|
SecureLogger.error("NostrTransport: failed to build Nostr event", category: .session)
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
let accepted = dependencies.sendPrivateEnvelopeBatch(events) { [self] in
|
if registerPending {
|
||||||
handlePrivateEnvelopeFailure(
|
dependencies.registerPendingGiftWrap(event.id)
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -524,14 +367,7 @@ extension NostrTransport {
|
|||||||
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendPrivateEnvelope(
|
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||||
content: ack,
|
|
||||||
recipientHex: recipientHex,
|
|
||||||
senderIdentity: senderIdentity,
|
|
||||||
failurePolicy: .retry(
|
|
||||||
retryKey: privateEnvelopeRetryKey(content: ack, recipientHex: recipientHex)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
case .deliveredDirect(let messageID, let peerID):
|
case .deliveredDirect(let messageID, let peerID):
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||||
@@ -542,40 +378,17 @@ extension NostrTransport {
|
|||||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendPrivateEnvelope(
|
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||||
content: ack,
|
|
||||||
recipientHex: recipientHex,
|
|
||||||
senderIdentity: senderIdentity,
|
|
||||||
failurePolicy: .retry(
|
|
||||||
retryKey: privateEnvelopeRetryKey(content: ack, recipientHex: recipientHex)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
case .deliveredGeohash(let messageID, let recipientHex, let identity):
|
case .deliveredGeohash(let messageID, let recipientHex, let identity):
|
||||||
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))…", category: .session)
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||||
sendPrivateEnvelope(
|
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||||
content: embedded,
|
|
||||||
recipientHex: recipientHex,
|
|
||||||
senderIdentity: identity,
|
|
||||||
registerPending: true,
|
|
||||||
failurePolicy: .retry(
|
|
||||||
retryKey: privateEnvelopeRetryKey(content: embedded, recipientHex: recipientHex)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
case .readGeohash(let messageID, let recipientHex, let identity):
|
case .readGeohash(let messageID, let recipientHex, let identity):
|
||||||
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))…", category: .session)
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||||
sendPrivateEnvelope(
|
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||||
content: embedded,
|
|
||||||
recipientHex: recipientHex,
|
|
||||||
senderIdentity: identity,
|
|
||||||
registerPending: true,
|
|
||||||
failurePolicy: .retry(
|
|
||||||
retryKey: privateEnvelopeRetryKey(content: embedded, recipientHex: recipientHex)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -595,122 +408,3 @@ extension NostrTransport {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bounded retry owner for non-user private control payloads. It is separate
|
|
||||||
/// from `NostrTransport` so a scheduled retry from a short-lived geohash
|
|
||||||
/// transport remains valid after that transport deinitializes. Scheduler
|
|
||||||
/// callbacks retain this queue, never the transport; an evicted key simply
|
|
||||||
/// becomes a harmless no-op when its already-scheduled callback fires.
|
|
||||||
@MainActor
|
|
||||||
final class NostrPrivateEnvelopeRetryQueue {
|
|
||||||
private struct PendingRetry {
|
|
||||||
let events: [NostrEvent]
|
|
||||||
let registerPending: Bool
|
|
||||||
var attempt: Int
|
|
||||||
var isScheduled: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
private let sendPrivateEnvelopeBatch: @MainActor (
|
|
||||||
[NostrEvent],
|
|
||||||
@escaping @MainActor () -> Void
|
|
||||||
) -> Bool
|
|
||||||
private let registerPendingPrivateEnvelope: @MainActor (String) -> Void
|
|
||||||
private let scheduleAfter: @Sendable (
|
|
||||||
TimeInterval,
|
|
||||||
@escaping @Sendable () -> Void
|
|
||||||
) -> Void
|
|
||||||
private var pending: [String: PendingRetry] = [:]
|
|
||||||
private var insertionOrder: [String] = []
|
|
||||||
|
|
||||||
init(
|
|
||||||
sendPrivateEnvelopeBatch: @escaping @MainActor (
|
|
||||||
[NostrEvent],
|
|
||||||
@escaping @MainActor () -> Void
|
|
||||||
) -> Bool,
|
|
||||||
registerPendingPrivateEnvelope: @escaping @MainActor (String) -> Void,
|
|
||||||
scheduleAfter: @escaping @Sendable (
|
|
||||||
TimeInterval,
|
|
||||||
@escaping @Sendable () -> Void
|
|
||||||
) -> Void
|
|
||||||
) {
|
|
||||||
self.sendPrivateEnvelopeBatch = sendPrivateEnvelopeBatch
|
|
||||||
self.registerPendingPrivateEnvelope = registerPendingPrivateEnvelope
|
|
||||||
self.scheduleAfter = scheduleAfter
|
|
||||||
}
|
|
||||||
|
|
||||||
func enqueue(key: String, events: [NostrEvent], registerPending: Bool) {
|
|
||||||
guard pending[key] == nil else { return }
|
|
||||||
if pending.count >= TransportConfig.nostrPrivateEnvelopeRetryQueueCap,
|
|
||||||
let evictedKey = insertionOrder.first {
|
|
||||||
insertionOrder.removeFirst()
|
|
||||||
pending.removeValue(forKey: evictedKey)
|
|
||||||
// These are control payloads, never user-authored messages. Keep
|
|
||||||
// the bounded-loss decision explicit rather than silently growing
|
|
||||||
// memory during a prolonged outage.
|
|
||||||
SecureLogger.warning(
|
|
||||||
"📮 Private control retry queue full — evicted oldest whole migration pair",
|
|
||||||
category: .session
|
|
||||||
)
|
|
||||||
}
|
|
||||||
pending[key] = PendingRetry(
|
|
||||||
events: events,
|
|
||||||
registerPending: registerPending,
|
|
||||||
attempt: 0,
|
|
||||||
isScheduled: false
|
|
||||||
)
|
|
||||||
insertionOrder.append(key)
|
|
||||||
schedule(key: key)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func schedule(key: String) {
|
|
||||||
guard var item = pending[key], !item.isScheduled else { return }
|
|
||||||
item.isScheduled = true
|
|
||||||
pending[key] = item
|
|
||||||
let exponent = min(item.attempt, 5)
|
|
||||||
let delay = min(2.0 * pow(2.0, Double(exponent)), 60.0)
|
|
||||||
scheduleAfter(delay) { [self] in
|
|
||||||
Task { @MainActor [self] in
|
|
||||||
self.retry(key: key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func retry(key: String) {
|
|
||||||
guard var item = pending[key] else { return }
|
|
||||||
item.isScheduled = false
|
|
||||||
pending[key] = item
|
|
||||||
|
|
||||||
let accepted = sendPrivateEnvelopeBatch(item.events) { [self] in
|
|
||||||
self.enqueue(
|
|
||||||
key: key,
|
|
||||||
events: item.events,
|
|
||||||
registerPending: item.registerPending
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if accepted {
|
|
||||||
remove(key: key)
|
|
||||||
if item.registerPending {
|
|
||||||
for event in item.events {
|
|
||||||
registerPendingPrivateEnvelope(event.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
item.attempt += 1
|
|
||||||
pending[key] = item
|
|
||||||
schedule(key: key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func remove(key: String) {
|
|
||||||
pending.removeValue(forKey: key)
|
|
||||||
insertionOrder.removeAll { $0 == key }
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeAll() {
|
|
||||||
pending.removeAll()
|
|
||||||
insertionOrder.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
var debugPendingCount: Int { pending.count }
|
|
||||||
func debugContains(key: String) -> Bool { pending[key] != nil }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -171,22 +171,7 @@ enum TransportConfig {
|
|||||||
static let nostrGeoRelayCount: Int = 5
|
static let nostrGeoRelayCount: Int = 5
|
||||||
static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300
|
static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300
|
||||||
static let nostrGeohashSampleLimit: Int = 100
|
static let nostrGeohashSampleLimit: Int = 100
|
||||||
/// New iOS public-envelope timestamps are deliberately shifted into the
|
static let nostrDMSubscribeLookbackSeconds: TimeInterval = 86400
|
||||||
/// past for privacy and relay compatibility.
|
|
||||||
static let nostrPrivateEnvelopeTimestampFuzzSeconds: TimeInterval = 15 * 60
|
|
||||||
/// Deployed Android clients can shift legacy kind-1059 timestamps by the
|
|
||||||
/// full preceding 48 hours.
|
|
||||||
static let nostrLegacyAndroidTimestampFuzzSeconds: TimeInterval = 48 * 60 * 60
|
|
||||||
/// Private mail remains eligible for delivery for the same 24-hour window
|
|
||||||
/// as the persistent sender outbox. The relay query must add timestamp
|
|
||||||
/// randomization to that window rather than replacing it: an Android event
|
|
||||||
/// sent at t0 can legitimately be stamped t0-48h and fetched at t0+24h.
|
|
||||||
static let nostrPrivateEnvelopeDeliveryWindowSeconds: TimeInterval = 24 * 60 * 60
|
|
||||||
static let nostrDMSubscribeClockSkewSeconds: TimeInterval = 15 * 60
|
|
||||||
static let nostrDMSubscribeLookbackSeconds: TimeInterval =
|
|
||||||
nostrPrivateEnvelopeDeliveryWindowSeconds
|
|
||||||
+ nostrLegacyAndroidTimestampFuzzSeconds
|
|
||||||
+ nostrDMSubscribeClockSkewSeconds
|
|
||||||
// A sampled chat message this recent means "a conversation is happening
|
// A sampled chat message this recent means "a conversation is happening
|
||||||
// there" for the empty-timeline nearby-activity hint.
|
// there" for the empty-timeline nearby-activity hint.
|
||||||
static let uiGeohashChatActivityWindowSeconds: TimeInterval = 900
|
static let uiGeohashChatActivityWindowSeconds: TimeInterval = 900
|
||||||
@@ -214,20 +199,11 @@ enum TransportConfig {
|
|||||||
// Reconnect delays get ±20% random jitter so relays that dropped together
|
// Reconnect delays get ±20% random jitter so relays that dropped together
|
||||||
// (e.g. a network blip) don't thundering-herd the same reconnect instant.
|
// (e.g. a network blip) don't thundering-herd the same reconnect instant.
|
||||||
static let nostrRelayBackoffJitterRatio: Double = 0.2
|
static let nostrRelayBackoffJitterRatio: Double = 0.2
|
||||||
/// Migration recovery uses one independent relay filter per wire kind so
|
static let nostrRelayDefaultFetchLimit: Int = 100
|
||||||
/// primary traffic cannot consume the legacy result budget (or vice
|
|
||||||
/// versa). Five hundred per kind bounds startup work while preserving a
|
|
||||||
/// materially deeper offline mailbox than the generic feed default.
|
|
||||||
static let nostrPrivateEnvelopeFetchLimitPerKind: Int = 500
|
|
||||||
// How many consecutive Tor-readiness waits (each bounded by TorManager's
|
// How many consecutive Tor-readiness waits (each bounded by TorManager's
|
||||||
// bootstrap deadline) to attempt before unblocking pending EOSE callers.
|
// bootstrap deadline) to attempt before unblocking pending EOSE callers.
|
||||||
static let nostrTorReadyMaxWaitAttempts: Int = 3
|
static let nostrTorReadyMaxWaitAttempts: Int = 3
|
||||||
static let nostrPendingSendQueueCap: Int = 200
|
static let nostrPendingSendQueueCap: Int = 200
|
||||||
/// Control-payload pairs (delivery/read acknowledgements and favorite
|
|
||||||
/// notifications) that could not enter the relay queue. User messages do
|
|
||||||
/// not use this queue: they fail visibly, while direct messages also
|
|
||||||
/// remain in the router outbox.
|
|
||||||
static let nostrPrivateEnvelopeRetryQueueCap: Int = 256
|
|
||||||
// Sample interval for the send-queue overflow warning (first + every Nth
|
// Sample interval for the send-queue overflow warning (first + every Nth
|
||||||
// dropped event). Drops are ephemeral presence/geo traffic — log-only.
|
// dropped event). Drops are ephemeral presence/geo traffic — log-only.
|
||||||
static let nostrPendingSendDropLogInterval: Int = 10
|
static let nostrPendingSendDropLogInterval: Int = 10
|
||||||
@@ -239,7 +215,7 @@ enum TransportConfig {
|
|||||||
// Fallback deadline for treating a subscription's initial fetch as complete
|
// Fallback deadline for treating a subscription's initial fetch as complete
|
||||||
// when a relay never sends EOSE (generous to cover Tor circuit setup).
|
// when a relay never sends EOSE (generous to cover Tor circuit setup).
|
||||||
static let nostrSubscriptionEOSEFallbackSeconds: TimeInterval = 10.0
|
static let nostrSubscriptionEOSEFallbackSeconds: TimeInterval = 10.0
|
||||||
// A bridge drop is durable only after NIP-01 `OK`. Relays that omit `OK` must
|
// A bridge drop is durable only after NIP-20 OK. Relays that omit OK must
|
||||||
// not pin the router's in-flight state indefinitely.
|
// not pin the router's in-flight state indefinitely.
|
||||||
static let nostrConfirmedSendAckTimeoutSeconds: TimeInterval = 10.0
|
static let nostrConfirmedSendAckTimeoutSeconds: TimeInterval = 10.0
|
||||||
// After this long, a relay marked permanently failed gets another chance.
|
// After this long, a relay marked permanently failed gets another chance.
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ extension ChatViewModel: ChatPrivateConversationContext {
|
|||||||
final class ChatPrivateConversationCoordinator {
|
final class ChatPrivateConversationCoordinator {
|
||||||
private unowned let context: any ChatPrivateConversationContext
|
private unowned let context: any ChatPrivateConversationContext
|
||||||
|
|
||||||
// Outbox retries re-envelope the same message in fresh private events, so
|
// Outbox retries re-wrap the same message in fresh gift-wrap events, so
|
||||||
// relay-level event-ID dedup can't catch them; track inbound GeoDM
|
// relay-level event-ID dedup can't catch them; track inbound GeoDM
|
||||||
// message IDs so each copy past the first costs one (already-deduped)
|
// message IDs so each copy past the first costs one (already-deduped)
|
||||||
// ack check and nothing else.
|
// ack check and nothing else.
|
||||||
|
|||||||
@@ -1241,7 +1241,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
// Drop relay subscriptions, handlers, pending sends, and replay state.
|
// Drop relay subscriptions, handlers, pending sends, and replay state.
|
||||||
// Geohash DM handlers can capture pre-wipe Nostr identities, so a plain
|
// Geohash DM handlers can capture pre-wipe Nostr identities, so a plain
|
||||||
// disconnect is not enough here.
|
// disconnect is not enough here.
|
||||||
NostrTransport.resetControlRetriesForPanicWipe()
|
|
||||||
NostrRelayManager.shared.resetForPanicWipe()
|
NostrRelayManager.shared.resetForPanicWipe()
|
||||||
nostrRelayManager = nil
|
nostrRelayManager = nil
|
||||||
|
|
||||||
|
|||||||
@@ -41,11 +41,10 @@ struct ChatViewModelServiceBundle {
|
|||||||
self.privateChatManager = privateChatManager
|
self.privateChatManager = privateChatManager
|
||||||
self.unifiedPeerService = unifiedPeerService
|
self.unifiedPeerService = unifiedPeerService
|
||||||
self.autocompleteService = AutocompleteService()
|
self.autocompleteService = AutocompleteService()
|
||||||
// Persist processed private-envelope event IDs: legacy Android can
|
// Persist processed gift-wrap event IDs: NIP-59 randomizes their
|
||||||
// randomize timestamps across the full 72h15m mailbox lookback, so DM
|
// timestamps, so the 24h-lookback DM subscriptions redeliver the same
|
||||||
// subscriptions redeliver the same events on every launch and only a
|
// events on every launch and only a cross-launch record stops the
|
||||||
// cross-launch record stops the reprocessing (re-sent DELIVERED
|
// reprocessing (re-sent DELIVERED bursts, phantom-ack noise).
|
||||||
// bursts, phantom-ack noise).
|
|
||||||
self.deduplicationService = MessageDeduplicationService(nostrEventStore: NostrProcessedEventStore())
|
self.deduplicationService = MessageDeduplicationService(nostrEventStore: NostrProcessedEventStore())
|
||||||
self.publicMessagePipeline = PublicMessagePipeline()
|
self.publicMessagePipeline = PublicMessagePipeline()
|
||||||
}
|
}
|
||||||
@@ -166,7 +165,7 @@ private extension ChatViewModelBootstrapper {
|
|||||||
|
|
||||||
func configureTransport() {
|
func configureTransport() {
|
||||||
viewModel.meshService.delegate = viewModel
|
viewModel.meshService.delegate = viewModel
|
||||||
viewModel.messageRouter.setEventDelegate(viewModel)
|
viewModel.meshService.eventDelegate = viewModel
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiStartupInitialDelaySeconds) { [weak viewModel] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiStartupInitialDelaySeconds) { [weak viewModel] in
|
||||||
guard let viewModel else { return }
|
guard let viewModel else { return }
|
||||||
@@ -548,7 +547,7 @@ private extension ChatViewModelBootstrapper {
|
|||||||
// Default (DM) relays: drops need the standing global relay set,
|
// Default (DM) relays: drops need the standing global relay set,
|
||||||
// not geo relays — sender and recipient share no cell.
|
// not geo relays — sender and recipient share no cell.
|
||||||
// This confirmed path never falls back to the volatile relay
|
// This confirmed path never falls back to the volatile relay
|
||||||
// queue; bridge dedup is committed only after NIP-01 `OK`.
|
// queue; bridge dedup is committed only after NIP-20 OK.
|
||||||
NostrRelayManager.shared.sendEventImmediately(event, completion: completion)
|
NostrRelayManager.shared.sendEventImmediately(event, completion: completion)
|
||||||
}
|
}
|
||||||
courier.openSubscription = { tagsHex in
|
courier.openSubscription = { tagsHex in
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ extension ChatViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func subscribePrivateEnvelope(_ envelope: NostrEvent, id: NostrIdentity) {
|
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||||
nostrCoordinator.inbound.subscribePrivateEnvelope(envelope, id: id)
|
nostrCoordinator.inbound.subscribeGiftWrap(giftWrap, id: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -36,8 +36,8 @@ extension ChatViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func handlePrivateEnvelope(_ envelope: NostrEvent, id: NostrIdentity) {
|
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||||
nostrCoordinator.inbound.handlePrivateEnvelope(envelope, id: id)
|
nostrCoordinator.inbound.handleGiftWrap(giftWrap, id: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ extension ChatViewModel: GeohashSubscriptionContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Owns subscription IDs and relay lifecycle for geohash channels, geohash
|
/// Owns subscription IDs and relay lifecycle for geohash channels, geohash
|
||||||
/// DMs, the account private-envelope mailbox, and background geohash sampling. The
|
/// DMs, the account gift-wrap mailbox, and background geohash sampling. The
|
||||||
/// only component that talks to `NostrRelayManager`; inbound events are
|
/// only component that talks to `NostrRelayManager`; inbound events are
|
||||||
/// forwarded to `NostrInboundPipeline` / `GeoPresenceTracker`.
|
/// forwarded to `NostrInboundPipeline` / `GeoPresenceTracker`.
|
||||||
final class GeohashSubscriptionManager {
|
final class GeohashSubscriptionManager {
|
||||||
@@ -162,13 +162,13 @@ final class GeohashSubscriptionManager {
|
|||||||
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
|
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
|
||||||
let dmSub = "geo-dm-\(channel.geohash)"
|
let dmSub = "geo-dm-\(channel.geohash)"
|
||||||
context.setGeoDmSubscriptionID(dmSub)
|
context.setGeoDmSubscriptionID(dmSub)
|
||||||
let dmFilters = NostrFilter.privateEnvelopeFiltersFor(
|
let dmFilter = NostrFilter.giftWrapsFor(
|
||||||
pubkey: identity.publicKeyHex,
|
pubkey: identity.publicKeyHex,
|
||||||
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
||||||
)
|
)
|
||||||
NostrRelayManager.shared.subscribe(filters: dmFilters, id: dmSub) { [weak self] envelope in
|
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
self?.inbound.subscribePrivateEnvelope(envelope, id: identity)
|
self?.inbound.subscribeGiftWrap(giftWrap, id: identity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,13 +260,13 @@ final class GeohashSubscriptionManager {
|
|||||||
if TorManager.shared.isReady {
|
if TorManager.shared.isReady {
|
||||||
SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
|
SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
|
||||||
}
|
}
|
||||||
let dmFilters = NostrFilter.privateEnvelopeFiltersFor(
|
let dmFilter = NostrFilter.giftWrapsFor(
|
||||||
pubkey: identity.publicKeyHex,
|
pubkey: identity.publicKeyHex,
|
||||||
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
||||||
)
|
)
|
||||||
NostrRelayManager.shared.subscribe(filters: dmFilters, id: dmSub) { [weak self] envelope in
|
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
self?.inbound.handlePrivateEnvelope(envelope, id: identity)
|
self?.inbound.handleGiftWrap(giftWrap, id: identity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -388,14 +388,14 @@ final class GeohashSubscriptionManager {
|
|||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
|
|
||||||
let filters = NostrFilter.privateEnvelopeFiltersFor(
|
let filter = NostrFilter.giftWrapsFor(
|
||||||
pubkey: currentIdentity.publicKeyHex,
|
pubkey: currentIdentity.publicKeyHex,
|
||||||
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
||||||
)
|
)
|
||||||
|
|
||||||
context.nostrRelayManager?.subscribe(filters: filters, id: "chat-messages") { [weak self] event in
|
context.nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
self?.inbound.handleAccountPrivateEnvelope(event)
|
self?.inbound.handleNostrMessage(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,21 +84,13 @@ extension ChatViewModel: NostrInboundPipelineContext {
|
|||||||
/// Ordering is deliberate and performance-critical: cheap rejects (kind,
|
/// Ordering is deliberate and performance-critical: cheap rejects (kind,
|
||||||
/// dedup lookup) run BEFORE Schnorr signature verification because duplicates
|
/// dedup lookup) run BEFORE Schnorr signature verification because duplicates
|
||||||
/// dominate real relay traffic; events are recorded only AFTER verification so
|
/// dominate real relay traffic; events are recorded only AFTER verification so
|
||||||
/// a forged-signature copy can never poison the dedup set; private-envelope
|
/// a forged-signature copy can never poison the dedup set; gift-wrap
|
||||||
/// verification for the account mailbox runs off-main with an atomic
|
/// verification for the account mailbox runs off-main with an atomic
|
||||||
/// main-actor check-and-record.
|
/// main-actor check-and-record.
|
||||||
final class NostrInboundPipeline {
|
final class NostrInboundPipeline {
|
||||||
private weak var context: (any NostrInboundPipelineContext)?
|
private weak var context: (any NostrInboundPipelineContext)?
|
||||||
private let presence: GeoPresenceTracker
|
private let presence: GeoPresenceTracker
|
||||||
private var geoEventLogCount = 0
|
private var geoEventLogCount = 0
|
||||||
// During the coordinated wire-format migration, one logical private
|
|
||||||
// payload is published under both primary and compatibility formats. Outer
|
|
||||||
// event IDs differ, so collapse the authenticated embedded payload before
|
|
||||||
// invoking message/ack side effects. Keep this bounded like the outer-ID
|
|
||||||
// caches; the recipient and authenticated sender are part of the key.
|
|
||||||
private var recentPrivatePayloadFormats: [String: UInt8] = [:]
|
|
||||||
private var recentPrivatePayloadKeyOrder: [String] = []
|
|
||||||
private static let privatePayloadDedupCapacity = 2_048
|
|
||||||
|
|
||||||
init(context: any NostrInboundPipelineContext, presence: GeoPresenceTracker) {
|
init(context: any NostrInboundPipelineContext, presence: GeoPresenceTracker) {
|
||||||
self.context = context
|
self.context = context
|
||||||
@@ -276,16 +268,15 @@ final class NostrInboundPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func subscribePrivateEnvelope(_ envelope: NostrEvent, id: NostrIdentity) {
|
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
// Dedup lookup before Schnorr verification; record only after it passes.
|
// Dedup lookup before Schnorr verification; record only after it passes.
|
||||||
guard !context.hasProcessedNostrEvent(envelope.id) else { return }
|
guard !context.hasProcessedNostrEvent(giftWrap.id) else { return }
|
||||||
guard envelope.content.utf8.count <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes else { return }
|
guard giftWrap.isValidSignature() else { return }
|
||||||
guard envelope.isValidSignature() else { return }
|
context.recordProcessedNostrEvent(giftWrap.id)
|
||||||
context.recordProcessedNostrEvent(envelope.id)
|
|
||||||
|
|
||||||
guard let (content, senderPubkey, messageTs) = try? NostrProtocol.decryptPrivateEnvelope(
|
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
|
||||||
envelope: envelope,
|
giftWrap: giftWrap,
|
||||||
recipientIdentity: id
|
recipientIdentity: id
|
||||||
),
|
),
|
||||||
let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
||||||
@@ -294,14 +285,8 @@ final class NostrInboundPipeline {
|
|||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard shouldProcessPrivatePayload(
|
|
||||||
noisePayload,
|
|
||||||
senderPubkey: senderPubkey,
|
|
||||||
recipientPubkey: id.publicKeyHex,
|
|
||||||
envelopeKind: envelope.kind
|
|
||||||
) else { return }
|
|
||||||
|
|
||||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(messageTs))
|
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
||||||
let convKey = PeerID(nostr_: senderPubkey)
|
let convKey = PeerID(nostr_: senderPubkey)
|
||||||
context.registerNostrKeyMapping(senderPubkey, for: convKey)
|
context.registerNostrKeyMapping(senderPubkey, for: convKey)
|
||||||
|
|
||||||
@@ -328,26 +313,25 @@ final class NostrInboundPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func handlePrivateEnvelope(_ envelope: NostrEvent, id: NostrIdentity) {
|
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
// Dedup lookup before Schnorr verification; record only after it passes.
|
// Dedup lookup before Schnorr verification; record only after it passes.
|
||||||
if context.hasProcessedNostrEvent(envelope.id) {
|
if context.hasProcessedNostrEvent(giftWrap.id) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard envelope.content.utf8.count <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes else { return }
|
guard giftWrap.isValidSignature() else { return }
|
||||||
guard envelope.isValidSignature() else { return }
|
context.recordProcessedNostrEvent(giftWrap.id)
|
||||||
context.recordProcessedNostrEvent(envelope.id)
|
|
||||||
|
|
||||||
guard let (content, senderPubkey, messageTs) = try? NostrProtocol.decryptPrivateEnvelope(
|
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
|
||||||
envelope: envelope,
|
giftWrap: giftWrap,
|
||||||
recipientIdentity: id
|
recipientIdentity: id
|
||||||
) else {
|
) else {
|
||||||
SecureLogger.warning("GeoDM: failed decrypt private envelope id=\(envelope.id.prefix(8))…", category: .session)
|
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.debug(
|
SecureLogger.debug(
|
||||||
"GeoDM: decrypted private envelope id=\(envelope.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
|
"GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
|
||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -357,19 +341,13 @@ final class NostrInboundPipeline {
|
|||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard shouldProcessPrivatePayload(
|
|
||||||
payload,
|
|
||||||
senderPubkey: senderPubkey,
|
|
||||||
recipientPubkey: id.publicKeyHex,
|
|
||||||
envelopeKind: envelope.kind
|
|
||||||
) else { return }
|
|
||||||
|
|
||||||
let convKey = PeerID(nostr_: senderPubkey)
|
let convKey = PeerID(nostr_: senderPubkey)
|
||||||
context.registerNostrKeyMapping(senderPubkey, for: convKey)
|
context.registerNostrKeyMapping(senderPubkey, for: convKey)
|
||||||
|
|
||||||
switch payload.type {
|
switch payload.type {
|
||||||
case .privateMessage:
|
case .privateMessage:
|
||||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(messageTs))
|
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
||||||
context.handlePrivateMessage(
|
context.handlePrivateMessage(
|
||||||
payload,
|
payload,
|
||||||
senderPubkey: senderPubkey,
|
senderPubkey: senderPubkey,
|
||||||
@@ -391,29 +369,28 @@ final class NostrInboundPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func handleAccountPrivateEnvelope(_ envelope: NostrEvent) {
|
func handleNostrMessage(_ giftWrap: NostrEvent) {
|
||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
// Cheap dedup pre-check only; Schnorr verification runs off-main in
|
// Cheap dedup pre-check only; Schnorr verification runs off-main in
|
||||||
// processAccountPrivateEnvelope, which then does the authoritative
|
// processNostrMessage, which then does the authoritative
|
||||||
// check-and-record. Recording stays after verification so a
|
// check-and-record. Recording stays after verification so a
|
||||||
// forged-signature copy can never poison the dedup set and suppress
|
// forged-signature copy can never poison the dedup set and suppress
|
||||||
// the genuine event.
|
// the genuine event.
|
||||||
if context.hasProcessedNostrEvent(envelope.id) { return }
|
if context.hasProcessedNostrEvent(giftWrap.id) { return }
|
||||||
|
|
||||||
Task.detached(priority: .userInitiated) { [weak self] in
|
Task.detached(priority: .userInitiated) { [weak self] in
|
||||||
await self?.processAccountPrivateEnvelope(envelope)
|
await self?.processNostrMessage(giftWrap)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func processAccountPrivateEnvelope(_ envelope: NostrEvent) async {
|
func processNostrMessage(_ giftWrap: NostrEvent) async {
|
||||||
guard envelope.content.utf8.count <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes else { return }
|
guard giftWrap.isValidSignature() else { return }
|
||||||
guard envelope.isValidSignature() else { return }
|
|
||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
// Authoritative check-and-record, atomic on the main actor so two
|
// Authoritative check-and-record, atomic on the main actor so two
|
||||||
// concurrent detached tasks can't both process the same event.
|
// concurrent detached tasks can't both process the same event.
|
||||||
let alreadyProcessed: Bool = await MainActor.run {
|
let alreadyProcessed: Bool = await MainActor.run {
|
||||||
if context.hasProcessedNostrEvent(envelope.id) { return true }
|
if context.hasProcessedNostrEvent(giftWrap.id) { return true }
|
||||||
context.recordProcessedNostrEvent(envelope.id)
|
context.recordProcessedNostrEvent(giftWrap.id)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if alreadyProcessed { return }
|
if alreadyProcessed { return }
|
||||||
@@ -423,8 +400,8 @@ final class NostrInboundPipeline {
|
|||||||
guard let currentIdentity else { return }
|
guard let currentIdentity else { return }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let (content, senderPubkey, messageTimestampSeconds) = try NostrProtocol.decryptPrivateEnvelope(
|
let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage(
|
||||||
envelope: envelope,
|
giftWrap: giftWrap,
|
||||||
recipientIdentity: currentIdentity
|
recipientIdentity: currentIdentity
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -448,14 +425,8 @@ final class NostrInboundPipeline {
|
|||||||
|
|
||||||
if packet.type == MessageType.noiseEncrypted.rawValue,
|
if packet.type == MessageType.noiseEncrypted.rawValue,
|
||||||
let payload = NoisePayload.decode(packet.payload) {
|
let payload = NoisePayload.decode(packet.payload) {
|
||||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(messageTimestampSeconds))
|
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
guard self.shouldProcessPrivatePayload(
|
|
||||||
payload,
|
|
||||||
senderPubkey: senderPubkey,
|
|
||||||
recipientPubkey: currentIdentity.publicKeyHex,
|
|
||||||
envelopeKind: envelope.kind
|
|
||||||
) else { return }
|
|
||||||
context.registerNostrKeyMapping(senderPubkey, for: targetPeerID)
|
context.registerNostrKeyMapping(senderPubkey, for: targetPeerID)
|
||||||
|
|
||||||
switch payload.type {
|
switch payload.type {
|
||||||
@@ -529,47 +500,6 @@ final class NostrInboundPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extension NostrInboundPipeline {
|
private extension NostrInboundPipeline {
|
||||||
@MainActor
|
|
||||||
func shouldProcessPrivatePayload(
|
|
||||||
_ payload: NoisePayload,
|
|
||||||
senderPubkey: String,
|
|
||||||
recipientPubkey: String,
|
|
||||||
envelopeKind: Int
|
|
||||||
) -> Bool {
|
|
||||||
let digest = payload.encode().sha256Fingerprint()
|
|
||||||
let key = "\(recipientPubkey.lowercased()):\(senderPubkey.lowercased()):\(digest)"
|
|
||||||
let formatBit: UInt8
|
|
||||||
switch envelopeKind {
|
|
||||||
case NostrProtocol.EventKind.privateEnvelope.rawValue:
|
|
||||||
formatBit = 1 << 0
|
|
||||||
case NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue:
|
|
||||||
formatBit = 1 << 1
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if let observedFormats = recentPrivatePayloadFormats[key] {
|
|
||||||
if observedFormats & formatBit != 0 {
|
|
||||||
// A same-format re-envelope is a delivery retry. Let it reach
|
|
||||||
// the coordinator so a lost DELIVERED acknowledgement can be
|
|
||||||
// sent again; downstream message-ID dedup prevents rerendering.
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// The same authenticated payload under the other migration format
|
|
||||||
// is the compatibility twin, not a new message or acknowledgement.
|
|
||||||
recentPrivatePayloadFormats[key] = observedFormats | formatBit
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
recentPrivatePayloadFormats[key] = formatBit
|
|
||||||
recentPrivatePayloadKeyOrder.append(key)
|
|
||||||
if recentPrivatePayloadKeyOrder.count > Self.privatePayloadDedupCapacity {
|
|
||||||
let evicted = recentPrivatePayloadKeyOrder.removeFirst()
|
|
||||||
recentPrivatePayloadFormats.removeValue(forKey: evicted)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {
|
static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {
|
||||||
guard content.hasPrefix("bitchat1:") else { return nil }
|
guard content.hasPrefix("bitchat1:") else { return nil }
|
||||||
|
|||||||
@@ -446,8 +446,7 @@ private struct ContentPrivateChatSheetView: View {
|
|||||||
if privateConversationModel.selectedPeerID?.isGroup == true {
|
if privateConversationModel.selectedPeerID?.isGroup == true {
|
||||||
return String(localized: "content.private.caption_group", comment: "Caption above the group chat composer noting messages are encrypted to group members")
|
return String(localized: "content.private.caption_group", comment: "Caption above the group chat composer noting messages are encrypted to group members")
|
||||||
}
|
}
|
||||||
// Geohash DMs use BitChat's private-envelope transport over Nostr —
|
// Geohash DMs are NIP-17 gift-wrapped — always end-to-end encrypted,
|
||||||
// always end-to-end encrypted,
|
|
||||||
// even though they carry no Noise session status. Mesh DMs earn the
|
// even though they carry no Noise session status. Mesh DMs earn the
|
||||||
// "encrypted" claim only once the Noise handshake has secured.
|
// "encrypted" claim only once the Noise handshake has secured.
|
||||||
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ private func drainMainQueue() async {
|
|||||||
|
|
||||||
/// Exercises `ChatNostrCoordinator` against `MockChatNostrContext` with no
|
/// Exercises `ChatNostrCoordinator` against `MockChatNostrContext` with no
|
||||||
/// `ChatViewModel`. Scoped to the inbound event pipeline (dedup, presence,
|
/// `ChatViewModel`. Scoped to the inbound event pipeline (dedup, presence,
|
||||||
/// public-message ingest), private-envelope ingest, key mapping, channel-switch
|
/// public-message ingest), gift-wrap DM ingest, key mapping, channel-switch
|
||||||
/// teardown, embedded ack flows, and — now that favorites and notifications
|
/// teardown, embedded ack flows, and — now that favorites and notifications
|
||||||
/// are injected through the context — the favorite-notification ingest and
|
/// are injected through the context — the favorite-notification ingest and
|
||||||
/// the sampled-geohash notification cooldown. Flows that hit live singletons
|
/// the sampled-geohash notification cooldown. Flows that hit live singletons
|
||||||
@@ -335,7 +335,7 @@ struct ChatNostrCoordinatorContextTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func handlePrivateEnvelope_routesEmbeddedPrivateMessageAndDeduplicates() async throws {
|
func handleGiftWrap_routesEmbeddedPrivateMessageAndDeduplicates() async throws {
|
||||||
let context = MockChatNostrContext()
|
let context = MockChatNostrContext()
|
||||||
let coordinator = ChatNostrCoordinator(context: context)
|
let coordinator = ChatNostrCoordinator(context: context)
|
||||||
|
|
||||||
@@ -346,21 +346,17 @@ struct ChatNostrCoordinatorContextTests {
|
|||||||
messageID: "gm-1",
|
messageID: "gm-1",
|
||||||
senderPeerID: PeerID(str: "aabbccddeeff0011")
|
senderPeerID: PeerID(str: "aabbccddeeff0011")
|
||||||
))
|
))
|
||||||
let envelopes = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||||
content: embedded,
|
content: embedded,
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
for envelope in envelopes {
|
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
|
||||||
coordinator.inbound.handlePrivateEnvelope(envelope, id: recipient)
|
|
||||||
}
|
|
||||||
|
|
||||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||||
#expect(context.recordedNostrEventIDs == envelopes.map(\.id))
|
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
||||||
#expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex)
|
#expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex)
|
||||||
// The primary and compatibility envelopes carry the same authenticated
|
|
||||||
// embedded payload and must invoke message side effects only once.
|
|
||||||
#expect(context.handledPrivateMessages.count == 1)
|
#expect(context.handledPrivateMessages.count == 1)
|
||||||
#expect(context.handledPrivateMessages.first?.senderPubkey == sender.publicKeyHex)
|
#expect(context.handledPrivateMessages.first?.senderPubkey == sender.publicKeyHex)
|
||||||
#expect(context.handledPrivateMessages.first?.convKey == convKey)
|
#expect(context.handledPrivateMessages.first?.convKey == convKey)
|
||||||
@@ -372,84 +368,34 @@ struct ChatNostrCoordinatorContextTests {
|
|||||||
#expect(pm.messageID == "gm-1")
|
#expect(pm.messageID == "gm-1")
|
||||||
#expect(pm.content == "psst")
|
#expect(pm.content == "psst")
|
||||||
|
|
||||||
// The same private envelope is dropped on replay.
|
// The same gift wrap is dropped on replay.
|
||||||
coordinator.inbound.handlePrivateEnvelope(envelopes[0], id: recipient)
|
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
|
||||||
#expect(context.recordedNostrEventIDs == envelopes.map(\.id))
|
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
||||||
#expect(context.handledPrivateMessages.count == 1)
|
#expect(context.handledPrivateMessages.count == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func migrationEnvelopePairs_processEachMessageAndAckOnlyOnce() throws {
|
func processNostrMessage_invalidSignatureDoesNotPoisonDedup() async throws {
|
||||||
let context = MockChatNostrContext()
|
|
||||||
let coordinator = ChatNostrCoordinator(context: context)
|
|
||||||
let recipient = try NostrIdentity.generate()
|
|
||||||
let sender = try NostrIdentity.generate()
|
|
||||||
let messageContent = try #require(NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
|
|
||||||
content: "migration message",
|
|
||||||
messageID: "migration-message-id",
|
|
||||||
senderPeerID: PeerID(str: "aabbccddeeff0011")
|
|
||||||
))
|
|
||||||
let deliveredContent = try #require(NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(
|
|
||||||
type: .delivered,
|
|
||||||
messageID: "migration-ack-id",
|
|
||||||
senderPeerID: PeerID(str: "aabbccddeeff0011")
|
|
||||||
))
|
|
||||||
let readContent = try #require(NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(
|
|
||||||
type: .readReceipt,
|
|
||||||
messageID: "migration-ack-id",
|
|
||||||
senderPeerID: PeerID(str: "aabbccddeeff0011")
|
|
||||||
))
|
|
||||||
|
|
||||||
for content in [messageContent, deliveredContent, readContent] {
|
|
||||||
let envelopes = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
|
||||||
content: content,
|
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
|
||||||
senderIdentity: sender
|
|
||||||
)
|
|
||||||
#expect(envelopes.count == 2)
|
|
||||||
for envelope in envelopes {
|
|
||||||
coordinator.inbound.handlePrivateEnvelope(envelope, id: recipient)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#expect(context.handledPrivateMessages.count == 1)
|
|
||||||
#expect(context.handledDelivered.count == 1)
|
|
||||||
#expect(context.handledReadReceipts.count == 1)
|
|
||||||
|
|
||||||
// A later same-format re-envelope is a delivery retry, not the
|
|
||||||
// migration twin, so it must still reach downstream message-ID dedup
|
|
||||||
// and acknowledgement resend logic.
|
|
||||||
let primaryRetry = try NostrProtocol.createPrivateEnvelope(
|
|
||||||
content: messageContent,
|
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
|
||||||
senderIdentity: sender
|
|
||||||
)
|
|
||||||
coordinator.inbound.handlePrivateEnvelope(primaryRetry, id: recipient)
|
|
||||||
#expect(context.handledPrivateMessages.count == 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func processAccountPrivateEnvelope_invalidSignatureDoesNotPoisonDedup() async throws {
|
|
||||||
let context = MockChatNostrContext()
|
let context = MockChatNostrContext()
|
||||||
let coordinator = ChatNostrCoordinator(context: context)
|
let coordinator = ChatNostrCoordinator(context: context)
|
||||||
|
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||||
content: "verify:noop",
|
content: "verify:noop",
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
var invalidEnvelope = envelope
|
var invalidGiftWrap = giftWrap
|
||||||
invalidEnvelope.sig = String(repeating: "0", count: 128)
|
invalidGiftWrap.sig = String(repeating: "0", count: 128)
|
||||||
|
|
||||||
// A forged-signature copy is rejected WITHOUT entering the dedup set...
|
// A forged-signature copy is rejected WITHOUT entering the dedup set...
|
||||||
await coordinator.inbound.processAccountPrivateEnvelope(invalidEnvelope)
|
await coordinator.inbound.processNostrMessage(invalidGiftWrap)
|
||||||
#expect(context.recordedNostrEventIDs.isEmpty)
|
#expect(context.recordedNostrEventIDs.isEmpty)
|
||||||
|
|
||||||
// ...so the genuine event with the same ID still processes and records.
|
// ...so the genuine event with the same ID still processes and records.
|
||||||
await coordinator.inbound.processAccountPrivateEnvelope(envelope)
|
await coordinator.inbound.processNostrMessage(giftWrap)
|
||||||
#expect(context.recordedNostrEventIDs == [envelope.id])
|
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
|
|||||||
@@ -393,25 +393,20 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func privateEnvelope_rejectsOversizedEmbeddedPacketBeforePublication() async throws {
|
func subscribeGiftWrap_rejectsOversizedEmbeddedPacket() async throws {
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
|
|
||||||
let oversized = Data(repeating: 0x41, count: FileTransferLimits.maxFramedFileBytes + 1)
|
let oversized = Data(repeating: 0x41, count: FileTransferLimits.maxFramedFileBytes + 1)
|
||||||
let content = "bitchat1:" + base64URLEncode(oversized)
|
let content = "bitchat1:" + base64URLEncode(oversized)
|
||||||
do {
|
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||||
_ = try NostrProtocol.createPrivateEnvelope(
|
content: content,
|
||||||
content: content,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
senderIdentity: sender
|
||||||
senderIdentity: sender
|
)
|
||||||
)
|
|
||||||
Issue.record("Expected oversized private-envelope plaintext to be rejected")
|
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
|
||||||
} catch NostrError.invalidCiphertext {
|
|
||||||
// Rejected before encryption/publication, as intended.
|
|
||||||
} catch {
|
|
||||||
Issue.record("Expected NostrError.invalidCiphertext, got \(error)")
|
|
||||||
}
|
|
||||||
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||||
#expect(viewModel.privateChats.isEmpty)
|
#expect(viewModel.privateChats.isEmpty)
|
||||||
@@ -456,7 +451,7 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func subscribePrivateEnvelope_deliveredAckUpdatesExistingMessage() async throws {
|
func subscribeGiftWrap_deliveredAckUpdatesExistingMessage() async throws {
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
@@ -478,13 +473,13 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
], for: convKey)
|
], for: convKey)
|
||||||
|
|
||||||
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
||||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||||
content: content,
|
content: content,
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
viewModel.subscribePrivateEnvelope(envelope, id: recipient)
|
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
|
||||||
|
|
||||||
let didUpdate = await TestHelpers.waitUntil(
|
let didUpdate = await TestHelpers.waitUntil(
|
||||||
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||||
@@ -494,7 +489,7 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func subscribePrivateEnvelope_readAckUpdatesExistingMessage() async throws {
|
func subscribeGiftWrap_readAckUpdatesExistingMessage() async throws {
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
@@ -516,13 +511,13 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
], for: convKey)
|
], for: convKey)
|
||||||
|
|
||||||
let content = try ackContent(type: .readReceipt, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
let content = try ackContent(type: .readReceipt, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
||||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||||
content: content,
|
content: content,
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
viewModel.subscribePrivateEnvelope(envelope, id: recipient)
|
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
|
||||||
|
|
||||||
let didUpdate = await TestHelpers.waitUntil(
|
let didUpdate = await TestHelpers.waitUntil(
|
||||||
{ isRead(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
{ isRead(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||||
@@ -532,28 +527,28 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func handlePrivateEnvelope_privateMessageStoresConversationAndMapping() async throws {
|
func handleGiftWrap_privateMessageStoresConversationAndMapping() async throws {
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
let messageID = "envelope-private"
|
let messageID = "gift-private"
|
||||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||||
|
|
||||||
let content = try privateMessageContent(
|
let content = try privateMessageContent(
|
||||||
text: "Hello from private envelope",
|
text: "Hello from gift wrap",
|
||||||
messageID: messageID,
|
messageID: messageID,
|
||||||
senderPeerID: PeerID(str: "0123456789abcdef")
|
senderPeerID: PeerID(str: "0123456789abcdef")
|
||||||
)
|
)
|
||||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||||
content: content,
|
content: content,
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
viewModel.handlePrivateEnvelope(envelope, id: recipient)
|
viewModel.handleGiftWrap(giftWrap, id: recipient)
|
||||||
|
|
||||||
let didStore = await TestHelpers.waitUntil(
|
let didStore = await TestHelpers.waitUntil(
|
||||||
{ viewModel.privateChats[convKey]?.first?.content == "Hello from private envelope" },
|
{ viewModel.privateChats[convKey]?.first?.content == "Hello from gift wrap" },
|
||||||
timeout: 5.0
|
timeout: 5.0
|
||||||
)
|
)
|
||||||
#expect(didStore)
|
#expect(didStore)
|
||||||
@@ -562,11 +557,11 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func handlePrivateEnvelope_blockedSenderSkipsMessageStorage() async throws {
|
func handleGiftWrap_blockedSenderSkipsMessageStorage() async throws {
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
let messageID = "envelope-blocked"
|
let messageID = "gift-blocked"
|
||||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||||
|
|
||||||
viewModel.identityManager.setNostrBlocked(sender.publicKeyHex, isBlocked: true)
|
viewModel.identityManager.setNostrBlocked(sender.publicKeyHex, isBlocked: true)
|
||||||
@@ -576,13 +571,13 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
messageID: messageID,
|
messageID: messageID,
|
||||||
senderPeerID: PeerID(str: "0123456789abcdef")
|
senderPeerID: PeerID(str: "0123456789abcdef")
|
||||||
)
|
)
|
||||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||||
content: content,
|
content: content,
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
viewModel.handlePrivateEnvelope(envelope, id: recipient)
|
viewModel.handleGiftWrap(giftWrap, id: recipient)
|
||||||
|
|
||||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||||
#expect(viewModel.privateChats[convKey] == nil)
|
#expect(viewModel.privateChats[convKey] == nil)
|
||||||
@@ -590,12 +585,12 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func handlePrivateEnvelope_deliveredAckUpdatesExistingMessage() async throws {
|
func handleGiftWrap_deliveredAckUpdatesExistingMessage() async throws {
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||||
let messageID = "envelope-delivered"
|
let messageID = "gift-delivered"
|
||||||
|
|
||||||
viewModel.seedPrivateChat([
|
viewModel.seedPrivateChat([
|
||||||
BitchatMessage(
|
BitchatMessage(
|
||||||
@@ -612,13 +607,13 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
], for: convKey)
|
], for: convKey)
|
||||||
|
|
||||||
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
||||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||||
content: content,
|
content: content,
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
viewModel.handlePrivateEnvelope(envelope, id: recipient)
|
viewModel.handleGiftWrap(giftWrap, id: recipient)
|
||||||
|
|
||||||
let didUpdate = await TestHelpers.waitUntil(
|
let didUpdate = await TestHelpers.waitUntil(
|
||||||
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||||
|
|||||||
@@ -45,6 +45,154 @@ private func makeDirectConversationID(_ suffix: String) -> ConversationID {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deliberately simple O(n) model used to differentially test the store's
|
||||||
|
/// optimized logical-index bookkeeping. It models observable behavior only;
|
||||||
|
/// it has no offset or ID index and therefore cannot reproduce the same bug.
|
||||||
|
private struct ReferenceConversationTimeline {
|
||||||
|
struct Message: Equatable {
|
||||||
|
let id: String
|
||||||
|
let timestamp: Date
|
||||||
|
let content: String
|
||||||
|
var deliveryStatus: DeliveryStatus?
|
||||||
|
|
||||||
|
init(_ message: BitchatMessage) {
|
||||||
|
id = message.id
|
||||||
|
timestamp = message.timestamp
|
||||||
|
content = message.content
|
||||||
|
deliveryStatus = message.deliveryStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppendResult {
|
||||||
|
let inserted: Bool
|
||||||
|
let trimmedCount: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
let cap: Int
|
||||||
|
private(set) var messages: [Message] = []
|
||||||
|
|
||||||
|
func contains(_ id: String) -> Bool {
|
||||||
|
messages.contains { $0.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func append(_ message: BitchatMessage) -> AppendResult {
|
||||||
|
guard !contains(message.id) else {
|
||||||
|
return AppendResult(inserted: false, trimmedCount: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshot = Message(message)
|
||||||
|
var low = 0
|
||||||
|
var high = messages.count
|
||||||
|
while low < high {
|
||||||
|
let mid = (low + high) / 2
|
||||||
|
if messages[mid].timestamp <= snapshot.timestamp {
|
||||||
|
low = mid + 1
|
||||||
|
} else {
|
||||||
|
high = mid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messages.insert(snapshot, at: low)
|
||||||
|
|
||||||
|
let overflow = max(0, messages.count - cap)
|
||||||
|
if overflow > 0 {
|
||||||
|
messages.removeFirst(overflow)
|
||||||
|
}
|
||||||
|
return AppendResult(inserted: true, trimmedCount: overflow)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func upsert(_ message: BitchatMessage) -> Int {
|
||||||
|
if let index = messages.firstIndex(where: { $0.id == message.id }) {
|
||||||
|
messages[index] = Message(message)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return append(message).trimmedCount
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func applyDeliveryStatus(_ status: DeliveryStatus, to id: String) -> Bool {
|
||||||
|
guard let index = messages.firstIndex(where: { $0.id == id }),
|
||||||
|
messages[index].deliveryStatus != status else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// The differential stream uses only unique `.delivered` values (or
|
||||||
|
// an exact repeat), so no-downgrade policy is intentionally outside
|
||||||
|
// this index-focused reference model.
|
||||||
|
messages[index].deliveryStatus = status
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func remove(at index: Int) -> Message {
|
||||||
|
messages.remove(at: index)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll(where predicate: (Message) -> Bool) {
|
||||||
|
messages.removeAll(where: predicate)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func clear() {
|
||||||
|
messages.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ConversationStoreDifferentialRNG {
|
||||||
|
private var state: UInt64
|
||||||
|
|
||||||
|
init(seed: UInt64) {
|
||||||
|
state = seed
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func next() -> UInt64 {
|
||||||
|
state &+= 0x9E37_79B9_7F4A_7C15
|
||||||
|
var value = state
|
||||||
|
value = (value ^ (value >> 30)) &* 0xBF58_476D_1CE4_E5B9
|
||||||
|
value = (value ^ (value >> 27)) &* 0x94D0_49BB_1331_11EB
|
||||||
|
return value ^ (value >> 31)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func index(upperBound: Int) -> Int {
|
||||||
|
precondition(upperBound > 0)
|
||||||
|
return Int(next() % UInt64(upperBound))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func expectStore(
|
||||||
|
_ store: ConversationStore,
|
||||||
|
matches reference: ReferenceConversationTimeline,
|
||||||
|
issuedIDs: [String],
|
||||||
|
checkpoint: String
|
||||||
|
) {
|
||||||
|
let conversation = store.conversation(for: .mesh)
|
||||||
|
let actual = conversation.messages.map(ReferenceConversationTimeline.Message.init)
|
||||||
|
#expect(actual == reference.messages, "timeline mismatch at \(checkpoint)")
|
||||||
|
|
||||||
|
let lookupSnapshot = reference.messages.compactMap { expected in
|
||||||
|
conversation.message(withID: expected.id).map(ReferenceConversationTimeline.Message.init)
|
||||||
|
}
|
||||||
|
#expect(lookupSnapshot == reference.messages, "ID lookup mismatch at \(checkpoint)")
|
||||||
|
#expect(
|
||||||
|
Set(conversation.messageIDs) == Set(reference.messages.map(\.id)),
|
||||||
|
"per-conversation ID set mismatch at \(checkpoint)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if !reference.messages.isEmpty {
|
||||||
|
for index in Set([0, reference.messages.count / 2, reference.messages.count - 1]) {
|
||||||
|
let id = reference.messages[index].id
|
||||||
|
#expect(store.conversationIDs(forMessageID: id) == [.mesh], "store ID map mismatch at \(checkpoint)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let activeIDs = Set(reference.messages.map(\.id))
|
||||||
|
var checkedStaleIDs = 0
|
||||||
|
for id in issuedIDs.reversed() where !activeIDs.contains(id) {
|
||||||
|
#expect(conversation.message(withID: id) == nil, "stale conversation index entry at \(checkpoint)")
|
||||||
|
#expect(store.conversationIDs(forMessageID: id).isEmpty, "stale store ID map entry at \(checkpoint)")
|
||||||
|
checkedStaleIDs += 1
|
||||||
|
if checkedStaleIDs == 16 { break }
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(store.auditInvariants().isEmpty, "invariant audit failed at \(checkpoint)")
|
||||||
|
}
|
||||||
|
|
||||||
@Suite("ConversationStore")
|
@Suite("ConversationStore")
|
||||||
struct ConversationStoreTests {
|
struct ConversationStoreTests {
|
||||||
|
|
||||||
@@ -140,6 +288,282 @@ struct ConversationStoreTests {
|
|||||||
#expect(conversation.message(withID: probeID)?.deliveryStatus == .sent)
|
#expect(conversation.message(withID: probeID)?.deliveryStatus == .sent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("steady-state cap trimming keeps lookups exact across mixed mutations")
|
||||||
|
@MainActor
|
||||||
|
func steadyStateCapTrimmingKeepsLogicalIndexExact() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
let conversation = store.conversation(for: .mesh)
|
||||||
|
let overflow = 64
|
||||||
|
|
||||||
|
for i in 0..<(conversation.cap + overflow) {
|
||||||
|
store.append(makeMessage(id: "m\(i)", timestamp: TimeInterval(i)), to: .mesh)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(conversation.messages.first?.id == "m\(overflow)")
|
||||||
|
#expect(conversation.message(withID: "m\(overflow)")?.id == "m\(overflow)")
|
||||||
|
|
||||||
|
// Exercise a suffix reindex after the head offset has advanced, then
|
||||||
|
// trim the old head. The late row becomes the new first element.
|
||||||
|
let late = makeMessage(id: "late", timestamp: TimeInterval(overflow) + 0.5)
|
||||||
|
#expect(store.append(late, to: .mesh))
|
||||||
|
#expect(conversation.messages.first?.id == "late")
|
||||||
|
#expect(conversation.message(withID: "m\(overflow + 1)")?.id == "m\(overflow + 1)")
|
||||||
|
|
||||||
|
// Head and middle removals, an in-place upsert, and a status update
|
||||||
|
// must all resolve through the same logical index representation.
|
||||||
|
#expect(store.removeMessage(withID: "late", from: .mesh)?.id == "late")
|
||||||
|
let middleID = "m\(overflow + conversation.cap / 2)"
|
||||||
|
#expect(store.removeMessage(withID: middleID, from: .mesh)?.id == middleID)
|
||||||
|
|
||||||
|
let probeID = "m\(overflow + 10)"
|
||||||
|
store.upsertByID(
|
||||||
|
makeMessage(id: probeID, timestamp: TimeInterval(overflow + 10), content: "edited"),
|
||||||
|
in: .mesh
|
||||||
|
)
|
||||||
|
#expect(conversation.message(withID: probeID)?.content == "edited")
|
||||||
|
#expect(store.setDeliveryStatus(.sent, forMessageID: probeID, in: .mesh))
|
||||||
|
#expect(conversation.message(withID: probeID)?.deliveryStatus == .sent)
|
||||||
|
#expect(store.auditInvariants().isEmpty)
|
||||||
|
|
||||||
|
// Clearing resets the logical offset as well as the maps.
|
||||||
|
store.clear(.mesh)
|
||||||
|
#expect(store.append(makeMessage(id: "after-clear", timestamp: 10_000), to: .mesh))
|
||||||
|
#expect(conversation.message(withID: "after-clear")?.id == "after-clear")
|
||||||
|
#expect(store.auditInvariants().isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("logical index offset matches a reference model under adversarial mutations")
|
||||||
|
@MainActor
|
||||||
|
func logicalIndexOffsetDifferentialStress() async {
|
||||||
|
let store = ConversationStore()
|
||||||
|
let cap = store.conversation(for: .mesh).cap
|
||||||
|
var reference = ReferenceConversationTimeline(cap: cap)
|
||||||
|
var rng = ConversationStoreDifferentialRNG(seed: 0xC0FF_EE13_37CA_FE42)
|
||||||
|
var issuedIDs: [String] = []
|
||||||
|
var nextID = 0
|
||||||
|
var nextTailTimestamp: TimeInterval = 1_700_000_000
|
||||||
|
var trimmedCount = 0
|
||||||
|
|
||||||
|
var tailAppendCount = 0
|
||||||
|
var outOfOrderCount = 0
|
||||||
|
var duplicateOrReuseCount = 0
|
||||||
|
var headRemovalCount = 0
|
||||||
|
var middleRemovalCount = 0
|
||||||
|
var upsertCount = 0
|
||||||
|
var deliveryUpdateCount = 0
|
||||||
|
var filterCount = 0
|
||||||
|
var clearCount = 0
|
||||||
|
|
||||||
|
func issueMessage(timestamp: TimeInterval? = nil, tag: String) -> BitchatMessage {
|
||||||
|
let number = nextID
|
||||||
|
nextID += 1
|
||||||
|
let id = "diff-\(number)"
|
||||||
|
issuedIDs.append(id)
|
||||||
|
let resolvedTimestamp: TimeInterval
|
||||||
|
if let timestamp {
|
||||||
|
resolvedTimestamp = timestamp
|
||||||
|
} else {
|
||||||
|
resolvedTimestamp = nextTailTimestamp
|
||||||
|
nextTailTimestamp += 1
|
||||||
|
}
|
||||||
|
let dropMarker = number.isMultiple(of: 11) ? " [drop]" : ""
|
||||||
|
return makeMessage(
|
||||||
|
id: id,
|
||||||
|
timestamp: resolvedTimestamp,
|
||||||
|
content: "\(tag) \(number)\(dropMarker)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func appendAndCompare(_ message: BitchatMessage, checkpoint: String) -> ReferenceConversationTimeline.AppendResult {
|
||||||
|
let expected = reference.append(message)
|
||||||
|
let actual = store.append(message, to: .mesh)
|
||||||
|
#expect(actual == expected.inserted, "append result mismatch at \(checkpoint)")
|
||||||
|
trimmedCount += expected.trimmedCount
|
||||||
|
return expected
|
||||||
|
}
|
||||||
|
|
||||||
|
func refill(extra: Int, checkpoint: String) async {
|
||||||
|
let appendCount = max(0, cap - reference.messages.count) + extra
|
||||||
|
for index in 0..<appendCount {
|
||||||
|
appendAndCompare(
|
||||||
|
issueMessage(tag: "refill"),
|
||||||
|
checkpoint: "\(checkpoint)-\(index)"
|
||||||
|
)
|
||||||
|
if index.isMultiple(of: 64) {
|
||||||
|
await Task.yield()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expectStore(store, matches: reference, issuedIDs: issuedIDs, checkpoint: checkpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start well into steady state so the offset is already non-zero
|
||||||
|
// before any mixed operations begin.
|
||||||
|
await refill(extra: 384, checkpoint: "initial steady-state fill")
|
||||||
|
|
||||||
|
for step in 0..<1_200 {
|
||||||
|
if step == 300 || step == 900 {
|
||||||
|
store.removeMessages(from: .mesh) { $0.content.contains("[drop]") }
|
||||||
|
reference.removeAll { $0.content.contains("[drop]") }
|
||||||
|
filterCount += 1
|
||||||
|
expectStore(
|
||||||
|
store,
|
||||||
|
matches: reference,
|
||||||
|
issuedIDs: issuedIDs,
|
||||||
|
checkpoint: "filter at step \(step)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if step == 600 {
|
||||||
|
store.clear(.mesh)
|
||||||
|
reference.clear()
|
||||||
|
clearCount += 1
|
||||||
|
expectStore(
|
||||||
|
store,
|
||||||
|
matches: reference,
|
||||||
|
issuedIDs: issuedIDs,
|
||||||
|
checkpoint: "clear at step \(step)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch rng.index(upperBound: 100) {
|
||||||
|
case 0..<35:
|
||||||
|
appendAndCompare(issueMessage(tag: "tail"), checkpoint: "tail append \(step)")
|
||||||
|
tailAppendCount += 1
|
||||||
|
|
||||||
|
case 35..<55:
|
||||||
|
if reference.messages.isEmpty {
|
||||||
|
appendAndCompare(issueMessage(tag: "tail-fallback"), checkpoint: "OOO fallback \(step)")
|
||||||
|
} else {
|
||||||
|
let target = reference.messages[rng.index(upperBound: reference.messages.count)]
|
||||||
|
let jitter = [-0.25, 0.0, 0.25][rng.index(upperBound: 3)]
|
||||||
|
let timestamp = target.timestamp.timeIntervalSince1970 + jitter
|
||||||
|
appendAndCompare(
|
||||||
|
issueMessage(timestamp: timestamp, tag: "out-of-order"),
|
||||||
|
checkpoint: "out-of-order append \(step)"
|
||||||
|
)
|
||||||
|
outOfOrderCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
case 55..<65:
|
||||||
|
if issuedIDs.isEmpty {
|
||||||
|
appendAndCompare(issueMessage(tag: "reuse-fallback"), checkpoint: "reuse fallback \(step)")
|
||||||
|
} else {
|
||||||
|
let reusedID = issuedIDs[rng.index(upperBound: issuedIDs.count)]
|
||||||
|
let message = makeMessage(
|
||||||
|
id: reusedID,
|
||||||
|
timestamp: nextTailTimestamp,
|
||||||
|
content: "duplicate-or-trimmed-reuse \(step)"
|
||||||
|
)
|
||||||
|
nextTailTimestamp += 1
|
||||||
|
appendAndCompare(message, checkpoint: "duplicate or reuse \(step)")
|
||||||
|
duplicateOrReuseCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
case 65..<73:
|
||||||
|
if !reference.messages.isEmpty {
|
||||||
|
let expected = reference.remove(at: 0)
|
||||||
|
let actual = store.removeMessage(withID: expected.id, from: .mesh)
|
||||||
|
.map(ReferenceConversationTimeline.Message.init)
|
||||||
|
#expect(actual == expected, "head removal mismatch at step \(step)")
|
||||||
|
headRemovalCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
case 73..<81:
|
||||||
|
if !reference.messages.isEmpty {
|
||||||
|
let middleStart = reference.messages.count / 4
|
||||||
|
let middleWidth = max(1, reference.messages.count / 2)
|
||||||
|
let index = min(
|
||||||
|
reference.messages.count - 1,
|
||||||
|
middleStart + rng.index(upperBound: middleWidth)
|
||||||
|
)
|
||||||
|
let expected = reference.remove(at: index)
|
||||||
|
let actual = store.removeMessage(withID: expected.id, from: .mesh)
|
||||||
|
.map(ReferenceConversationTimeline.Message.init)
|
||||||
|
#expect(actual == expected, "middle removal mismatch at step \(step)")
|
||||||
|
middleRemovalCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
case 81..<90:
|
||||||
|
let message: BitchatMessage
|
||||||
|
if step.isMultiple(of: 4) || reference.messages.isEmpty {
|
||||||
|
let timestamp = reference.messages.isEmpty
|
||||||
|
? nil
|
||||||
|
: reference.messages[rng.index(upperBound: reference.messages.count)]
|
||||||
|
.timestamp.timeIntervalSince1970
|
||||||
|
message = issueMessage(timestamp: timestamp, tag: "upsert-new")
|
||||||
|
} else {
|
||||||
|
let current = reference.messages[rng.index(upperBound: reference.messages.count)]
|
||||||
|
message = makeMessage(
|
||||||
|
id: current.id,
|
||||||
|
timestamp: current.timestamp.timeIntervalSince1970,
|
||||||
|
content: "upsert-existing \(step)",
|
||||||
|
deliveryStatus: current.deliveryStatus
|
||||||
|
)
|
||||||
|
}
|
||||||
|
trimmedCount += reference.upsert(message)
|
||||||
|
store.upsertByID(message, in: .mesh)
|
||||||
|
upsertCount += 1
|
||||||
|
|
||||||
|
default:
|
||||||
|
let id: String
|
||||||
|
let repeatedStatus: DeliveryStatus?
|
||||||
|
if step.isMultiple(of: 6) || reference.messages.isEmpty {
|
||||||
|
id = "missing-\(step)"
|
||||||
|
repeatedStatus = nil
|
||||||
|
} else {
|
||||||
|
let current = reference.messages[rng.index(upperBound: reference.messages.count)]
|
||||||
|
id = current.id
|
||||||
|
repeatedStatus = current.deliveryStatus
|
||||||
|
}
|
||||||
|
let status: DeliveryStatus
|
||||||
|
if step.isMultiple(of: 4), let repeatedStatus {
|
||||||
|
status = repeatedStatus
|
||||||
|
} else {
|
||||||
|
status = .delivered(
|
||||||
|
to: "peer",
|
||||||
|
at: Date(timeIntervalSince1970: 2_000_000_000 + Double(step))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let expected = reference.applyDeliveryStatus(status, to: id)
|
||||||
|
let actual = store.setDeliveryStatus(status, forMessageID: id, in: .mesh)
|
||||||
|
#expect(actual == expected, "delivery update mismatch at step \(step)")
|
||||||
|
deliveryUpdateCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
expectStore(
|
||||||
|
store,
|
||||||
|
matches: reference,
|
||||||
|
issuedIDs: issuedIDs,
|
||||||
|
checkpoint: "mixed operation \(step)"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This intentionally expensive MainActor stress test runs beside
|
||||||
|
// async audio/UI tests in SwiftPM's parallel phase. Cooperatively
|
||||||
|
// release the actor so their bounded waits can make progress.
|
||||||
|
await Task.yield()
|
||||||
|
|
||||||
|
if (step + 1).isMultiple(of: 100) {
|
||||||
|
await refill(extra: 32, checkpoint: "periodic refill after step \(step)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guarantee another long run of one-row evictions after every other
|
||||||
|
// mutation family has perturbed and rebuilt the offset/index state.
|
||||||
|
await refill(extra: 512, checkpoint: "final steady-state trim run")
|
||||||
|
|
||||||
|
#expect(trimmedCount > 1_200)
|
||||||
|
#expect(tailAppendCount > 300)
|
||||||
|
#expect(outOfOrderCount > 150)
|
||||||
|
#expect(duplicateOrReuseCount > 75)
|
||||||
|
#expect(headRemovalCount > 50)
|
||||||
|
#expect(middleRemovalCount > 50)
|
||||||
|
#expect(upsertCount > 75)
|
||||||
|
#expect(deliveryUpdateCount > 75)
|
||||||
|
#expect(filterCount == 2)
|
||||||
|
#expect(clearCount == 1)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Upsert
|
// MARK: - Upsert
|
||||||
|
|
||||||
@Test("upsertByID replaces in place and appends when absent")
|
@Test("upsertByID replaces in place and appends when absent")
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
{"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"}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"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"]]}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"recipient_private_key":"8355a5c110cdfef2e644f4ad5d51c39f253b2c2c80ebb6856379fb16531dc1fa"}
|
|
||||||
@@ -2,17 +2,18 @@
|
|||||||
// NostrProtocolTests.swift
|
// NostrProtocolTests.swift
|
||||||
// bitchatTests
|
// bitchatTests
|
||||||
//
|
//
|
||||||
// Tests for BitChat's proprietary private-envelope transport over Nostr.
|
// Tests for NIP-17 gift-wrapped private messages
|
||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
|
import CryptoKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
struct NostrProtocolTests {
|
struct NostrProtocolTests {
|
||||||
|
|
||||||
@Test func privateEnvelopeRoundTrip() throws {
|
@Test func nip17MessageRoundTrip() throws {
|
||||||
// Create sender and recipient identities
|
// Create sender and recipient identities
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
@@ -21,19 +22,21 @@ struct NostrProtocolTests {
|
|||||||
print("Recipient pubkey: \(recipient.publicKeyHex)")
|
print("Recipient pubkey: \(recipient.publicKeyHex)")
|
||||||
|
|
||||||
// Create a test message
|
// Create a test message
|
||||||
let originalContent = "Hello from BitChat private-envelope test!"
|
let originalContent = "Hello from NIP-17 test!"
|
||||||
|
|
||||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
// Create encrypted gift wrap
|
||||||
|
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||||
content: originalContent,
|
content: originalContent,
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
print("Private envelope created with ID: \(envelope.id)")
|
print("Gift wrap created with ID: \(giftWrap.id)")
|
||||||
print("Private envelope pubkey: \(envelope.pubkey)")
|
print("Gift wrap pubkey: \(giftWrap.pubkey)")
|
||||||
|
|
||||||
let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateEnvelope(
|
// Decrypt the gift wrap
|
||||||
envelope: envelope,
|
let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateMessage(
|
||||||
|
giftWrap: giftWrap,
|
||||||
recipientIdentity: recipient
|
recipientIdentity: recipient
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,37 +52,37 @@ struct NostrProtocolTests {
|
|||||||
print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey) at \(messageDate)")
|
print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey) at \(messageDate)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func privateEnvelopesUseUniqueEphemeralKeys() throws {
|
@Test func giftWrapUsesUniqueEphemeralKeys() throws {
|
||||||
// Create identities
|
// Create identities
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
|
|
||||||
// Create two messages
|
// Create two messages
|
||||||
let message1 = try NostrProtocol.createPrivateEnvelope(
|
let message1 = try NostrProtocol.createPrivateMessage(
|
||||||
content: "Message 1",
|
content: "Message 1",
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
let message2 = try NostrProtocol.createPrivateEnvelope(
|
let message2 = try NostrProtocol.createPrivateMessage(
|
||||||
content: "Message 2",
|
content: "Message 2",
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
// Public envelope keys must be one-time use.
|
// Gift wrap pubkeys should be different (unique ephemeral keys)
|
||||||
#expect(message1.pubkey != message2.pubkey)
|
#expect(message1.pubkey != message2.pubkey)
|
||||||
|
|
||||||
print("Message 1 envelope pubkey: \(message1.pubkey)")
|
print("Message 1 gift wrap pubkey: \(message1.pubkey)")
|
||||||
print("Message 2 envelope pubkey: \(message2.pubkey)")
|
print("Message 2 gift wrap pubkey: \(message2.pubkey)")
|
||||||
|
|
||||||
// Both should decrypt successfully
|
// Both should decrypt successfully
|
||||||
let (content1, _, _) = try NostrProtocol.decryptPrivateEnvelope(
|
let (content1, _, _) = try NostrProtocol.decryptPrivateMessage(
|
||||||
envelope: message1,
|
giftWrap: message1,
|
||||||
recipientIdentity: recipient
|
recipientIdentity: recipient
|
||||||
)
|
)
|
||||||
let (content2, _, _) = try NostrProtocol.decryptPrivateEnvelope(
|
let (content2, _, _) = try NostrProtocol.decryptPrivateMessage(
|
||||||
envelope: message2,
|
giftWrap: message2,
|
||||||
recipientIdentity: recipient
|
recipientIdentity: recipient
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -87,410 +90,74 @@ struct NostrProtocolTests {
|
|||||||
#expect(content2 == "Message 2")
|
#expect(content2 == "Message 2")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func privateEnvelopeUsesBitChatWireFormatAtEveryLayer() throws {
|
|
||||||
let sender = try NostrIdentity.generate()
|
|
||||||
let recipient = try NostrIdentity.generate()
|
|
||||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
|
||||||
content: "bitchat-specific",
|
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
|
||||||
senderIdentity: sender
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(envelope.kind == NostrProtocol.EventKind.privateEnvelope.rawValue)
|
|
||||||
#expect(envelope.kind != NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue)
|
|
||||||
#expect(envelope.content.hasPrefix(NostrProtocol.privateEnvelopeContentPrefix))
|
|
||||||
#expect(!envelope.content.hasPrefix("v2:"))
|
|
||||||
#expect(envelope.tags == [["p", recipient.publicKeyHex]])
|
|
||||||
#expect(envelope.created_at <= Int(Date().timeIntervalSince1970))
|
|
||||||
|
|
||||||
let layers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
|
|
||||||
envelope: envelope,
|
|
||||||
recipientIdentity: recipient
|
|
||||||
)
|
|
||||||
#expect(layers.seal.kind == NostrProtocol.EventKind.privateSeal.rawValue)
|
|
||||||
#expect(layers.seal.content.hasPrefix(NostrProtocol.privateEnvelopeContentPrefix))
|
|
||||||
#expect(layers.seal.tags.isEmpty)
|
|
||||||
#expect(layers.message.kind == NostrProtocol.EventKind.privateMessage.rawValue)
|
|
||||||
#expect(layers.message.tags.isEmpty)
|
|
||||||
#expect(layers.message.sig == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func decryptAcceptsReceiveOnlyLegacyBitChatEnvelope() throws {
|
|
||||||
let sender = try NostrIdentity.generate()
|
|
||||||
let recipient = try NostrIdentity.generate()
|
|
||||||
let envelope = try NostrProtocol.createLegacyPrivateEnvelopeForTesting(
|
|
||||||
content: "legacy in-flight message",
|
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
|
||||||
senderIdentity: sender
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(envelope.kind == NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue)
|
|
||||||
#expect(envelope.content.hasPrefix("v2:"))
|
|
||||||
|
|
||||||
let result = try NostrProtocol.decryptPrivateEnvelope(
|
|
||||||
envelope: envelope,
|
|
||||||
recipientIdentity: recipient
|
|
||||||
)
|
|
||||||
#expect(result.content == "legacy in-flight message")
|
|
||||||
#expect(result.senderPubkey == sender.publicKeyHex)
|
|
||||||
|
|
||||||
let layers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
|
|
||||||
envelope: envelope,
|
|
||||||
recipientIdentity: recipient
|
|
||||||
)
|
|
||||||
#expect(layers.seal.kind == NostrProtocol.EventKind.legacyNIP59Seal.rawValue)
|
|
||||||
#expect(layers.message.kind == NostrProtocol.EventKind.legacyNIP17DirectMessage.rawValue)
|
|
||||||
#expect(layers.message.tags.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func decryptAcceptsCurrentAndroidLegacyInnerRecipientTag() throws {
|
|
||||||
let sender = try NostrIdentity.generate()
|
|
||||||
let recipient = try NostrIdentity.generate()
|
|
||||||
|
|
||||||
// Current Android's `createPrivateMessage` emits an unsigned kind-14
|
|
||||||
// inner event with exactly [["p", recipient]], while its outer/seal
|
|
||||||
// layers use the deployed BitChat legacy v2 crypto. This isolated
|
|
||||||
// generator reproduces that cross-platform wire shape without making
|
|
||||||
// the production encoder depend on Android's historical tag choice.
|
|
||||||
let envelope = try NostrProtocol.createLegacyPrivateEnvelopeForTesting(
|
|
||||||
content: "legacy message from Android",
|
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
|
||||||
senderIdentity: sender,
|
|
||||||
innerMessageTags: [["p", recipient.publicKeyHex]]
|
|
||||||
)
|
|
||||||
|
|
||||||
let layers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
|
|
||||||
envelope: envelope,
|
|
||||||
recipientIdentity: recipient
|
|
||||||
)
|
|
||||||
#expect(layers.message.tags == [["p", recipient.publicKeyHex]])
|
|
||||||
|
|
||||||
let result = try NostrProtocol.decryptPrivateEnvelope(
|
|
||||||
envelope: envelope,
|
|
||||||
recipientIdentity: recipient
|
|
||||||
)
|
|
||||||
#expect(result.content == "legacy message from Android")
|
|
||||||
#expect(result.senderPubkey == sender.publicKeyHex)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func decryptsFrozenLegacyEnvelopeProducedByAndroidB7f0b33d() throws {
|
|
||||||
let eventData = try Data(contentsOf: fixtureURL(
|
|
||||||
name: "AndroidLegacyPrivateEnvelopeB7f0b33d"
|
|
||||||
))
|
|
||||||
let metadataData = try Data(contentsOf: fixtureURL(
|
|
||||||
name: "AndroidLegacyPrivateEnvelopeB7f0b33dMetadata"
|
|
||||||
))
|
|
||||||
let envelope = try JSONDecoder().decode(NostrEvent.self, from: eventData)
|
|
||||||
let metadata = try JSONDecoder().decode(AndroidLegacyEnvelopeFixture.self, from: metadataData)
|
|
||||||
let recipientKey = try #require(Data(hexString: metadata.recipientPrivateKey))
|
|
||||||
let recipient = try NostrIdentity(privateKeyData: recipientKey)
|
|
||||||
|
|
||||||
#expect(metadata.androidCommit == "b7f0b33d3a267c770d3d5a65ee2d8c7e755450db")
|
|
||||||
#expect(metadata.generator == "NostrProtocol.createPrivateMessage")
|
|
||||||
#expect(metadata.generatorPatch == "AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch")
|
|
||||||
#expect(metadata.fixtureSHA256 == eventData.sha256Fingerprint())
|
|
||||||
#expect(metadata.gradleTest.contains("NostrProtocolTest.emitCrossPlatformFixtures"))
|
|
||||||
let generatorPatch = try String(contentsOf: fixtureURL(
|
|
||||||
name: "AndroidLegacyPrivateEnvelopeB7f0b33dGenerator",
|
|
||||||
extension: "patch"
|
|
||||||
))
|
|
||||||
#expect(metadata.generatorPatchSHA256 == Data(generatorPatch.utf8).sha256Fingerprint())
|
|
||||||
#expect(generatorPatch.contains("fun emitCrossPlatformFixtures()"))
|
|
||||||
#expect(generatorPatch.contains(metadata.recipientPrivateKey))
|
|
||||||
#expect(envelope.isValidSignature())
|
|
||||||
#expect(envelope.kind == NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue)
|
|
||||||
#expect(envelope.tags == [["p", recipient.publicKeyHex]])
|
|
||||||
|
|
||||||
let layers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
|
|
||||||
envelope: envelope,
|
|
||||||
recipientIdentity: recipient
|
|
||||||
)
|
|
||||||
#expect(layers.message.tags == [["p", recipient.publicKeyHex]])
|
|
||||||
|
|
||||||
let result = try NostrProtocol.decryptPrivateEnvelope(
|
|
||||||
envelope: envelope,
|
|
||||||
recipientIdentity: recipient
|
|
||||||
)
|
|
||||||
#expect(result.content == "legacy fixture from Android b7f0b33d")
|
|
||||||
#expect(result.senderPubkey == metadata.senderPublicKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func decryptRejectsAlternateLegacyInnerTags() throws {
|
|
||||||
let sender = try NostrIdentity.generate()
|
|
||||||
let recipient = try NostrIdentity.generate()
|
|
||||||
let otherRecipient = try NostrIdentity.generate()
|
|
||||||
let invalidTagShapes = [
|
|
||||||
[["p", otherRecipient.publicKeyHex]],
|
|
||||||
[["p", recipient.publicKeyHex], ["p", recipient.publicKeyHex]],
|
|
||||||
[["p", recipient.publicKeyHex, "unexpected"]],
|
|
||||||
[["x", recipient.publicKeyHex]]
|
|
||||||
]
|
|
||||||
|
|
||||||
for tags in invalidTagShapes {
|
|
||||||
let envelope = try NostrProtocol.createLegacyPrivateEnvelopeForTesting(
|
|
||||||
content: "invalid Android-shaped tags",
|
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
|
||||||
senderIdentity: sender,
|
|
||||||
innerMessageTags: tags
|
|
||||||
)
|
|
||||||
expectInvalidEvent {
|
|
||||||
_ = try NostrProtocol.decryptPrivateEnvelope(
|
|
||||||
envelope: envelope,
|
|
||||||
recipientIdentity: recipient
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func newPrivateEnvelopeRejectsInnerRecipientTag() throws {
|
|
||||||
let sender = try NostrIdentity.generate()
|
|
||||||
let recipient = try NostrIdentity.generate()
|
|
||||||
let envelope = try NostrProtocol.createPrivateEnvelopeWithInnerTagsForTesting(
|
|
||||||
content: "new format stays strict",
|
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
|
||||||
senderIdentity: sender,
|
|
||||||
innerMessageTags: [["p", recipient.publicKeyHex]]
|
|
||||||
)
|
|
||||||
|
|
||||||
expectInvalidEvent {
|
|
||||||
_ = try NostrProtocol.decryptPrivateEnvelope(
|
|
||||||
envelope: envelope,
|
|
||||||
recipientIdentity: recipient
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func decryptsFrozenLegacyEnvelopeProducedByRelease733098bb() throws {
|
|
||||||
let eventData = try Data(contentsOf: fixtureURL(
|
|
||||||
name: "LegacyPrivateEnvelope733098bb"
|
|
||||||
))
|
|
||||||
let keyData = try Data(contentsOf: fixtureURL(
|
|
||||||
name: "LegacyPrivateEnvelope733098bbRecipientKey"
|
|
||||||
))
|
|
||||||
let envelope = try JSONDecoder().decode(NostrEvent.self, from: eventData)
|
|
||||||
let keyFixture = try JSONDecoder().decode(LegacyRecipientKeyFixture.self, from: keyData)
|
|
||||||
let recipientKey = try #require(Data(hexString: keyFixture.recipientPrivateKey))
|
|
||||||
let recipient = try NostrIdentity(privateKeyData: recipientKey)
|
|
||||||
|
|
||||||
#expect(envelope.isValidSignature())
|
|
||||||
let result = try NostrProtocol.decryptPrivateEnvelope(
|
|
||||||
envelope: envelope,
|
|
||||||
recipientIdentity: recipient
|
|
||||||
)
|
|
||||||
#expect(result.content == "legacy fixture from 733098bb")
|
|
||||||
#expect(result.senderPubkey == "2e3d79df7047204f02b726c574e256f8de1dd80510f7dcb8b0d12df13acb87e6")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func publicationBatchAlwaysDualPublishesForCoordinatedMigration() throws {
|
|
||||||
let sender = try NostrIdentity.generate()
|
|
||||||
let recipient = try NostrIdentity.generate()
|
|
||||||
|
|
||||||
let migrationBatch = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
|
||||||
content: "mixed-version",
|
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
|
||||||
senderIdentity: sender
|
|
||||||
)
|
|
||||||
#expect(migrationBatch.map(\.kind) == [
|
|
||||||
NostrProtocol.EventKind.privateEnvelope.rawValue,
|
|
||||||
NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue
|
|
||||||
])
|
|
||||||
for envelope in migrationBatch {
|
|
||||||
let result = try NostrProtocol.decryptPrivateEnvelope(
|
|
||||||
envelope: envelope,
|
|
||||||
recipientIdentity: recipient
|
|
||||||
)
|
|
||||||
#expect(result.content == "mixed-version")
|
|
||||||
}
|
|
||||||
|
|
||||||
// The compatibility copy retains the exact released-iOS legacy shape,
|
|
||||||
// which current Android also accepts: kinds 1059/13/14, v2 prefix, and
|
|
||||||
// no inner tags. There is no wall-clock branch that can silently stop
|
|
||||||
// old-client delivery.
|
|
||||||
let compatibilityLayers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
|
|
||||||
envelope: migrationBatch[1],
|
|
||||||
recipientIdentity: recipient
|
|
||||||
)
|
|
||||||
#expect(migrationBatch[1].content.hasPrefix("v2:"))
|
|
||||||
#expect(compatibilityLayers.seal.kind == NostrProtocol.EventKind.legacyNIP59Seal.rawValue)
|
|
||||||
#expect(compatibilityLayers.message.kind == NostrProtocol.EventKind.legacyNIP17DirectMessage.rawValue)
|
|
||||||
#expect(compatibilityLayers.message.tags.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func mailboxLookbackCoversDeliveryWindowAndroidFuzzAndClockSkew() {
|
|
||||||
let sentAt = Date(timeIntervalSince1970: 1_800_000_000)
|
|
||||||
let earliestAndroidTimestamp = sentAt.addingTimeInterval(
|
|
||||||
-TransportConfig.nostrLegacyAndroidTimestampFuzzSeconds
|
|
||||||
)
|
|
||||||
let subscribeAtDeliveryBoundary = sentAt.addingTimeInterval(
|
|
||||||
TransportConfig.nostrPrivateEnvelopeDeliveryWindowSeconds
|
|
||||||
)
|
|
||||||
let filterSince = subscribeAtDeliveryBoundary.addingTimeInterval(
|
|
||||||
-TransportConfig.nostrDMSubscribeLookbackSeconds
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(TransportConfig.nostrPrivateEnvelopeDeliveryWindowSeconds == 24 * 60 * 60)
|
|
||||||
#expect(TransportConfig.nostrLegacyAndroidTimestampFuzzSeconds == 48 * 60 * 60)
|
|
||||||
#expect(TransportConfig.nostrDMSubscribeClockSkewSeconds == 15 * 60)
|
|
||||||
let expectedLookback: TimeInterval = 72 * 60 * 60 + 15 * 60
|
|
||||||
#expect(TransportConfig.nostrDMSubscribeLookbackSeconds == expectedLookback)
|
|
||||||
#expect(filterSince == earliestAndroidTimestamp.addingTimeInterval(-(15 * 60)))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func largePrivateEnvelopeFitsLayerSpecificExpansionLimits() throws {
|
|
||||||
let sender = try NostrIdentity.generate()
|
|
||||||
let recipient = try NostrIdentity.generate()
|
|
||||||
// Large enough that the nested Base64 seal exceeds the inner 32 KiB
|
|
||||||
// cap, while the inner message JSON itself remains below that cap.
|
|
||||||
let content = String(repeating: "A", count: 30 * 1024)
|
|
||||||
|
|
||||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
|
||||||
content: content,
|
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
|
||||||
senderIdentity: sender
|
|
||||||
)
|
|
||||||
let decrypted = try NostrProtocol.decryptPrivateEnvelope(
|
|
||||||
envelope: envelope,
|
|
||||||
recipientIdentity: recipient
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(decrypted.content == content)
|
|
||||||
#expect(envelope.content.utf8.count <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func privateEnvelopeRejectsOversizedCiphertextBeforeDecoding() throws {
|
|
||||||
let recipient = try NostrIdentity.generate()
|
|
||||||
let wrapper = try NostrIdentity.generate()
|
|
||||||
let oversizedContent = NostrProtocol.privateEnvelopeContentPrefix
|
|
||||||
+ String(
|
|
||||||
repeating: "A",
|
|
||||||
count: NostrProtocol.maximumPrivateEnvelopeCiphertextBytes
|
|
||||||
)
|
|
||||||
let event = NostrEvent(
|
|
||||||
pubkey: wrapper.publicKeyHex,
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .privateEnvelope,
|
|
||||||
tags: [["p", recipient.publicKeyHex]],
|
|
||||||
content: oversizedContent
|
|
||||||
)
|
|
||||||
let signed = try event.sign(with: wrapper.schnorrSigningKey())
|
|
||||||
|
|
||||||
expectInvalidCiphertext {
|
|
||||||
_ = try NostrProtocol.decryptPrivateEnvelope(
|
|
||||||
envelope: signed,
|
|
||||||
recipientIdentity: recipient
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func privateEnvelopeRejectsOversizedPlaintextBeforeEncryption() throws {
|
|
||||||
let sender = try NostrIdentity.generate()
|
|
||||||
let recipient = try NostrIdentity.generate()
|
|
||||||
let oversizedPlaintext = String(
|
|
||||||
repeating: "x",
|
|
||||||
count: NostrProtocol.maximumPrivateEnvelopePlaintextBytes + 1
|
|
||||||
)
|
|
||||||
|
|
||||||
expectInvalidCiphertext {
|
|
||||||
_ = try NostrProtocol.createPrivateEnvelope(
|
|
||||||
content: oversizedPlaintext,
|
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
|
||||||
senderIdentity: sender
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func privateEnvelopeRejectsOversizedNestedJSONBeforeParsing() {
|
|
||||||
let oversizedJSON = String(
|
|
||||||
repeating: "{",
|
|
||||||
count: NostrProtocol.maximumPrivateEnvelopePlaintextBytes + 1
|
|
||||||
)
|
|
||||||
expectInvalidCiphertext {
|
|
||||||
_ = try NostrProtocol.decodePrivateEnvelopeEventJSONForTesting(oversizedJSON)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func decryptDoesNotMisinterpretStandardNIP44PayloadAsLegacyBitChat() throws {
|
|
||||||
let recipient = try NostrIdentity.generate()
|
|
||||||
let wrapper = try NostrIdentity.generate()
|
|
||||||
// A valid NIP-44 v2 payload from the official test vectors. Its wire
|
|
||||||
// format starts with a version byte in standard Base64, not BitChat's
|
|
||||||
// historical `v2:` prefix.
|
|
||||||
let standardPayload = "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb"
|
|
||||||
let event = NostrEvent(
|
|
||||||
pubkey: wrapper.publicKeyHex,
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .legacyNIP59GiftWrap,
|
|
||||||
tags: [["p", recipient.publicKeyHex]],
|
|
||||||
content: standardPayload
|
|
||||||
)
|
|
||||||
let signed = try event.sign(with: wrapper.schnorrSigningKey())
|
|
||||||
|
|
||||||
expectInvalidCiphertext {
|
|
||||||
_ = try NostrProtocol.decryptPrivateEnvelope(
|
|
||||||
envelope: signed,
|
|
||||||
recipientIdentity: recipient
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func decryptionFailsWithWrongRecipient() throws {
|
@Test func decryptionFailsWithWrongRecipient() throws {
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
let wrongRecipient = try NostrIdentity.generate()
|
let wrongRecipient = try NostrIdentity.generate()
|
||||||
|
|
||||||
// Create message for recipient
|
// Create message for recipient
|
||||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||||
content: "Secret message",
|
content: "Secret message",
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
expectInvalidEvent {
|
// Try to decrypt with wrong recipient
|
||||||
_ = try NostrProtocol.decryptPrivateEnvelope(
|
if #available(macOS 14.4, iOS 17.4, *) {
|
||||||
envelope: envelope,
|
#expect(throws: CryptoKitError.authenticationFailure) {
|
||||||
recipientIdentity: wrongRecipient
|
try NostrProtocol.decryptPrivateMessage(
|
||||||
)
|
giftWrap: giftWrap,
|
||||||
|
recipientIdentity: wrongRecipient
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
try NostrProtocol.decryptPrivateMessage(
|
||||||
|
giftWrap: giftWrap,
|
||||||
|
recipientIdentity: wrongRecipient
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func decryptRejectsInvalidSealSignature() throws {
|
@Test func decryptRejectsInvalidSealSignature() throws {
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
let envelope = try NostrProtocol.createPrivateEnvelopeWithInvalidSealSignatureForTesting(
|
let giftWrap = try NostrProtocol.createPrivateMessageWithInvalidSealSignatureForTesting(
|
||||||
content: "forged signature",
|
content: "forged signature",
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
expectInvalidEvent {
|
expectInvalidEvent {
|
||||||
_ = try NostrProtocol.decryptPrivateEnvelope(
|
_ = try NostrProtocol.decryptPrivateMessage(
|
||||||
envelope: envelope,
|
giftWrap: giftWrap,
|
||||||
recipientIdentity: recipient
|
recipientIdentity: recipient
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func decryptRejectsSealMessagePubkeyMismatch() throws {
|
@Test func decryptRejectsSealRumorPubkeyMismatch() throws {
|
||||||
let claimedSender = try NostrIdentity.generate()
|
let claimedSender = try NostrIdentity.generate()
|
||||||
let sealSigner = try NostrIdentity.generate()
|
let sealSigner = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
let envelope = try NostrProtocol.createPrivateEnvelopeWithMismatchedSealMessagePubkeyForTesting(
|
let giftWrap = try NostrProtocol.createPrivateMessageWithMismatchedSealRumorPubkeyForTesting(
|
||||||
content: "spoofed sender",
|
content: "spoofed sender",
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
messageIdentity: claimedSender,
|
rumorIdentity: claimedSender,
|
||||||
sealSignerIdentity: sealSigner
|
sealSignerIdentity: sealSigner
|
||||||
)
|
)
|
||||||
|
|
||||||
expectInvalidEvent {
|
expectInvalidEvent {
|
||||||
_ = try NostrProtocol.decryptPrivateEnvelope(
|
_ = try NostrProtocol.decryptPrivateMessage(
|
||||||
envelope: envelope,
|
giftWrap: giftWrap,
|
||||||
recipientIdentity: recipient
|
recipientIdentity: recipient
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func deliveredAckRoundTripsInsidePrivateEnvelope() throws {
|
func testAckRoundTripNIP44V2_Delivered() throws {
|
||||||
// Identities
|
// Identities
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
@@ -504,17 +171,19 @@ struct NostrProtocolTests {
|
|||||||
"Failed to embed delivered ack"
|
"Failed to embed delivered ack"
|
||||||
)
|
)
|
||||||
|
|
||||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
// Create NIP-17 gift wrap to recipient (uses NIP-44 v2 internally)
|
||||||
|
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||||
content: embedded,
|
content: embedded,
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(envelope.content.hasPrefix(NostrProtocol.privateEnvelopeContentPrefix))
|
// Ensure v2 format was used for ciphertext
|
||||||
|
#expect(giftWrap.content.hasPrefix("v2:"))
|
||||||
|
|
||||||
// Decrypt as recipient
|
// Decrypt as recipient
|
||||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateEnvelope(
|
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
||||||
envelope: envelope,
|
giftWrap: giftWrap,
|
||||||
recipientIdentity: recipient
|
recipientIdentity: recipient
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -539,7 +208,7 @@ struct NostrProtocolTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func readReceiptRoundTripsInsidePrivateEnvelope() throws {
|
@Test func ackRoundTripNIP44V2_ReadReceipt() throws {
|
||||||
// Identities
|
// Identities
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
@@ -551,16 +220,16 @@ struct NostrProtocolTests {
|
|||||||
"Failed to embed read ack"
|
"Failed to embed read ack"
|
||||||
)
|
)
|
||||||
|
|
||||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||||
content: embedded,
|
content: embedded,
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(envelope.content.hasPrefix(NostrProtocol.privateEnvelopeContentPrefix))
|
#expect(giftWrap.content.hasPrefix("v2:"))
|
||||||
|
|
||||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateEnvelope(
|
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
||||||
envelope: envelope,
|
giftWrap: giftWrap,
|
||||||
recipientIdentity: recipient
|
recipientIdentity: recipient
|
||||||
)
|
)
|
||||||
#expect(senderPubkey == sender.publicKeyHex)
|
#expect(senderPubkey == sender.publicKeyHex)
|
||||||
@@ -621,73 +290,7 @@ struct NostrProtocolTests {
|
|||||||
#expect(object["limit"] as? Int == 42)
|
#expect(object["limit"] as? Int == 42)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func privateEnvelopeFiltersGiveEachMigrationKindAnIndependentRecoveryBudget() throws {
|
|
||||||
let since = Date(timeIntervalSince1970: 1_234_567)
|
|
||||||
let filters = NostrFilter.privateEnvelopeFiltersFor(
|
|
||||||
pubkey: "recipient",
|
|
||||||
since: since
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(filters.count == 2)
|
|
||||||
let objects = try filters.map { filter in
|
|
||||||
let data = try JSONEncoder().encode(filter)
|
|
||||||
return try #require(
|
|
||||||
try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
#expect(objects.compactMap { $0["kinds"] as? [Int] } == [
|
|
||||||
[NostrProtocol.EventKind.privateEnvelope.rawValue],
|
|
||||||
[NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue]
|
|
||||||
])
|
|
||||||
for object in objects {
|
|
||||||
#expect(object["#p"] as? [String] == ["recipient"])
|
|
||||||
#expect(object["since"] as? Int == 1_234_567)
|
|
||||||
#expect(object["limit"] as? Int ==
|
|
||||||
TransportConfig.nostrPrivateEnvelopeFetchLimitPerKind
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
private struct LegacyRecipientKeyFixture: Decodable {
|
|
||||||
let recipientPrivateKey: String
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case recipientPrivateKey = "recipient_private_key"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct AndroidLegacyEnvelopeFixture: Decodable {
|
|
||||||
let androidCommit: String
|
|
||||||
let generator: String
|
|
||||||
let generatorPatch: String
|
|
||||||
let generatorPatchSHA256: String
|
|
||||||
let gradleTest: String
|
|
||||||
let fixtureSHA256: String
|
|
||||||
let recipientPrivateKey: String
|
|
||||||
let senderPublicKey: String
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case androidCommit = "android_commit"
|
|
||||||
case generator
|
|
||||||
case generatorPatch = "generator_patch"
|
|
||||||
case generatorPatchSHA256 = "generator_patch_sha256"
|
|
||||||
case gradleTest = "gradle_test"
|
|
||||||
case fixtureSHA256 = "fixture_sha256"
|
|
||||||
case recipientPrivateKey = "recipient_private_key"
|
|
||||||
case senderPublicKey = "sender_public_key"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func fixtureURL(name: String, extension fileExtension: String = "json") throws -> URL {
|
|
||||||
#if SWIFT_PACKAGE
|
|
||||||
let bundle = Bundle.module
|
|
||||||
#else
|
|
||||||
let bundle = Bundle(for: MockKeychain.self)
|
|
||||||
#endif
|
|
||||||
return try #require(bundle.url(forResource: name, withExtension: fileExtension))
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func base64URLDecode(_ s: String) -> Data? {
|
private static func base64URLDecode(_ s: String) -> Data? {
|
||||||
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||||
let rem = str.count % 4
|
let rem = str.count % 4
|
||||||
@@ -705,15 +308,4 @@ struct NostrProtocolTests {
|
|||||||
Issue.record("Expected NostrError.invalidEvent, got \(error)")
|
Issue.record("Expected NostrError.invalidEvent, got \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func expectInvalidCiphertext(_ operation: () throws -> Void) {
|
|
||||||
do {
|
|
||||||
try operation()
|
|
||||||
Issue.record("Expected NostrError.invalidCiphertext")
|
|
||||||
} catch NostrError.invalidCiphertext {
|
|
||||||
return
|
|
||||||
} catch {
|
|
||||||
Issue.record("Expected NostrError.invalidCiphertext, got \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -501,6 +501,62 @@ final class PerformanceBaselineTests: XCTestCase {
|
|||||||
reportThroughput("store.append", samples: samples, operations: messageCount, unit: "messages")
|
reportThroughput("store.append", samples: samples, operations: messageCount, unit: "messages")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - 7b. ConversationStore append at the retention cap
|
||||||
|
|
||||||
|
/// Steady-state public timeline traffic after the 1337-message retention
|
||||||
|
/// cap has been reached. Every tail append evicts the oldest row, which is
|
||||||
|
/// the long-lived workload the cold `store.append` benchmark does not
|
||||||
|
/// exercise.
|
||||||
|
func testConversationStoreSteadyStateAppend() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
let cap = TransportConfig.meshTimelineCap
|
||||||
|
let messagesPerPass = 500
|
||||||
|
let base = Date(timeIntervalSince1970: 1_700_000_000)
|
||||||
|
|
||||||
|
for i in 0..<cap {
|
||||||
|
store.append(
|
||||||
|
BitchatMessage(
|
||||||
|
id: "perf-steady-seed-\(i)",
|
||||||
|
sender: "perfsender",
|
||||||
|
content: "steady-state seed \(i)",
|
||||||
|
timestamp: base.addingTimeInterval(Double(i)),
|
||||||
|
isRelay: false
|
||||||
|
),
|
||||||
|
to: .mesh
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pass = 0
|
||||||
|
var samples: [TimeInterval] = []
|
||||||
|
measure {
|
||||||
|
let startIndex = cap + pass * messagesPerPass
|
||||||
|
let start = Date()
|
||||||
|
for offset in 0..<messagesPerPass {
|
||||||
|
let i = startIndex + offset
|
||||||
|
store.append(
|
||||||
|
BitchatMessage(
|
||||||
|
id: "perf-steady-\(i)",
|
||||||
|
sender: "perfsender",
|
||||||
|
content: "steady-state message \(i)",
|
||||||
|
timestamp: base.addingTimeInterval(Double(i)),
|
||||||
|
isRelay: false
|
||||||
|
),
|
||||||
|
to: .mesh
|
||||||
|
)
|
||||||
|
}
|
||||||
|
samples.append(Date().timeIntervalSince(start))
|
||||||
|
pass += 1
|
||||||
|
XCTAssertEqual(store.conversation(for: .mesh).messages.count, cap)
|
||||||
|
}
|
||||||
|
|
||||||
|
reportThroughput(
|
||||||
|
"store.steadyStateAppend",
|
||||||
|
samples: samples,
|
||||||
|
operations: messagesPerPass,
|
||||||
|
unit: "messages"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - 8. ConversationStore invariant audit (field observability)
|
// MARK: - 8. ConversationStore invariant audit (field observability)
|
||||||
|
|
||||||
/// `ConversationStore.auditInvariants()` over a realistic 5k-message
|
/// `ConversationStore.auditInvariants()` over a realistic 5k-message
|
||||||
|
|||||||
@@ -30,6 +30,10 @@
|
|||||||
"store.append": 213201,
|
"store.append": 213201,
|
||||||
"store.audit": 362
|
"store.audit": 362
|
||||||
},
|
},
|
||||||
|
"_reference_local_numbers_2026_07": {
|
||||||
|
"store.steadyStateAppend_before": 2315,
|
||||||
|
"store.steadyStateAppend": 53976
|
||||||
|
},
|
||||||
"floors": {
|
"floors": {
|
||||||
"nostrInbound.fresh": 450,
|
"nostrInbound.fresh": 450,
|
||||||
"nostrInbound.duplicate": 250000,
|
"nostrInbound.duplicate": 250000,
|
||||||
@@ -41,6 +45,7 @@
|
|||||||
"pipeline.privateIngest": 3000,
|
"pipeline.privateIngest": 3000,
|
||||||
"pipeline.publicIngest": 2400,
|
"pipeline.publicIngest": 2400,
|
||||||
"store.append": 48000,
|
"store.append": 48000,
|
||||||
|
"store.steadyStateAppend": 10000,
|
||||||
"store.audit": 70
|
"store.audit": 70
|
||||||
},
|
},
|
||||||
"_slowest_observed_ci_numbers_2026_06": {
|
"_slowest_observed_ci_numbers_2026_06": {
|
||||||
|
|||||||
@@ -267,312 +267,6 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, TransportConfig.nostrPendingSendQueueCap)
|
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, TransportConfig.nostrPendingSendQueueCap)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_privateEnvelopeBatchEvictsEphemeralTrafficAndRemainsAtomic() async throws {
|
|
||||||
let relayURL = "wss://private-batch-priority.example"
|
|
||||||
let context = makeContext(
|
|
||||||
permission: .denied,
|
|
||||||
userTorEnabled: true,
|
|
||||||
torEnforced: true,
|
|
||||||
torIsReady: false
|
|
||||||
)
|
|
||||||
var ephemeralIDs: [String] = []
|
|
||||||
for i in 0..<(TransportConfig.nostrPendingSendQueueCap - 1) {
|
|
||||||
let event = try makeSignedEvent(
|
|
||||||
content: "ephemeral-\(i)",
|
|
||||||
kind: .ephemeralEvent
|
|
||||||
)
|
|
||||||
ephemeralIDs.append(event.id)
|
|
||||||
context.manager.sendEvent(event, to: [relayURL])
|
|
||||||
}
|
|
||||||
let regular = try makeSignedEvent(content: "regular survives")
|
|
||||||
context.manager.sendEvent(regular, to: [relayURL])
|
|
||||||
XCTAssertEqual(
|
|
||||||
context.manager.debugPendingMessageQueueCount,
|
|
||||||
TransportConfig.nostrPendingSendQueueCap
|
|
||||||
)
|
|
||||||
|
|
||||||
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
|
||||||
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
|
||||||
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch([primary, legacy], to: [relayURL]))
|
|
||||||
|
|
||||||
let batches = context.manager.debugPendingMessageQueueEventIDsByBatch
|
|
||||||
let allIDs = Set(batches.flatMap { $0 })
|
|
||||||
XCTAssertEqual(
|
|
||||||
context.manager.debugPendingMessageQueueCount,
|
|
||||||
TransportConfig.nostrPendingSendQueueCap
|
|
||||||
)
|
|
||||||
XCTAssertTrue(batches.contains([primary.id, legacy.id]))
|
|
||||||
XCTAssertTrue(allIDs.contains(regular.id))
|
|
||||||
XCTAssertFalse(allIDs.contains(ephemeralIDs[0]))
|
|
||||||
XCTAssertFalse(allIDs.contains(ephemeralIDs[1]))
|
|
||||||
|
|
||||||
// Later low-priority traffic may evict another ephemeral event, but
|
|
||||||
// never one half (or both halves) of the protected private batch.
|
|
||||||
let lateEphemeral = try makeSignedEvent(content: "late", kind: .geohashPresence)
|
|
||||||
context.manager.sendEvent(lateEphemeral, to: [relayURL])
|
|
||||||
XCTAssertTrue(
|
|
||||||
context.manager.debugPendingMessageQueueEventIDsByBatch
|
|
||||||
.contains([primary.id, legacy.id])
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func test_privateEnvelopeBatchDoesNotWriteStaleSocketUntilTorRecovers() async throws {
|
|
||||||
let relayURL = "wss://private-batch-tor-gate.example"
|
|
||||||
let context = makeContext(
|
|
||||||
permission: .denied,
|
|
||||||
userTorEnabled: true,
|
|
||||||
torEnforced: true,
|
|
||||||
torIsReady: true
|
|
||||||
)
|
|
||||||
context.manager.ensureConnections(to: [relayURL])
|
|
||||||
let connected = await waitUntil {
|
|
||||||
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
|
|
||||||
}
|
|
||||||
XCTAssertTrue(connected)
|
|
||||||
let connection = try XCTUnwrap(context.sessionFactory.latestConnection(for: relayURL))
|
|
||||||
|
|
||||||
context.torWaiter.isReady = false
|
|
||||||
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
|
||||||
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
|
||||||
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch([primary, legacy], to: [relayURL]))
|
|
||||||
|
|
||||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
|
||||||
XCTAssertTrue(connection.sentStrings.isEmpty)
|
|
||||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
|
|
||||||
XCTAssertEqual(context.torWaiter.awaitCallCount, 1)
|
|
||||||
|
|
||||||
context.torWaiter.resolve(true)
|
|
||||||
let flushed = await waitUntil {
|
|
||||||
connection.sentStrings.count == 2 &&
|
|
||||||
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 {
|
func test_sendEvent_waitsForTorReadinessBeforeSending() async throws {
|
||||||
let relayURL = "wss://tor-ready.example"
|
let relayURL = "wss://tor-ready.example"
|
||||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||||
@@ -854,52 +548,6 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
XCTAssertEqual(context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count, 1)
|
XCTAssertEqual(context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_subscribe_multiplePrivateEnvelopeFiltersEncodesIndependentLimits() async throws {
|
|
||||||
let relayURL = "wss://private-recovery-filters.example"
|
|
||||||
let context = makeContext(permission: .denied)
|
|
||||||
let filters = NostrFilter.privateEnvelopeFiltersFor(
|
|
||||||
pubkey: "recipient",
|
|
||||||
since: Date(timeIntervalSince1970: 1_234_567)
|
|
||||||
)
|
|
||||||
|
|
||||||
context.manager.subscribe(
|
|
||||||
filters: filters,
|
|
||||||
id: "private-recovery",
|
|
||||||
relayUrls: [relayURL],
|
|
||||||
handler: { _ in }
|
|
||||||
)
|
|
||||||
|
|
||||||
let sent = await waitUntil {
|
|
||||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
|
|
||||||
}
|
|
||||||
XCTAssertTrue(sent)
|
|
||||||
let request = try XCTUnwrap(
|
|
||||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.first
|
|
||||||
)
|
|
||||||
let data = try XCTUnwrap(request.data(using: .utf8))
|
|
||||||
let array = try XCTUnwrap(
|
|
||||||
try JSONSerialization.jsonObject(with: data) as? [Any]
|
|
||||||
)
|
|
||||||
XCTAssertEqual(array.count, 4)
|
|
||||||
XCTAssertEqual(array[0] as? String, "REQ")
|
|
||||||
XCTAssertEqual(array[1] as? String, "private-recovery")
|
|
||||||
let encodedFilters = try [array[2], array[3]].map {
|
|
||||||
try XCTUnwrap($0 as? [String: Any])
|
|
||||||
}
|
|
||||||
XCTAssertEqual(encodedFilters.compactMap { $0["kinds"] as? [Int] }, [
|
|
||||||
[NostrProtocol.EventKind.privateEnvelope.rawValue],
|
|
||||||
[NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue]
|
|
||||||
])
|
|
||||||
for filter in encodedFilters {
|
|
||||||
XCTAssertEqual(
|
|
||||||
filter["limit"] as? Int,
|
|
||||||
TransportConfig.nostrPrivateEnvelopeFetchLimitPerKind
|
|
||||||
)
|
|
||||||
XCTAssertEqual(filter["#p"] as? [String], ["recipient"])
|
|
||||||
XCTAssertEqual(filter["since"] as? Int, 1_234_567)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func test_subscribe_coalescesDuplicateRequestsBeforeTorReadyAndDefersEOSE() async throws {
|
func test_subscribe_coalescesDuplicateRequestsBeforeTorReadyAndDefersEOSE() async throws {
|
||||||
let relayURL = "wss://tor-subscribe-coalesce.example"
|
let relayURL = "wss://tor-subscribe-coalesce.example"
|
||||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||||
@@ -1245,11 +893,11 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
XCTAssertTrue(secondDelivered)
|
XCTAssertTrue(secondDelivered)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_okMessages_clearPendingPrivateEnvelopeIDs() async throws {
|
func test_okMessages_clearPendingGiftWrapIDs() async throws {
|
||||||
let relayURL = "wss://ok.example"
|
let relayURL = "wss://ok.example"
|
||||||
let context = makeContext(permission: .denied)
|
let context = makeContext(permission: .denied)
|
||||||
let successID = "private-envelope-success"
|
let successID = "gift-wrap-success"
|
||||||
let failureID = "private-envelope-failure"
|
let failureID = "gift-wrap-failure"
|
||||||
|
|
||||||
context.manager.ensureConnections(to: [relayURL])
|
context.manager.ensureConnections(to: [relayURL])
|
||||||
let connected = await waitUntil {
|
let connected = await waitUntil {
|
||||||
@@ -1258,17 +906,17 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
XCTAssertTrue(connected)
|
XCTAssertTrue(connected)
|
||||||
|
|
||||||
NostrRelayManager.registerPendingPrivateEnvelope(id: successID)
|
NostrRelayManager.registerPendingGiftWrap(id: successID)
|
||||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: successID, success: true, reason: "ok")
|
try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: successID, success: true, reason: "ok")
|
||||||
let successCleared = await waitUntil {
|
let successCleared = await waitUntil {
|
||||||
!NostrRelayManager.pendingPrivateEnvelopeIDs.contains(successID)
|
!NostrRelayManager.pendingGiftWrapIDs.contains(successID)
|
||||||
}
|
}
|
||||||
XCTAssertTrue(successCleared)
|
XCTAssertTrue(successCleared)
|
||||||
|
|
||||||
NostrRelayManager.registerPendingPrivateEnvelope(id: failureID)
|
NostrRelayManager.registerPendingGiftWrap(id: failureID)
|
||||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: failureID, success: false, reason: "rejected")
|
try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: failureID, success: false, reason: "rejected")
|
||||||
let failureCleared = await waitUntil {
|
let failureCleared = await waitUntil {
|
||||||
!NostrRelayManager.pendingPrivateEnvelopeIDs.contains(failureID)
|
!NostrRelayManager.pendingGiftWrapIDs.contains(failureID)
|
||||||
}
|
}
|
||||||
XCTAssertTrue(failureCleared)
|
XCTAssertTrue(failureCleared)
|
||||||
}
|
}
|
||||||
@@ -1998,15 +1646,12 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
return filter
|
return filter
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeSignedEvent(
|
private func makeSignedEvent(content: String) throws -> NostrEvent {
|
||||||
content: String,
|
|
||||||
kind: NostrProtocol.EventKind = .textNote
|
|
||||||
) throws -> NostrEvent {
|
|
||||||
let identity = try NostrIdentity.generate()
|
let identity = try NostrIdentity.generate()
|
||||||
let event = NostrEvent(
|
let event = NostrEvent(
|
||||||
pubkey: identity.publicKeyHex,
|
pubkey: identity.publicKeyHex,
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
kind: kind,
|
kind: .textNote,
|
||||||
tags: [],
|
tags: [],
|
||||||
content: content
|
content: content
|
||||||
)
|
)
|
||||||
@@ -2146,7 +1791,6 @@ private final class MockRelaySessionFactory: NostrRelaySessionProtocol {
|
|||||||
private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
||||||
private let pingError: Error?
|
private let pingError: Error?
|
||||||
var sendError: Error?
|
var sendError: Error?
|
||||||
var sendErrorSequence: [Error?] = []
|
|
||||||
private var receiveHandler: ((Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
|
private var receiveHandler: ((Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
|
||||||
private(set) var resumeCallCount = 0
|
private(set) var resumeCallCount = 0
|
||||||
private(set) var cancelCallCount = 0
|
private(set) var cancelCallCount = 0
|
||||||
@@ -2176,15 +1820,14 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var deferSendCompletions = false
|
var deferSendCompletions = false
|
||||||
private var deferredSendCompletions: [(error: Error?, completion: (Error?) -> Void)] = []
|
private var deferredSendCompletions: [(Error?) -> Void] = []
|
||||||
|
|
||||||
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) {
|
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) {
|
||||||
sentMessages.append(message)
|
sentMessages.append(message)
|
||||||
let error = sendErrorSequence.isEmpty ? sendError : sendErrorSequence.removeFirst()
|
|
||||||
if deferSendCompletions {
|
if deferSendCompletions {
|
||||||
deferredSendCompletions.append((error, completionHandler))
|
deferredSendCompletions.append(completionHandler)
|
||||||
} else {
|
} else {
|
||||||
completionHandler(error)
|
completionHandler(sendError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2192,7 +1835,7 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
|||||||
let pending = deferredSendCompletions
|
let pending = deferredSendCompletions
|
||||||
deferredSendCompletions = []
|
deferredSendCompletions = []
|
||||||
pending.forEach {
|
pending.forEach {
|
||||||
$0.completion($0.error)
|
$0(sendError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ struct NostrTransportTests {
|
|||||||
#expect(undeliverable)
|
#expect(undeliverable)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Private message resolves short peer ID and emits both migration formats")
|
@Test("Private message resolves short peer ID and emits decryptable packet")
|
||||||
@MainActor
|
@MainActor
|
||||||
func sendPrivateMessageResolvesShortPeerID() async throws {
|
func sendPrivateMessageResolvesShortPeerID() async throws {
|
||||||
let keychain = MockKeychain()
|
let keychain = MockKeychain()
|
||||||
@@ -153,8 +153,8 @@ struct NostrTransportTests {
|
|||||||
favoriteStatusForNoiseKey: { _ in nil },
|
favoriteStatusForNoiseKey: { _ in nil },
|
||||||
favoriteStatusForPeerID: { $0 == shortPeerID ? relationship : nil },
|
favoriteStatusForPeerID: { $0 == shortPeerID ? relationship : nil },
|
||||||
currentIdentity: { sender },
|
currentIdentity: { sender },
|
||||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
||||||
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
|
sendEvent: probe.record(event:),
|
||||||
scheduleAfter: { delay, action in
|
scheduleAfter: { delay, action in
|
||||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||||
}
|
}
|
||||||
@@ -164,12 +164,8 @@ struct NostrTransportTests {
|
|||||||
|
|
||||||
transport.sendPrivateMessage("hello over nostr", to: shortPeerID, recipientNickname: "Carol", messageID: "pm-1")
|
transport.sendPrivateMessage("hello over nostr", to: shortPeerID, recipientNickname: "Carol", messageID: "pm-1")
|
||||||
|
|
||||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 5.0)
|
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
|
||||||
#expect(didSend)
|
#expect(didSend)
|
||||||
#expect(probe.sentEvents.map(\.kind) == [
|
|
||||||
NostrProtocol.EventKind.privateEnvelope.rawValue,
|
|
||||||
NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue
|
|
||||||
])
|
|
||||||
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
||||||
let privateMessage = try decodePrivateMessage(from: result.payload)
|
let privateMessage = try decodePrivateMessage(from: result.payload)
|
||||||
|
|
||||||
@@ -177,332 +173,7 @@ struct NostrTransportTests {
|
|||||||
#expect(privateMessage.messageID == "pm-1")
|
#expect(privateMessage.messageID == "pm-1")
|
||||||
#expect(privateMessage.content == "hello over nostr")
|
#expect(privateMessage.content == "hello over nostr")
|
||||||
#expect(result.packet.recipientID == shortPeerID.routingData)
|
#expect(result.packet.recipientID == shortPeerID.routingData)
|
||||||
#expect(probe.pendingPrivateEnvelopeIDs.isEmpty)
|
#expect(probe.pendingGiftWrapIDs.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")
|
@Test("Favorite notification embeds current npub")
|
||||||
@@ -527,8 +198,8 @@ struct NostrTransportTests {
|
|||||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||||
favoriteStatusForPeerID: { _ in nil },
|
favoriteStatusForPeerID: { _ in nil },
|
||||||
currentIdentity: { sender },
|
currentIdentity: { sender },
|
||||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
||||||
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
|
sendEvent: probe.record(event:),
|
||||||
scheduleAfter: { delay, action in
|
scheduleAfter: { delay, action in
|
||||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||||
}
|
}
|
||||||
@@ -538,7 +209,7 @@ struct NostrTransportTests {
|
|||||||
|
|
||||||
transport.sendFavoriteNotification(to: fullPeerID, isFavorite: true)
|
transport.sendFavoriteNotification(to: fullPeerID, isFavorite: true)
|
||||||
|
|
||||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 5.0)
|
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
|
||||||
#expect(didSend)
|
#expect(didSend)
|
||||||
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
||||||
let privateMessage = try decodePrivateMessage(from: result.payload)
|
let privateMessage = try decodePrivateMessage(from: result.payload)
|
||||||
@@ -568,8 +239,8 @@ struct NostrTransportTests {
|
|||||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||||
favoriteStatusForPeerID: { _ in nil },
|
favoriteStatusForPeerID: { _ in nil },
|
||||||
currentIdentity: { sender },
|
currentIdentity: { sender },
|
||||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
||||||
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
|
sendEvent: probe.record(event:),
|
||||||
scheduleAfter: { delay, action in
|
scheduleAfter: { delay, action in
|
||||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||||
}
|
}
|
||||||
@@ -579,7 +250,7 @@ struct NostrTransportTests {
|
|||||||
|
|
||||||
transport.sendDeliveryAck(for: "ack-1", to: fullPeerID)
|
transport.sendDeliveryAck(for: "ack-1", to: fullPeerID)
|
||||||
|
|
||||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 5.0)
|
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
|
||||||
#expect(didSend)
|
#expect(didSend)
|
||||||
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
||||||
|
|
||||||
@@ -588,9 +259,9 @@ struct NostrTransportTests {
|
|||||||
#expect(result.packet.recipientID == fullPeerID.toShort().routingData)
|
#expect(result.packet.recipientID == fullPeerID.toShort().routingData)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Geohash private message registers pending private envelope")
|
@Test("Geohash private message registers pending gift wrap")
|
||||||
@MainActor
|
@MainActor
|
||||||
func sendPrivateMessageGeohashRegistersPendingPrivateEnvelope() async throws {
|
func sendPrivateMessageGeohashRegistersPendingGiftWrap() async throws {
|
||||||
let keychain = MockKeychain()
|
let keychain = MockKeychain()
|
||||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
@@ -601,8 +272,8 @@ struct NostrTransportTests {
|
|||||||
idBridge: idBridge,
|
idBridge: idBridge,
|
||||||
dependencies: makeDependencies(
|
dependencies: makeDependencies(
|
||||||
currentIdentity: { sender },
|
currentIdentity: { sender },
|
||||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
||||||
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
|
sendEvent: probe.record(event:),
|
||||||
scheduleAfter: { delay, action in
|
scheduleAfter: { delay, action in
|
||||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||||
}
|
}
|
||||||
@@ -617,7 +288,7 @@ struct NostrTransportTests {
|
|||||||
messageID: "geo-1"
|
messageID: "geo-1"
|
||||||
)
|
)
|
||||||
|
|
||||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 5.0)
|
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
|
||||||
#expect(didSend)
|
#expect(didSend)
|
||||||
let event = probe.sentEvents[0]
|
let event = probe.sentEvents[0]
|
||||||
let result = try decodeEmbeddedPayload(from: event, recipient: recipient)
|
let result = try decodeEmbeddedPayload(from: event, recipient: recipient)
|
||||||
@@ -626,7 +297,7 @@ struct NostrTransportTests {
|
|||||||
#expect(privateMessage.messageID == "geo-1")
|
#expect(privateMessage.messageID == "geo-1")
|
||||||
#expect(privateMessage.content == "geo hello")
|
#expect(privateMessage.content == "geo hello")
|
||||||
#expect(result.packet.recipientID == nil)
|
#expect(result.packet.recipientID == nil)
|
||||||
#expect(probe.pendingPrivateEnvelopeIDs == probe.sentEvents.map(\.id))
|
#expect(probe.pendingGiftWrapIDs == [event.id])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Read receipt queue sends in order and waits for scheduler")
|
@Test("Read receipt queue sends in order and waits for scheduler")
|
||||||
@@ -651,8 +322,8 @@ struct NostrTransportTests {
|
|||||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||||
favoriteStatusForPeerID: { _ in nil },
|
favoriteStatusForPeerID: { _ in nil },
|
||||||
currentIdentity: { sender },
|
currentIdentity: { sender },
|
||||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
||||||
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
|
sendEvent: probe.record(event:),
|
||||||
scheduleAfter: { delay, action in
|
scheduleAfter: { delay, action in
|
||||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||||
}
|
}
|
||||||
@@ -667,20 +338,20 @@ struct NostrTransportTests {
|
|||||||
transport.sendReadReceipt(second, to: fullPeerID)
|
transport.sendReadReceipt(second, to: fullPeerID)
|
||||||
|
|
||||||
let readReceiptTimeout: TimeInterval = 5.0
|
let readReceiptTimeout: TimeInterval = 5.0
|
||||||
let sentFirst = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: readReceiptTimeout)
|
let sentFirst = await TestHelpers.waitUntil({ probe.sentEvents.count >= 1 }, timeout: readReceiptTimeout)
|
||||||
try #require(sentFirst, "Expected first queued read receipt pair")
|
try #require(sentFirst, "Expected first queued read receipt event")
|
||||||
let scheduledThrottle = await TestHelpers.waitUntil({ probe.scheduledActionCount == 1 }, timeout: readReceiptTimeout)
|
let scheduledThrottle = await TestHelpers.waitUntil({ probe.scheduledActionCount == 1 }, timeout: readReceiptTimeout)
|
||||||
try #require(scheduledThrottle, "Expected queued throttle action after first read receipt")
|
try #require(scheduledThrottle, "Expected queued throttle action after first read receipt")
|
||||||
let firstEvent = try #require(probe.sentEvents.first, "Expected first queued read receipt pair")
|
let firstEvent = try #require(probe.sentEvents.first, "Expected first queued read receipt event")
|
||||||
let firstPayload = try decodeEmbeddedPayload(from: firstEvent, recipient: recipient).payload
|
let firstPayload = try decodeEmbeddedPayload(from: firstEvent, recipient: recipient).payload
|
||||||
#expect(firstPayload.type == .readReceipt)
|
#expect(firstPayload.type == .readReceipt)
|
||||||
#expect(String(data: firstPayload.data, encoding: .utf8) == "read-1")
|
#expect(String(data: firstPayload.data, encoding: .utf8) == "read-1")
|
||||||
|
|
||||||
try #require(probe.runNextScheduledAction(), "Expected queued throttle action after first read receipt")
|
try #require(probe.runNextScheduledAction(), "Expected queued throttle action after first read receipt")
|
||||||
|
|
||||||
let sentSecond = await TestHelpers.waitUntil({ probe.sentEvents.count == 4 }, timeout: readReceiptTimeout)
|
let sentSecond = await TestHelpers.waitUntil({ probe.sentEvents.count >= 2 }, timeout: readReceiptTimeout)
|
||||||
try #require(sentSecond, "Expected second read receipt pair after running throttle action")
|
try #require(sentSecond, "Expected second read receipt after running throttle action")
|
||||||
let secondEvent = probe.sentEvents[2]
|
let secondEvent = try #require(probe.sentEvents.last, "Expected second queued read receipt event")
|
||||||
let secondPayload = try decodeEmbeddedPayload(from: secondEvent, recipient: recipient).payload
|
let secondPayload = try decodeEmbeddedPayload(from: secondEvent, recipient: recipient).payload
|
||||||
#expect(secondPayload.type == .readReceipt)
|
#expect(secondPayload.type == .readReceipt)
|
||||||
#expect(String(data: secondPayload.data, encoding: .utf8) == "read-2")
|
#expect(String(data: secondPayload.data, encoding: .utf8) == "read-2")
|
||||||
@@ -748,14 +419,10 @@ struct NostrTransportTests {
|
|||||||
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoriteRelationship? = { _ in nil },
|
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoriteRelationship? = { _ in nil },
|
||||||
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoriteRelationship? = { _ in nil },
|
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoriteRelationship? = { _ in nil },
|
||||||
currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil },
|
currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil },
|
||||||
registerPendingPrivateEnvelope: @escaping @MainActor (String) -> Void = { _ in },
|
registerPendingGiftWrap: @escaping @MainActor (String) -> Void = { _ in },
|
||||||
sendPrivateEnvelopeBatch: @escaping @MainActor (
|
sendEvent: @escaping @MainActor (NostrEvent) -> Void = { _ in },
|
||||||
[NostrEvent],
|
|
||||||
@escaping @MainActor () -> Void
|
|
||||||
) -> Bool = { _, _ in true },
|
|
||||||
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in },
|
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in },
|
||||||
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never> = { Just(false).eraseToAnyPublisher() },
|
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never> = { Just(false).eraseToAnyPublisher() }
|
||||||
envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue? = nil
|
|
||||||
) -> NostrTransport.Dependencies {
|
) -> NostrTransport.Dependencies {
|
||||||
NostrTransport.Dependencies(
|
NostrTransport.Dependencies(
|
||||||
notificationCenter: notificationCenter,
|
notificationCenter: notificationCenter,
|
||||||
@@ -763,11 +430,10 @@ struct NostrTransportTests {
|
|||||||
favoriteStatusForNoiseKey: favoriteStatusForNoiseKey,
|
favoriteStatusForNoiseKey: favoriteStatusForNoiseKey,
|
||||||
favoriteStatusForPeerID: favoriteStatusForPeerID,
|
favoriteStatusForPeerID: favoriteStatusForPeerID,
|
||||||
currentIdentity: currentIdentity,
|
currentIdentity: currentIdentity,
|
||||||
registerPendingPrivateEnvelope: registerPendingPrivateEnvelope,
|
registerPendingGiftWrap: registerPendingGiftWrap,
|
||||||
sendPrivateEnvelopeBatch: sendPrivateEnvelopeBatch,
|
sendEvent: sendEvent,
|
||||||
scheduleAfter: scheduleAfter,
|
scheduleAfter: scheduleAfter,
|
||||||
relayConnectivity: relayConnectivity,
|
relayConnectivity: relayConnectivity
|
||||||
envelopeRetryQueue: envelopeRetryQueue
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -791,8 +457,8 @@ struct NostrTransportTests {
|
|||||||
from event: NostrEvent,
|
from event: NostrEvent,
|
||||||
recipient: NostrIdentity
|
recipient: NostrIdentity
|
||||||
) throws -> (packet: BitchatPacket, payload: NoisePayload, senderPubkey: String) {
|
) throws -> (packet: BitchatPacket, payload: NoisePayload, senderPubkey: String) {
|
||||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateEnvelope(
|
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
||||||
envelope: event,
|
giftWrap: event,
|
||||||
recipientIdentity: recipient
|
recipientIdentity: recipient
|
||||||
)
|
)
|
||||||
guard content.hasPrefix("bitchat1:") else {
|
guard content.hasPrefix("bitchat1:") else {
|
||||||
@@ -822,17 +488,6 @@ private enum NostrTransportTestError: Error {
|
|||||||
case invalidPrivateMessage
|
case invalidPrivateMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private final class NostrTransportEventProbe: TransportEventDelegate {
|
|
||||||
private(set) var failedMessageIDs: [String] = []
|
|
||||||
|
|
||||||
func didReceiveTransportEvent(_ event: TransportEvent) {
|
|
||||||
guard case .messageDeliveryStatusUpdated(let messageID, let status) = event,
|
|
||||||
case .failed = status else { return }
|
|
||||||
failedMessageIDs.append(messageID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func base64URLDecode(_ string: String) -> Data? {
|
private func base64URLDecode(_ string: String) -> Data? {
|
||||||
var candidate = string
|
var candidate = string
|
||||||
let padding = (4 - (candidate.count % 4)) % 4
|
let padding = (4 - (candidate.count % 4)) % 4
|
||||||
@@ -848,7 +503,7 @@ private func base64URLDecode(_ string: String) -> Data? {
|
|||||||
private final class NostrTransportProbe: @unchecked Sendable {
|
private final class NostrTransportProbe: @unchecked Sendable {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var sentEventsStorage: [NostrEvent] = []
|
private var sentEventsStorage: [NostrEvent] = []
|
||||||
private var pendingPrivateEnvelopeIDsStorage: [String] = []
|
private var pendingGiftWrapIDsStorage: [String] = []
|
||||||
private var scheduledActionsStorage: [(@Sendable () -> Void)] = []
|
private var scheduledActionsStorage: [(@Sendable () -> Void)] = []
|
||||||
|
|
||||||
var sentEvents: [NostrEvent] {
|
var sentEvents: [NostrEvent] {
|
||||||
@@ -857,10 +512,10 @@ private final class NostrTransportProbe: @unchecked Sendable {
|
|||||||
return sentEventsStorage
|
return sentEventsStorage
|
||||||
}
|
}
|
||||||
|
|
||||||
var pendingPrivateEnvelopeIDs: [String] {
|
var pendingGiftWrapIDs: [String] {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
return pendingPrivateEnvelopeIDsStorage
|
return pendingGiftWrapIDsStorage
|
||||||
}
|
}
|
||||||
|
|
||||||
var scheduledActionCount: Int {
|
var scheduledActionCount: Int {
|
||||||
@@ -869,16 +524,15 @@ private final class NostrTransportProbe: @unchecked Sendable {
|
|||||||
return scheduledActionsStorage.count
|
return scheduledActionsStorage.count
|
||||||
}
|
}
|
||||||
|
|
||||||
func record(batch: [NostrEvent]) -> Bool {
|
func record(event: NostrEvent) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
sentEventsStorage.append(contentsOf: batch)
|
sentEventsStorage.append(event)
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func recordPendingPrivateEnvelope(id: String) {
|
func recordPendingGiftWrap(id: String) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
pendingPrivateEnvelopeIDsStorage.append(id)
|
pendingGiftWrapIDsStorage.append(id)
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ Public archives contain content already intended for public mesh/board distribut
|
|||||||
|
|
||||||
## Nostr and Mesh Bridge
|
## Nostr and Mesh Bridge
|
||||||
|
|
||||||
- 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.
|
- 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.
|
||||||
- Bridge courier drops use a throwaway publisher key, an opaque Noise-sealed envelope, and a day-rotating recipient tag. Only a party already holding the recipient's Noise static key can compute candidate tags.
|
- Bridge courier drops use a throwaway publisher key, an opaque Noise-sealed envelope, and a day-rotating recipient tag. Only a party already holding the recipient's Noise static key can compute candidate tags.
|
||||||
- Relay publication is considered successful only after an explicit NIP-01 `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-20 `OK true` from at least one target relay. Rejected, disconnected, timed-out, or merely socket-written events stay retryable.
|
||||||
- When mesh bridge is enabled, public mesh messages not marked “nearby only” are signed under a per-cell Nostr identity and published to a neighborhood rendezvous geohash. Presence and public bridge traffic therefore expose a coarse area to relays and participants.
|
- When mesh bridge is enabled, public mesh messages not marked “nearby only” are signed under a per-cell Nostr identity and published to a neighborhood rendezvous geohash. Presence and public bridge traffic therefore expose a coarse area to relays and participants.
|
||||||
- A bridge gateway can carry signed bridge/location events and opaque courier drops for nearby mesh-only peers. It cannot validly publish a neighbor's radio-only message because the author must first sign the bridge event.
|
- A bridge gateway can carry signed bridge/location events and opaque courier drops for nearby mesh-only peers. It cannot validly publish a neighbor's radio-only message because the author must first sign the bridge event.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user