mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 06:05:20 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84add32552 | ||
|
|
cc32f0b596 |
+2
-2
@@ -73,7 +73,7 @@ Private group members receive the group's name, roster, key epoch, and encrypted
|
||||
|
||||
Internet-backed features are optional. When enabled or used:
|
||||
|
||||
- Private fallback messages use encrypted NIP-17 gift wraps. Relays can observe event and network metadata but not the message plaintext.
|
||||
- Private fallback messages use BitChat's app-specific encrypted envelopes. This format is not NIP-17, NIP-44, or NIP-59 compatible. Relays can observe the recipient public-key tag, event timing and size, and network metadata, but not the message plaintext or stable sender identity.
|
||||
- Public location-channel messages, notes, notices, and presence include a geohash tag, event kind, timestamp, and a public key. A geohash reveals an approximate area; finer precision reveals a smaller area.
|
||||
- The optional mesh bridge publishes bridge-enabled public mesh messages and presence to a neighborhood rendezvous cell. Those messages are public to participants and relays for that cell. A per-message “nearby only” choice prevents that message from crossing the bridge.
|
||||
- Bridge courier drops contain opaque end-to-end encrypted envelopes and a rotating recipient tag. Relays still observe timing and network metadata.
|
||||
@@ -103,7 +103,7 @@ Private and public features use different protections:
|
||||
|
||||
- Mesh private sessions use Noise XX with X25519, ChaCha20-Poly1305, and SHA-256.
|
||||
- Private group messages use ChaCha20-Poly1305; group state and relevant mesh packets use Ed25519 signatures.
|
||||
- Nostr events use secp256k1 Schnorr signatures. NIP-44 v2 private payloads use secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305.
|
||||
- Nostr events use secp256k1 Schnorr signatures. BitChat private envelopes use secp256k1 key agreement, HKDF-SHA256 with a BitChat-specific domain separator, and XChaCha20-Poly1305. The envelope format is proprietary, only interoperates with BitChat clients, and does not provide forward secrecy against later compromise of the recipient's static Nostr private key.
|
||||
- The persistent private-message outbox uses ChaCha20-Poly1305 with a key held in the keychain. Some other protected local identity state uses AES-GCM.
|
||||
- Public mesh, bridge, geohash, and board content is signed or authenticated as appropriate but is intentionally not confidential.
|
||||
|
||||
|
||||
+5
-1
@@ -63,7 +63,11 @@ let package = Package(
|
||||
// Only the vector fixture: declaring the whole "Noise"
|
||||
// directory would claim its .swift test files as resources
|
||||
// and silently drop them from compilation.
|
||||
.process("Noise/NoiseTestVectors.json")
|
||||
.process("Noise/NoiseTestVectors.json"),
|
||||
// Frozen output produced by the released 733098bb private-DM
|
||||
// implementation; proves receive compatibility independently
|
||||
// of the refactored legacy generator.
|
||||
.process("Nostr/Fixtures")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
@@ -19,7 +19,7 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file
|
||||
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
|
||||
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
|
||||
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
|
||||
- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, NIP-17 for Nostr
|
||||
- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, BitChat private envelopes for Nostr fallback
|
||||
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
|
||||
- **Universal App**: Native support for iOS and macOS
|
||||
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
||||
@@ -44,9 +44,50 @@ BitChat uses a **hybrid messaging architecture** with two complementary transpor
|
||||
- **Global Reach**: Connect with users worldwide via internet relays
|
||||
- **Location Channels**: Geographic chat rooms using geohash coordinates
|
||||
- **290+ Relay Network**: Distributed across the globe for reliability
|
||||
- **NIP-17 Encryption**: Gift-wrapped private messages for internet privacy
|
||||
- **BitChat Private Envelopes**: App-specific encrypted private messages over Nostr relays
|
||||
- **Ephemeral Keys**: Fresh cryptographic identity per geohash area
|
||||
|
||||
BitChat's private-envelope format is proprietary and is **not** NIP-17,
|
||||
NIP-44, or NIP-59 compatible. It uses Nostr as a relay transport but only
|
||||
interoperates with BitChat clients. New envelopes use provisional,
|
||||
BitChat-specific public kind 1402 (not a formally reserved Nostr kind),
|
||||
encrypted inner kinds 1403/1404, and the `bitchat-pm-v1:` content prefix.
|
||||
For mixed-version delivery, clients publish both the primary kind-1402
|
||||
envelope and a compatibility kind-1059 copy. There is no date-based cutoff:
|
||||
kind 1059 must remain enabled until a coordinated iOS/Android release confirms
|
||||
that supported older clients have migrated. Receivers subscribe to both kinds
|
||||
and deduplicate the authenticated embedded BitChat payload.
|
||||
|
||||
Private-envelope migration compatibility:
|
||||
|
||||
| Sender | Receiver | Delivery path |
|
||||
| --- | --- | --- |
|
||||
| New iOS | New iOS | Kind 1402 is primary; the kind-1059 twin is deduplicated |
|
||||
| New iOS | Released iOS | Compatibility kind 1059 |
|
||||
| New iOS | Current Android | Compatibility kind 1059 |
|
||||
| Released iOS | New iOS | Kind 1059 with the released empty inner-tag shape |
|
||||
| Current Android | New iOS | Kind 1059 with exactly the authenticated recipient `p` tag |
|
||||
|
||||
New kind-1402 envelopes require an empty inner tag list. The Android recipient
|
||||
tag exception is intentionally confined to legacy kind 1059 and accepts only
|
||||
the exact addressed recipient. Mailbox subscriptions cover the 24-hour
|
||||
delivery window plus Android's full 48-hour timestamp randomization and 15
|
||||
minutes of clock skew. Recovery uses
|
||||
one independent 500-event relay filter per wire kind so either format cannot
|
||||
consume the other's result budget.
|
||||
|
||||
The two outbound migration copies are admitted to the relay queue as one
|
||||
protected batch. Queue pressure evicts ephemeral traffic first, never one copy
|
||||
of a private pair; if protected capacity is exhausted, the entire new pair is
|
||||
rejected as a whole. User-message rejection becomes a visible failed delivery;
|
||||
acknowledgements and favorite notifications retain the exact pair in a
|
||||
process-wide 256-entry bounded retry queue. A sustained outage beyond that
|
||||
bound evicts the oldest whole control pair with an explicit warning, never half
|
||||
a pair.
|
||||
If either socket write fails, the same queued pair remains pending and both
|
||||
copies are replayed on the replacement connection. A terminal relay target is
|
||||
pruned after bounded retries so one dead relay cannot wedge healthy delivery.
|
||||
|
||||
### Channel Types
|
||||
|
||||
#### `mesh #bluetooth`
|
||||
@@ -80,7 +121,7 @@ Private messages use **intelligent transport selection**:
|
||||
2. **Nostr Fallback** (when Bluetooth unavailable)
|
||||
|
||||
- Uses recipient's Nostr public key
|
||||
- NIP-17 gift-wrapping for privacy
|
||||
- BitChat's app-specific private-envelope encryption
|
||||
- Routes through global relay network
|
||||
|
||||
3. **Smart Queuing** (when neither available)
|
||||
|
||||
+10
-4
@@ -25,7 +25,7 @@ bitchat is a decentralized, peer-to-peer messaging application for secure, priva
|
||||
Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`:
|
||||
|
||||
* **BLE mesh** — every device is simultaneously a GATT central and peripheral, relaying packets in a controlled flood. No infrastructure, pairing, or accounts.
|
||||
* **Nostr** — private messages to mutual favorites travel as NIP-17 gift-wrapped events over public relays (over Tor where enabled), bridging separate meshes through the internet.
|
||||
* **Nostr** — private messages to mutual favorites travel in BitChat's app-specific encrypted envelopes over public relays (over Tor where enabled), bridging separate meshes through the internet.
|
||||
|
||||
The router prefers a live mesh link, falls back to Nostr, and engages the courier system when neither can deliver promptly.
|
||||
|
||||
@@ -78,7 +78,9 @@ Courier envelopes are sealed to the recipient's *static* key with the one-way No
|
||||
|
||||
### 5.3 Nostr Path
|
||||
|
||||
Private messages to mutual favorites are wrapped per NIP-17/NIP-59: a rumor (kind 14) sealed (kind 13) and gift-wrapped (kind 1059) under a throwaway ephemeral key, so relays learn neither sender nor content.
|
||||
Private messages to mutual favorites use BitChat's proprietary private-envelope protocol. An unsigned inner message (kind 1404) is encrypted and placed in a sender-signed seal (kind 1403); that seal is encrypted again inside a public envelope (kind 1402) signed by a one-time key. Kind 1402 is a provisional BitChat-specific assignment, not a formally reserved Nostr kind. Each encrypted content field is `bitchat-pm-v1:` followed by base64url of a 24-byte nonce, XChaCha20-Poly1305 ciphertext, and its 16-byte tag. Keys come from secp256k1 ECDH and HKDF-SHA256 with a BitChat-specific domain separator.
|
||||
|
||||
This format is **not NIP-17, NIP-44, or NIP-59 compatible** and interoperates only with BitChat clients. The outer `p` tag exposes the recipient's Nostr public key to relays; the stable sender identity and plaintext remain inside authenticated ciphertext. New-format seal and envelope timestamps are randomized up to 15 minutes into the past, while the actual message timestamp is encrypted. Legacy Android envelopes can carry public-layer timestamps randomized across the preceding 48 hours. The protocol does not provide forward secrecy: compromise of the recipient's static Nostr private key can expose stored envelopes.
|
||||
|
||||
## 6. Store and Forward
|
||||
|
||||
@@ -105,7 +107,11 @@ Public broadcast messages are cached (1000 packets) and reconciled between peers
|
||||
|
||||
### 6.4 Nostr Mailboxes
|
||||
|
||||
Gift-wrapped messages rest on Nostr relays; clients re-subscribe with a 24-hour lookback on reconnect, covering the both-devices-offline case for mutual favorites whenever either side touches the internet.
|
||||
BitChat private envelopes rest on Nostr relays; clients re-subscribe across the 24-hour delivery window plus the full 48-hour timestamp randomization used by deployed Android clients and 15 minutes of clock skew (72 hours 15 minutes total). During the rolling format migration, clients subscribe to both the provisional BitChat-specific kind 1402 and historical kind 1059. Recovery places the kinds in separate filters within one REQ, each with an independent 500-event limit, so traffic in one format cannot starve the other. Each logical payload is published first in the primary kind-1402 format and then as a compatibility legacy copy for older iOS and current Android clients. There is no calendar cutoff: legacy publication and reception remain until a coordinated cross-platform release confirms supported clients have migrated. Bounded dedup of the authenticated embedded payload collapses the migration pair at receivers.
|
||||
|
||||
The relay send queue treats those two events as one protected batch. Capacity pressure removes ephemeral events before regular traffic and never evicts only one private-envelope copy. A queue containing only protected batches rejects a new pair as a whole: user messages surface a failed delivery, while acknowledgements and favorite notifications retain the exact pair in one process-wide 256-entry bounded-backoff retry queue shared by account and short-lived geohash transports. Sustained control-payload overflow evicts the oldest whole pair with an explicit warning rather than growing memory or splitting formats. After either socket write fails, the same pair remains pending and both copies are replayed on the replacement connection. Terminal relay targets are pruned after bounded connection retries; a batch that succeeded elsewhere retires normally, while an all-target failure returns to the transport's failure/retry policy. Receive-side dedup suppresses the replay if one copy had already reached the relay.
|
||||
|
||||
Released iOS legacy envelopes use an empty inner tag list. Current Android legacy envelopes use exactly one inner `p` tag naming the recipient. The kind-1059 decoder accepts only those two shapes after authenticating the outer recipient and sender-signed seal; alternate recipients, duplicate tags, and extra tags are rejected. Kind 1402 remains strict and permits no inner tags.
|
||||
|
||||
### 6.5 Delivery Metrics
|
||||
|
||||
@@ -127,7 +133,7 @@ Bare local counters (deposits, handovers, sprays, opens, outbox flushes and drop
|
||||
* **Flooding abuse** is bounded by TTL clamps, deduplication, per-depositor quotas, connect-rate limits, and announce-rate limiting.
|
||||
* **Replay** of public broadcasts is bounded by the 6-hour acceptance window plus deduplication; private payloads are protected by Noise nonces.
|
||||
* **Metadata.** BLE proximity is inherently observable; ephemeral IDs and daily-rotating courier tags limit long-term correlation. Nostr traffic can ride Tor.
|
||||
* **No forward secrecy for sealed mail** (§5.2) is the main cryptographic trade-off of the offline path.
|
||||
* **No forward secrecy for sealed mail or Nostr envelopes** (§5.2–5.3) means compromise of a recipient's static key can expose retained ciphertext addressed to that key.
|
||||
|
||||
## 9. Future Work
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ final class ConversationUIModel: ObservableObject {
|
||||
@Published private(set) var currentNickname: String
|
||||
@Published private(set) var isBatchingPublic = false
|
||||
@Published private(set) var canSendMediaInCurrentContext = true
|
||||
@Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest?
|
||||
/// Who is talking live in the public mesh channel right now (floor
|
||||
/// courtesy: the composer mic tints "busy" while someone holds the floor).
|
||||
@Published private(set) var activeLiveVoiceTalker: String?
|
||||
@@ -154,13 +153,6 @@ final class ConversationUIModel: ObservableObject {
|
||||
chatViewModel.sendVoiceNote(at: url)
|
||||
}
|
||||
|
||||
func resolveLegacyPrivateMediaConsent(requestID: UUID, approved: Bool) {
|
||||
chatViewModel.resolveLegacyPrivateMediaConsent(
|
||||
requestID: requestID,
|
||||
approved: approved
|
||||
)
|
||||
}
|
||||
|
||||
/// Capture backend for the mic gesture: live PTT when the current DM
|
||||
/// peer can hear it now, classic voice note otherwise.
|
||||
func makeVoiceCaptureSession() -> VoiceCaptureSession {
|
||||
@@ -201,10 +193,6 @@ final class ConversationUIModel: ObservableObject {
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$activeLiveVoiceTalker)
|
||||
|
||||
chatViewModel.$legacyPrivateMediaConsentRequest
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$legacyPrivateMediaConsentRequest)
|
||||
|
||||
conversations.$activeChannel
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] channel in
|
||||
|
||||
@@ -265,10 +265,10 @@ final class PrivateConversationModel: ObservableObject {
|
||||
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
||||
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
||||
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
||||
// Geo DMs are always routed over Nostr (NIP-17); their nostr_ keys
|
||||
// never resolve to a reachable mesh peer, so resolveAvailability would
|
||||
// report .offline. Report .nostrAvailable so the header shows the
|
||||
// globe instead of a misleading "offline" tag.
|
||||
// Geo DMs are always routed through BitChat private envelopes over
|
||||
// Nostr; their nostr_ keys never resolve to a reachable mesh peer, so
|
||||
// resolveAvailability would report .offline. Report .nostrAvailable
|
||||
// so the header shows the globe instead of a misleading "offline" tag.
|
||||
let availability = conversationPeerID.isGeoDM
|
||||
? .nostrAvailable
|
||||
: resolveAvailability(for: headerPeerID, peer: peer)
|
||||
|
||||
@@ -189,18 +189,6 @@ struct IdentityCache: Codable {
|
||||
// Fingerprint -> when we verified it (orders outgoing vouch batches;
|
||||
// entries verified before this field exists sort as oldest)
|
||||
var verifiedAt: [String: Date]? = nil
|
||||
|
||||
// Stable Noise fingerprints that proved encrypted private-media support
|
||||
// inside an authenticated Noise session. Optional for decoding caches
|
||||
// written before this migration. Entries are monotonic until a panic wipe
|
||||
// so an old/replayed announce cannot silently downgrade a peer.
|
||||
var privateMediaCapableFingerprints: Set<String>? = nil
|
||||
|
||||
// Noise-fingerprint -> Ed25519 announcement key, learned only from the
|
||||
// authenticated peer-state payload. This prevents a self-signed announce
|
||||
// containing a copied public Noise key from replacing a previously bound
|
||||
// public-message signing identity. Optional for old cache compatibility.
|
||||
var authenticatedSigningKeysByFingerprint: [String: Data]? = nil
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
@@ -140,14 +140,6 @@ protocol SecureIdentityStateManagerProtocol {
|
||||
func markVouchBatchSent(to fingerprint: String, at date: Date)
|
||||
func signingPublicKey(forFingerprint fingerprint: String) -> Data?
|
||||
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String]
|
||||
|
||||
// MARK: Noise-authenticated announcement identity
|
||||
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String)
|
||||
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data?
|
||||
|
||||
// MARK: Private-media downgrade protection
|
||||
func markPrivateMediaCapable(fingerprint: String)
|
||||
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool
|
||||
}
|
||||
|
||||
/// Singleton manager for secure identity state persistence and retrieval.
|
||||
@@ -165,7 +157,6 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
// Thread safety
|
||||
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
|
||||
private let queueSpecificKey = DispatchSpecificKey<UInt8>()
|
||||
|
||||
// Pending-save coalescing flag. Reads/writes are serialized on `queue`.
|
||||
// Persistence is done with a fire-and-forget `queue.async(.barrier)` rather
|
||||
@@ -223,7 +214,6 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
self.encryptionKey = loadedKey
|
||||
self.encryptionKeyIsEphemeral = keyIsEphemeral
|
||||
queue.setSpecific(key: queueSpecificKey, value: 1)
|
||||
|
||||
// Only read the persisted cache when we hold the real key; with an
|
||||
// ephemeral key the decrypt would fail and discard the real cache.
|
||||
@@ -380,66 +370,6 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private-media downgrade protection
|
||||
|
||||
func markPrivateMediaCapable(fingerprint: String) {
|
||||
guard !fingerprint.isEmpty else { return }
|
||||
let insertAndPersist = {
|
||||
var pinned = self.cache.privateMediaCapableFingerprints ?? []
|
||||
guard pinned.insert(fingerprint).inserted else { return }
|
||||
self.cache.privateMediaCapableFingerprints = pinned
|
||||
self.saveIdentityCache()
|
||||
}
|
||||
// Downgrade decisions can run immediately after an authenticated
|
||||
// announce. Make the pin visible before returning; merely enqueueing a
|
||||
// barrier leaves a cross-queue window where a replay can look legacy.
|
||||
// The queue-specific fast path prevents self-deadlock if a future
|
||||
// identity-state mutation records the capability from inside `queue`.
|
||||
if DispatchQueue.getSpecific(key: queueSpecificKey) != nil {
|
||||
insertAndPersist()
|
||||
} else {
|
||||
queue.sync(flags: .barrier, execute: insertAndPersist)
|
||||
}
|
||||
}
|
||||
|
||||
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool {
|
||||
guard !fingerprint.isEmpty else { return false }
|
||||
return queue.sync {
|
||||
cache.privateMediaCapableFingerprints?.contains(fingerprint) == true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Noise-authenticated announcement identity
|
||||
|
||||
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) {
|
||||
guard signingPublicKey.count == AuthenticatedPeerStatePacket.signingPublicKeyLength,
|
||||
!fingerprint.isEmpty else { return }
|
||||
let bindAndPersist = {
|
||||
var bindings = self.cache.authenticatedSigningKeysByFingerprint ?? [:]
|
||||
let bindingChanged = bindings[fingerprint] != signingPublicKey
|
||||
bindings[fingerprint] = signingPublicKey
|
||||
self.cache.authenticatedSigningKeysByFingerprint = bindings
|
||||
if var cryptoIdentity = self.cryptographicIdentities[fingerprint] {
|
||||
cryptoIdentity.signingPublicKey = signingPublicKey
|
||||
self.cryptographicIdentities[fingerprint] = cryptoIdentity
|
||||
}
|
||||
guard bindingChanged else { return }
|
||||
self.saveIdentityCache()
|
||||
}
|
||||
if DispatchQueue.getSpecific(key: queueSpecificKey) != nil {
|
||||
bindAndPersist()
|
||||
} else {
|
||||
queue.sync(flags: .barrier, execute: bindAndPersist)
|
||||
}
|
||||
}
|
||||
|
||||
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? {
|
||||
guard !fingerprint.isEmpty else { return nil }
|
||||
return queue.sync {
|
||||
cache.authenticatedSigningKeysByFingerprint?[fingerprint]
|
||||
}
|
||||
}
|
||||
|
||||
func updateSocialIdentity(_ identity: SocialIdentity) {
|
||||
queue.async(flags: .barrier) {
|
||||
|
||||
@@ -30,7 +30,7 @@ struct NoisePayload {
|
||||
|
||||
// Safely get the first byte
|
||||
let firstByte = data[data.startIndex]
|
||||
guard let type = NoisePayloadType.decoded(rawValue: firstByte) else {
|
||||
guard let type = NoisePayloadType(rawValue: firstByte) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -6,36 +6,14 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
enum NoiseSecurityConstants {
|
||||
// Maximum message size to prevent memory exhaustion
|
||||
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
||||
|
||||
/// The extracted transport nonce (4 bytes) and Poly1305 tag (16 bytes)
|
||||
/// added by `NoiseCipherState` around every transport plaintext.
|
||||
static let transportCiphertextOverhead = 20
|
||||
|
||||
/// Private files are an explicit BitChat extension to the ordinary Noise
|
||||
/// message-size ceiling. They remain bounded by the same framed-file cap
|
||||
/// used by the binary and fragment decoders. Only the `.privateFile`
|
||||
/// typed-payload path is allowed to use this larger budget.
|
||||
private static let privateFileOuterPacketOverhead =
|
||||
(BinaryProtocol.v1HeaderSize + 2) // v2 adds two length bytes
|
||||
+ BinaryProtocol.senderIDSize
|
||||
+ BinaryProtocol.recipientIDSize
|
||||
static let maxPrivateFilePlaintextSize = FileTransferLimits.maxFramedFileBytes
|
||||
- privateFileOuterPacketOverhead
|
||||
- transportCiphertextOverhead
|
||||
static let maxPrivateFileCiphertextSize =
|
||||
maxPrivateFilePlaintextSize + transportCiphertextOverhead
|
||||
|
||||
// Maximum handshake message size
|
||||
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
||||
|
||||
// Noise XX message 1 contains only the initiator's 32-byte ephemeral key.
|
||||
static let xxInitialMessageSize = 32
|
||||
|
||||
// Session timeout - sessions older than this should be renegotiated
|
||||
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
||||
|
||||
@@ -14,19 +14,6 @@ struct NoiseSecurityValidator {
|
||||
static func validateMessageSize(_ data: Data) -> Bool {
|
||||
return data.count <= NoiseSecurityConstants.maxMessageSize
|
||||
}
|
||||
|
||||
static func validateCiphertextSize(_ data: Data) -> Bool {
|
||||
data.count <= NoiseSecurityConstants.maxMessageSize
|
||||
+ NoiseSecurityConstants.transportCiphertextOverhead
|
||||
}
|
||||
|
||||
static func validatePrivateFileMessageSize(_ data: Data) -> Bool {
|
||||
data.count <= NoiseSecurityConstants.maxPrivateFilePlaintextSize
|
||||
}
|
||||
|
||||
static func validatePrivateFileCiphertextSize(_ data: Data) -> Bool {
|
||||
data.count <= NoiseSecurityConstants.maxPrivateFileCiphertextSize
|
||||
}
|
||||
|
||||
/// Validate handshake message size
|
||||
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||
|
||||
@@ -11,5 +11,4 @@ enum NoiseSessionError: Error, Equatable {
|
||||
case notEstablished
|
||||
case sessionNotFound
|
||||
case alreadyEstablished
|
||||
case peerIdentityMismatch
|
||||
}
|
||||
|
||||
@@ -13,20 +13,11 @@ import BitFoundation
|
||||
|
||||
final class NoiseSessionManager {
|
||||
private var sessions: [PeerID: NoiseSession] = [:]
|
||||
/// Opaque identity for each exact entry in `sessions`. The generation is
|
||||
/// created and removed under the same barrier as the session itself, so a
|
||||
/// caller can never authenticate data with one session and lease another.
|
||||
private var sessionGenerations: [PeerID: UUID] = [:]
|
||||
/// A responder rehandshake must not evict a working transport session
|
||||
/// before the candidate proves that its authenticated static key belongs
|
||||
/// to the claimed wire ID. Candidates therefore live outside `sessions`
|
||||
/// until the XX handshake completes and the binding is validated.
|
||||
private var responderCandidates: [PeerID: NoiseSession] = [:]
|
||||
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
|
||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||
|
||||
// Callbacks
|
||||
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey, UUID) -> Void)?
|
||||
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)?
|
||||
var onSessionFailed: ((PeerID, Error) -> Void)?
|
||||
|
||||
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
||||
@@ -63,10 +54,6 @@ final class NoiseSessionManager {
|
||||
if let session = sessions.removeValue(forKey: peerID) {
|
||||
session.reset() // Clear sensitive data before removing
|
||||
}
|
||||
sessionGenerations.removeValue(forKey: peerID)
|
||||
if let candidate = responderCandidates.removeValue(forKey: peerID) {
|
||||
candidate.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,12 +62,7 @@ final class NoiseSessionManager {
|
||||
for (_, session) in sessions {
|
||||
session.reset()
|
||||
}
|
||||
for (_, candidate) in responderCandidates {
|
||||
candidate.reset()
|
||||
}
|
||||
sessions.removeAll()
|
||||
sessionGenerations.removeAll()
|
||||
responderCandidates.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,14 +79,11 @@ final class NoiseSessionManager {
|
||||
// Remove any existing non-established session
|
||||
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
sessionGenerations.removeValue(forKey: peerID)
|
||||
existingSession.reset()
|
||||
}
|
||||
|
||||
// Create new initiator session
|
||||
let session = sessionFactory(peerID, .initiator)
|
||||
sessions[peerID] = session
|
||||
sessionGenerations[peerID] = UUID()
|
||||
|
||||
do {
|
||||
let handshakeData = try session.startHandshake()
|
||||
@@ -112,8 +91,6 @@ final class NoiseSessionManager {
|
||||
} catch {
|
||||
// Clean up failed session
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
sessionGenerations.removeValue(forKey: peerID)
|
||||
session.reset()
|
||||
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
||||
throw error
|
||||
}
|
||||
@@ -121,64 +98,41 @@ final class NoiseSessionManager {
|
||||
}
|
||||
|
||||
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
|
||||
// Process everything within the synchronized block to prevent race conditions.
|
||||
// Return establishment metadata and publish the callback only after the
|
||||
// manager barrier is released, avoiding both a deadlock and a window in
|
||||
// which `processHandshakeMessage` returns before authentication state.
|
||||
let result: (
|
||||
response: Data?,
|
||||
establishedSession: (
|
||||
remoteKey: Curve25519.KeyAgreement.PublicKey,
|
||||
generation: UUID
|
||||
)?
|
||||
) = try managerQueue.sync(flags: .barrier) {
|
||||
let session: NoiseSession
|
||||
let isReplacementCandidate: Bool
|
||||
|
||||
if let candidate = responderCandidates[peerID] {
|
||||
// A fresh XX message 1 supersedes an incomplete candidate,
|
||||
// but never the established session it is trying to replace.
|
||||
if message.count == NoiseSecurityConstants.xxInitialMessageSize {
|
||||
candidate.reset()
|
||||
let replacement = sessionFactory(peerID, .responder)
|
||||
responderCandidates[peerID] = replacement
|
||||
session = replacement
|
||||
} else {
|
||||
session = candidate
|
||||
}
|
||||
isReplacementCandidate = true
|
||||
} else if let existing = sessions[peerID] {
|
||||
// Process everything within the synchronized block to prevent race conditions
|
||||
return try managerQueue.sync(flags: .barrier) {
|
||||
var shouldCreateNew = false
|
||||
var existingSession: NoiseSession? = nil
|
||||
|
||||
if let existing = sessions[peerID] {
|
||||
// If we have an established session, the peer must have cleared their session
|
||||
// for a good reason (e.g., decryption failure, restart, etc.)
|
||||
// We should accept the new handshake to re-establish encryption
|
||||
if existing.isEstablished() {
|
||||
SecureLogger.info(
|
||||
"Validating replacement handshake from \(peerID) while preserving the established session",
|
||||
category: .session
|
||||
)
|
||||
let candidate = sessionFactory(peerID, .responder)
|
||||
responderCandidates[peerID] = candidate
|
||||
session = candidate
|
||||
isReplacementCandidate = true
|
||||
} else if existing.getState() == .handshaking,
|
||||
message.count == NoiseSecurityConstants.xxInitialMessageSize {
|
||||
// No established transport state exists to preserve. A
|
||||
// fresh initiation replaces the incomplete handshake.
|
||||
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
sessionGenerations.removeValue(forKey: peerID)
|
||||
existing.reset()
|
||||
let replacement = sessionFactory(peerID, .responder)
|
||||
sessions[peerID] = replacement
|
||||
sessionGenerations[peerID] = UUID()
|
||||
session = replacement
|
||||
isReplacementCandidate = false
|
||||
shouldCreateNew = true
|
||||
} else {
|
||||
session = existing
|
||||
isReplacementCandidate = false
|
||||
// If we're in the middle of a handshake and receive a new initiation,
|
||||
// reset and start fresh (the other side may have restarted)
|
||||
if existing.getState() == .handshaking && message.count == 32 {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
shouldCreateNew = true
|
||||
} else {
|
||||
existingSession = existing
|
||||
}
|
||||
}
|
||||
} else {
|
||||
shouldCreateNew = true
|
||||
}
|
||||
|
||||
// Get or create session
|
||||
let session: NoiseSession
|
||||
if shouldCreateNew {
|
||||
let newSession = sessionFactory(peerID, .responder)
|
||||
sessions[peerID] = newSession
|
||||
sessionGenerations[peerID] = UUID()
|
||||
session = newSession
|
||||
isReplacementCandidate = false
|
||||
} else {
|
||||
session = existingSession!
|
||||
}
|
||||
|
||||
// Process the handshake message within the synchronized block
|
||||
@@ -186,46 +140,19 @@ final class NoiseSessionManager {
|
||||
let response = try session.processHandshakeMessage(message)
|
||||
|
||||
// Check if session is established after processing
|
||||
var establishedSession: (
|
||||
remoteKey: Curve25519.KeyAgreement.PublicKey,
|
||||
generation: UUID
|
||||
)?
|
||||
if session.isEstablished() {
|
||||
guard let remoteKey = session.getRemoteStaticPublicKey(),
|
||||
authenticatedRemoteKey(remoteKey, matches: peerID) else {
|
||||
throw NoiseSessionError.peerIdentityMismatch
|
||||
}
|
||||
|
||||
if isReplacementCandidate {
|
||||
_ = responderCandidates.removeValue(forKey: peerID)
|
||||
let previous = sessions.updateValue(session, forKey: peerID)
|
||||
sessionGenerations[peerID] = UUID()
|
||||
if let previous, previous !== session {
|
||||
previous.reset()
|
||||
if let remoteKey = session.getRemoteStaticPublicKey() {
|
||||
// Schedule callback outside the synchronized block to prevent deadlock
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
self?.onSessionEstablished?(peerID, remoteKey)
|
||||
}
|
||||
}
|
||||
guard let generation = sessionGenerations[peerID] else {
|
||||
throw NoiseEncryptionError.sessionNotEstablished
|
||||
}
|
||||
establishedSession = (remoteKey, generation)
|
||||
}
|
||||
|
||||
return (response, establishedSession)
|
||||
return response
|
||||
} catch {
|
||||
// A failed candidate is discarded without touching the
|
||||
// established session. Ordinary failed handshakes retain the
|
||||
// historical cleanup behavior.
|
||||
if isReplacementCandidate {
|
||||
if let storedCandidate = responderCandidates[peerID],
|
||||
storedCandidate === session {
|
||||
_ = responderCandidates.removeValue(forKey: peerID)
|
||||
}
|
||||
} else if let storedSession = sessions[peerID],
|
||||
storedSession === session {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
sessionGenerations.removeValue(forKey: peerID)
|
||||
}
|
||||
session.reset()
|
||||
// Reset the session on handshake failure so next attempt can start fresh
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
|
||||
// Schedule callback outside the synchronized block to prevent deadlock
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
@@ -236,102 +163,24 @@ final class NoiseSessionManager {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if let established = result.establishedSession {
|
||||
onSessionEstablished?(peerID, established.remoteKey, established.generation)
|
||||
}
|
||||
return result.response
|
||||
}
|
||||
|
||||
/// Mesh handshakes normally use a 16-hex wire ID. Full Noise-key IDs are
|
||||
/// also accepted by internal callers when they exactly match the static
|
||||
/// key. Non-wire identifiers remain available to protocol test harnesses;
|
||||
/// BLE packet ingress always supplies a short hexadecimal ID.
|
||||
private func authenticatedRemoteKey(
|
||||
_ remoteKey: Curve25519.KeyAgreement.PublicKey,
|
||||
matches claimedPeerID: PeerID
|
||||
) -> Bool {
|
||||
let rawKey = remoteKey.rawRepresentation
|
||||
if claimedPeerID.isShort {
|
||||
return PeerID(publicKey: rawKey) == claimedPeerID
|
||||
}
|
||||
if let claimedNoiseKey = claimedPeerID.noiseKey {
|
||||
return claimedNoiseKey == rawKey
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Encryption/Decryption
|
||||
|
||||
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
|
||||
try managerQueue.sync {
|
||||
guard let session = sessions[peerID] else {
|
||||
throw NoiseSessionError.sessionNotFound
|
||||
}
|
||||
return try session.encrypt(plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypts only if `expected` still names the current established entry.
|
||||
/// A rekey between capability proof and media encryption therefore fails
|
||||
/// closed instead of sending on an unproven replacement session.
|
||||
func encrypt(
|
||||
_ plaintext: Data,
|
||||
for peerID: PeerID,
|
||||
expectedSessionGeneration expected: UUID
|
||||
) throws -> Data {
|
||||
try managerQueue.sync {
|
||||
guard let session = sessions[peerID],
|
||||
session.isEstablished(),
|
||||
sessionGenerations[peerID] == expected else {
|
||||
throw NoiseEncryptionError.sessionNotEstablished
|
||||
}
|
||||
return try session.encrypt(plaintext)
|
||||
guard let session = getSession(for: peerID) else {
|
||||
throw NoiseSessionError.sessionNotFound
|
||||
}
|
||||
|
||||
return try session.encrypt(plaintext)
|
||||
}
|
||||
|
||||
func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {
|
||||
try decryptWithSessionGeneration(ciphertext, from: peerID).plaintext
|
||||
}
|
||||
|
||||
func sessionGeneration(for peerID: PeerID) -> UUID? {
|
||||
managerQueue.sync {
|
||||
guard sessions[peerID]?.isEstablished() == true else { return nil }
|
||||
return sessionGenerations[peerID]
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypts while holding the manager's read lease. Session promotion and
|
||||
/// removal require its barrier, so the returned generation always names
|
||||
/// the exact session object that authenticated these bytes.
|
||||
func decryptWithSessionGeneration(
|
||||
_ ciphertext: Data,
|
||||
from peerID: PeerID
|
||||
) throws -> (plaintext: Data, sessionGeneration: UUID) {
|
||||
try managerQueue.sync {
|
||||
guard let session = sessions[peerID] else {
|
||||
throw NoiseSessionError.sessionNotFound
|
||||
}
|
||||
guard session.isEstablished(),
|
||||
let generation = sessionGenerations[peerID] else {
|
||||
throw NoiseEncryptionError.sessionNotEstablished
|
||||
}
|
||||
return (try session.decrypt(ciphertext), generation)
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a state commit under a read lease for the exact established
|
||||
/// session. Rekey, replacement, and removal all need the same barrier.
|
||||
func withCurrentSessionGeneration<Result>(
|
||||
for peerID: PeerID,
|
||||
expected: UUID,
|
||||
_ body: () -> Result
|
||||
) -> Result? {
|
||||
managerQueue.sync {
|
||||
guard sessions[peerID]?.isEstablished() == true,
|
||||
sessionGenerations[peerID] == expected else { return nil }
|
||||
return body()
|
||||
guard let session = getSession(for: peerID) else {
|
||||
throw NoiseSessionError.sessionNotFound
|
||||
}
|
||||
|
||||
return try session.decrypt(ciphertext)
|
||||
}
|
||||
|
||||
// MARK: - Key Management
|
||||
@@ -358,11 +207,11 @@ final class NoiseSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
func initiateRekey(for peerID: PeerID) throws -> Data {
|
||||
func initiateRekey(for peerID: PeerID) throws {
|
||||
// Remove old session
|
||||
removeSession(for: peerID)
|
||||
|
||||
// Initiate new handshake
|
||||
return try initiateHandshake(with: peerID)
|
||||
_ = try initiateHandshake(with: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,12 +24,8 @@ final class SecureNoiseSession: NoiseSession {
|
||||
throw NoiseSecurityError.sessionExhausted
|
||||
}
|
||||
|
||||
// Ordinary Noise messages keep the protocol ceiling. Finalized media
|
||||
// is the sole typed-payload extension and remains under the framed-file
|
||||
// cap enforced again at the service and file-decoder layers.
|
||||
let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: plaintext.first)
|
||||
&& NoiseSecurityValidator.validatePrivateFileMessageSize(plaintext)
|
||||
guard NoiseSecurityValidator.validateMessageSize(plaintext) || isPrivateFile else {
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
@@ -46,11 +42,8 @@ final class SecureNoiseSession: NoiseSession {
|
||||
throw NoiseSecurityError.sessionExpired
|
||||
}
|
||||
|
||||
// The payload type is encrypted, so a large candidate can only be
|
||||
// bounded here; `NoiseEncryptionService.decrypt` authenticates it and
|
||||
// then requires the resulting type to be `.privateFile`.
|
||||
guard NoiseSecurityValidator.validateCiphertextSize(ciphertext)
|
||||
|| NoiseSecurityValidator.validatePrivateFileCiphertextSize(ciphertext) else {
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import Foundation
|
||||
import P256K
|
||||
|
||||
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
|
||||
/// Manages the secp256k1 identity used by BitChat's Nostr relay features,
|
||||
/// including the proprietary private-envelope transport.
|
||||
struct NostrIdentity: Codable {
|
||||
let privateKey: Data
|
||||
let publicKey: Data
|
||||
|
||||
+441
-231
@@ -1,4 +1,3 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import P256K
|
||||
@@ -7,16 +6,31 @@ import Security
|
||||
// Note: This file depends on Data extension from BinaryEncodingUtils.swift
|
||||
// Make sure BinaryEncodingUtils.swift is included in the target
|
||||
|
||||
/// NIP-17 Protocol Implementation for Private Direct Messages
|
||||
/// BitChat's private-envelope protocol transported over Nostr relays.
|
||||
///
|
||||
/// This is deliberately BitChat-specific and is not NIP-17, NIP-44, or NIP-59.
|
||||
/// It uses Nostr events and secp256k1 identities, but its XChaCha20-Poly1305
|
||||
/// payload layout is proprietary and interoperates only with BitChat clients.
|
||||
struct NostrProtocol {
|
||||
|
||||
/// Nostr event kinds
|
||||
enum EventKind: Int {
|
||||
case metadata = 0
|
||||
case textNote = 1
|
||||
case dm = 14 // NIP-17 DM rumor kind
|
||||
case seal = 13 // NIP-17 sealed event
|
||||
case giftWrap = 1059 // NIP-59 gift wrap
|
||||
// Compatibility for BitChat releases that incorrectly emitted the
|
||||
// proprietary payload under standard NIP kinds. Kind 1059 continues
|
||||
// to be published and read until a coordinated cross-platform release
|
||||
// explicitly removes it; all three legacy layers remain readable.
|
||||
case legacyNIP59Seal = 13
|
||||
case legacyNIP17DirectMessage = 14
|
||||
case legacyNIP59GiftWrap = 1059
|
||||
// Provisional BitChat-specific regular event kinds. These are not
|
||||
// formally reserved by the Nostr kind registry. Only
|
||||
// `privateEnvelope` is published; message and seal exist solely
|
||||
// inside ciphertext.
|
||||
case privateEnvelope = 1402
|
||||
case privateSeal = 1403
|
||||
case privateMessage = 1404
|
||||
case ephemeralEvent = 20000
|
||||
case geohashPresence = 20001
|
||||
case deletion = 5 // NIP-09 event deletion request
|
||||
@@ -25,145 +39,286 @@ struct NostrProtocol {
|
||||
/// its NIP-40 expiration — the whole point is store-and-forward.
|
||||
case courierDrop = 1401
|
||||
}
|
||||
|
||||
/// Prefix for BitChat private-envelope ciphertext. The suffix is
|
||||
/// base64url(nonce24 || ciphertext || poly1305Tag).
|
||||
static let privateEnvelopeContentPrefix = "bitchat-pm-v1:"
|
||||
|
||||
/// Bound work before Base64 decoding either encrypted layer. Current
|
||||
/// private messages are normally only a few KiB; 64 KiB leaves ample
|
||||
/// migration headroom without allowing an addressed relay event to drive
|
||||
/// unbounded allocation.
|
||||
static let maximumPrivateEnvelopeCiphertextBytes = 64 * 1024
|
||||
|
||||
/// Bound the inner authenticated message JSON before allocation/parsing.
|
||||
static let maximumPrivateEnvelopePlaintextBytes = 32 * 1024
|
||||
|
||||
/// The outer authenticated seal JSON contains a Base64-encoded encrypted
|
||||
/// copy of the inner JSON, so it needs expansion headroom of its own. Keep
|
||||
/// the layer-specific cap below the public ciphertext ceiling.
|
||||
private static let maximumPrivateEnvelopeSealPlaintextBytes = 48 * 1024
|
||||
|
||||
/// New clients subscribe to the provisional BitChat-specific kind and the
|
||||
/// compatibility legacy kind so both sides of a rolling rollout can
|
||||
/// recover stored messages. Do not remove kind 1059 here until all
|
||||
/// supported iOS and Android releases have migrated.
|
||||
static let acceptedPrivateEnvelopeKinds = [
|
||||
EventKind.privateEnvelope.rawValue,
|
||||
EventKind.legacyNIP59GiftWrap.rawValue
|
||||
]
|
||||
|
||||
private enum PrivateEnvelopeWireFormat {
|
||||
case bitchatV1
|
||||
case legacyMislabelledV2
|
||||
|
||||
init?(outerKind: Int) {
|
||||
switch outerKind {
|
||||
case EventKind.privateEnvelope.rawValue:
|
||||
self = .bitchatV1
|
||||
case EventKind.legacyNIP59GiftWrap.rawValue:
|
||||
self = .legacyMislabelledV2
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var messageKind: EventKind {
|
||||
switch self {
|
||||
case .bitchatV1: .privateMessage
|
||||
case .legacyMislabelledV2: .legacyNIP17DirectMessage
|
||||
}
|
||||
}
|
||||
|
||||
var sealKind: EventKind {
|
||||
switch self {
|
||||
case .bitchatV1: .privateSeal
|
||||
case .legacyMislabelledV2: .legacyNIP59Seal
|
||||
}
|
||||
}
|
||||
|
||||
var envelopeKind: EventKind {
|
||||
switch self {
|
||||
case .bitchatV1: .privateEnvelope
|
||||
case .legacyMislabelledV2: .legacyNIP59GiftWrap
|
||||
}
|
||||
}
|
||||
|
||||
var contentPrefix: String {
|
||||
switch self {
|
||||
case .bitchatV1: NostrProtocol.privateEnvelopeContentPrefix
|
||||
case .legacyMislabelledV2: "v2:"
|
||||
}
|
||||
}
|
||||
|
||||
var hkdfSalt: Data {
|
||||
switch self {
|
||||
case .bitchatV1: Data("bitchat-private-envelope-v1".utf8)
|
||||
case .legacyMislabelledV2: Data()
|
||||
}
|
||||
}
|
||||
|
||||
var hkdfInfo: Data {
|
||||
switch self {
|
||||
case .bitchatV1: Data()
|
||||
case .legacyMislabelledV2: Data("nip44-v2".utf8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a NIP-17 private message
|
||||
static func createPrivateMessage(
|
||||
/// Create a BitChat private envelope for relay transport.
|
||||
static func createPrivateEnvelope(
|
||||
content: String,
|
||||
recipientPubkey: String,
|
||||
senderIdentity: NostrIdentity
|
||||
) throws -> NostrEvent {
|
||||
|
||||
// Creating private message
|
||||
|
||||
// 1. Create the rumor (unsigned event)
|
||||
let rumor = NostrEvent(
|
||||
try createPrivateEnvelope(
|
||||
content: content,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderIdentity: senderIdentity,
|
||||
format: .bitchatV1
|
||||
)
|
||||
}
|
||||
|
||||
/// Events to publish for one logical private payload. The primary
|
||||
/// BitChat-specific format is always first and a legacy copy follows for
|
||||
/// clients that still subscribe only to kind 1059. There is deliberately
|
||||
/// no date-based cutoff: removal requires a coordinated iOS/Android
|
||||
/// release after supported old clients have migrated. Both encrypt the
|
||||
/// exact same embedded BitChat payload, so receive-side logical-payload
|
||||
/// dedup collapses the pair.
|
||||
static func createPrivateEnvelopePublicationBatch(
|
||||
content: String,
|
||||
recipientPubkey: String,
|
||||
senderIdentity: NostrIdentity
|
||||
) throws -> [NostrEvent] {
|
||||
let primary = try createPrivateEnvelope(
|
||||
content: content,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderIdentity: senderIdentity
|
||||
)
|
||||
let compatibilityCopy = try createPrivateEnvelope(
|
||||
content: content,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderIdentity: senderIdentity,
|
||||
format: .legacyMislabelledV2
|
||||
)
|
||||
return [primary, compatibilityCopy]
|
||||
}
|
||||
|
||||
private static func createPrivateEnvelope(
|
||||
content: String,
|
||||
recipientPubkey: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
format: PrivateEnvelopeWireFormat,
|
||||
messageTags: [[String]] = []
|
||||
) throws -> NostrEvent {
|
||||
// 1. Create the unsigned inner BitChat message.
|
||||
let message = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .dm, // NIP-17: DM rumor kind 14
|
||||
tags: [],
|
||||
kind: format.messageKind,
|
||||
tags: messageTags,
|
||||
content: content
|
||||
)
|
||||
|
||||
// 2. Seal the rumor (encrypt to recipient) and sign it with the SENDER'S
|
||||
// real identity key. NIP-17 requires the seal be signed by the sender
|
||||
// so the recipient can authenticate who sent the message; signing with
|
||||
// a throwaway key leaves DMs forgeable/impersonatable.
|
||||
// 2. Encrypt the message to the recipient and sign the private seal
|
||||
// with the sender's stable Nostr identity for sender authentication.
|
||||
let senderKey = try senderIdentity.schnorrSigningKey()
|
||||
let sealedEvent = try createSeal(
|
||||
rumor: rumor,
|
||||
let sealedEvent = try createPrivateSeal(
|
||||
message: message,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderKey: senderKey
|
||||
senderKey: senderKey,
|
||||
format: format
|
||||
)
|
||||
|
||||
// 3. Gift wrap the sealed event with a throwaway ephemeral key (the wrap
|
||||
// layer hides the sender's identity from relays; createGiftWrap mints
|
||||
// its own ephemeral key internally).
|
||||
let giftWrap = try createGiftWrap(
|
||||
// 3. Encrypt the seal under a one-time key so the public envelope does
|
||||
// not reveal the stable sender identity.
|
||||
return try createPrivateEnvelopeEvent(
|
||||
seal: sealedEvent,
|
||||
recipientPubkey: recipientPubkey
|
||||
recipientPubkey: recipientPubkey,
|
||||
format: format
|
||||
)
|
||||
|
||||
// Created gift wrap
|
||||
|
||||
return giftWrap
|
||||
}
|
||||
|
||||
/// Decrypt a received NIP-17 message
|
||||
/// Returns the content, sender pubkey, and the actual message timestamp (not the randomized gift wrap timestamp)
|
||||
static func decryptPrivateMessage(
|
||||
giftWrap: NostrEvent,
|
||||
/// Decrypt a BitChat private envelope. Legacy proprietary envelopes that
|
||||
/// older BitChat releases placed under kinds 1059/13/14 are accepted only
|
||||
/// through the format-isolated receive path.
|
||||
static func decryptPrivateEnvelope(
|
||||
envelope: NostrEvent,
|
||||
recipientIdentity: NostrIdentity
|
||||
) throws -> (content: String, senderPubkey: String, timestamp: Int) {
|
||||
|
||||
// Starting decryption
|
||||
|
||||
// 1. Unwrap the gift wrap
|
||||
let seal: NostrEvent
|
||||
do {
|
||||
seal = try unwrapGiftWrap(
|
||||
giftWrap: giftWrap,
|
||||
recipientKey: recipientIdentity.schnorrSigningKey()
|
||||
)
|
||||
// Successfully unwrapped gift wrap
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to unwrap gift wrap: \(error)", category: .session)
|
||||
throw error
|
||||
}
|
||||
|
||||
// 2. Authenticate the seal. The seal MUST be signed by the sender's real
|
||||
// identity key (NIP-17); without this check a DM is forgeable by anyone
|
||||
// who knows the recipient's npub. Verify the seal's own signature.
|
||||
guard seal.isValidSignature() else {
|
||||
SecureLogger.error("❌ Rejecting DM: seal signature is missing or invalid", category: .session)
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
// 3. Open the seal
|
||||
let rumor: NostrEvent
|
||||
do {
|
||||
rumor = try openSeal(
|
||||
seal: seal,
|
||||
recipientKey: recipientIdentity.schnorrSigningKey()
|
||||
)
|
||||
// Successfully opened seal
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to open seal: \(error)", category: .session)
|
||||
throw error
|
||||
}
|
||||
|
||||
// 4. The sender claimed inside the rumor must match the key that actually
|
||||
// signed the seal, otherwise the sender field is unauthenticated and
|
||||
// spoofable.
|
||||
guard seal.pubkey == rumor.pubkey else {
|
||||
SecureLogger.error("❌ Rejecting DM: rumor pubkey does not match seal signer", category: .session)
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
// Return the seal signer's pubkey as the authenticated sender.
|
||||
return (content: rumor.content, senderPubkey: seal.pubkey, timestamp: rumor.created_at)
|
||||
let layers = try decodePrivateEnvelopeLayers(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipientIdentity
|
||||
)
|
||||
return (
|
||||
content: layers.message.content,
|
||||
senderPubkey: layers.seal.pubkey,
|
||||
timestamp: layers.message.created_at
|
||||
)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
static func createPrivateMessageWithInvalidSealSignatureForTesting(
|
||||
static func createPrivateEnvelopeWithInvalidSealSignatureForTesting(
|
||||
content: String,
|
||||
recipientPubkey: String,
|
||||
senderIdentity: NostrIdentity
|
||||
) throws -> NostrEvent {
|
||||
let rumor = NostrEvent(
|
||||
let format = PrivateEnvelopeWireFormat.bitchatV1
|
||||
let message = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .dm,
|
||||
kind: format.messageKind,
|
||||
tags: [],
|
||||
content: content
|
||||
)
|
||||
var seal = try createSeal(
|
||||
rumor: rumor,
|
||||
var seal = try createPrivateSeal(
|
||||
message: message,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderKey: senderIdentity.schnorrSigningKey()
|
||||
senderKey: senderIdentity.schnorrSigningKey(),
|
||||
format: format
|
||||
)
|
||||
seal.sig = String(repeating: "0", count: 128)
|
||||
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
|
||||
return try createPrivateEnvelopeEvent(
|
||||
seal: seal,
|
||||
recipientPubkey: recipientPubkey,
|
||||
format: format
|
||||
)
|
||||
}
|
||||
|
||||
static func createPrivateMessageWithMismatchedSealRumorPubkeyForTesting(
|
||||
static func createPrivateEnvelopeWithMismatchedSealMessagePubkeyForTesting(
|
||||
content: String,
|
||||
recipientPubkey: String,
|
||||
rumorIdentity: NostrIdentity,
|
||||
messageIdentity: NostrIdentity,
|
||||
sealSignerIdentity: NostrIdentity
|
||||
) throws -> NostrEvent {
|
||||
let rumor = NostrEvent(
|
||||
pubkey: rumorIdentity.publicKeyHex,
|
||||
let format = PrivateEnvelopeWireFormat.bitchatV1
|
||||
let message = NostrEvent(
|
||||
pubkey: messageIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .dm,
|
||||
kind: format.messageKind,
|
||||
tags: [],
|
||||
content: content
|
||||
)
|
||||
let seal = try createSeal(
|
||||
rumor: rumor,
|
||||
let seal = try createPrivateSeal(
|
||||
message: message,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderKey: sealSignerIdentity.schnorrSigningKey()
|
||||
senderKey: sealSignerIdentity.schnorrSigningKey(),
|
||||
format: format
|
||||
)
|
||||
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
|
||||
return try createPrivateEnvelopeEvent(
|
||||
seal: seal,
|
||||
recipientPubkey: recipientPubkey,
|
||||
format: format
|
||||
)
|
||||
}
|
||||
|
||||
static func createPrivateEnvelopeWithInnerTagsForTesting(
|
||||
content: String,
|
||||
recipientPubkey: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
innerMessageTags: [[String]]
|
||||
) throws -> NostrEvent {
|
||||
try createPrivateEnvelope(
|
||||
content: content,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderIdentity: senderIdentity,
|
||||
format: .bitchatV1,
|
||||
messageTags: innerMessageTags
|
||||
)
|
||||
}
|
||||
|
||||
static func createLegacyPrivateEnvelopeForTesting(
|
||||
content: String,
|
||||
recipientPubkey: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
innerMessageTags: [[String]] = []
|
||||
) throws -> NostrEvent {
|
||||
// Current Android legacy envelopes use exactly one recipient `p` tag
|
||||
// on the unsigned inner kind-14 event; released iOS envelopes use no
|
||||
// inner tags. Tests pass the Android shape explicitly so this helper
|
||||
// cannot silently make the production encoder depend on that quirk.
|
||||
try createPrivateEnvelope(
|
||||
content: content,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderIdentity: senderIdentity,
|
||||
format: .legacyMislabelledV2,
|
||||
messageTags: innerMessageTags
|
||||
)
|
||||
}
|
||||
|
||||
static func decodePrivateEnvelopeLayersForTesting(
|
||||
envelope: NostrEvent,
|
||||
recipientIdentity: NostrIdentity
|
||||
) throws -> (seal: NostrEvent, message: NostrEvent) {
|
||||
try decodePrivateEnvelopeLayers(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipientIdentity
|
||||
)
|
||||
}
|
||||
|
||||
static func decodePrivateEnvelopeEventJSONForTesting(_ json: String) throws -> NostrEvent {
|
||||
try decodePrivateEnvelopeEventJSON(json)
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -400,151 +555,207 @@ struct NostrProtocol {
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private static func createSeal(
|
||||
rumor: NostrEvent,
|
||||
private static func createPrivateSeal(
|
||||
message: NostrEvent,
|
||||
recipientPubkey: String,
|
||||
senderKey: P256K.Schnorr.PrivateKey
|
||||
senderKey: P256K.Schnorr.PrivateKey,
|
||||
format: PrivateEnvelopeWireFormat
|
||||
) throws -> NostrEvent {
|
||||
|
||||
let rumorJSON = try rumor.jsonString()
|
||||
let encrypted = try encrypt(
|
||||
plaintext: rumorJSON,
|
||||
plaintext: message.jsonString(),
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderKey: senderKey
|
||||
senderKey: senderKey,
|
||||
format: format,
|
||||
maximumPlaintextBytes: maximumPrivateEnvelopePlaintextBytes
|
||||
)
|
||||
|
||||
|
||||
let seal = NostrEvent(
|
||||
pubkey: Data(senderKey.xonly.bytes).hexEncodedString(),
|
||||
createdAt: randomizedTimestamp(),
|
||||
kind: .seal,
|
||||
createdAt: randomizedPastTimestamp(),
|
||||
kind: format.sealKind,
|
||||
tags: [],
|
||||
content: encrypted
|
||||
)
|
||||
|
||||
// Sign the seal with the sender's Schnorr private key
|
||||
return try seal.sign(with: senderKey)
|
||||
}
|
||||
|
||||
private static func createGiftWrap(
|
||||
seal: NostrEvent,
|
||||
recipientPubkey: String
|
||||
) throws -> NostrEvent {
|
||||
|
||||
let sealJSON = try seal.jsonString()
|
||||
|
||||
// Create new ephemeral key for gift wrap
|
||||
let wrapKey = try P256K.Schnorr.PrivateKey()
|
||||
// Creating gift wrap with ephemeral key
|
||||
|
||||
// Encrypt the seal with the new ephemeral key (not the seal's key)
|
||||
private static func createPrivateEnvelopeEvent(
|
||||
seal: NostrEvent,
|
||||
recipientPubkey: String,
|
||||
format: PrivateEnvelopeWireFormat
|
||||
) throws -> NostrEvent {
|
||||
// A fresh signing/encryption key for every public envelope keeps the
|
||||
// stable sender identity inside ciphertext.
|
||||
let envelopeKey = try P256K.Schnorr.PrivateKey()
|
||||
let encrypted = try encrypt(
|
||||
plaintext: sealJSON,
|
||||
plaintext: seal.jsonString(),
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderKey: wrapKey // Use the gift wrap ephemeral key
|
||||
senderKey: envelopeKey,
|
||||
format: format,
|
||||
maximumPlaintextBytes: maximumPrivateEnvelopeSealPlaintextBytes
|
||||
)
|
||||
|
||||
let giftWrap = NostrEvent(
|
||||
pubkey: Data(wrapKey.xonly.bytes).hexEncodedString(),
|
||||
createdAt: randomizedTimestamp(),
|
||||
kind: .giftWrap,
|
||||
tags: [["p", recipientPubkey]], // Tag recipient
|
||||
|
||||
let envelope = NostrEvent(
|
||||
pubkey: Data(envelopeKey.xonly.bytes).hexEncodedString(),
|
||||
createdAt: randomizedPastTimestamp(),
|
||||
kind: format.envelopeKind,
|
||||
tags: [["p", recipientPubkey]],
|
||||
content: encrypted
|
||||
)
|
||||
|
||||
// Sign the gift wrap with the wrap Schnorr private key
|
||||
return try giftWrap.sign(with: wrapKey)
|
||||
return try envelope.sign(with: envelopeKey)
|
||||
}
|
||||
|
||||
private static func unwrapGiftWrap(
|
||||
giftWrap: NostrEvent,
|
||||
recipientKey: P256K.Schnorr.PrivateKey
|
||||
) throws -> NostrEvent {
|
||||
|
||||
// Unwrapping gift wrap
|
||||
|
||||
let decrypted = try decrypt(
|
||||
ciphertext: giftWrap.content,
|
||||
senderPubkey: giftWrap.pubkey,
|
||||
recipientKey: recipientKey
|
||||
)
|
||||
|
||||
guard let data = decrypted.data(using: .utf8),
|
||||
let sealDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
|
||||
private static func decodePrivateEnvelopeLayers(
|
||||
envelope: NostrEvent,
|
||||
recipientIdentity: NostrIdentity
|
||||
) throws -> (seal: NostrEvent, message: NostrEvent) {
|
||||
guard envelope.content.utf8.count <= maximumPrivateEnvelopeCiphertextBytes else {
|
||||
throw NostrError.invalidCiphertext
|
||||
}
|
||||
guard let format = PrivateEnvelopeWireFormat(outerKind: envelope.kind),
|
||||
envelope.tags == [["p", recipientIdentity.publicKeyHex]],
|
||||
envelope.isValidSignature() else {
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
let seal = try NostrEvent(from: sealDict)
|
||||
// Unwrapped seal
|
||||
|
||||
return seal
|
||||
}
|
||||
|
||||
private static func openSeal(
|
||||
seal: NostrEvent,
|
||||
recipientKey: P256K.Schnorr.PrivateKey
|
||||
) throws -> NostrEvent {
|
||||
|
||||
let decrypted = try decrypt(
|
||||
|
||||
let recipientKey = try recipientIdentity.schnorrSigningKey()
|
||||
let sealJSON = try decrypt(
|
||||
ciphertext: envelope.content,
|
||||
senderPubkey: envelope.pubkey,
|
||||
recipientKey: recipientKey,
|
||||
format: format,
|
||||
maximumPlaintextBytes: maximumPrivateEnvelopeSealPlaintextBytes
|
||||
)
|
||||
let seal = try decodePrivateEnvelopeEventJSON(
|
||||
sealJSON,
|
||||
maximumBytes: maximumPrivateEnvelopeSealPlaintextBytes
|
||||
)
|
||||
guard seal.kind == format.sealKind.rawValue,
|
||||
seal.tags.isEmpty,
|
||||
seal.isValidSignature() else {
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
let messageJSON = try decrypt(
|
||||
ciphertext: seal.content,
|
||||
senderPubkey: seal.pubkey,
|
||||
recipientKey: recipientKey
|
||||
recipientKey: recipientKey,
|
||||
format: format,
|
||||
maximumPlaintextBytes: maximumPrivateEnvelopePlaintextBytes
|
||||
)
|
||||
|
||||
guard let data = decrypted.data(using: .utf8),
|
||||
let rumorDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
let message = try decodePrivateEnvelopeEventJSON(
|
||||
messageJSON,
|
||||
maximumBytes: maximumPrivateEnvelopePlaintextBytes
|
||||
)
|
||||
|
||||
// The inner message is intentionally unsigned; sender authentication
|
||||
// comes from the seal. Bind its claimed sender and custom kind to that
|
||||
// authenticated layer before exposing content.
|
||||
guard message.kind == format.messageKind.rawValue,
|
||||
validInnerMessageTags(
|
||||
message.tags,
|
||||
format: format,
|
||||
recipientPubkey: recipientIdentity.publicKeyHex
|
||||
),
|
||||
message.sig == nil,
|
||||
seal.pubkey == message.pubkey else {
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
return try NostrEvent(from: rumorDict)
|
||||
|
||||
return (seal, message)
|
||||
}
|
||||
|
||||
// MARK: - Encryption (NIP-44 v2)
|
||||
|
||||
|
||||
/// Released iOS legacy envelopes used no inner tags, while current
|
||||
/// Android legacy envelopes use exactly the authenticated recipient tag.
|
||||
/// Accept only those two historical shapes for kind 1059. The new kind
|
||||
/// 1402 format remains strict and rejects every inner tag.
|
||||
private static func validInnerMessageTags(
|
||||
_ tags: [[String]],
|
||||
format: PrivateEnvelopeWireFormat,
|
||||
recipientPubkey: String
|
||||
) -> Bool {
|
||||
switch format {
|
||||
case .bitchatV1:
|
||||
return tags.isEmpty
|
||||
case .legacyMislabelledV2:
|
||||
return tags.isEmpty || tags == [["p", recipientPubkey]]
|
||||
}
|
||||
}
|
||||
|
||||
private static func decodePrivateEnvelopeEventJSON(
|
||||
_ json: String,
|
||||
maximumBytes: Int = maximumPrivateEnvelopePlaintextBytes
|
||||
) throws -> NostrEvent {
|
||||
// Check UTF-8 size before allocating Data or invoking the general JSON
|
||||
// parser. `decrypt` enforces the same cap on authenticated bytes; this
|
||||
// local guard keeps the parser boundary explicit and independently
|
||||
// testable.
|
||||
guard json.utf8.count <= maximumBytes else {
|
||||
throw NostrError.invalidCiphertext
|
||||
}
|
||||
guard let data = json.data(using: .utf8),
|
||||
let dictionary = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
return try NostrEvent(from: dictionary)
|
||||
}
|
||||
|
||||
// MARK: - BitChat private-envelope encryption
|
||||
|
||||
private static func encrypt(
|
||||
plaintext: String,
|
||||
recipientPubkey: String,
|
||||
senderKey: P256K.Schnorr.PrivateKey
|
||||
senderKey: P256K.Schnorr.PrivateKey,
|
||||
format: PrivateEnvelopeWireFormat,
|
||||
maximumPlaintextBytes: Int
|
||||
) throws -> String {
|
||||
|
||||
guard let recipientPubkeyData = Data(hexString: recipientPubkey) else {
|
||||
throw NostrError.invalidPublicKey
|
||||
}
|
||||
|
||||
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
|
||||
|
||||
// Derive shared secret
|
||||
|
||||
let sharedSecret = try deriveSharedSecret(
|
||||
privateKey: senderKey,
|
||||
publicKey: recipientPubkeyData
|
||||
)
|
||||
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info)
|
||||
let key = try deriveNIP44V2Key(from: sharedSecret)
|
||||
|
||||
// 24-byte random nonce for XChaCha20-Poly1305
|
||||
let key = derivePrivateEnvelopeKey(from: sharedSecret, format: format)
|
||||
|
||||
var nonce24 = Data(count: 24)
|
||||
_ = nonce24.withUnsafeMutableBytes { ptr in
|
||||
let randomStatus = nonce24.withUnsafeMutableBytes { ptr in
|
||||
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
|
||||
}
|
||||
|
||||
let pt = Data(plaintext.utf8)
|
||||
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
|
||||
|
||||
// v2: base64url(nonce24 || ciphertext || tag)
|
||||
guard randomStatus == errSecSuccess else {
|
||||
throw NostrError.cryptographicFailure
|
||||
}
|
||||
|
||||
let plaintextData = Data(plaintext.utf8)
|
||||
guard plaintextData.count <= maximumPlaintextBytes else {
|
||||
throw NostrError.invalidCiphertext
|
||||
}
|
||||
let sealed = try XChaCha20Poly1305Compat.seal(
|
||||
plaintext: plaintextData,
|
||||
key: key,
|
||||
nonce24: nonce24
|
||||
)
|
||||
|
||||
var combined = Data()
|
||||
combined.append(nonce24)
|
||||
combined.append(sealed.ciphertext)
|
||||
combined.append(sealed.tag)
|
||||
return "v2:" + Base64URLCoding.encode(combined)
|
||||
return format.contentPrefix + Base64URLCoding.encode(combined)
|
||||
}
|
||||
|
||||
|
||||
private static func decrypt(
|
||||
ciphertext: String,
|
||||
senderPubkey: String,
|
||||
recipientKey: P256K.Schnorr.PrivateKey
|
||||
recipientKey: P256K.Schnorr.PrivateKey,
|
||||
format: PrivateEnvelopeWireFormat,
|
||||
maximumPlaintextBytes: Int
|
||||
) throws -> String {
|
||||
// Expect NIP-44 v2 format
|
||||
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
|
||||
let encoded = String(ciphertext.dropFirst(3))
|
||||
guard ciphertext.utf8.count <= maximumPrivateEnvelopeCiphertextBytes,
|
||||
ciphertext.hasPrefix(format.contentPrefix) else {
|
||||
throw NostrError.invalidCiphertext
|
||||
}
|
||||
let encoded = String(ciphertext.dropFirst(format.contentPrefix.count))
|
||||
guard let data = Base64URLCoding.decode(encoded),
|
||||
data.count > (24 + 16),
|
||||
let senderPubkeyData = Data(hexString: senderPubkey) else {
|
||||
@@ -554,33 +765,40 @@ struct NostrProtocol {
|
||||
let nonce24 = data.prefix(24)
|
||||
let rest = data.dropFirst(24)
|
||||
let tag = rest.suffix(16)
|
||||
let ct = rest.dropLast(16)
|
||||
let ciphertextBytes = rest.dropLast(16)
|
||||
|
||||
// Try decryption with even-Y then odd-Y when sender pubkey is x-only
|
||||
func attemptDecrypt(using pubKeyData: Data) throws -> Data {
|
||||
let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData)
|
||||
let key = try deriveNIP44V2Key(from: ss)
|
||||
func attemptDecrypt(using publicKeyData: Data) throws -> Data {
|
||||
let sharedSecret = try deriveSharedSecret(
|
||||
privateKey: recipientKey,
|
||||
publicKey: publicKeyData
|
||||
)
|
||||
let key = derivePrivateEnvelopeKey(from: sharedSecret, format: format)
|
||||
return try XChaCha20Poly1305Compat.open(
|
||||
ciphertext: Data(ct),
|
||||
ciphertext: Data(ciphertextBytes),
|
||||
tag: Data(tag),
|
||||
key: key,
|
||||
nonce24: Data(nonce24)
|
||||
)
|
||||
}
|
||||
|
||||
// If 32 bytes (x-only) try both parities, otherwise single try
|
||||
let plaintext: Data
|
||||
if senderPubkeyData.count == 32 {
|
||||
let even = Data([0x02]) + senderPubkeyData
|
||||
if let pt = try? attemptDecrypt(using: even) {
|
||||
return String(data: pt, encoding: .utf8) ?? ""
|
||||
let evenKey = Data([0x02]) + senderPubkeyData
|
||||
if let opened = try? attemptDecrypt(using: evenKey) {
|
||||
plaintext = opened
|
||||
} else {
|
||||
let oddKey = Data([0x03]) + senderPubkeyData
|
||||
plaintext = try attemptDecrypt(using: oddKey)
|
||||
}
|
||||
let odd = Data([0x03]) + senderPubkeyData
|
||||
let pt = try attemptDecrypt(using: odd)
|
||||
return String(data: pt, encoding: .utf8) ?? ""
|
||||
} else {
|
||||
let pt = try attemptDecrypt(using: senderPubkeyData)
|
||||
return String(data: pt, encoding: .utf8) ?? ""
|
||||
plaintext = try attemptDecrypt(using: senderPubkeyData)
|
||||
}
|
||||
|
||||
guard plaintext.count <= maximumPlaintextBytes,
|
||||
let decoded = String(data: plaintext, encoding: .utf8) else {
|
||||
throw NostrError.invalidCiphertext
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
private static func deriveSharedSecret(
|
||||
@@ -640,29 +858,17 @@ struct NostrProtocol {
|
||||
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
||||
// ECDH shared secret derived
|
||||
|
||||
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
|
||||
// Return raw ECDH shared secret; the wire-format-specific HKDF is
|
||||
// applied by derivePrivateEnvelopeKey.
|
||||
return sharedSecretData
|
||||
}
|
||||
|
||||
private static func randomizedTimestamp() -> Date {
|
||||
// Add random offset to current time for privacy
|
||||
// This prevents timing correlation attacks while the actual message timestamp
|
||||
// is preserved in the encrypted rumor
|
||||
let offset = TimeInterval.random(in: -900...900) // +/- 15 minutes
|
||||
let now = Date()
|
||||
let randomized = now.addingTimeInterval(offset)
|
||||
|
||||
// Log with explicit UTC and local time for debugging
|
||||
let formatter = DateFormatter()
|
||||
//
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||
formatter.timeZone = TimeZone(abbreviation: "UTC")
|
||||
|
||||
formatter.timeZone = TimeZone.current
|
||||
|
||||
// Timestamp randomized for privacy
|
||||
|
||||
return randomized
|
||||
private static func randomizedPastTimestamp() -> Date {
|
||||
// Keep public timestamps in the past: future-dated events are rejected
|
||||
// by some relays. The actual message timestamp remains encrypted.
|
||||
Date().addingTimeInterval(
|
||||
-TimeInterval.random(in: 0...TransportConfig.nostrPrivateEnvelopeTimestampFuzzSeconds)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -775,16 +981,20 @@ enum NostrError: Error {
|
||||
case invalidPublicKey
|
||||
case invalidEvent
|
||||
case invalidCiphertext
|
||||
case cryptographicFailure
|
||||
}
|
||||
|
||||
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
|
||||
// MARK: - BitChat private-envelope key derivation
|
||||
|
||||
private extension NostrProtocol {
|
||||
static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {
|
||||
private static func derivePrivateEnvelopeKey(
|
||||
from sharedSecretData: Data,
|
||||
format: PrivateEnvelopeWireFormat
|
||||
) -> Data {
|
||||
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
||||
salt: Data(),
|
||||
info: Data("nip44-v2".utf8),
|
||||
salt: format.hkdfSalt,
|
||||
info: format.hkdfInfo,
|
||||
outputByteCount: 32
|
||||
)
|
||||
return derivedKey.withUnsafeBytes { Data($0) }
|
||||
|
||||
@@ -106,10 +106,11 @@ private extension NostrRelayManagerDependencies {
|
||||
@MainActor
|
||||
final class NostrRelayManager: ObservableObject {
|
||||
static let shared = NostrRelayManager()
|
||||
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info
|
||||
private(set) static var pendingGiftWrapIDs = Set<String>()
|
||||
static func registerPendingGiftWrap(id: String) {
|
||||
pendingGiftWrapIDs.insert(id)
|
||||
// Track BitChat private envelopes we initiated so relay rejections can be
|
||||
// reported without misclassifying ordinary public-event failures.
|
||||
private(set) static var pendingPrivateEnvelopeIDs = Set<String>()
|
||||
static func registerPendingPrivateEnvelope(id: String) {
|
||||
pendingPrivateEnvelopeIDs.insert(id)
|
||||
}
|
||||
|
||||
struct Relay: Identifiable {
|
||||
@@ -124,7 +125,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
var nextReconnectTime: Date?
|
||||
}
|
||||
|
||||
// Default relays carry NIP-17 gift wraps, so avoid relays known to reject kind 1059.
|
||||
// Default relays carry persisted BitChat private-envelope events.
|
||||
private static let defaultRelays = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
@@ -137,7 +138,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
@Published private(set) var relays: [Relay] = []
|
||||
@Published private(set) var isConnected = false
|
||||
/// Whether a relay that carries private messages is connected. DMs
|
||||
/// target the default (gift-wrap-capable) relay set, so a connected
|
||||
/// target the default private-envelope relay set, so a connected
|
||||
/// geohash/custom relay alone must not count — sends would still queue.
|
||||
@Published private(set) var isDMRelayConnected = false
|
||||
|
||||
@@ -174,6 +175,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
private var subscribeCoalesce: [String: Date] = [:]
|
||||
private var pendingTorConnectionURLs = Set<String>()
|
||||
private var awaitingTorForConnections = false
|
||||
private var awaitingTorForQueueFlush = false
|
||||
private var torReadyWaitAttempts = 0
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
@@ -208,16 +210,37 @@ final class NostrRelayManager: ObservableObject {
|
||||
private var eoseTrackerEpoch = 0
|
||||
private var pendingEOSECallbacks: [String: () -> Void] = [:]
|
||||
|
||||
// Message queue for reliability
|
||||
// Pending sends held only for relays that are not yet connected.
|
||||
// Message queue for reliability. Pending sends are grouped so a private
|
||||
// envelope's primary and legacy migration copies can never be split by
|
||||
// queue eviction. Private batches are protected; lower-priority traffic
|
||||
// is evicted first and a full protected queue rejects the entire new pair.
|
||||
private enum PendingSendPriority: Int {
|
||||
case ephemeral
|
||||
case regular
|
||||
case privateEnvelope
|
||||
}
|
||||
|
||||
private struct PendingSend {
|
||||
var event: NostrEvent
|
||||
let id: UUID
|
||||
let events: [NostrEvent]
|
||||
var pendingRelays: Set<String>
|
||||
var inFlightConnections: [String: ObjectIdentifier]
|
||||
var hasSuccessfulDelivery: Bool
|
||||
let priority: PendingSendPriority
|
||||
let terminalFailure: (() -> Void)?
|
||||
}
|
||||
|
||||
private struct PendingDelivery {
|
||||
let itemID: UUID
|
||||
let events: [NostrEvent]
|
||||
let priority: PendingSendPriority
|
||||
let relayUrl: String
|
||||
let connection: NostrRelayConnectionProtocol
|
||||
}
|
||||
private var messageQueue: [PendingSend] = []
|
||||
private let messageQueueLock = NSLock()
|
||||
/// Non-queued sends whose callers require relay durability. A WebSocket
|
||||
/// write only proves bytes left this process; NIP-20 OK is the relay's
|
||||
/// write only proves bytes left this process; NIP-01 `OK` is the relay's
|
||||
/// accept/reject acknowledgment.
|
||||
private struct ConfirmedSendState {
|
||||
let token: UUID
|
||||
@@ -304,7 +327,8 @@ final class NostrRelayManager: ObservableObject {
|
||||
/// Disconnect from all relays
|
||||
func disconnect() {
|
||||
connectionGeneration &+= 1
|
||||
for (_, task) in connections {
|
||||
for (relayURL, task) in connections {
|
||||
clearPendingSendInFlight(relayUrl: relayURL, connection: task)
|
||||
task.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
connections.removeAll()
|
||||
@@ -326,6 +350,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
confirmed.forEach { $0(false) }
|
||||
pendingTorConnectionURLs.removeAll()
|
||||
awaitingTorForConnections = false
|
||||
awaitingTorForQueueFlush = false
|
||||
torReadyWaitAttempts = 0
|
||||
updateConnectionStatus()
|
||||
}
|
||||
@@ -350,13 +375,14 @@ final class NostrRelayManager: ObservableObject {
|
||||
pendingEOSECallbacks.removeAll()
|
||||
pendingTorConnectionURLs.removeAll()
|
||||
awaitingTorForConnections = false
|
||||
awaitingTorForQueueFlush = false
|
||||
torReadyWaitAttempts = 0
|
||||
recentInboundEventKeys.removeAll()
|
||||
recentInboundEventKeyOrder.removeAll()
|
||||
duplicateInboundEventDropCount = 0
|
||||
duplicateInboundEventDropCountBySubscription.removeAll()
|
||||
inboundEventLogCount = 0
|
||||
Self.pendingGiftWrapIDs.removeAll()
|
||||
Self.pendingPrivateEnvelopeIDs.removeAll()
|
||||
confirmedSends.removeAll()
|
||||
|
||||
messageQueueLock.lock()
|
||||
@@ -398,43 +424,132 @@ final class NostrRelayManager: ObservableObject {
|
||||
connectToRelays(targets)
|
||||
}
|
||||
|
||||
/// Send an event to specified relays (or all if none specified)
|
||||
/// Send an event to specified relays (or all if none specified).
|
||||
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
||||
// Global network policy gate
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
_ = sendEvents(
|
||||
[event],
|
||||
to: relayUrls,
|
||||
priority: Self.pendingSendPriority(for: event),
|
||||
terminalFailure: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// Atomically admits a complete private-envelope migration pair. The
|
||||
/// queue stores the pair as one protected item, so capacity pressure can
|
||||
/// neither retain only kind 1402 nor only kind 1059. A `false` result means
|
||||
/// the entire pair was rejected before any connected-relay sends began.
|
||||
@discardableResult
|
||||
func sendPrivateEnvelopeBatch(
|
||||
_ events: [NostrEvent],
|
||||
to relayUrls: [String]? = nil,
|
||||
terminalFailure: (() -> Void)? = nil
|
||||
) -> Bool {
|
||||
guard events.map(\.kind) == [
|
||||
NostrProtocol.EventKind.privateEnvelope.rawValue,
|
||||
NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue
|
||||
] else {
|
||||
SecureLogger.error(
|
||||
"Refusing malformed private-envelope migration batch kinds=\(events.map(\.kind))",
|
||||
category: .session
|
||||
)
|
||||
return false
|
||||
}
|
||||
return sendEvents(
|
||||
events,
|
||||
to: relayUrls,
|
||||
priority: .privateEnvelope,
|
||||
terminalFailure: terminalFailure
|
||||
)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func sendEvents(
|
||||
_ events: [NostrEvent],
|
||||
to relayUrls: [String]?,
|
||||
priority: PendingSendPriority,
|
||||
terminalFailure: (() -> Void)?
|
||||
) -> Bool {
|
||||
guard dependencies.activationAllowed(), !events.isEmpty else { return false }
|
||||
let requestedRelays = relayUrls ?? Self.defaultRelays
|
||||
var targetRelays = allowedRelayList(from: requestedRelays)
|
||||
if priority == .privateEnvelope {
|
||||
// Do not admit a protected batch against a relay already in its
|
||||
// terminal cooldown. Such a target has no connection attempt that
|
||||
// could ever retire the queue record; retry callers can try it
|
||||
// again after the cooldown decays.
|
||||
targetRelays.removeAll(where: isPermanentlyFailed)
|
||||
}
|
||||
guard !targetRelays.isEmpty else { return false }
|
||||
|
||||
if priority == .privateEnvelope {
|
||||
// Protected pairs are admitted for every target before any socket
|
||||
// write, including targets already connected. The queue entry
|
||||
// remains authoritative until both writes succeed per relay.
|
||||
guard enqueuePendingSendBatch(
|
||||
events,
|
||||
pendingRelays: Set(targetRelays),
|
||||
priority: priority,
|
||||
terminalFailure: terminalFailure
|
||||
) != nil else { return false }
|
||||
ensureConnections(to: targetRelays)
|
||||
// Preserve the same fail-closed Tor gate as ordinary sends. The
|
||||
// queue is already durable; a stale socket that still appears
|
||||
// connected must not be written while enforced Tor is unready.
|
||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
||||
flushMessageQueueWhenTorIsReady()
|
||||
} else {
|
||||
for relayUrl in targetRelays where connectedConnection(for: relayUrl) != nil {
|
||||
flushMessageQueue(for: relayUrl)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
||||
// Fail-closed: nothing touches the network until Tor is up. Queue the
|
||||
// event locally so it survives a slow bootstrap (queued sends flush
|
||||
// when relays connect), then kick off connection setup, which itself
|
||||
// waits for Tor readiness.
|
||||
let targetRelays = allowedRelayList(from: relayUrls ?? Self.defaultRelays)
|
||||
guard !targetRelays.isEmpty else { return }
|
||||
enqueuePendingSend(event, pendingRelays: Set(targetRelays))
|
||||
// complete item locally so it survives a slow bootstrap, then kick
|
||||
// off connection setup, which itself waits for Tor readiness.
|
||||
guard enqueuePendingSendBatch(
|
||||
events,
|
||||
pendingRelays: Set(targetRelays),
|
||||
priority: priority,
|
||||
terminalFailure: nil
|
||||
) != nil else { return false }
|
||||
ensureConnections(to: targetRelays)
|
||||
return
|
||||
return true
|
||||
}
|
||||
let requestedRelays = relayUrls ?? Self.defaultRelays
|
||||
let targetRelays = allowedRelayList(from: requestedRelays)
|
||||
guard !targetRelays.isEmpty else { return }
|
||||
ensureConnections(to: targetRelays)
|
||||
|
||||
// Attempt immediate send to relays with active connections; queue the rest
|
||||
// Ordinary single events retain their existing immediate-send path.
|
||||
var connectedTargets: [(String, NostrRelayConnectionProtocol)] = []
|
||||
var stillPending = Set<String>()
|
||||
for relayUrl in targetRelays {
|
||||
if let connection = connectedConnection(for: relayUrl) {
|
||||
sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
|
||||
connectedTargets.append((relayUrl, connection))
|
||||
} else {
|
||||
stillPending.insert(relayUrl)
|
||||
}
|
||||
}
|
||||
if !stillPending.isEmpty {
|
||||
enqueuePendingSend(event, pendingRelays: stillPending)
|
||||
guard enqueuePendingSendBatch(
|
||||
events,
|
||||
pendingRelays: stillPending,
|
||||
priority: priority,
|
||||
terminalFailure: nil
|
||||
) != nil else { return false }
|
||||
}
|
||||
|
||||
ensureConnections(to: targetRelays)
|
||||
for (relayUrl, connection) in connectedTargets {
|
||||
for event in events {
|
||||
sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Attempts an event only on currently connected target relays and
|
||||
/// reports whether at least one relay explicitly accepted it via NIP-20
|
||||
/// OK. A successful WebSocket write alone is not durable acceptance.
|
||||
/// reports whether at least one relay explicitly accepted it via NIP-01
|
||||
/// `OK`. A successful WebSocket write alone is not durable acceptance.
|
||||
/// Unlike `sendEvent`, this never enters the process-local pending queue;
|
||||
/// callers use it when success unlocks durable state or user-visible
|
||||
/// delivery progress.
|
||||
@@ -523,66 +638,244 @@ final class NostrRelayManager: ObservableObject {
|
||||
state.completion(false)
|
||||
}
|
||||
|
||||
private func enqueuePendingSend(_ event: NostrEvent, pendingRelays: Set<String>) {
|
||||
messageQueueLock.lock()
|
||||
messageQueue.append(PendingSend(event: event, pendingRelays: pendingRelays))
|
||||
let overflow = messageQueue.count - TransportConfig.nostrPendingSendQueueCap
|
||||
if overflow > 0 {
|
||||
messageQueue.removeFirst(overflow)
|
||||
private static func pendingSendPriority(for event: NostrEvent) -> PendingSendPriority {
|
||||
switch event.kind {
|
||||
case NostrProtocol.EventKind.ephemeralEvent.rawValue,
|
||||
NostrProtocol.EventKind.geohashPresence.rawValue:
|
||||
return .ephemeral
|
||||
default:
|
||||
return .regular
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically append a whole send item while preserving protected private
|
||||
/// batches. Capacity is measured in events, not queue records.
|
||||
private func enqueuePendingSendBatch(
|
||||
_ events: [NostrEvent],
|
||||
pendingRelays: Set<String>,
|
||||
priority: PendingSendPriority,
|
||||
terminalFailure: (() -> Void)?
|
||||
) -> UUID? {
|
||||
guard !events.isEmpty, !pendingRelays.isEmpty,
|
||||
events.count <= TransportConfig.nostrPendingSendQueueCap else {
|
||||
SecureLogger.error("Refusing invalid or oversized relay send batch", category: .session)
|
||||
return nil
|
||||
}
|
||||
|
||||
messageQueueLock.lock()
|
||||
let queuedEventCount = messageQueue.reduce(0) { $0 + $1.events.count }
|
||||
let overflow = queuedEventCount + events.count - TransportConfig.nostrPendingSendQueueCap
|
||||
var removalIndexes: [Int] = []
|
||||
var evictedEventCount = 0
|
||||
if overflow > 0 {
|
||||
var eventsToFree = overflow
|
||||
let candidates = messageQueue.indices
|
||||
.filter {
|
||||
messageQueue[$0].priority != .privateEnvelope &&
|
||||
messageQueue[$0].priority.rawValue <= priority.rawValue
|
||||
}
|
||||
.sorted {
|
||||
let left = messageQueue[$0].priority.rawValue
|
||||
let right = messageQueue[$1].priority.rawValue
|
||||
return left == right ? $0 < $1 : left < right
|
||||
}
|
||||
for index in candidates where eventsToFree > 0 {
|
||||
removalIndexes.append(index)
|
||||
let count = messageQueue[index].events.count
|
||||
evictedEventCount += count
|
||||
eventsToFree -= count
|
||||
}
|
||||
if eventsToFree > 0 {
|
||||
messageQueueLock.unlock()
|
||||
SecureLogger.error(
|
||||
"Relay send queue protected-capacity exhausted; rejected entire \(events.count)-event batch",
|
||||
category: .session
|
||||
)
|
||||
return nil
|
||||
}
|
||||
for index in removalIndexes.sorted(by: >) {
|
||||
messageQueue.remove(at: index)
|
||||
}
|
||||
}
|
||||
let itemID = UUID()
|
||||
messageQueue.append(PendingSend(
|
||||
id: itemID,
|
||||
events: events,
|
||||
pendingRelays: pendingRelays,
|
||||
inFlightConnections: [:],
|
||||
hasSuccessfulDelivery: false,
|
||||
priority: priority,
|
||||
terminalFailure: terminalFailure
|
||||
))
|
||||
messageQueueLock.unlock()
|
||||
guard overflow > 0 else { return }
|
||||
// Dropped events are ephemeral (presence/geo), so no status surfacing
|
||||
// is needed — but the drops should be visible. Sampled so a sustained
|
||||
// relay stall can't flood the log.
|
||||
pendingSendDropCount += overflow
|
||||
if pendingSendDropCount == 1 ||
|
||||
guard evictedEventCount > 0 else { return itemID }
|
||||
let isFirstEviction = pendingSendDropCount == 0
|
||||
pendingSendDropCount += evictedEventCount
|
||||
if isFirstEviction ||
|
||||
pendingSendDropCount.isMultiple(of: TransportConfig.nostrPendingSendDropLogInterval) {
|
||||
SecureLogger.warning(
|
||||
"📤 Relay send queue full — dropped \(pendingSendDropCount) oldest event(s)",
|
||||
"📤 Relay send queue full — evicted \(pendingSendDropCount) lower-priority event(s)",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
return itemID
|
||||
}
|
||||
|
||||
/// Try to flush any queued messages for relays that are now connected.
|
||||
private func flushMessageQueue(for relayUrl: String? = nil) {
|
||||
guard !(shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady()) else {
|
||||
flushMessageQueueWhenTorIsReady()
|
||||
return
|
||||
}
|
||||
var deliveries: [PendingDelivery] = []
|
||||
messageQueueLock.lock()
|
||||
defer { messageQueueLock.unlock() }
|
||||
guard !messageQueue.isEmpty else { return }
|
||||
if let target = relayUrl {
|
||||
// Flush only for a specific relay
|
||||
for i in (0..<messageQueue.count).reversed() {
|
||||
var item = messageQueue[i]
|
||||
if item.pendingRelays.contains(target), let conn = connectedConnection(for: target) {
|
||||
sendToRelay(event: item.event, connection: conn, relayUrl: target)
|
||||
for index in (0..<messageQueue.count).reversed() {
|
||||
var item = messageQueue[index]
|
||||
let targets = relayUrl.map { [$0] } ?? Array(item.pendingRelays)
|
||||
for target in targets {
|
||||
guard item.pendingRelays.contains(target),
|
||||
item.inFlightConnections[target] == nil,
|
||||
let connection = connectedConnection(for: target) else { continue }
|
||||
deliveries.append(PendingDelivery(
|
||||
itemID: item.id,
|
||||
events: item.events,
|
||||
priority: item.priority,
|
||||
relayUrl: target,
|
||||
connection: connection
|
||||
))
|
||||
if item.priority == .privateEnvelope {
|
||||
// Keep the relay pending until both asynchronous writes
|
||||
// report success. `inFlightConnections` prevents duplicate
|
||||
// flushes while those callbacks are outstanding.
|
||||
item.inFlightConnections[target] = ObjectIdentifier(connection)
|
||||
} else {
|
||||
item.pendingRelays.remove(target)
|
||||
if item.pendingRelays.isEmpty {
|
||||
messageQueue.remove(at: i)
|
||||
} else {
|
||||
messageQueue[i] = item
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Flush for any relays that now have connections
|
||||
for i in (0..<messageQueue.count).reversed() {
|
||||
var item = messageQueue[i]
|
||||
for url in item.pendingRelays {
|
||||
if let conn = connectedConnection(for: url) {
|
||||
sendToRelay(event: item.event, connection: conn, relayUrl: url)
|
||||
item.pendingRelays.remove(url)
|
||||
}
|
||||
if item.pendingRelays.isEmpty {
|
||||
messageQueue.remove(at: index)
|
||||
} else {
|
||||
messageQueue[index] = item
|
||||
}
|
||||
}
|
||||
messageQueueLock.unlock()
|
||||
|
||||
// Never invoke WebSocket callbacks while holding messageQueueLock.
|
||||
for delivery in deliveries {
|
||||
if delivery.priority == .privateEnvelope {
|
||||
sendPrivateEnvelopeBatchToRelay(
|
||||
delivery.events,
|
||||
connection: delivery.connection,
|
||||
relayUrl: delivery.relayUrl
|
||||
) { [weak self] succeeded in
|
||||
self?.completePrivateEnvelopeBatchDelivery(
|
||||
itemID: delivery.itemID,
|
||||
relayUrl: delivery.relayUrl,
|
||||
connection: delivery.connection,
|
||||
succeeded: succeeded
|
||||
)
|
||||
}
|
||||
if item.pendingRelays.isEmpty {
|
||||
messageQueue.remove(at: i)
|
||||
} else {
|
||||
messageQueue[i] = item
|
||||
} else {
|
||||
for event in delivery.events {
|
||||
sendToRelay(
|
||||
event: event,
|
||||
connection: delivery.connection,
|
||||
relayUrl: delivery.relayUrl
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func flushMessageQueueWhenTorIsReady() {
|
||||
guard !awaitingTorForQueueFlush else { return }
|
||||
awaitingTorForQueueFlush = true
|
||||
let generation = connectionGeneration
|
||||
dependencies.awaitTorReady { [weak self] ready in
|
||||
guard let self else { return }
|
||||
guard generation == self.connectionGeneration else { return }
|
||||
self.awaitingTorForQueueFlush = false
|
||||
guard ready else { return }
|
||||
self.flushMessageQueue(for: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Send both copies in order and report one result for the complete pair.
|
||||
/// Stop on the first failure; the still-pending queue item retries both
|
||||
/// after reconnection, which is safe because receivers deduplicate twins.
|
||||
private func sendPrivateEnvelopeBatchToRelay(
|
||||
_ events: [NostrEvent],
|
||||
connection: NostrRelayConnectionProtocol,
|
||||
relayUrl: String,
|
||||
index: Int = 0,
|
||||
completion: @escaping (Bool) -> Void
|
||||
) {
|
||||
guard index < events.count else {
|
||||
completion(true)
|
||||
return
|
||||
}
|
||||
sendToRelay(
|
||||
event: events[index],
|
||||
connection: connection,
|
||||
relayUrl: relayUrl
|
||||
) { [weak self] succeeded in
|
||||
guard succeeded,
|
||||
let self,
|
||||
self.connections[relayUrl] === connection else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
self.sendPrivateEnvelopeBatchToRelay(
|
||||
events,
|
||||
connection: connection,
|
||||
relayUrl: relayUrl,
|
||||
index: index + 1,
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func completePrivateEnvelopeBatchDelivery(
|
||||
itemID: UUID,
|
||||
relayUrl: String,
|
||||
connection: NostrRelayConnectionProtocol,
|
||||
succeeded: Bool
|
||||
) {
|
||||
messageQueueLock.lock()
|
||||
if let index = messageQueue.firstIndex(where: { $0.id == itemID }) {
|
||||
var item = messageQueue[index]
|
||||
guard item.inFlightConnections[relayUrl] == ObjectIdentifier(connection) else {
|
||||
messageQueueLock.unlock()
|
||||
return
|
||||
}
|
||||
item.inFlightConnections.removeValue(forKey: relayUrl)
|
||||
if succeeded {
|
||||
item.hasSuccessfulDelivery = true
|
||||
item.pendingRelays.remove(relayUrl)
|
||||
}
|
||||
if item.pendingRelays.isEmpty {
|
||||
messageQueue.remove(at: index)
|
||||
} else {
|
||||
messageQueue[index] = item
|
||||
}
|
||||
}
|
||||
messageQueueLock.unlock()
|
||||
|
||||
guard !succeeded else { return }
|
||||
// A failed WebSocket write means this connection cannot advance the
|
||||
// durable queue. Keep the original pair pending and reconnect with the
|
||||
// manager's bounded backoff; the next successful ping flushes it.
|
||||
handleDisconnection(
|
||||
relayUrl: relayUrl,
|
||||
error: NSError(
|
||||
domain: "NostrRelayPrivateEnvelopeBatch",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "private-envelope batch write failed"]
|
||||
),
|
||||
connection: connection
|
||||
)
|
||||
}
|
||||
|
||||
private func connectedConnection(for relayUrl: String) -> NostrRelayConnectionProtocol? {
|
||||
guard let connection = connections[relayUrl],
|
||||
relays.first(where: { $0.url == relayUrl })?.isConnected == true else {
|
||||
@@ -598,9 +891,28 @@ final class NostrRelayManager: ObservableObject {
|
||||
relayUrls: [String]? = nil,
|
||||
handler: @escaping (NostrEvent) -> Void,
|
||||
onEOSE: (() -> Void)? = nil
|
||||
) {
|
||||
subscribe(
|
||||
filters: [filter],
|
||||
id: id,
|
||||
relayUrls: relayUrls,
|
||||
handler: handler,
|
||||
onEOSE: onEOSE
|
||||
)
|
||||
}
|
||||
|
||||
/// Subscribe with independent Nostr filters in one REQ. Each filter keeps
|
||||
/// its own relay-side limit; this is required for mixed-version mailbox
|
||||
/// recovery so kind 1402 cannot starve kind 1059 (or the reverse).
|
||||
func subscribe(
|
||||
filters: [NostrFilter],
|
||||
id: String = UUID().uuidString,
|
||||
relayUrls: [String]? = nil,
|
||||
handler: @escaping (NostrEvent) -> Void,
|
||||
onEOSE: (() -> Void)? = nil
|
||||
) {
|
||||
// Global network policy gate
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
guard dependencies.activationAllowed(), !filters.isEmpty else { return }
|
||||
// Coalesce rapid duplicate subscribe requests even while Tor readiness is pending.
|
||||
let now = dependencies.now()
|
||||
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < subscribeCoalesceInterval {
|
||||
@@ -609,7 +921,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
subscribeCoalesce[id] = now
|
||||
messageHandlers[id] = handler
|
||||
|
||||
let req = NostrRequest.subscribe(id: id, filters: [filter])
|
||||
let req = NostrRequest.subscribe(id: id, filters: filters)
|
||||
|
||||
do {
|
||||
let message = try encoder.encode(req)
|
||||
@@ -676,6 +988,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
ensureConnections(to: Self.defaultRelays)
|
||||
}
|
||||
} else {
|
||||
var terminalFailures: [() -> Void] = []
|
||||
for url in Self.defaultRelays {
|
||||
if let connection = connections[url] {
|
||||
connection.cancel(with: .goingAway, reason: nil)
|
||||
@@ -683,18 +996,9 @@ final class NostrRelayManager: ObservableObject {
|
||||
connections.removeValue(forKey: url)
|
||||
subscriptions.removeValue(forKey: url)
|
||||
pendingSubscriptions.removeValue(forKey: url)
|
||||
terminalFailures.append(contentsOf: retirePendingSendRelay(url))
|
||||
}
|
||||
messageQueueLock.lock()
|
||||
for index in (0..<messageQueue.count).reversed() {
|
||||
var item = messageQueue[index]
|
||||
item.pendingRelays.subtract(Self.defaultRelaySet)
|
||||
if item.pendingRelays.isEmpty {
|
||||
messageQueue.remove(at: index)
|
||||
} else {
|
||||
messageQueue[index] = item
|
||||
}
|
||||
}
|
||||
messageQueueLock.unlock()
|
||||
terminalFailures.forEach { $0() }
|
||||
relays.removeAll { Self.defaultRelaySet.contains($0.url) }
|
||||
updateConnectionStatus()
|
||||
}
|
||||
@@ -1139,7 +1443,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else {
|
||||
return
|
||||
}
|
||||
if event.kind != 1059 {
|
||||
if !NostrProtocol.acceptedPrivateEnvelopeKinds.contains(event.kind) {
|
||||
// Per-event logging floods dev builds in busy geohashes; sample it.
|
||||
inboundEventLogCount += 1
|
||||
if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
|
||||
@@ -1168,11 +1472,11 @@ final class NostrRelayManager: ObservableObject {
|
||||
case .ok(let eventId, let success, let reason):
|
||||
resolveConfirmedSend(eventID: eventId, relayURL: relayUrl, accepted: success)
|
||||
if success {
|
||||
_ = Self.pendingGiftWrapIDs.remove(eventId)
|
||||
_ = Self.pendingPrivateEnvelopeIDs.remove(eventId)
|
||||
SecureLogger.debug("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", category: .session)
|
||||
} else {
|
||||
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
|
||||
if isGiftWrap {
|
||||
let isPrivateEnvelope = Self.pendingPrivateEnvelopeIDs.remove(eventId) != nil
|
||||
if isPrivateEnvelope {
|
||||
SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16))… relay=\(relayUrl) reason=\(reason)", category: .session)
|
||||
} else {
|
||||
SecureLogger.error("📮 Rejected id=\(eventId.prefix(16))… relay=\(relayUrl) reason=\(reason)", category: .session)
|
||||
@@ -1284,6 +1588,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
connection: NostrRelayConnectionProtocol? = nil
|
||||
) {
|
||||
if let connection, connections[relayUrl] !== connection { return }
|
||||
clearPendingSendInFlight(relayUrl: relayUrl, connection: connection)
|
||||
connections.removeValue(forKey: relayUrl)
|
||||
subscriptions.removeValue(forKey: relayUrl)
|
||||
let awaitingConfirmation = confirmedSends.compactMap { eventID, state in
|
||||
@@ -1304,7 +1609,10 @@ final class NostrRelayManager: ObservableObject {
|
||||
let ns = error as NSError
|
||||
if errorDescription.contains("hostname could not be found") ||
|
||||
errorDescription.contains("dns") ||
|
||||
(ns.domain == NSURLErrorDomain && ns.code == NSURLErrorBadServerResponse) {
|
||||
(ns.domain == NSURLErrorDomain && (
|
||||
ns.code == NSURLErrorBadServerResponse ||
|
||||
ns.code == NSURLErrorCannotFindHost
|
||||
)) {
|
||||
if relays.first(where: { $0.url == relayUrl })?.lastError == nil {
|
||||
SecureLogger.warning("Nostr relay permanent failure for \(relayUrl) - not retrying (code=\(ns.code))", category: .session)
|
||||
}
|
||||
@@ -1314,6 +1622,8 @@ final class NostrRelayManager: ObservableObject {
|
||||
relays[index].nextReconnectTime = nil
|
||||
}
|
||||
pendingSubscriptions[relayUrl] = nil
|
||||
let terminalFailures = retirePendingSendRelay(relayUrl)
|
||||
terminalFailures.forEach { $0() }
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1325,6 +1635,8 @@ final class NostrRelayManager: ObservableObject {
|
||||
// Stop attempting after max attempts
|
||||
if relays[index].reconnectAttempts >= maxReconnectAttempts {
|
||||
SecureLogger.warning("Max reconnection attempts (\(maxReconnectAttempts)) reached for \(relayUrl)", category: .session)
|
||||
let terminalFailures = retirePendingSendRelay(relayUrl)
|
||||
terminalFailures.forEach { $0() }
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1362,6 +1674,54 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A relay in terminal cooldown cannot make progress on queued sends. Drop
|
||||
/// it from every target set so one unavailable default relay cannot pin
|
||||
/// otherwise-delivered protected batches forever. If this was the last
|
||||
/// target and no relay completed the pair, report whole-batch failure to
|
||||
/// the originating transport for visible failure or retry.
|
||||
private func retirePendingSendRelay(_ relayUrl: String) -> [() -> Void] {
|
||||
var terminalFailures: [() -> Void] = []
|
||||
messageQueueLock.lock()
|
||||
for index in (0..<messageQueue.count).reversed() {
|
||||
var item = messageQueue[index]
|
||||
guard item.pendingRelays.remove(relayUrl) != nil else { continue }
|
||||
item.inFlightConnections.removeValue(forKey: relayUrl)
|
||||
if item.pendingRelays.isEmpty {
|
||||
if item.priority == .privateEnvelope,
|
||||
!item.hasSuccessfulDelivery,
|
||||
let terminalFailure = item.terminalFailure {
|
||||
terminalFailures.append(terminalFailure)
|
||||
}
|
||||
messageQueue.remove(at: index)
|
||||
} else {
|
||||
messageQueue[index] = item
|
||||
}
|
||||
}
|
||||
messageQueueLock.unlock()
|
||||
return terminalFailures
|
||||
}
|
||||
|
||||
/// Release the in-flight marker owned by a dead socket without removing
|
||||
/// the relay from the durable pending set. A stale completion from that
|
||||
/// socket is ignored by its ObjectIdentifier check.
|
||||
private func clearPendingSendInFlight(
|
||||
relayUrl: String,
|
||||
connection: NostrRelayConnectionProtocol?
|
||||
) {
|
||||
let expectedIdentifier = connection.map(ObjectIdentifier.init)
|
||||
messageQueueLock.lock()
|
||||
for index in messageQueue.indices {
|
||||
var item = messageQueue[index]
|
||||
guard let inFlightIdentifier = item.inFlightConnections[relayUrl],
|
||||
expectedIdentifier == nil || expectedIdentifier == inFlightIdentifier else {
|
||||
continue
|
||||
}
|
||||
item.inFlightConnections.removeValue(forKey: relayUrl)
|
||||
messageQueue[index] = item
|
||||
}
|
||||
messageQueueLock.unlock()
|
||||
}
|
||||
|
||||
// MARK: - Public Utility Methods
|
||||
|
||||
@@ -1377,6 +1737,10 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
// Disconnect if connected
|
||||
if let connection = connections[normalizedRelayUrl] {
|
||||
clearPendingSendInFlight(
|
||||
relayUrl: normalizedRelayUrl,
|
||||
connection: connection
|
||||
)
|
||||
connection.cancel(with: .goingAway, reason: nil)
|
||||
connections.removeValue(forKey: normalizedRelayUrl)
|
||||
}
|
||||
@@ -1398,7 +1762,13 @@ final class NostrRelayManager: ObservableObject {
|
||||
var debugPendingMessageQueueCount: Int {
|
||||
messageQueueLock.lock()
|
||||
defer { messageQueueLock.unlock() }
|
||||
return messageQueue.count
|
||||
return messageQueue.reduce(0) { $0 + $1.events.count }
|
||||
}
|
||||
|
||||
var debugPendingMessageQueueEventIDsByBatch: [[String]] {
|
||||
messageQueueLock.lock()
|
||||
defer { messageQueueLock.unlock() }
|
||||
return messageQueue.map { $0.events.map(\.id) }
|
||||
}
|
||||
|
||||
func debugPendingSubscriptionCount(for relayUrl: String) -> Int {
|
||||
@@ -1602,14 +1972,18 @@ struct NostrFilter: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
// For NIP-17 gift wraps
|
||||
static func giftWrapsFor(pubkey: String, since: Date? = nil) -> NostrFilter {
|
||||
var filter = NostrFilter()
|
||||
filter.kinds = [1059] // Gift wrap kind
|
||||
filter.since = since?.timeIntervalSince1970.toInt()
|
||||
filter.tagFilters = ["p": [pubkey]]
|
||||
filter.limit = TransportConfig.nostrRelayDefaultFetchLimit // reasonable limit
|
||||
return filter
|
||||
// BitChat private envelopes, plus compatibility legacy envelopes emitted
|
||||
// throughout the coordinated migration and stored by older releases as
|
||||
// kind 1059.
|
||||
static func privateEnvelopeFiltersFor(pubkey: String, since: Date? = nil) -> [NostrFilter] {
|
||||
NostrProtocol.acceptedPrivateEnvelopeKinds.map { kind in
|
||||
var filter = NostrFilter()
|
||||
filter.kinds = [kind]
|
||||
filter.since = since?.timeIntervalSince1970.toInt()
|
||||
filter.tagFilters = ["p": [pubkey]]
|
||||
filter.limit = TransportConfig.nostrPrivateEnvelopeFetchLimitPerKind
|
||||
return filter
|
||||
}
|
||||
}
|
||||
|
||||
// For location channels: geohash-scoped ephemeral events (kind 20000) and presence (kind 20001)
|
||||
|
||||
@@ -79,35 +79,12 @@ enum NoisePayloadType: UInt8 {
|
||||
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
|
||||
// Live voice (push-to-talk)
|
||||
case voiceFrame = 0x08 // One live voice-burst packet (see VoiceBurstPacket)
|
||||
// Finalized private media. `0x20` is the value already deployed by the
|
||||
// Android client. The complete BitchatFilePacket is encrypted inside
|
||||
// Noise before the outer noiseEncrypted packet is fragmented.
|
||||
case privateFile = 0x20
|
||||
// Versioned peer state authenticated by the surrounding Noise session.
|
||||
// This is intentionally distinct from the public announce: announce
|
||||
// capabilities are discovery hints, while this payload proves possession
|
||||
// of the advertised Noise static key before downgrade state is pinned.
|
||||
case authenticatedPeerState = 0x21
|
||||
// Verification (QR-based OOB binding)
|
||||
case verifyChallenge = 0x10 // Verification challenge
|
||||
case verifyResponse = 0x11 // Verification response
|
||||
// Transitive verification (web of trust)
|
||||
case vouch = 0x12 // Batch of vouch attestations
|
||||
|
||||
/// #1434 briefly used 0x09 before release. Accept it while prerelease
|
||||
/// builds age out, but never emit it. Decoders canonicalize both values to
|
||||
/// `.privateFile` so the compatibility alias cannot leak into app logic.
|
||||
static let prereleasePrivateFileRawValue: UInt8 = 0x09
|
||||
|
||||
static func decoded(rawValue: UInt8) -> NoisePayloadType? {
|
||||
rawValue == prereleasePrivateFileRawValue ? .privateFile : Self(rawValue: rawValue)
|
||||
}
|
||||
|
||||
static func isPrivateFile(rawValue: UInt8?) -> Bool {
|
||||
guard let rawValue else { return false }
|
||||
return rawValue == privateFile.rawValue || rawValue == prereleasePrivateFileRawValue
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .privateMessage: return "privateMessage"
|
||||
@@ -116,8 +93,6 @@ enum NoisePayloadType: UInt8 {
|
||||
case .groupInvite: return "groupInvite"
|
||||
case .groupKeyUpdate: return "groupKeyUpdate"
|
||||
case .voiceFrame: return "voiceFrame"
|
||||
case .privateFile: return "privateFile"
|
||||
case .authenticatedPeerState: return "authenticatedPeerState"
|
||||
case .verifyChallenge: return "verifyChallenge"
|
||||
case .verifyResponse: return "verifyResponse"
|
||||
case .vouch: return "vouch"
|
||||
|
||||
@@ -156,89 +156,6 @@ struct AnnouncementPacket {
|
||||
}
|
||||
}
|
||||
|
||||
/// State that is authoritative only because it is carried inside an
|
||||
/// established Noise session. The public announce remains useful for
|
||||
/// discovery, but its self-signature cannot prove possession of the copied
|
||||
/// Noise public key it contains.
|
||||
///
|
||||
/// Wire format (v1):
|
||||
/// `[version=0x01][type][length][value]...`
|
||||
/// - TLV `0x01`: canonical minimal little-endian `PeerCapabilities`
|
||||
/// - TLV `0x02`: 32-byte Ed25519 signing public key
|
||||
///
|
||||
/// Unknown TLVs are skipped for forward compatibility. Unknown versions,
|
||||
/// duplicates, non-canonical capability fields, and malformed lengths are
|
||||
/// rejected without changing authenticated state.
|
||||
struct AuthenticatedPeerStatePacket: Equatable {
|
||||
static let currentVersion: UInt8 = 1
|
||||
static let signingPublicKeyLength = 32
|
||||
|
||||
let capabilities: PeerCapabilities
|
||||
let signingPublicKey: Data
|
||||
|
||||
private enum TLVType: UInt8 {
|
||||
case capabilities = 0x01
|
||||
case signingPublicKey = 0x02
|
||||
}
|
||||
|
||||
func encode() -> Data? {
|
||||
guard signingPublicKey.count == Self.signingPublicKeyLength else { return nil }
|
||||
let capabilityBytes = capabilities.encoded()
|
||||
guard !capabilityBytes.isEmpty, capabilityBytes.count <= 8 else { return nil }
|
||||
|
||||
var data = Data([Self.currentVersion])
|
||||
data.append(TLVType.capabilities.rawValue)
|
||||
data.append(UInt8(capabilityBytes.count))
|
||||
data.append(capabilityBytes)
|
||||
data.append(TLVType.signingPublicKey.rawValue)
|
||||
data.append(UInt8(signingPublicKey.count))
|
||||
data.append(signingPublicKey)
|
||||
return data
|
||||
}
|
||||
|
||||
static func decode(from data: Data) -> AuthenticatedPeerStatePacket? {
|
||||
guard data.first == Self.currentVersion else { return nil }
|
||||
|
||||
var offset = 1
|
||||
var capabilities: PeerCapabilities?
|
||||
var signingPublicKey: Data?
|
||||
|
||||
while offset < data.count {
|
||||
guard offset + 2 <= data.count else { return nil }
|
||||
let typeRaw = data[offset]
|
||||
let length = Int(data[offset + 1])
|
||||
offset += 2
|
||||
guard offset + length <= data.count else { return nil }
|
||||
let value = Data(data[offset..<(offset + length)])
|
||||
offset += length
|
||||
|
||||
guard let type = TLVType(rawValue: typeRaw) else {
|
||||
continue
|
||||
}
|
||||
switch type {
|
||||
case .capabilities:
|
||||
guard capabilities == nil,
|
||||
!value.isEmpty,
|
||||
value.count <= 8 else { return nil }
|
||||
let decoded = PeerCapabilities(encoded: value)
|
||||
guard decoded.encoded() == value else { return nil }
|
||||
capabilities = decoded
|
||||
|
||||
case .signingPublicKey:
|
||||
guard signingPublicKey == nil,
|
||||
value.count == Self.signingPublicKeyLength else { return nil }
|
||||
signingPublicKey = value
|
||||
}
|
||||
}
|
||||
|
||||
guard let capabilities, let signingPublicKey else { return nil }
|
||||
return AuthenticatedPeerStatePacket(
|
||||
capabilities: capabilities,
|
||||
signingPublicKey: signingPublicKey
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct PrivateMessagePacket {
|
||||
let messageID: String
|
||||
let content: String
|
||||
|
||||
@@ -3,5 +3,5 @@ import BitFoundation
|
||||
extension PeerCapabilities {
|
||||
/// Capabilities this build advertises in its announce packets.
|
||||
/// Each feature adds its bit here when it ships.
|
||||
static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups, .privateMedia]
|
||||
static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups]
|
||||
}
|
||||
|
||||
@@ -16,9 +16,6 @@ struct BLEAnnounceHandlerEnvironment {
|
||||
let now: () -> Date
|
||||
/// Noise public key already recorded for the peer, if any (registry read).
|
||||
let existingNoisePublicKey: (PeerID) -> Data?
|
||||
/// Ed25519 key previously bound to this Noise identity by an authenticated
|
||||
/// peer-state payload, if any (persistent identity-state read).
|
||||
let authenticatedSigningPublicKey: (_ noisePublicKey: Data) -> Data?
|
||||
/// Verifies the packet signature against the announced signing key.
|
||||
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
||||
/// Direct link state for the peer (BLE-queue read).
|
||||
@@ -133,21 +130,11 @@ final class BLEAnnounceHandler {
|
||||
hasSignature: hasSignature,
|
||||
signatureValid: signatureValid,
|
||||
existingNoisePublicKey: existingNoisePublicKey,
|
||||
announcedNoisePublicKey: announcement.noisePublicKey,
|
||||
authenticatedSigningPublicKey: env.authenticatedSigningPublicKey(
|
||||
announcement.noisePublicKey
|
||||
),
|
||||
announcedSigningPublicKey: announcement.signingPublicKey
|
||||
announcedNoisePublicKey: announcement.noisePublicKey
|
||||
)
|
||||
if case .reject(.keyMismatch) = trustDecision {
|
||||
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
|
||||
}
|
||||
if case .reject(.authenticatedSigningKeyMismatch) = trustDecision {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Announce signing-key replacement rejected for Noise-authenticated peer \(peerID.id.prefix(8))…",
|
||||
category: .security
|
||||
)
|
||||
}
|
||||
let verifiedAnnounce = trustDecision.isVerified
|
||||
|
||||
var isNewPeer = false
|
||||
|
||||
@@ -56,7 +56,6 @@ enum BLEAnnounceTrustRejection: Equatable {
|
||||
case missingSignature
|
||||
case invalidSignature
|
||||
case keyMismatch
|
||||
case authenticatedSigningKeyMismatch
|
||||
}
|
||||
|
||||
enum BLEAnnounceTrustDecision: Equatable {
|
||||
@@ -73,19 +72,12 @@ enum BLEAnnounceTrustPolicy {
|
||||
hasSignature: Bool,
|
||||
signatureValid: Bool,
|
||||
existingNoisePublicKey: Data?,
|
||||
announcedNoisePublicKey: Data,
|
||||
authenticatedSigningPublicKey: Data? = nil,
|
||||
announcedSigningPublicKey: Data? = nil
|
||||
announcedNoisePublicKey: Data
|
||||
) -> BLEAnnounceTrustDecision {
|
||||
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
|
||||
return .reject(.keyMismatch)
|
||||
}
|
||||
|
||||
if let authenticatedSigningPublicKey,
|
||||
announcedSigningPublicKey != authenticatedSigningPublicKey {
|
||||
return .reject(.authenticatedSigningKeyMismatch)
|
||||
}
|
||||
|
||||
guard hasSignature else {
|
||||
return .reject(.missingSignature)
|
||||
}
|
||||
|
||||
@@ -16,8 +16,6 @@ struct BLEFileTransferHandlerEnvironment {
|
||||
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
|
||||
/// Verifies a packet's signature against a candidate signing key (registry path).
|
||||
let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
||||
/// Local signing key used to authenticate our own gossip-sync replays.
|
||||
let localSigningPublicKey: () -> Data
|
||||
/// Resolves a display name from a verified packet signature for peers missing from the registry.
|
||||
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
|
||||
/// Tracks the broadcast file packet for gossip sync.
|
||||
@@ -48,105 +46,54 @@ final class BLEFileTransferHandler {
|
||||
self.environment = environment
|
||||
}
|
||||
|
||||
/// Returns `false` when the raw packet fails sender authentication (or is
|
||||
/// a live self-echo) and must not be relayed onward. Authentication runs
|
||||
/// before the routing decision, so a forged directed packet cannot use a
|
||||
/// node that is not its recipient as an unsigned forwarding hop.
|
||||
/// Returns `false` when the packet fails sender authentication and must
|
||||
/// not be relayed onward. Every other outcome returns `true`: files
|
||||
/// directed to another peer are forwarded untouched, and local-only drops
|
||||
/// (malformed payload, quota, save failure) don't affect multi-hop
|
||||
/// delivery to nodes that may handle them fine.
|
||||
@discardableResult
|
||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||||
let env = environment
|
||||
let localPeerID = env.localPeerID()
|
||||
let peersSnapshot = env.peersSnapshot()
|
||||
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return true }
|
||||
|
||||
guard let senderNickname = authenticatedRawSenderNickname(
|
||||
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else {
|
||||
return true
|
||||
}
|
||||
|
||||
let peersSnapshot = env.peersSnapshot()
|
||||
guard let senderNickname = resolveSenderNickname(
|
||||
packet: packet,
|
||||
from: peerID,
|
||||
isBroadcast: !deliveryPlan.isPrivateMessage,
|
||||
peers: peersSnapshot,
|
||||
env: env
|
||||
) else {
|
||||
SecureLogger.warning("🚫 Dropping raw file transfer with missing/invalid signature from \(peerID.id.prefix(8))…", category: .security)
|
||||
SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: localPeerID) {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: localPeerID) else {
|
||||
return true
|
||||
}
|
||||
|
||||
if deliveryPlan.shouldTrackForSync {
|
||||
env.trackPacketSeen(packet)
|
||||
}
|
||||
|
||||
_ = storeIncomingPayload(
|
||||
packet.payload,
|
||||
from: peerID,
|
||||
senderNickname: senderNickname,
|
||||
timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000),
|
||||
isPrivate: deliveryPlan.isPrivateMessage,
|
||||
env: env
|
||||
)
|
||||
// Once authenticated, a local decode/quota/save failure is not proof
|
||||
// that downstream nodes should be denied the valid signed packet.
|
||||
return true
|
||||
}
|
||||
|
||||
/// Accepts a file packet only after it has been authenticated and
|
||||
/// decrypted by the peer's Noise session. The inner packet deliberately
|
||||
/// has no redundant signature: Noise supplies sender authentication and
|
||||
/// confidentiality, while this handler retains the same validation,
|
||||
/// quota, persistence, and UI-delivery behavior as public files.
|
||||
@discardableResult
|
||||
func handlePrivatePayload(_ payload: Data, from peerID: PeerID, timestamp: Date) -> Bool {
|
||||
let env = environment
|
||||
let peers = env.peersSnapshot()
|
||||
let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||
peerID: peerID,
|
||||
localPeerID: env.localPeerID(),
|
||||
localNickname: env.localNickname(),
|
||||
peers: peers,
|
||||
allowConnectedUnverified: true
|
||||
) ?? BLEPeerSenderDisplayName.anonymousNickname(for: peerID)
|
||||
|
||||
return storeIncomingPayload(
|
||||
payload,
|
||||
from: peerID,
|
||||
senderNickname: senderNickname,
|
||||
timestamp: timestamp,
|
||||
isPrivate: true,
|
||||
env: env
|
||||
)
|
||||
}
|
||||
|
||||
private func storeIncomingPayload(
|
||||
_ payload: Data,
|
||||
from peerID: PeerID,
|
||||
senderNickname: String,
|
||||
timestamp: Date,
|
||||
isPrivate: Bool,
|
||||
env: BLEFileTransferHandlerEnvironment
|
||||
) -> Bool {
|
||||
|
||||
let filePacket: BitchatFilePacket
|
||||
let mime: MimeType
|
||||
switch BLEIncomingFileValidator.validate(payload: payload) {
|
||||
switch BLEIncomingFileValidator.validate(payload: packet.payload) {
|
||||
case .success(let acceptance):
|
||||
filePacket = acceptance.filePacket
|
||||
mime = acceptance.mime
|
||||
case .failure(.malformedPayload):
|
||||
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session)
|
||||
return false
|
||||
return true
|
||||
case .failure(.payloadTooLarge(let bytes)):
|
||||
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security)
|
||||
return false
|
||||
return true
|
||||
case .failure(.unsupportedMime(let mimeType, let bytes)):
|
||||
SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "<empty>")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security)
|
||||
return false
|
||||
return true
|
||||
case .failure(.magicMismatch(let mime, let bytes, let prefixHex)):
|
||||
SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security)
|
||||
return false
|
||||
return true
|
||||
}
|
||||
|
||||
// BCH-01-002: Enforce storage quota before saving
|
||||
@@ -159,27 +106,28 @@ final class BLEFileTransferHandler {
|
||||
mime.defaultExtension,
|
||||
mime.category.rawValue
|
||||
) else {
|
||||
return false
|
||||
return true
|
||||
}
|
||||
|
||||
if isPrivate {
|
||||
if deliveryPlan.isPrivateMessage {
|
||||
env.updatePeerLastSeen(peerID)
|
||||
}
|
||||
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
let message = BitchatMessage(
|
||||
sender: senderNickname,
|
||||
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
|
||||
timestamp: timestamp,
|
||||
timestamp: ts,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: isPrivate,
|
||||
isPrivate: deliveryPlan.isPrivateMessage,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: peerID,
|
||||
// Received messages need an explicit status: BitchatMessage
|
||||
// defaults private messages to .sending, which the media views
|
||||
// render as an in-flight send (empty reveal mask, disabled tap).
|
||||
deliveryStatus: isPrivate
|
||||
? .delivered(to: env.localNickname(), at: timestamp)
|
||||
deliveryStatus: deliveryPlan.isPrivateMessage
|
||||
? .delivered(to: env.localNickname(), at: ts)
|
||||
: nil
|
||||
)
|
||||
|
||||
@@ -189,38 +137,51 @@ final class BLEFileTransferHandler {
|
||||
return true
|
||||
}
|
||||
|
||||
/// Every remaining raw file transfer is signed, regardless of whether it
|
||||
/// is broadcast, addressed to us, or merely passing through. Registry
|
||||
/// signing keys are preferred; persisted identities cover peers that have
|
||||
/// rotated or are not currently present in the registry.
|
||||
private func authenticatedRawSenderNickname(
|
||||
/// Resolves the authenticated display name for a file transfer's sender.
|
||||
///
|
||||
/// Directed (private) transfers are addressed to us specifically and keep
|
||||
/// the lenient connected-peer path. Broadcast transfers carry an
|
||||
/// attacker-controllable `senderID` exactly like public messages and public
|
||||
/// voice frames — registry membership alone is NOT proof of identity, so a
|
||||
/// valid packet signature from the claimed sender is required before we
|
||||
/// trust it. Without this, a peer that observed a public voice burst could
|
||||
/// spoof a broadcast `voice_<burstID>.m4a` note under the talker's ID and
|
||||
/// overwrite the signature-verified live bubble with attacker audio.
|
||||
private func resolveSenderNickname(
|
||||
packet: BitchatPacket,
|
||||
from peerID: PeerID,
|
||||
isBroadcast: Bool,
|
||||
peers: [PeerID: BLEPeerInfo],
|
||||
env: BLEFileTransferHandlerEnvironment
|
||||
) -> String? {
|
||||
guard packet.signature != nil else { return nil }
|
||||
guard isBroadcast else {
|
||||
return BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||
peerID: peerID,
|
||||
localPeerID: env.localPeerID(),
|
||||
localNickname: env.localNickname(),
|
||||
peers: peers,
|
||||
allowConnectedUnverified: true
|
||||
) ?? env.signedSenderDisplayName(packet, peerID)
|
||||
}
|
||||
|
||||
let localPeerID = env.localPeerID()
|
||||
let candidateKey = peerID == localPeerID
|
||||
? env.localSigningPublicKey()
|
||||
: peers[peerID]?.signingPublicKey
|
||||
let verifiedWithKnownKey = candidateKey.map {
|
||||
env.verifyPacketSignature(packet, $0)
|
||||
} ?? false
|
||||
let signedDisplayName = verifiedWithKnownKey
|
||||
? nil
|
||||
: env.signedSenderDisplayName(packet, peerID)
|
||||
guard verifiedWithKnownKey || signedDisplayName != nil else { return nil }
|
||||
// Our own broadcasts replayed back via gossip sync (ttl==0) are
|
||||
// trivially authentic and cannot be verified against the peer registry
|
||||
// or identity cache, so exempt self exactly as `BLEPublicMessageHandler`
|
||||
// does. Verify against the signing key already in the
|
||||
// (synchronously-updated) registry first, then fall back to the
|
||||
// persisted-identity signature lookup for peers not yet cached there.
|
||||
let isSelf = peerID == env.localPeerID()
|
||||
let registrySigningKey = peers[peerID]?.signingPublicKey
|
||||
let verifiedViaRegistry = !isSelf && (registrySigningKey.map { env.verifyPacketSignature(packet, $0) } ?? false)
|
||||
let signedDisplayName = (isSelf || verifiedViaRegistry) ? nil : env.signedSenderDisplayName(packet, peerID)
|
||||
guard isSelf || verifiedViaRegistry || signedDisplayName != nil else { return nil }
|
||||
|
||||
return BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||
peerID: peerID,
|
||||
localPeerID: localPeerID,
|
||||
localPeerID: env.localPeerID(),
|
||||
localNickname: env.localNickname(),
|
||||
peers: peers,
|
||||
// The packet signature authenticates the announced peer; the old
|
||||
// connected-but-unsigned leniency is not involved.
|
||||
allowConnectedUnverified: true
|
||||
) ?? signedDisplayName ?? BLEPeerSenderDisplayName.anonymousNickname(for: peerID)
|
||||
allowConnectedUnverified: false
|
||||
) ?? signedDisplayName
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,11 +201,8 @@ struct BLEFragmentAssemblyBuffer {
|
||||
}
|
||||
|
||||
private static func assemblyLimit(for originalType: UInt8) -> Int {
|
||||
if originalType == MessageType.fileTransfer.rawValue
|
||||
|| originalType == MessageType.noiseEncrypted.rawValue {
|
||||
if originalType == MessageType.fileTransfer.rawValue {
|
||||
// Allow headroom for TLV metadata and binary framing overhead.
|
||||
// A large noiseEncrypted packet can be an E2E-encrypted private
|
||||
// file; its authenticated plaintext is validated after decrypt.
|
||||
return FileTransferLimits.maxFramedFileBytes
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,6 @@ import BitFoundation
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
struct BLENoiseDecryptionResult {
|
||||
let plaintext: Data
|
||||
let sessionGeneration: UUID
|
||||
}
|
||||
|
||||
/// Narrow environment for `BLENoisePacketHandler`.
|
||||
///
|
||||
/// All queue hops (collections barrier writes, main-actor UI notification)
|
||||
@@ -32,16 +27,9 @@ struct BLENoisePacketHandlerEnvironment {
|
||||
/// Updates the registry last-seen timestamp for the peer (async barrier write).
|
||||
let updatePeerLastSeen: (PeerID) -> Void
|
||||
/// Decrypts an encrypted payload from the peer (crypto).
|
||||
let decrypt: (_ payload: Data, _ peerID: PeerID) throws -> BLENoiseDecryptionResult
|
||||
let decrypt: (_ payload: Data, _ peerID: PeerID) throws -> Data
|
||||
/// Clears the peer's Noise session after an unrecoverable decrypt failure (crypto).
|
||||
let clearSession: (PeerID) -> Void
|
||||
/// Consumes session-authenticated protocol state inside the transport. It
|
||||
/// must never escape to UI or Nostr payload dispatch.
|
||||
let handleAuthenticatedPeerState: (
|
||||
_ peerID: PeerID,
|
||||
_ payload: Data,
|
||||
_ sessionGeneration: UUID
|
||||
) -> Void
|
||||
/// Delivers `.noisePayloadReceived` to the UI as one main-actor hop.
|
||||
let deliverNoisePayload: (
|
||||
_ peerID: PeerID,
|
||||
@@ -61,11 +49,7 @@ final class BLENoisePacketHandler {
|
||||
self.environment = environment
|
||||
}
|
||||
|
||||
/// Returns true when the handshake message was processed successfully.
|
||||
/// Callers use this to distinguish an authenticated replacement completion
|
||||
/// from a rejected candidate while an older session remains established.
|
||||
@discardableResult
|
||||
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||||
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
let env = environment
|
||||
// Use NoiseEncryptionService for handshake processing
|
||||
if PeerID(hexData: packet.recipientID) == env.localPeerID() {
|
||||
@@ -88,26 +72,14 @@ final class BLENoisePacketHandler {
|
||||
|
||||
// Session establishment will trigger onPeerAuthenticated callback
|
||||
// which will send any pending messages at the right time
|
||||
return true
|
||||
} catch NoiseSessionError.peerIdentityMismatch {
|
||||
// The candidate was already discarded by the session manager.
|
||||
// Do not let a spoofed claimed ID trigger a fresh outbound
|
||||
// handshake or recreate state for the attacker-selected ID.
|
||||
SecureLogger.warning(
|
||||
"Rejected Noise handshake whose static key does not match \(peerID.id.prefix(8))…",
|
||||
category: .security
|
||||
)
|
||||
return false
|
||||
} catch {
|
||||
SecureLogger.error("Failed to process handshake: \(error)")
|
||||
// Try initiating a new handshake
|
||||
if !env.hasNoiseSession(peerID) {
|
||||
env.initiateHandshake(peerID)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
@@ -126,30 +98,20 @@ final class BLENoisePacketHandler {
|
||||
env.updatePeerLastSeen(peerID)
|
||||
|
||||
do {
|
||||
let decryption = try env.decrypt(packet.payload, peerID)
|
||||
let decrypted = decryption.plaintext
|
||||
let decrypted = try env.decrypt(packet.payload, peerID)
|
||||
guard decrypted.count > 0 else { return }
|
||||
|
||||
// First byte indicates the payload type
|
||||
let payloadType = decrypted[0]
|
||||
let payloadData = decrypted.dropFirst()
|
||||
|
||||
guard let noisePayloadType = NoisePayloadType.decoded(rawValue: payloadType) else {
|
||||
guard let noisePayloadType = NoisePayloadType(rawValue: payloadType) else {
|
||||
SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)")
|
||||
return
|
||||
}
|
||||
|
||||
SecureLogger.debug("🔐 Decrypted noise payload type \(noisePayloadType.description) from \(peerID.id.prefix(8))…", category: .session)
|
||||
|
||||
if noisePayloadType == .authenticatedPeerState {
|
||||
env.handleAuthenticatedPeerState(
|
||||
peerID,
|
||||
Data(payloadData),
|
||||
decryption.sessionGeneration
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts)
|
||||
} catch NoiseEncryptionError.sessionNotEstablished {
|
||||
|
||||
@@ -17,16 +17,6 @@ enum BLENoisePayloadFactory {
|
||||
typedPayload(.delivered, payload: Data(messageID.utf8))
|
||||
}
|
||||
|
||||
static func privateFile(_ filePacket: BitchatFilePacket) -> Data? {
|
||||
guard let payload = filePacket.encode() else { return nil }
|
||||
return typedPayload(.privateFile, payload: payload)
|
||||
}
|
||||
|
||||
static func authenticatedPeerState(_ state: AuthenticatedPeerStatePacket) -> Data? {
|
||||
guard let payload = state.encode() else { return nil }
|
||||
return typedPayload(.authenticatedPeerState, payload: payload)
|
||||
}
|
||||
|
||||
static func typedPayload(_ type: NoisePayloadType, payload: Data) -> Data {
|
||||
var typed = Data([type.rawValue])
|
||||
typed.append(payload)
|
||||
|
||||
@@ -6,16 +6,9 @@ struct BLEPendingPrivateMessage: Equatable {
|
||||
let messageID: String
|
||||
}
|
||||
|
||||
struct BLEPendingTypedPayload: Equatable {
|
||||
let payload: Data
|
||||
/// Present for app-initiated media so handshake queuing preserves the
|
||||
/// fragment scheduler's progress/cancellation identity.
|
||||
let transferId: String?
|
||||
}
|
||||
|
||||
struct BLENoiseSessionQueues {
|
||||
private var privateMessagesByPeerID: [PeerID: [BLEPendingPrivateMessage]] = [:]
|
||||
private var typedPayloadsByPeerID: [PeerID: [BLEPendingTypedPayload]] = [:]
|
||||
private var typedPayloadsByPeerID: [PeerID: [Data]] = [:]
|
||||
|
||||
var isEmpty: Bool {
|
||||
privateMessagesByPeerID.isEmpty && typedPayloadsByPeerID.isEmpty
|
||||
@@ -41,35 +34,13 @@ struct BLENoiseSessionQueues {
|
||||
privateMessagesByPeerID[peerID, default: []].insert(contentsOf: messages, at: 0)
|
||||
}
|
||||
|
||||
mutating func appendTypedPayload(_ payload: Data, transferId: String? = nil, for peerID: PeerID) {
|
||||
typedPayloadsByPeerID[peerID, default: []].append(
|
||||
BLEPendingTypedPayload(payload: payload, transferId: transferId)
|
||||
)
|
||||
mutating func appendTypedPayload(_ payload: Data, for peerID: PeerID) {
|
||||
typedPayloadsByPeerID[peerID, default: []].append(payload)
|
||||
}
|
||||
|
||||
mutating func takeTypedPayloads(for peerID: PeerID) -> [BLEPendingTypedPayload] {
|
||||
mutating func takeTypedPayloads(for peerID: PeerID) -> [Data] {
|
||||
let payloads = typedPayloadsByPeerID[peerID] ?? []
|
||||
typedPayloadsByPeerID.removeValue(forKey: peerID)
|
||||
return payloads
|
||||
}
|
||||
|
||||
func containsTypedPayload(transferId: String) -> Bool {
|
||||
typedPayloadsByPeerID.values.contains { payloads in
|
||||
payloads.contains { $0.transferId == transferId }
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
mutating func removeTypedPayload(transferId: String) -> Bool {
|
||||
for peerID in Array(typedPayloadsByPeerID.keys) {
|
||||
guard var payloads = typedPayloadsByPeerID[peerID],
|
||||
let index = payloads.firstIndex(where: { $0.transferId == transferId }) else {
|
||||
continue
|
||||
}
|
||||
payloads.remove(at: index)
|
||||
typedPayloadsByPeerID[peerID] = payloads.isEmpty ? nil : payloads
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,6 @@ struct BLEOutboundFragmentPlan {
|
||||
}
|
||||
|
||||
enum BLEOutboundFragmentPlanner {
|
||||
/// Current Android receivers reject fragment sets above 256. Private
|
||||
/// media v1 treats that deployed ceiling as a cross-platform contract.
|
||||
static let privateMediaV1MaxFragments = 256
|
||||
private static let minimumChunkSize = 64
|
||||
private static let fragmentIDLength = 8
|
||||
|
||||
@@ -74,10 +71,6 @@ enum BLEOutboundFragmentPlanner {
|
||||
)
|
||||
}
|
||||
|
||||
static func isPrivateMediaV1Compatible(_ plan: BLEOutboundFragmentPlan) -> Bool {
|
||||
plan.totalFragments <= privateMediaV1MaxFragments
|
||||
}
|
||||
|
||||
private static func sizingPolicy(
|
||||
for packet: BitchatPacket,
|
||||
requestedMaxChunk: Int?,
|
||||
|
||||
@@ -29,9 +29,8 @@ struct BLEOutboundFragmentTransferRequest {
|
||||
}
|
||||
|
||||
var resolvedTransferId: String? {
|
||||
if let transferId { return transferId }
|
||||
guard packet.type == MessageType.fileTransfer.rawValue else { return nil }
|
||||
return packet.payload.sha256Hex()
|
||||
return transferId ?? packet.payload.sha256Hex()
|
||||
}
|
||||
|
||||
/// Content identity independent of the caller-chosen transfer ID: the
|
||||
|
||||
@@ -10,9 +10,6 @@ struct BLEPeerInfo: Equatable {
|
||||
var isVerifiedNickname: Bool
|
||||
var lastSeen: Date
|
||||
var capabilities: PeerCapabilities = []
|
||||
/// Distinguishes an old client that omitted the capabilities TLV from a
|
||||
/// modern client that explicitly advertised a set without a given bit.
|
||||
var capabilitiesWereExplicitlyAdvertised: Bool = false
|
||||
/// Rendezvous cell from the peer's announce when it advertises `.bridge`.
|
||||
var bridgeGeohash: String?
|
||||
}
|
||||
@@ -117,10 +114,6 @@ struct BLEPeerRegistry {
|
||||
peers[peerID.toShort()]?.capabilities ?? []
|
||||
}
|
||||
|
||||
func capabilitiesWereExplicitlyAdvertised(for peerID: PeerID) -> Bool {
|
||||
peers[peerID.toShort()]?.capabilitiesWereExplicitlyAdvertised == true
|
||||
}
|
||||
|
||||
/// Peers whose last verified announce advertised the given capability.
|
||||
func peers(advertising capability: PeerCapabilities) -> [PeerID] {
|
||||
peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID)
|
||||
@@ -181,14 +174,6 @@ struct BLEPeerRegistry {
|
||||
peers[peerID] = peer
|
||||
}
|
||||
|
||||
/// Replaces the announcement signing key only after the surrounding Noise
|
||||
/// session proved possession of this peer's static key.
|
||||
mutating func bindAuthenticatedSigningPublicKey(_ key: Data, for peerID: PeerID) {
|
||||
guard var peer = peers[peerID.toShort()] else { return }
|
||||
peer.signingPublicKey = key
|
||||
peers[peer.peerID] = peer
|
||||
}
|
||||
|
||||
mutating func upsertVerifiedAnnounce(
|
||||
peerID: PeerID,
|
||||
nickname: String,
|
||||
@@ -196,7 +181,7 @@ struct BLEPeerRegistry {
|
||||
signingPublicKey: Data?,
|
||||
isConnected: Bool,
|
||||
now: Date,
|
||||
capabilities: PeerCapabilities? = nil,
|
||||
capabilities: PeerCapabilities = [],
|
||||
bridgeGeohash: String? = nil
|
||||
) -> BLEPeerAnnounceUpdate {
|
||||
let existing = peers[peerID]
|
||||
@@ -214,8 +199,7 @@ struct BLEPeerRegistry {
|
||||
signingPublicKey: signingPublicKey,
|
||||
isVerifiedNickname: true,
|
||||
lastSeen: now,
|
||||
capabilities: capabilities ?? [],
|
||||
capabilitiesWereExplicitlyAdvertised: capabilities != nil,
|
||||
capabilities: capabilities,
|
||||
bridgeGeohash: bridgeGeohash
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -322,7 +322,7 @@ final class CourierStore {
|
||||
|
||||
/// Envelopes eligible to park on relays as bridge courier drops. Merely
|
||||
/// offering one does not start its cooldown: the caller commits that only
|
||||
/// after a relay explicitly accepts the event via NIP-20 OK.
|
||||
/// after a relay explicitly accepts the event via NIP-01 `OK`.
|
||||
func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] {
|
||||
let date = now()
|
||||
return queue.sync {
|
||||
|
||||
@@ -67,7 +67,7 @@ final class BridgeCourierService: ObservableObject {
|
||||
var relaysConnected: (@MainActor () -> Bool)?
|
||||
/// Publishes a signed drop event directly to connected default (DM)
|
||||
/// relays. Completion is true only after at least one relay explicitly
|
||||
/// accepts the event via NIP-20 OK; this must never mean "queued in RAM"
|
||||
/// accepts the event via NIP-01 `OK`; this must never mean "queued in RAM"
|
||||
/// or merely "written to a socket".
|
||||
var publishEvent: (@MainActor (NostrEvent, @escaping @MainActor (Bool) -> Void) -> Void)?
|
||||
/// (Re)opens the drop subscription for the given hex tags.
|
||||
@@ -129,7 +129,7 @@ final class BridgeCourierService: ObservableObject {
|
||||
/// a newer attempt for the same message.
|
||||
private var activeDropOperations: [String: ActiveDropOperation] = [:]
|
||||
/// Held-envelope publishes have no sender message ID, but still need an
|
||||
/// in-flight identity: repeated refreshes inside the NIP-20 wait window
|
||||
/// in-flight identity: repeated refreshes inside the relay-OK wait window
|
||||
/// must not mint duplicate relay events for the same opaque envelope.
|
||||
private var heldDropOperations: [Data: UUID] = [:]
|
||||
/// Deterministically invalid envelopes are suppressed for this process,
|
||||
@@ -275,7 +275,7 @@ final class BridgeCourierService: ObservableObject {
|
||||
/// Publishes a drop, or queues it when relays are down. `messageID` is the
|
||||
/// sender-side dedup key (nil for held/relayed envelopes we don't track);
|
||||
/// it rides the pending queue so an evicted or failed drop can release its
|
||||
/// in-flight slot. Completion reports actual NIP-20 relay acceptance.
|
||||
/// in-flight slot. Completion reports actual NIP-01 relay acceptance.
|
||||
private func publishDrop(
|
||||
_ envelope: CourierEnvelope,
|
||||
messageID: String? = nil,
|
||||
|
||||
@@ -172,11 +172,11 @@ final class MessageDeduplicationService {
|
||||
/// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format)
|
||||
private let nostrAckCache: LRUDeduplicationCache<Bool>
|
||||
|
||||
/// Optional cross-launch persistence for the Nostr event cache. NIP-59
|
||||
/// randomizes gift-wrap timestamps, so DM subscriptions look back 24h and
|
||||
/// relays redeliver the same events on every launch; without this record
|
||||
/// each relaunch reprocesses old PMs and acks. Nil (tests, macOS callers
|
||||
/// that don't opt in) keeps the cache purely in-memory.
|
||||
/// Optional cross-launch persistence for the Nostr event cache. BitChat
|
||||
/// randomizes private-envelope timestamps, so DM subscriptions look back
|
||||
/// 24h and relays redeliver the same events on every launch; without this
|
||||
/// record each relaunch reprocesses old PMs and acks. Nil (tests, macOS
|
||||
/// callers that don't opt in) keeps the cache purely in-memory.
|
||||
private let nostrEventStore: NostrProcessedEventStore?
|
||||
private let nostrEventCapacity: Int
|
||||
private var persistScheduled = false
|
||||
@@ -314,7 +314,7 @@ final class MessageDeduplicationService {
|
||||
// MARK: - Clear
|
||||
|
||||
/// Clears all caches. This is the wipe/panic path: the persisted
|
||||
/// gift-wrap record goes with everything else.
|
||||
/// private-envelope record goes with everything else.
|
||||
func clearAll() {
|
||||
contentCache.clear()
|
||||
nostrEventCache.clear()
|
||||
@@ -325,7 +325,7 @@ final class MessageDeduplicationService {
|
||||
|
||||
/// Clears only the in-memory Nostr caches (events and ACKs). Runs on
|
||||
/// every geohash channel switch, so the disk record deliberately
|
||||
/// survives — wiping it here would forfeit cross-launch gift-wrap dedup
|
||||
/// survives — wiping it here would forfeit cross-launch private-envelope dedup
|
||||
/// each time the user changes channels (flagged by Codex on #1398).
|
||||
func clearNostrCaches() {
|
||||
nostrEventCache.clear()
|
||||
|
||||
@@ -158,6 +158,16 @@ final class MessageRouter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire one typed event sink to every route, including the queue-backed
|
||||
/// Nostr transport. Keeping this at the router boundary prevents a relay
|
||||
/// admission failure from disappearing merely because only the primary
|
||||
/// mesh transport was assigned a UI delegate.
|
||||
func setEventDelegate(_ delegate: TransportEventDelegate?) {
|
||||
for transport in transports {
|
||||
transport.eventDelegate = delegate
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Transport Selection
|
||||
|
||||
private func reachableTransport(for peerID: PeerID) -> Transport? {
|
||||
|
||||
@@ -165,6 +165,7 @@ final class NoiseEncryptionService {
|
||||
// Peer fingerprints (SHA256 hash of static public key)
|
||||
private var peerFingerprints: [PeerID: String] = [:]
|
||||
private var fingerprintToPeerID: [String: PeerID] = [:]
|
||||
|
||||
// Thread safety
|
||||
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
|
||||
|
||||
@@ -182,18 +183,12 @@ final class NoiseEncryptionService {
|
||||
|
||||
// Callbacks
|
||||
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
|
||||
private var onPeerAuthenticatedWithGenerationHandlers: [((PeerID, String, UUID) -> Void)] = []
|
||||
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
||||
/// Automatic rekey removed the old session and produced XX message 1.
|
||||
/// The transport must clear session-scoped state and put these exact bytes
|
||||
/// on the wire; merely reporting "handshake required" strands the partial
|
||||
/// initiator session because a second initiate call sees it already exists.
|
||||
var onRekeyHandshakeReady: ((_ peerID: PeerID, _ message: Data) -> Void)?
|
||||
|
||||
// Add a handler for peer authentication
|
||||
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
|
||||
serviceQueue.sync(flags: .barrier) {
|
||||
onPeerAuthenticatedHandlers.append(handler)
|
||||
serviceQueue.async(flags: .barrier) { [weak self] in
|
||||
self?.onPeerAuthenticatedHandlers.append(handler)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,18 +201,6 @@ final class NoiseEncryptionService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generation-aware authentication notifications are used by protocols
|
||||
/// whose state must be bound to one exact Noise transport session.
|
||||
var onPeerAuthenticatedWithGeneration: ((PeerID, String, UUID) -> Void)? {
|
||||
get { nil }
|
||||
set {
|
||||
guard let handler = newValue else { return }
|
||||
serviceQueue.sync(flags: .barrier) {
|
||||
onPeerAuthenticatedWithGenerationHandlers.append(handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init(keychain: KeychainManagerProtocol) {
|
||||
self.keychain = keychain
|
||||
@@ -312,12 +295,8 @@ final class NoiseEncryptionService {
|
||||
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
|
||||
|
||||
// Set up session callbacks
|
||||
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey, generation in
|
||||
self?.handleSessionEstablished(
|
||||
peerID: peerID,
|
||||
remoteStaticKey: remoteStaticKey,
|
||||
sessionGeneration: generation
|
||||
)
|
||||
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in
|
||||
self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey)
|
||||
}
|
||||
|
||||
// Start session maintenance timer
|
||||
@@ -746,56 +725,11 @@ final class NoiseEncryptionService {
|
||||
|
||||
return try sessionManager.encrypt(data, for: peerID)
|
||||
}
|
||||
|
||||
/// Encrypts a finalized private-media packet. Ordinary Noise application
|
||||
/// messages retain the 64 KiB ceiling; this purpose-specific path permits
|
||||
/// the bounded `BitchatFilePacket` envelope and refuses every other typed
|
||||
/// payload so the larger allocation budget cannot become a generic bypass.
|
||||
func encryptPrivateFilePayload(
|
||||
_ data: Data,
|
||||
for peerID: PeerID,
|
||||
sessionGeneration: UUID? = nil
|
||||
) throws -> Data {
|
||||
guard NoisePayloadType.isPrivateFile(rawValue: data.first),
|
||||
NoiseSecurityValidator.validatePrivateFileMessageSize(data) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
guard rateLimiter.allowMessage(from: peerID) else {
|
||||
throw NoiseSecurityError.rateLimitExceeded
|
||||
}
|
||||
|
||||
guard hasEstablishedSession(with: peerID) else {
|
||||
onHandshakeRequired?(peerID)
|
||||
throw NoiseEncryptionError.handshakeRequired
|
||||
}
|
||||
|
||||
// `maxPrivateFilePlaintextSize` already subtracts the cipher's fixed
|
||||
// nonce/tag overhead, so the result is bounded without a second copy.
|
||||
if let sessionGeneration {
|
||||
return try sessionManager.encrypt(
|
||||
data,
|
||||
for: peerID,
|
||||
expectedSessionGeneration: sessionGeneration
|
||||
)
|
||||
}
|
||||
return try sessionManager.encrypt(data, for: peerID)
|
||||
}
|
||||
|
||||
/// Decrypt data from a specific peer
|
||||
func decrypt(_ data: Data, from peerID: PeerID) throws -> Data {
|
||||
try decryptWithSessionGeneration(data, from: peerID).plaintext
|
||||
}
|
||||
|
||||
func decryptWithSessionGeneration(
|
||||
_ data: Data,
|
||||
from peerID: PeerID
|
||||
) throws -> (plaintext: Data, sessionGeneration: UUID) {
|
||||
// Standard transport ciphertext has 20 bytes of nonce/tag overhead.
|
||||
// A larger candidate is admitted only up to the framed-file ceiling;
|
||||
// after authenticated decryption it must prove it is `.privateFile`.
|
||||
let isStandardCiphertext = NoiseSecurityValidator.validateCiphertextSize(data)
|
||||
guard isStandardCiphertext || NoiseSecurityValidator.validatePrivateFileCiphertextSize(data) else {
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
@@ -809,14 +743,7 @@ final class NoiseEncryptionService {
|
||||
throw NoiseEncryptionError.sessionNotEstablished
|
||||
}
|
||||
|
||||
let result = try sessionManager.decryptWithSessionGeneration(data, from: peerID)
|
||||
if !isStandardCiphertext {
|
||||
guard NoisePayloadType.isPrivateFile(rawValue: result.plaintext.first),
|
||||
NoiseSecurityValidator.validatePrivateFileMessageSize(result.plaintext) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
}
|
||||
return result
|
||||
return try sessionManager.decrypt(data, from: peerID)
|
||||
}
|
||||
|
||||
// MARK: - Peer Management
|
||||
@@ -828,25 +755,6 @@ final class NoiseEncryptionService {
|
||||
}
|
||||
}
|
||||
|
||||
func sessionGeneration(for peerID: PeerID) -> UUID? {
|
||||
sessionManager.sessionGeneration(for: peerID)
|
||||
}
|
||||
|
||||
/// Runs `body` while holding a read lease on the exact session generation.
|
||||
/// Session insertion, replacement, and removal use the same manager
|
||||
/// barrier, so they cannot interleave with an authenticated-state commit.
|
||||
func withCurrentSessionGeneration<Result>(
|
||||
for peerID: PeerID,
|
||||
expected: UUID,
|
||||
_ body: () -> Result
|
||||
) -> Result? {
|
||||
sessionManager.withCurrentSessionGeneration(
|
||||
for: peerID,
|
||||
expected: expected,
|
||||
body
|
||||
)
|
||||
}
|
||||
|
||||
func clearEphemeralStateForPanic() {
|
||||
sessionManager.removeAllSessions()
|
||||
serviceQueue.sync(flags: .barrier) {
|
||||
@@ -869,36 +777,24 @@ final class NoiseEncryptionService {
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func handleSessionEstablished(
|
||||
peerID: PeerID,
|
||||
remoteStaticKey: Curve25519.KeyAgreement.PublicKey,
|
||||
sessionGeneration: UUID
|
||||
) {
|
||||
private func handleSessionEstablished(peerID: PeerID, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
|
||||
// Calculate fingerprint
|
||||
let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint()
|
||||
|
||||
// Registering handlers is synchronous, and this barrier snapshots them
|
||||
// with the fingerprint update. Invoke the snapshot outside the queue:
|
||||
// parallel Swift Testing workers must not block behind queued callback
|
||||
// registration or allow a handler to re-enter serviceQueue.
|
||||
let handlers: (
|
||||
generationAware: [(PeerID, String, UUID) -> Void],
|
||||
legacy: [(PeerID, String) -> Void]
|
||||
) = serviceQueue.sync(flags: .barrier) {
|
||||
// Store fingerprint mapping
|
||||
serviceQueue.sync(flags: .barrier) {
|
||||
peerFingerprints[peerID] = fingerprint
|
||||
fingerprintToPeerID[fingerprint] = peerID
|
||||
return (onPeerAuthenticatedWithGenerationHandlers, onPeerAuthenticatedHandlers)
|
||||
}
|
||||
|
||||
// Log security event
|
||||
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||
|
||||
// Notify all handlers about authentication.
|
||||
handlers.generationAware.forEach { handler in
|
||||
handler(peerID, fingerprint, sessionGeneration)
|
||||
}
|
||||
handlers.legacy.forEach { handler in
|
||||
handler(peerID, fingerprint)
|
||||
// Notify all handlers about authentication
|
||||
serviceQueue.async { [weak self] in
|
||||
self?.onPeerAuthenticatedHandlers.forEach { handler in
|
||||
handler(peerID, fingerprint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -919,26 +815,19 @@ final class NoiseEncryptionService {
|
||||
let sessionsNeedingRekey = sessionManager.getSessionsNeedingRekey()
|
||||
|
||||
for (peerID, needsRekey) in sessionsNeedingRekey where needsRekey {
|
||||
|
||||
// Attempt to rekey the session
|
||||
do {
|
||||
try initiateAutomaticRekey(for: peerID)
|
||||
try sessionManager.initiateRekey(for: peerID)
|
||||
SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security)
|
||||
|
||||
// Signal that handshake is needed
|
||||
onHandshakeRequired?(peerID)
|
||||
} catch {
|
||||
SecureLogger.error(error, context: "Failed to initiate rekey for peer: \(peerID)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func initiateAutomaticRekey(for peerID: PeerID) throws {
|
||||
let handshakeMessage = try sessionManager.initiateRekey(for: peerID)
|
||||
SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security)
|
||||
onRekeyHandshakeReady?(peerID, handshakeMessage)
|
||||
onHandshakeRequired?(peerID)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func _test_initiateAutomaticRekey(for peerID: PeerID) throws {
|
||||
try initiateAutomaticRekey(for: peerID)
|
||||
}
|
||||
#endif
|
||||
|
||||
deinit {
|
||||
stopRekeyTimer()
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// Disk persistence for processed gift-wrap event IDs. NIP-59 randomizes
|
||||
/// gift-wrap timestamps, so DM subscriptions must look back generously (24h)
|
||||
/// and relays redeliver the same events on every launch — without a
|
||||
/// cross-launch record, each relaunch reprocesses old PMs and acks
|
||||
/// Disk persistence for processed private-envelope event IDs. BitChat
|
||||
/// randomizes envelope timestamps, so DM subscriptions must look back
|
||||
/// generously (24h) and relays redeliver the same events on every launch —
|
||||
/// without a cross-launch record, each relaunch reprocesses old PMs and acks
|
||||
/// (re-sent DELIVERED bursts, "delivered ack for unknown mid" noise).
|
||||
///
|
||||
/// Contents are event IDs already visible to every relay, so
|
||||
|
||||
@@ -11,8 +11,12 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
let favoriteStatusForNoiseKey: @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||
let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||
let currentIdentity: @MainActor () throws -> NostrIdentity?
|
||||
let registerPendingGiftWrap: @MainActor (String) -> Void
|
||||
let sendEvent: @MainActor (NostrEvent) -> Void
|
||||
let registerPendingPrivateEnvelope: @MainActor (String) -> Void
|
||||
let sendPrivateEnvelopeBatch: @MainActor (
|
||||
[NostrEvent],
|
||||
@escaping @MainActor () -> Void
|
||||
) -> Bool
|
||||
let envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue
|
||||
/// Emits whether a relay that carries private messages is up
|
||||
/// (fail-closed behind Tor). A connected geohash/custom relay alone
|
||||
/// doesn't count: DM sends target the default relay set and would
|
||||
@@ -22,25 +26,35 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
/// serialize behind each other; `live` passes the process-wide one.
|
||||
let ackPacer: AckPacer
|
||||
|
||||
@MainActor
|
||||
init(
|
||||
notificationCenter: NotificationCenter,
|
||||
loadFavorites: @escaping @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship],
|
||||
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?,
|
||||
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?,
|
||||
currentIdentity: @escaping @MainActor () throws -> NostrIdentity?,
|
||||
registerPendingGiftWrap: @escaping @MainActor (String) -> Void,
|
||||
sendEvent: @escaping @MainActor (NostrEvent) -> Void,
|
||||
registerPendingPrivateEnvelope: @escaping @MainActor (String) -> Void,
|
||||
sendPrivateEnvelopeBatch: @escaping @MainActor (
|
||||
[NostrEvent],
|
||||
@escaping @MainActor () -> Void
|
||||
) -> Bool,
|
||||
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void,
|
||||
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never>,
|
||||
ackPacer: AckPacer? = nil
|
||||
ackPacer: AckPacer? = nil,
|
||||
envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue? = nil
|
||||
) {
|
||||
self.notificationCenter = notificationCenter
|
||||
self.loadFavorites = loadFavorites
|
||||
self.favoriteStatusForNoiseKey = favoriteStatusForNoiseKey
|
||||
self.favoriteStatusForPeerID = favoriteStatusForPeerID
|
||||
self.currentIdentity = currentIdentity
|
||||
self.registerPendingGiftWrap = registerPendingGiftWrap
|
||||
self.sendEvent = sendEvent
|
||||
self.registerPendingPrivateEnvelope = registerPendingPrivateEnvelope
|
||||
self.sendPrivateEnvelopeBatch = sendPrivateEnvelopeBatch
|
||||
self.envelopeRetryQueue = envelopeRetryQueue ?? NostrPrivateEnvelopeRetryQueue(
|
||||
sendPrivateEnvelopeBatch: sendPrivateEnvelopeBatch,
|
||||
registerPendingPrivateEnvelope: registerPendingPrivateEnvelope,
|
||||
scheduleAfter: scheduleAfter
|
||||
)
|
||||
self.relayConnectivity = relayConnectivity
|
||||
// Default pacer drives its throttle through the same injected
|
||||
// scheduler, so tests that step scheduleAfter manually keep
|
||||
@@ -56,13 +70,19 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
favoriteStatusForNoiseKey: { FavoritesPersistenceService.shared.getFavoriteStatus(for: $0) },
|
||||
favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) },
|
||||
currentIdentity: { try idBridge.getCurrentNostrIdentity() },
|
||||
registerPendingGiftWrap: { NostrRelayManager.registerPendingGiftWrap(id: $0) },
|
||||
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
|
||||
registerPendingPrivateEnvelope: { NostrRelayManager.registerPendingPrivateEnvelope(id: $0) },
|
||||
sendPrivateEnvelopeBatch: { events, terminalFailure in
|
||||
NostrRelayManager.shared.sendPrivateEnvelopeBatch(
|
||||
events,
|
||||
terminalFailure: terminalFailure
|
||||
)
|
||||
},
|
||||
scheduleAfter: { delay, action in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
||||
},
|
||||
relayConnectivity: { NostrRelayManager.shared.$isDMRelayConnected.eraseToAnyPublisher() },
|
||||
ackPacer: NostrTransport.sharedAckPacer
|
||||
ackPacer: NostrTransport.sharedAckPacer,
|
||||
envelopeRetryQueue: NostrTransport.sharedEnvelopeRetryQueue
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -128,7 +148,37 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
static let sharedAckPacer = AckPacer()
|
||||
// Geohash acknowledgements use short-lived NostrTransport instances, so
|
||||
// the retry owner must be process-wide. A per-transport cap would still be
|
||||
// globally unbounded under outage as throwaway instances accumulated.
|
||||
@MainActor
|
||||
private static let sharedEnvelopeRetryQueue = NostrPrivateEnvelopeRetryQueue(
|
||||
sendPrivateEnvelopeBatch: { events, terminalFailure in
|
||||
NostrRelayManager.shared.sendPrivateEnvelopeBatch(
|
||||
events,
|
||||
terminalFailure: terminalFailure
|
||||
)
|
||||
},
|
||||
registerPendingPrivateEnvelope: {
|
||||
NostrRelayManager.registerPendingPrivateEnvelope(id: $0)
|
||||
},
|
||||
scheduleAfter: { delay, action in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
||||
}
|
||||
)
|
||||
|
||||
@MainActor
|
||||
static func resetControlRetriesForPanicWipe() {
|
||||
sharedEnvelopeRetryQueue.removeAll()
|
||||
}
|
||||
|
||||
private enum PrivateEnvelopeFailurePolicy {
|
||||
case userMessage(messageID: String)
|
||||
case retry(retryKey: String)
|
||||
}
|
||||
|
||||
private let dependencies: Dependencies
|
||||
private let envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue
|
||||
private var favoriteStatusObserver: NSObjectProtocol?
|
||||
|
||||
// Reachability Cache (thread-safe)
|
||||
@@ -145,7 +195,9 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
idBridge: NostrIdentityBridge,
|
||||
dependencies: Dependencies? = nil
|
||||
) {
|
||||
self.dependencies = dependencies ?? .live(idBridge: idBridge)
|
||||
let resolvedDependencies = dependencies ?? .live(idBridge: idBridge)
|
||||
self.dependencies = resolvedDependencies
|
||||
self.envelopeRetryQueue = resolvedDependencies.envelopeRetryQueue
|
||||
|
||||
setupObservers()
|
||||
|
||||
@@ -172,6 +224,18 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
@MainActor
|
||||
func debugEnqueueControlRetry(key: String, events: [NostrEvent]) {
|
||||
envelopeRetryQueue.enqueue(key: key, events: events, registerPending: false)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
var debugControlRetryCount: Int {
|
||||
envelopeRetryQueue.debugPendingCount
|
||||
}
|
||||
#endif
|
||||
|
||||
private func setupObservers() {
|
||||
favoriteStatusObserver = dependencies.notificationCenter.addObserver(
|
||||
forName: .favoriteStatusChanged,
|
||||
@@ -261,7 +325,12 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
||||
return
|
||||
}
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
sendPrivateEnvelope(
|
||||
content: embedded,
|
||||
recipientHex: recipientHex,
|
||||
senderIdentity: senderIdentity,
|
||||
failurePolicy: .userMessage(messageID: messageID)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,7 +356,14 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
||||
return
|
||||
}
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
sendPrivateEnvelope(
|
||||
content: embedded,
|
||||
recipientHex: recipientHex,
|
||||
senderIdentity: senderIdentity,
|
||||
failurePolicy: .retry(
|
||||
retryKey: privateEnvelopeRetryKey(content: embedded, recipientHex: recipientHex)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,7 +395,13 @@ extension NostrTransport {
|
||||
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
||||
return
|
||||
}
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||
sendPrivateEnvelope(
|
||||
content: embedded,
|
||||
recipientHex: recipientHex,
|
||||
senderIdentity: identity,
|
||||
registerPending: true,
|
||||
failurePolicy: .userMessage(messageID: messageID)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -340,17 +422,92 @@ extension NostrTransport {
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates and sends a gift-wrapped private message event
|
||||
/// Creates and sends a BitChat private-envelope event over Nostr.
|
||||
@MainActor
|
||||
private func sendWrappedMessage(content: String, recipientHex: String, senderIdentity: NostrIdentity, registerPending: Bool = false) {
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: content, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr event", category: .session)
|
||||
private func sendPrivateEnvelope(
|
||||
content: String,
|
||||
recipientHex: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
registerPending: Bool = false,
|
||||
failurePolicy: PrivateEnvelopeFailurePolicy
|
||||
) {
|
||||
guard let events = try? NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||
content: content,
|
||||
recipientPubkey: recipientHex,
|
||||
senderIdentity: senderIdentity
|
||||
) else {
|
||||
SecureLogger.error("NostrTransport: failed to build Nostr private-envelope batch", category: .session)
|
||||
return
|
||||
}
|
||||
if registerPending {
|
||||
dependencies.registerPendingGiftWrap(event.id)
|
||||
let accepted = dependencies.sendPrivateEnvelopeBatch(events) { [self] in
|
||||
handlePrivateEnvelopeFailure(
|
||||
events: events,
|
||||
registerPending: registerPending,
|
||||
policy: failurePolicy
|
||||
)
|
||||
}
|
||||
guard accepted else {
|
||||
SecureLogger.error(
|
||||
"NostrTransport: private-envelope migration pair was not accepted for relay delivery",
|
||||
category: .session
|
||||
)
|
||||
handlePrivateEnvelopeFailure(
|
||||
events: events,
|
||||
registerPending: registerPending,
|
||||
policy: failurePolicy
|
||||
)
|
||||
return
|
||||
}
|
||||
registerPendingPrivateEnvelopesIfNeeded(events, registerPending: registerPending)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handlePrivateEnvelopeFailure(
|
||||
events: [NostrEvent],
|
||||
registerPending: Bool,
|
||||
policy: PrivateEnvelopeFailurePolicy
|
||||
) {
|
||||
switch policy {
|
||||
case .userMessage(let messageID):
|
||||
deliverTransportEvent(.messageDeliveryStatusUpdated(
|
||||
messageID: messageID,
|
||||
status: .failed(reason: String(
|
||||
localized: "content.delivery.reason.not_delivered",
|
||||
comment: "Failure reason shown when a private message could not enter the relay delivery queue"
|
||||
))
|
||||
))
|
||||
case .retry(let retryKey):
|
||||
envelopeRetryQueue.enqueue(
|
||||
key: retryKey,
|
||||
events: events,
|
||||
registerPending: registerPending
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func registerPendingPrivateEnvelopesIfNeeded(
|
||||
_ events: [NostrEvent],
|
||||
registerPending: Bool
|
||||
) {
|
||||
guard registerPending else { return }
|
||||
for event in events {
|
||||
dependencies.registerPendingPrivateEnvelope(event.id)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func privateEnvelopeRetryKey(content: String, recipientHex: String) -> String {
|
||||
"\(recipientHex.lowercased()):\(Data(content.utf8).sha256Fingerprint())"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func deliverTransportEvent(_ event: TransportEvent) {
|
||||
if let eventDelegate {
|
||||
eventDelegate.didReceiveTransportEvent(event)
|
||||
} else {
|
||||
delegate?.receiveTransportEvent(event)
|
||||
}
|
||||
dependencies.sendEvent(event)
|
||||
}
|
||||
|
||||
|
||||
@@ -367,7 +524,14 @@ extension NostrTransport {
|
||||
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
||||
return
|
||||
}
|
||||
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
sendPrivateEnvelope(
|
||||
content: ack,
|
||||
recipientHex: recipientHex,
|
||||
senderIdentity: senderIdentity,
|
||||
failurePolicy: .retry(
|
||||
retryKey: privateEnvelopeRetryKey(content: ack, recipientHex: recipientHex)
|
||||
)
|
||||
)
|
||||
|
||||
case .deliveredDirect(let messageID, let peerID):
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||
@@ -378,17 +542,40 @@ extension NostrTransport {
|
||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||
return
|
||||
}
|
||||
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||
sendPrivateEnvelope(
|
||||
content: ack,
|
||||
recipientHex: recipientHex,
|
||||
senderIdentity: senderIdentity,
|
||||
failurePolicy: .retry(
|
||||
retryKey: privateEnvelopeRetryKey(content: ack, recipientHex: recipientHex)
|
||||
)
|
||||
)
|
||||
|
||||
case .deliveredGeohash(let messageID, let recipientHex, let identity):
|
||||
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||
sendPrivateEnvelope(
|
||||
content: embedded,
|
||||
recipientHex: recipientHex,
|
||||
senderIdentity: identity,
|
||||
registerPending: true,
|
||||
failurePolicy: .retry(
|
||||
retryKey: privateEnvelopeRetryKey(content: embedded, recipientHex: recipientHex)
|
||||
)
|
||||
)
|
||||
|
||||
case .readGeohash(let messageID, let recipientHex, let identity):
|
||||
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))…", category: .session)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||
sendPrivateEnvelope(
|
||||
content: embedded,
|
||||
recipientHex: recipientHex,
|
||||
senderIdentity: identity,
|
||||
registerPending: true,
|
||||
failurePolicy: .retry(
|
||||
retryKey: privateEnvelopeRetryKey(content: embedded, recipientHex: recipientHex)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -408,3 +595,122 @@ extension NostrTransport {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded retry owner for non-user private control payloads. It is separate
|
||||
/// from `NostrTransport` so a scheduled retry from a short-lived geohash
|
||||
/// transport remains valid after that transport deinitializes. Scheduler
|
||||
/// callbacks retain this queue, never the transport; an evicted key simply
|
||||
/// becomes a harmless no-op when its already-scheduled callback fires.
|
||||
@MainActor
|
||||
final class NostrPrivateEnvelopeRetryQueue {
|
||||
private struct PendingRetry {
|
||||
let events: [NostrEvent]
|
||||
let registerPending: Bool
|
||||
var attempt: Int
|
||||
var isScheduled: Bool
|
||||
}
|
||||
|
||||
private let sendPrivateEnvelopeBatch: @MainActor (
|
||||
[NostrEvent],
|
||||
@escaping @MainActor () -> Void
|
||||
) -> Bool
|
||||
private let registerPendingPrivateEnvelope: @MainActor (String) -> Void
|
||||
private let scheduleAfter: @Sendable (
|
||||
TimeInterval,
|
||||
@escaping @Sendable () -> Void
|
||||
) -> Void
|
||||
private var pending: [String: PendingRetry] = [:]
|
||||
private var insertionOrder: [String] = []
|
||||
|
||||
init(
|
||||
sendPrivateEnvelopeBatch: @escaping @MainActor (
|
||||
[NostrEvent],
|
||||
@escaping @MainActor () -> Void
|
||||
) -> Bool,
|
||||
registerPendingPrivateEnvelope: @escaping @MainActor (String) -> Void,
|
||||
scheduleAfter: @escaping @Sendable (
|
||||
TimeInterval,
|
||||
@escaping @Sendable () -> Void
|
||||
) -> Void
|
||||
) {
|
||||
self.sendPrivateEnvelopeBatch = sendPrivateEnvelopeBatch
|
||||
self.registerPendingPrivateEnvelope = registerPendingPrivateEnvelope
|
||||
self.scheduleAfter = scheduleAfter
|
||||
}
|
||||
|
||||
func enqueue(key: String, events: [NostrEvent], registerPending: Bool) {
|
||||
guard pending[key] == nil else { return }
|
||||
if pending.count >= TransportConfig.nostrPrivateEnvelopeRetryQueueCap,
|
||||
let evictedKey = insertionOrder.first {
|
||||
insertionOrder.removeFirst()
|
||||
pending.removeValue(forKey: evictedKey)
|
||||
// These are control payloads, never user-authored messages. Keep
|
||||
// the bounded-loss decision explicit rather than silently growing
|
||||
// memory during a prolonged outage.
|
||||
SecureLogger.warning(
|
||||
"📮 Private control retry queue full — evicted oldest whole migration pair",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
pending[key] = PendingRetry(
|
||||
events: events,
|
||||
registerPending: registerPending,
|
||||
attempt: 0,
|
||||
isScheduled: false
|
||||
)
|
||||
insertionOrder.append(key)
|
||||
schedule(key: key)
|
||||
}
|
||||
|
||||
private func schedule(key: String) {
|
||||
guard var item = pending[key], !item.isScheduled else { return }
|
||||
item.isScheduled = true
|
||||
pending[key] = item
|
||||
let exponent = min(item.attempt, 5)
|
||||
let delay = min(2.0 * pow(2.0, Double(exponent)), 60.0)
|
||||
scheduleAfter(delay) { [self] in
|
||||
Task { @MainActor [self] in
|
||||
self.retry(key: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func retry(key: String) {
|
||||
guard var item = pending[key] else { return }
|
||||
item.isScheduled = false
|
||||
pending[key] = item
|
||||
|
||||
let accepted = sendPrivateEnvelopeBatch(item.events) { [self] in
|
||||
self.enqueue(
|
||||
key: key,
|
||||
events: item.events,
|
||||
registerPending: item.registerPending
|
||||
)
|
||||
}
|
||||
if accepted {
|
||||
remove(key: key)
|
||||
if item.registerPending {
|
||||
for event in item.events {
|
||||
registerPendingPrivateEnvelope(event.id)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
item.attempt += 1
|
||||
pending[key] = item
|
||||
schedule(key: key)
|
||||
}
|
||||
}
|
||||
|
||||
private func remove(key: String) {
|
||||
pending.removeValue(forKey: key)
|
||||
insertionOrder.removeAll { $0 == key }
|
||||
}
|
||||
|
||||
func removeAll() {
|
||||
pending.removeAll()
|
||||
insertionOrder.removeAll()
|
||||
}
|
||||
|
||||
var debugPendingCount: Int { pending.count }
|
||||
func debugContains(key: String) -> Bool { pending[key] != nil }
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ final class TransferProgressManager {
|
||||
case updated(id: String, sentFragments: Int, totalFragments: Int)
|
||||
case completed(id: String, totalFragments: Int)
|
||||
case cancelled(id: String, sentFragments: Int, totalFragments: Int)
|
||||
case rejected(id: String, reason: String)
|
||||
}
|
||||
|
||||
private let subject = PassthroughSubject<Event, Never>()
|
||||
@@ -50,17 +49,6 @@ final class TransferProgressManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fails a preflight check while keeping the outgoing placeholder visible
|
||||
/// with an actionable reason instead of treating policy/size rejection as
|
||||
/// a user cancellation.
|
||||
func rejectBeforeStart(id: String, reason: String) {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.states.removeValue(forKey: id)
|
||||
self.subject.send(.rejected(id: id, reason: reason))
|
||||
}
|
||||
}
|
||||
|
||||
func snapshot(id: String) -> (sent: Int, total: Int)? {
|
||||
var result: (sent: Int, total: Int)?
|
||||
queue.sync {
|
||||
|
||||
@@ -83,20 +83,6 @@ enum TransportEvent: @unchecked Sendable {
|
||||
case bluetoothStateUpdated(CBManagerState)
|
||||
}
|
||||
|
||||
/// Downgrade-safe decision for a private-media recipient. Callers ask before
|
||||
/// prompting, and BLEService checks again when it consumes any one-shot
|
||||
/// legacy consent.
|
||||
enum PrivateMediaSendPolicy: Equatable {
|
||||
case encrypted
|
||||
/// A public announce hinted at encrypted media (or a prior authenticated
|
||||
/// pin exists), but this exact Noise session has not yet supplied its
|
||||
/// authenticated peer-state proof. Callers wait boundedly; they must not
|
||||
/// pre-queue encrypted bytes or silently select the legacy path.
|
||||
case awaitingCapabilityProof
|
||||
case legacyRequiresConsent
|
||||
case blockedDowngrade
|
||||
}
|
||||
|
||||
protocol TransportEventDelegate: AnyObject {
|
||||
@MainActor func didReceiveTransportEvent(_ event: TransportEvent)
|
||||
}
|
||||
@@ -177,12 +163,6 @@ protocol Transport: AnyObject {
|
||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID)
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
|
||||
func sendFilePrivate(
|
||||
_ packet: BitchatFilePacket,
|
||||
to peerID: PeerID,
|
||||
transferId: String,
|
||||
allowLegacyFallback: Bool
|
||||
)
|
||||
func cancelTransfer(_ transferId: String)
|
||||
|
||||
// Live voice / push-to-talk (mesh transports only): one encoded
|
||||
@@ -228,11 +208,6 @@ protocol Transport: AnyObject {
|
||||
/// Capabilities the peer advertised in its last verified announce;
|
||||
/// empty for peers that predate the capabilities TLV.
|
||||
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities
|
||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
|
||||
func resolvePrivateMediaSendPolicy(
|
||||
to peerID: PeerID,
|
||||
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||
)
|
||||
/// Sends an encoded vouch-attestation batch inside the Noise session.
|
||||
func sendVouchAttestations(_ payload: Data, to peerID: PeerID)
|
||||
/// Appends a peer-authenticated observer. Unlike
|
||||
@@ -303,16 +278,6 @@ extension Transport {
|
||||
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {}
|
||||
func broadcastGroupMessage(_ envelope: Data) {}
|
||||
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
|
||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { .blockedDowngrade }
|
||||
func resolvePrivateMediaSendPolicy(
|
||||
to peerID: PeerID,
|
||||
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||
) {
|
||||
let policy = privateMediaSendPolicy(to: peerID)
|
||||
Task { @MainActor in
|
||||
completion(policy == .awaitingCapabilityProof ? .blockedDowngrade : policy)
|
||||
}
|
||||
}
|
||||
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
|
||||
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
|
||||
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
|
||||
@@ -329,15 +294,6 @@ extension Transport {
|
||||
func currentMeshTopology() -> MeshTopologySnapshot? { nil }
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
||||
func sendFilePrivate(
|
||||
_ packet: BitchatFilePacket,
|
||||
to peerID: PeerID,
|
||||
transferId: String,
|
||||
allowLegacyFallback: Bool
|
||||
) {
|
||||
guard !allowLegacyFallback else { return }
|
||||
sendFilePrivate(packet, to: peerID, transferId: transferId)
|
||||
}
|
||||
func cancelTransfer(_ transferId: String) {}
|
||||
|
||||
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
||||
|
||||
@@ -9,12 +9,6 @@ enum TransportConfig {
|
||||
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
|
||||
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
|
||||
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends
|
||||
// Bounded wait for the session-authenticated capability proof used by
|
||||
// private-media migration. Expiry never auto-sends clear bytes; it only
|
||||
// resolves to the existing one-shot consent or downgrade-blocked path.
|
||||
static let privateMediaCapabilityProofTimeoutSeconds: TimeInterval = 5
|
||||
static let privateMediaCapabilityProofPendingPeerCap: Int = 64
|
||||
static let privateMediaCapabilityProofWaitersPerPeerCap: Int = 16
|
||||
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
|
||||
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
|
||||
// Fragment relay TTL in sparse graphs; matches messageTTLDefault so media
|
||||
@@ -177,7 +171,22 @@ enum TransportConfig {
|
||||
static let nostrGeoRelayCount: Int = 5
|
||||
static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300
|
||||
static let nostrGeohashSampleLimit: Int = 100
|
||||
static let nostrDMSubscribeLookbackSeconds: TimeInterval = 86400
|
||||
/// New iOS public-envelope timestamps are deliberately shifted into the
|
||||
/// past for privacy and relay compatibility.
|
||||
static let nostrPrivateEnvelopeTimestampFuzzSeconds: TimeInterval = 15 * 60
|
||||
/// Deployed Android clients can shift legacy kind-1059 timestamps by the
|
||||
/// full preceding 48 hours.
|
||||
static let nostrLegacyAndroidTimestampFuzzSeconds: TimeInterval = 48 * 60 * 60
|
||||
/// Private mail remains eligible for delivery for the same 24-hour window
|
||||
/// as the persistent sender outbox. The relay query must add timestamp
|
||||
/// randomization to that window rather than replacing it: an Android event
|
||||
/// sent at t0 can legitimately be stamped t0-48h and fetched at t0+24h.
|
||||
static let nostrPrivateEnvelopeDeliveryWindowSeconds: TimeInterval = 24 * 60 * 60
|
||||
static let nostrDMSubscribeClockSkewSeconds: TimeInterval = 15 * 60
|
||||
static let nostrDMSubscribeLookbackSeconds: TimeInterval =
|
||||
nostrPrivateEnvelopeDeliveryWindowSeconds
|
||||
+ nostrLegacyAndroidTimestampFuzzSeconds
|
||||
+ nostrDMSubscribeClockSkewSeconds
|
||||
// A sampled chat message this recent means "a conversation is happening
|
||||
// there" for the empty-timeline nearby-activity hint.
|
||||
static let uiGeohashChatActivityWindowSeconds: TimeInterval = 900
|
||||
@@ -205,11 +214,20 @@ enum TransportConfig {
|
||||
// Reconnect delays get ±20% random jitter so relays that dropped together
|
||||
// (e.g. a network blip) don't thundering-herd the same reconnect instant.
|
||||
static let nostrRelayBackoffJitterRatio: Double = 0.2
|
||||
static let nostrRelayDefaultFetchLimit: Int = 100
|
||||
/// Migration recovery uses one independent relay filter per wire kind so
|
||||
/// primary traffic cannot consume the legacy result budget (or vice
|
||||
/// versa). Five hundred per kind bounds startup work while preserving a
|
||||
/// materially deeper offline mailbox than the generic feed default.
|
||||
static let nostrPrivateEnvelopeFetchLimitPerKind: Int = 500
|
||||
// How many consecutive Tor-readiness waits (each bounded by TorManager's
|
||||
// bootstrap deadline) to attempt before unblocking pending EOSE callers.
|
||||
static let nostrTorReadyMaxWaitAttempts: Int = 3
|
||||
static let nostrPendingSendQueueCap: Int = 200
|
||||
/// Control-payload pairs (delivery/read acknowledgements and favorite
|
||||
/// notifications) that could not enter the relay queue. User messages do
|
||||
/// not use this queue: they fail visibly, while direct messages also
|
||||
/// remain in the router outbox.
|
||||
static let nostrPrivateEnvelopeRetryQueueCap: Int = 256
|
||||
// Sample interval for the send-queue overflow warning (first + every Nth
|
||||
// dropped event). Drops are ephemeral presence/geo traffic — log-only.
|
||||
static let nostrPendingSendDropLogInterval: Int = 10
|
||||
@@ -221,7 +239,7 @@ enum TransportConfig {
|
||||
// Fallback deadline for treating a subscription's initial fetch as complete
|
||||
// when a relay never sends EOSE (generous to cover Tor circuit setup).
|
||||
static let nostrSubscriptionEOSEFallbackSeconds: TimeInterval = 10.0
|
||||
// A bridge drop is durable only after NIP-20 OK. Relays that omit OK must
|
||||
// A bridge drop is durable only after NIP-01 `OK`. Relays that omit `OK` must
|
||||
// not pin the router's in-flight state indefinitely.
|
||||
static let nostrConfirmedSendAckTimeoutSeconds: TimeInterval = 10.0
|
||||
// After this long, a relay marked permanently failed gets another chance.
|
||||
|
||||
@@ -6,19 +6,6 @@ import Foundation
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
struct LegacyPrivateMediaConsentRequest: Identifiable, Equatable {
|
||||
let id: UUID
|
||||
let peerID: PeerID
|
||||
let peerName: String
|
||||
let transferId: String
|
||||
let messageID: String
|
||||
}
|
||||
|
||||
struct PendingLegacyPrivateMediaConsent {
|
||||
let request: LegacyPrivateMediaConsentRequest
|
||||
let completion: @MainActor (Bool) -> Void
|
||||
}
|
||||
|
||||
/// The narrow surface `ChatMediaTransferCoordinator` needs from its owner.
|
||||
///
|
||||
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
|
||||
@@ -56,24 +43,7 @@ protocol ChatMediaTransferContext: AnyObject {
|
||||
func recordContentKey(_ key: String, timestamp: Date)
|
||||
|
||||
// MARK: Mesh file transfer
|
||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
|
||||
func resolvePrivateMediaSendPolicy(
|
||||
to peerID: PeerID,
|
||||
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||
)
|
||||
func requestLegacyPrivateMediaConsent(
|
||||
for peerID: PeerID,
|
||||
transferId: String,
|
||||
messageID: String,
|
||||
completion: @escaping @MainActor (Bool) -> Void
|
||||
)
|
||||
func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String)
|
||||
func sendFilePrivate(
|
||||
_ packet: BitchatFilePacket,
|
||||
to peerID: PeerID,
|
||||
transferId: String,
|
||||
allowLegacyFallback: Bool
|
||||
)
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
|
||||
func cancelTransfer(_ transferId: String)
|
||||
}
|
||||
@@ -89,50 +59,8 @@ extension ChatViewModel: ChatMediaTransferContext {
|
||||
// other contexts or satisfied by existing `ChatViewModel` members. The
|
||||
// members below flatten mesh service accesses.
|
||||
|
||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
|
||||
meshService.privateMediaSendPolicy(to: peerID)
|
||||
}
|
||||
|
||||
func resolvePrivateMediaSendPolicy(
|
||||
to peerID: PeerID,
|
||||
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||
) {
|
||||
meshService.resolvePrivateMediaSendPolicy(to: peerID, completion: completion)
|
||||
}
|
||||
|
||||
func requestLegacyPrivateMediaConsent(
|
||||
for peerID: PeerID,
|
||||
transferId: String,
|
||||
messageID: String,
|
||||
completion: @escaping @MainActor (Bool) -> Void
|
||||
) {
|
||||
enqueueLegacyPrivateMediaConsent(
|
||||
for: peerID,
|
||||
transferId: transferId,
|
||||
messageID: messageID,
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
|
||||
func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String) {
|
||||
invalidateLegacyPrivateMediaConsent(
|
||||
transferId: transferId,
|
||||
messageID: messageID
|
||||
)
|
||||
}
|
||||
|
||||
func sendFilePrivate(
|
||||
_ packet: BitchatFilePacket,
|
||||
to peerID: PeerID,
|
||||
transferId: String,
|
||||
allowLegacyFallback: Bool
|
||||
) {
|
||||
meshService.sendFilePrivate(
|
||||
packet,
|
||||
to: peerID,
|
||||
transferId: transferId,
|
||||
allowLegacyFallback: allowLegacyFallback
|
||||
)
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
||||
meshService.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
||||
}
|
||||
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
|
||||
@@ -147,19 +75,12 @@ extension ChatViewModel: ChatMediaTransferContext {
|
||||
@MainActor
|
||||
final class ChatMediaTransferCoordinator {
|
||||
private unowned let context: any ChatMediaTransferContext
|
||||
private let prepareVoiceNotePacket: @Sendable (URL) throws -> BitchatFilePacket
|
||||
|
||||
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
|
||||
private(set) var messageIDToTransferId: [String: String] = [:]
|
||||
|
||||
init(
|
||||
context: any ChatMediaTransferContext,
|
||||
prepareVoiceNotePacket: @escaping @Sendable (URL) throws -> BitchatFilePacket = {
|
||||
try ChatMediaPreparation.prepareVoiceNotePacket(at: $0)
|
||||
}
|
||||
) {
|
||||
init(context: any ChatMediaTransferContext) {
|
||||
self.context = context
|
||||
self.prepareVoiceNotePacket = prepareVoiceNotePacket
|
||||
}
|
||||
|
||||
func sendVoiceNote(at url: URL) {
|
||||
@@ -177,28 +98,16 @@ final class ChatMediaTransferCoordinator {
|
||||
)
|
||||
let messageID = message.id
|
||||
let transferId = makeTransferID(messageID: messageID)
|
||||
// Own the transfer before detached preparation begins. Cancel/delete
|
||||
// must be able to invalidate this exact invocation even while file I/O
|
||||
// is still running off the main actor.
|
||||
registerTransfer(transferId: transferId, messageID: messageID)
|
||||
let prepareVoiceNotePacket = self.prepareVoiceNotePacket
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
do {
|
||||
let packet = try prepareVoiceNotePacket(url)
|
||||
let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
|
||||
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self,
|
||||
self.isRegisteredTransfer(transferId, messageID: messageID) else {
|
||||
return
|
||||
}
|
||||
guard let self else { return }
|
||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||
if let peerID = targetPeer {
|
||||
self.beginPrivateMediaSend(
|
||||
packet,
|
||||
to: peerID,
|
||||
transferId: transferId,
|
||||
messageID: messageID
|
||||
)
|
||||
self.context.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
||||
} else {
|
||||
self.context.sendFileBroadcast(packet, transferId: transferId)
|
||||
}
|
||||
@@ -207,19 +116,13 @@ final class ChatMediaTransferCoordinator {
|
||||
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self,
|
||||
self.isRegisteredTransfer(transferId, messageID: messageID) else {
|
||||
return
|
||||
}
|
||||
guard let self else { return }
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit"))
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self,
|
||||
self.isRegisteredTransfer(transferId, messageID: messageID) else {
|
||||
return
|
||||
}
|
||||
guard let self else { return }
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent"))
|
||||
}
|
||||
}
|
||||
@@ -290,12 +193,7 @@ final class ChatMediaTransferCoordinator {
|
||||
let transferId = self.makeTransferID(messageID: messageID)
|
||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||
if let peerID = targetPeer {
|
||||
self.beginPrivateMediaSend(
|
||||
prepared.packet,
|
||||
to: peerID,
|
||||
transferId: transferId,
|
||||
messageID: messageID
|
||||
)
|
||||
self.context.sendFilePrivate(prepared.packet, to: peerID, transferId: transferId)
|
||||
} else {
|
||||
self.context.sendFileBroadcast(prepared.packet, transferId: transferId)
|
||||
}
|
||||
@@ -355,127 +253,17 @@ final class ChatMediaTransferCoordinator {
|
||||
return message
|
||||
}
|
||||
|
||||
private func beginPrivateMediaSend(
|
||||
_ packet: BitchatFilePacket,
|
||||
to peerID: PeerID,
|
||||
transferId: String,
|
||||
messageID: String
|
||||
) {
|
||||
continuePrivateMediaSend(
|
||||
packet,
|
||||
to: peerID,
|
||||
transferId: transferId,
|
||||
messageID: messageID,
|
||||
policy: context.privateMediaSendPolicy(to: peerID)
|
||||
)
|
||||
}
|
||||
|
||||
private func continuePrivateMediaSend(
|
||||
_ packet: BitchatFilePacket,
|
||||
to peerID: PeerID,
|
||||
transferId: String,
|
||||
messageID: String,
|
||||
policy: PrivateMediaSendPolicy
|
||||
) {
|
||||
switch policy {
|
||||
case .encrypted:
|
||||
context.sendFilePrivate(
|
||||
packet,
|
||||
to: peerID,
|
||||
transferId: transferId,
|
||||
allowLegacyFallback: false
|
||||
)
|
||||
|
||||
case .awaitingCapabilityProof:
|
||||
context.resolvePrivateMediaSendPolicy(to: peerID) { [weak self] resolvedPolicy in
|
||||
guard let self,
|
||||
self.isRegisteredTransfer(transferId, messageID: messageID) else {
|
||||
return
|
||||
}
|
||||
guard resolvedPolicy != .awaitingCapabilityProof else {
|
||||
self.handleMediaSendFailure(
|
||||
messageID: messageID,
|
||||
reason: String(
|
||||
localized: "content.delivery.reason.private_media_capability_unresolved",
|
||||
defaultValue: "Could not confirm encrypted media support",
|
||||
comment: "Failure reason when private-media capability negotiation did not resolve"
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
self.continuePrivateMediaSend(
|
||||
packet,
|
||||
to: peerID,
|
||||
transferId: transferId,
|
||||
messageID: messageID,
|
||||
policy: resolvedPolicy
|
||||
)
|
||||
}
|
||||
|
||||
case .legacyRequiresConsent:
|
||||
context.requestLegacyPrivateMediaConsent(
|
||||
for: peerID,
|
||||
transferId: transferId,
|
||||
messageID: messageID
|
||||
) { [weak self] approved in
|
||||
guard let self else { return }
|
||||
// Consent belongs to this exact placeholder/transfer. A late
|
||||
// dialog callback after cancel/delete must never resurrect it.
|
||||
guard self.messageIDToTransferId[messageID] == transferId,
|
||||
self.transferIdToMessageIDs[transferId]?.contains(messageID) == true else {
|
||||
return
|
||||
}
|
||||
guard approved else {
|
||||
self.handleMediaSendFailure(
|
||||
messageID: messageID,
|
||||
reason: String(
|
||||
localized: "content.delivery.reason.legacy_media_declined",
|
||||
defaultValue: "Not sent without end-to-end encryption",
|
||||
comment: "Failure reason after declining the warning for a legacy clear private-media send"
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
self.context.sendFilePrivate(
|
||||
packet,
|
||||
to: peerID,
|
||||
transferId: transferId,
|
||||
allowLegacyFallback: true
|
||||
)
|
||||
}
|
||||
|
||||
case .blockedDowngrade:
|
||||
handleMediaSendFailure(
|
||||
messageID: messageID,
|
||||
reason: String(
|
||||
localized: "content.delivery.reason.private_media_downgrade_blocked",
|
||||
defaultValue: "Encrypted media required; ask this contact to upgrade",
|
||||
comment: "Failure reason when a peer that previously supported encrypted media appears to downgrade"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func registerTransfer(transferId: String, messageID: String) {
|
||||
transferIdToMessageIDs[transferId, default: []].append(messageID)
|
||||
messageIDToTransferId[messageID] = transferId
|
||||
}
|
||||
|
||||
private func isRegisteredTransfer(_ transferId: String, messageID: String) -> Bool {
|
||||
messageIDToTransferId[messageID] == transferId
|
||||
&& transferIdToMessageIDs[transferId]?.contains(messageID) == true
|
||||
}
|
||||
|
||||
func makeTransferID(messageID: String) -> String {
|
||||
"\(messageID)-\(UUID().uuidString)"
|
||||
}
|
||||
|
||||
func clearTransferMapping(for messageID: String) {
|
||||
guard let transferId = messageIDToTransferId.removeValue(forKey: messageID) else { return }
|
||||
context.cancelLegacyPrivateMediaConsent(
|
||||
transferId: transferId,
|
||||
messageID: messageID
|
||||
)
|
||||
guard var queue = transferIdToMessageIDs[transferId] else { return }
|
||||
|
||||
if !queue.isEmpty {
|
||||
@@ -510,9 +298,6 @@ final class ChatMediaTransferCoordinator {
|
||||
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
||||
clearTransferMapping(for: messageID)
|
||||
context.removeMessage(withID: messageID, cleanupFile: true)
|
||||
case .rejected(let id, let reason):
|
||||
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
||||
handleMediaSendFailure(messageID: messageID, reason: reason)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -553,13 +338,6 @@ final class ChatMediaTransferCoordinator {
|
||||
}
|
||||
|
||||
func deleteMediaMessage(messageID: String) {
|
||||
// Delete is also a send cancellation. In particular, an approved
|
||||
// legacy-clear send may still be waiting on BLEService.messageQueue;
|
||||
// removing only the UI mapping would let that deferred work transmit.
|
||||
if let transferId = messageIDToTransferId[messageID],
|
||||
transferIdToMessageIDs[transferId]?.first == messageID {
|
||||
context.cancelTransfer(transferId)
|
||||
}
|
||||
clearTransferMapping(for: messageID)
|
||||
context.removeMessage(withID: messageID, cleanupFile: true)
|
||||
}
|
||||
|
||||
@@ -227,7 +227,7 @@ extension ChatViewModel: ChatPrivateConversationContext {
|
||||
final class ChatPrivateConversationCoordinator {
|
||||
private unowned let context: any ChatPrivateConversationContext
|
||||
|
||||
// Outbox retries re-wrap the same message in fresh gift-wrap events, so
|
||||
// Outbox retries re-envelope the same message in fresh private events, so
|
||||
// relay-level event-ID dedup can't catch them; track inbound GeoDM
|
||||
// message IDs so each copy past the first costs one (already-deduped)
|
||||
// ack check and nothing else.
|
||||
|
||||
@@ -407,13 +407,6 @@ private extension ChatTransportEventCoordinator {
|
||||
|
||||
case .voiceFrame:
|
||||
context.handleVoiceFramePayload(from: peerID, payload: payload, timestamp: timestamp)
|
||||
|
||||
case .privateFile, .authenticatedPeerState:
|
||||
// BLEService validates and persists decrypted private files before
|
||||
// emitting a normal `.messageReceived` event, and consumes peer
|
||||
// state inside the transport. Neither payload crosses this
|
||||
// UI-facing typed-payload fallback.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -347,8 +347,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
@Published var showBluetoothAlert = false
|
||||
@Published var bluetoothAlertMessage = ""
|
||||
@Published var bluetoothState: CBManagerState = .unknown
|
||||
@Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest?
|
||||
private var pendingLegacyPrivateMediaConsents: [PendingLegacyPrivateMediaConsent] = []
|
||||
|
||||
private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) {
|
||||
if Thread.isMainThread {
|
||||
@@ -1158,10 +1156,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
func panicClearAllData() {
|
||||
// Messages are processed immediately - nothing to flush
|
||||
|
||||
// Deny and release any clear-media confirmations before identities,
|
||||
// message state, and local files are wiped.
|
||||
cancelAllLegacyPrivateMediaConsents()
|
||||
|
||||
// Clear all messages (public timelines and private chats live in the
|
||||
// single-writer ConversationStore; the derived `messages` view and
|
||||
// the legacy mirror empty with it)
|
||||
@@ -1247,6 +1241,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
// Drop relay subscriptions, handlers, pending sends, and replay state.
|
||||
// Geohash DM handlers can capture pre-wipe Nostr identities, so a plain
|
||||
// disconnect is not enough here.
|
||||
NostrTransport.resetControlRetriesForPanicWipe()
|
||||
NostrRelayManager.shared.resetForPanicWipe()
|
||||
nostrRelayManager = nil
|
||||
|
||||
@@ -1810,91 +1805,4 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
publicConversationCoordinator.sendHapticFeedback(for: message)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
extension ChatViewModel {
|
||||
func enqueueLegacyPrivateMediaConsent(
|
||||
for peerID: PeerID,
|
||||
transferId: String,
|
||||
messageID: String,
|
||||
completion: @escaping @MainActor (Bool) -> Void
|
||||
) {
|
||||
let request = LegacyPrivateMediaConsentRequest(
|
||||
id: UUID(),
|
||||
peerID: peerID,
|
||||
peerName: nicknameForPeer(peerID),
|
||||
transferId: transferId,
|
||||
messageID: messageID
|
||||
)
|
||||
pendingLegacyPrivateMediaConsents.append(PendingLegacyPrivateMediaConsent(
|
||||
request: request,
|
||||
completion: completion
|
||||
))
|
||||
if legacyPrivateMediaConsentRequest == nil {
|
||||
legacyPrivateMediaConsentRequest = request
|
||||
}
|
||||
}
|
||||
|
||||
func resolveLegacyPrivateMediaConsent(requestID: UUID, approved: Bool) {
|
||||
// SwiftUI may report both the selected button and the presentation
|
||||
// binding's dismissal. Resolve only the exact request that was shown;
|
||||
// a duplicate callback for it must not consume the next queued send.
|
||||
guard legacyPrivateMediaConsentRequest?.id == requestID,
|
||||
pendingLegacyPrivateMediaConsents.first?.request.id == requestID else {
|
||||
return
|
||||
}
|
||||
let resolved = pendingLegacyPrivateMediaConsents.removeFirst()
|
||||
// Drive the boolean presentation state through false before showing
|
||||
// the next queued per-send warning. Otherwise SwiftUI sees true→true,
|
||||
// closes the first dialog, and never presents the second.
|
||||
legacyPrivateMediaConsentRequest = nil
|
||||
resolved.completion(approved)
|
||||
presentNextLegacyPrivateMediaConsentDeferred()
|
||||
}
|
||||
|
||||
func invalidateLegacyPrivateMediaConsent(transferId: String, messageID: String) {
|
||||
let invalidatedIDs = Set(
|
||||
pendingLegacyPrivateMediaConsents.compactMap { pending -> UUID? in
|
||||
let request = pending.request
|
||||
return request.transferId == transferId && request.messageID == messageID
|
||||
? request.id
|
||||
: nil
|
||||
}
|
||||
)
|
||||
guard !invalidatedIDs.isEmpty else { return }
|
||||
|
||||
pendingLegacyPrivateMediaConsents.removeAll {
|
||||
invalidatedIDs.contains($0.request.id)
|
||||
}
|
||||
if let currentID = legacyPrivateMediaConsentRequest?.id,
|
||||
invalidatedIDs.contains(currentID) {
|
||||
legacyPrivateMediaConsentRequest = nil
|
||||
presentNextLegacyPrivateMediaConsentDeferred()
|
||||
}
|
||||
}
|
||||
|
||||
func cancelAllLegacyPrivateMediaConsents() {
|
||||
let pending = pendingLegacyPrivateMediaConsents
|
||||
pendingLegacyPrivateMediaConsents.removeAll()
|
||||
legacyPrivateMediaConsentRequest = nil
|
||||
for item in pending {
|
||||
item.completion(false)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentNextLegacyPrivateMediaConsentDeferred() {
|
||||
guard legacyPrivateMediaConsentRequest == nil,
|
||||
let nextRequestID = pendingLegacyPrivateMediaConsents.first?.request.id else {
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self,
|
||||
self.legacyPrivateMediaConsentRequest == nil,
|
||||
self.pendingLegacyPrivateMediaConsents.first?.request.id == nextRequestID else {
|
||||
return
|
||||
}
|
||||
self.legacyPrivateMediaConsentRequest = self.pendingLegacyPrivateMediaConsents[0].request
|
||||
}
|
||||
}
|
||||
}
|
||||
// End of ChatViewModel class
|
||||
|
||||
@@ -41,10 +41,11 @@ struct ChatViewModelServiceBundle {
|
||||
self.privateChatManager = privateChatManager
|
||||
self.unifiedPeerService = unifiedPeerService
|
||||
self.autocompleteService = AutocompleteService()
|
||||
// Persist processed gift-wrap event IDs: NIP-59 randomizes their
|
||||
// timestamps, so the 24h-lookback DM subscriptions redeliver the same
|
||||
// events on every launch and only a cross-launch record stops the
|
||||
// reprocessing (re-sent DELIVERED bursts, phantom-ack noise).
|
||||
// Persist processed private-envelope event IDs: legacy Android can
|
||||
// randomize timestamps across the full 72h15m mailbox lookback, so DM
|
||||
// subscriptions redeliver the same events on every launch and only a
|
||||
// cross-launch record stops the reprocessing (re-sent DELIVERED
|
||||
// bursts, phantom-ack noise).
|
||||
self.deduplicationService = MessageDeduplicationService(nostrEventStore: NostrProcessedEventStore())
|
||||
self.publicMessagePipeline = PublicMessagePipeline()
|
||||
}
|
||||
@@ -165,7 +166,7 @@ private extension ChatViewModelBootstrapper {
|
||||
|
||||
func configureTransport() {
|
||||
viewModel.meshService.delegate = viewModel
|
||||
viewModel.meshService.eventDelegate = viewModel
|
||||
viewModel.messageRouter.setEventDelegate(viewModel)
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiStartupInitialDelaySeconds) { [weak viewModel] in
|
||||
guard let viewModel else { return }
|
||||
@@ -547,7 +548,7 @@ private extension ChatViewModelBootstrapper {
|
||||
// Default (DM) relays: drops need the standing global relay set,
|
||||
// not geo relays — sender and recipient share no cell.
|
||||
// This confirmed path never falls back to the volatile relay
|
||||
// queue; bridge dedup is committed only after NIP-20 OK.
|
||||
// queue; bridge dedup is committed only after NIP-01 `OK`.
|
||||
NostrRelayManager.shared.sendEventImmediately(event, completion: completion)
|
||||
}
|
||||
courier.openSubscription = { tagsHex in
|
||||
|
||||
@@ -21,8 +21,8 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
nostrCoordinator.inbound.subscribeGiftWrap(giftWrap, id: id)
|
||||
func subscribePrivateEnvelope(_ envelope: NostrEvent, id: NostrIdentity) {
|
||||
nostrCoordinator.inbound.subscribePrivateEnvelope(envelope, id: id)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -36,8 +36,8 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
nostrCoordinator.inbound.handleGiftWrap(giftWrap, id: id)
|
||||
func handlePrivateEnvelope(_ envelope: NostrEvent, id: NostrIdentity) {
|
||||
nostrCoordinator.inbound.handlePrivateEnvelope(envelope, id: id)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -108,7 +108,7 @@ extension ChatViewModel: GeohashSubscriptionContext {
|
||||
}
|
||||
|
||||
/// Owns subscription IDs and relay lifecycle for geohash channels, geohash
|
||||
/// DMs, the account gift-wrap mailbox, and background geohash sampling. The
|
||||
/// DMs, the account private-envelope mailbox, and background geohash sampling. The
|
||||
/// only component that talks to `NostrRelayManager`; inbound events are
|
||||
/// forwarded to `NostrInboundPipeline` / `GeoPresenceTracker`.
|
||||
final class GeohashSubscriptionManager {
|
||||
@@ -162,13 +162,13 @@ final class GeohashSubscriptionManager {
|
||||
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
|
||||
let dmSub = "geo-dm-\(channel.geohash)"
|
||||
context.setGeoDmSubscriptionID(dmSub)
|
||||
let dmFilter = NostrFilter.giftWrapsFor(
|
||||
let dmFilters = NostrFilter.privateEnvelopeFiltersFor(
|
||||
pubkey: identity.publicKeyHex,
|
||||
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
||||
)
|
||||
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
|
||||
NostrRelayManager.shared.subscribe(filters: dmFilters, id: dmSub) { [weak self] envelope in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.inbound.subscribeGiftWrap(giftWrap, id: identity)
|
||||
self?.inbound.subscribePrivateEnvelope(envelope, id: identity)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -260,13 +260,13 @@ final class GeohashSubscriptionManager {
|
||||
if TorManager.shared.isReady {
|
||||
SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
|
||||
}
|
||||
let dmFilter = NostrFilter.giftWrapsFor(
|
||||
let dmFilters = NostrFilter.privateEnvelopeFiltersFor(
|
||||
pubkey: identity.publicKeyHex,
|
||||
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
||||
)
|
||||
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
|
||||
NostrRelayManager.shared.subscribe(filters: dmFilters, id: dmSub) { [weak self] envelope in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.inbound.handleGiftWrap(giftWrap, id: identity)
|
||||
self?.inbound.handlePrivateEnvelope(envelope, id: identity)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -388,14 +388,14 @@ final class GeohashSubscriptionManager {
|
||||
category: .session
|
||||
)
|
||||
|
||||
let filter = NostrFilter.giftWrapsFor(
|
||||
let filters = NostrFilter.privateEnvelopeFiltersFor(
|
||||
pubkey: currentIdentity.publicKeyHex,
|
||||
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
||||
)
|
||||
|
||||
context.nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in
|
||||
context.nostrRelayManager?.subscribe(filters: filters, id: "chat-messages") { [weak self] event in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.inbound.handleNostrMessage(event)
|
||||
self?.inbound.handleAccountPrivateEnvelope(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,13 +84,21 @@ extension ChatViewModel: NostrInboundPipelineContext {
|
||||
/// Ordering is deliberate and performance-critical: cheap rejects (kind,
|
||||
/// dedup lookup) run BEFORE Schnorr signature verification because duplicates
|
||||
/// dominate real relay traffic; events are recorded only AFTER verification so
|
||||
/// a forged-signature copy can never poison the dedup set; gift-wrap
|
||||
/// a forged-signature copy can never poison the dedup set; private-envelope
|
||||
/// verification for the account mailbox runs off-main with an atomic
|
||||
/// main-actor check-and-record.
|
||||
final class NostrInboundPipeline {
|
||||
private weak var context: (any NostrInboundPipelineContext)?
|
||||
private let presence: GeoPresenceTracker
|
||||
private var geoEventLogCount = 0
|
||||
// During the coordinated wire-format migration, one logical private
|
||||
// payload is published under both primary and compatibility formats. Outer
|
||||
// event IDs differ, so collapse the authenticated embedded payload before
|
||||
// invoking message/ack side effects. Keep this bounded like the outer-ID
|
||||
// caches; the recipient and authenticated sender are part of the key.
|
||||
private var recentPrivatePayloadFormats: [String: UInt8] = [:]
|
||||
private var recentPrivatePayloadKeyOrder: [String] = []
|
||||
private static let privatePayloadDedupCapacity = 2_048
|
||||
|
||||
init(context: any NostrInboundPipelineContext, presence: GeoPresenceTracker) {
|
||||
self.context = context
|
||||
@@ -268,15 +276,16 @@ final class NostrInboundPipeline {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
func subscribePrivateEnvelope(_ envelope: NostrEvent, id: NostrIdentity) {
|
||||
guard let context else { return }
|
||||
// Dedup lookup before Schnorr verification; record only after it passes.
|
||||
guard !context.hasProcessedNostrEvent(giftWrap.id) else { return }
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
context.recordProcessedNostrEvent(giftWrap.id)
|
||||
guard !context.hasProcessedNostrEvent(envelope.id) else { return }
|
||||
guard envelope.content.utf8.count <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes else { return }
|
||||
guard envelope.isValidSignature() else { return }
|
||||
context.recordProcessedNostrEvent(envelope.id)
|
||||
|
||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
guard let (content, senderPubkey, messageTs) = try? NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: id
|
||||
),
|
||||
let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
||||
@@ -285,8 +294,14 @@ final class NostrInboundPipeline {
|
||||
else {
|
||||
return
|
||||
}
|
||||
guard shouldProcessPrivatePayload(
|
||||
noisePayload,
|
||||
senderPubkey: senderPubkey,
|
||||
recipientPubkey: id.publicKeyHex,
|
||||
envelopeKind: envelope.kind
|
||||
) else { return }
|
||||
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(messageTs))
|
||||
let convKey = PeerID(nostr_: senderPubkey)
|
||||
context.registerNostrKeyMapping(senderPubkey, for: convKey)
|
||||
|
||||
@@ -307,31 +322,32 @@ final class NostrInboundPipeline {
|
||||
// claiming to be group traffic over Nostr is ignored.
|
||||
// Live voice is mesh-only: latency and relay cost make it
|
||||
// meaningless over Nostr.
|
||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState:
|
||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
func handlePrivateEnvelope(_ envelope: NostrEvent, id: NostrIdentity) {
|
||||
guard let context else { return }
|
||||
// Dedup lookup before Schnorr verification; record only after it passes.
|
||||
if context.hasProcessedNostrEvent(giftWrap.id) {
|
||||
if context.hasProcessedNostrEvent(envelope.id) {
|
||||
return
|
||||
}
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
context.recordProcessedNostrEvent(giftWrap.id)
|
||||
guard envelope.content.utf8.count <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes else { return }
|
||||
guard envelope.isValidSignature() else { return }
|
||||
context.recordProcessedNostrEvent(envelope.id)
|
||||
|
||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
guard let (content, senderPubkey, messageTs) = try? NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: id
|
||||
) else {
|
||||
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…", category: .session)
|
||||
SecureLogger.warning("GeoDM: failed decrypt private envelope id=\(envelope.id.prefix(8))…", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
SecureLogger.debug(
|
||||
"GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
|
||||
"GeoDM: decrypted private envelope id=\(envelope.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
|
||||
category: .session
|
||||
)
|
||||
|
||||
@@ -341,13 +357,19 @@ final class NostrInboundPipeline {
|
||||
else {
|
||||
return
|
||||
}
|
||||
guard shouldProcessPrivatePayload(
|
||||
payload,
|
||||
senderPubkey: senderPubkey,
|
||||
recipientPubkey: id.publicKeyHex,
|
||||
envelopeKind: envelope.kind
|
||||
) else { return }
|
||||
|
||||
let convKey = PeerID(nostr_: senderPubkey)
|
||||
context.registerNostrKeyMapping(senderPubkey, for: convKey)
|
||||
|
||||
switch payload.type {
|
||||
case .privateMessage:
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(messageTs))
|
||||
context.handlePrivateMessage(
|
||||
payload,
|
||||
senderPubkey: senderPubkey,
|
||||
@@ -363,34 +385,35 @@ final class NostrInboundPipeline {
|
||||
// claiming to be group traffic over Nostr is ignored.
|
||||
// Live voice is mesh-only: latency and relay cost make it
|
||||
// meaningless over Nostr.
|
||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState:
|
||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handleNostrMessage(_ giftWrap: NostrEvent) {
|
||||
func handleAccountPrivateEnvelope(_ envelope: NostrEvent) {
|
||||
guard let context else { return }
|
||||
// Cheap dedup pre-check only; Schnorr verification runs off-main in
|
||||
// processNostrMessage, which then does the authoritative
|
||||
// processAccountPrivateEnvelope, which then does the authoritative
|
||||
// check-and-record. Recording stays after verification so a
|
||||
// forged-signature copy can never poison the dedup set and suppress
|
||||
// the genuine event.
|
||||
if context.hasProcessedNostrEvent(giftWrap.id) { return }
|
||||
if context.hasProcessedNostrEvent(envelope.id) { return }
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
await self?.processNostrMessage(giftWrap)
|
||||
await self?.processAccountPrivateEnvelope(envelope)
|
||||
}
|
||||
}
|
||||
|
||||
func processNostrMessage(_ giftWrap: NostrEvent) async {
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
func processAccountPrivateEnvelope(_ envelope: NostrEvent) async {
|
||||
guard envelope.content.utf8.count <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes else { return }
|
||||
guard envelope.isValidSignature() else { return }
|
||||
guard let context else { return }
|
||||
// Authoritative check-and-record, atomic on the main actor so two
|
||||
// concurrent detached tasks can't both process the same event.
|
||||
let alreadyProcessed: Bool = await MainActor.run {
|
||||
if context.hasProcessedNostrEvent(giftWrap.id) { return true }
|
||||
context.recordProcessedNostrEvent(giftWrap.id)
|
||||
if context.hasProcessedNostrEvent(envelope.id) { return true }
|
||||
context.recordProcessedNostrEvent(envelope.id)
|
||||
return false
|
||||
}
|
||||
if alreadyProcessed { return }
|
||||
@@ -400,8 +423,8 @@ final class NostrInboundPipeline {
|
||||
guard let currentIdentity else { return }
|
||||
|
||||
do {
|
||||
let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
let (content, senderPubkey, messageTimestampSeconds) = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: currentIdentity
|
||||
)
|
||||
|
||||
@@ -425,8 +448,14 @@ final class NostrInboundPipeline {
|
||||
|
||||
if packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
let payload = NoisePayload.decode(packet.payload) {
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(messageTimestampSeconds))
|
||||
await MainActor.run {
|
||||
guard self.shouldProcessPrivatePayload(
|
||||
payload,
|
||||
senderPubkey: senderPubkey,
|
||||
recipientPubkey: currentIdentity.publicKeyHex,
|
||||
envelopeKind: envelope.kind
|
||||
) else { return }
|
||||
context.registerNostrKeyMapping(senderPubkey, for: targetPeerID)
|
||||
|
||||
switch payload.type {
|
||||
@@ -446,7 +475,7 @@ final class NostrInboundPipeline {
|
||||
// in v1; group traffic over Nostr is ignored.
|
||||
// Live voice is mesh-only: latency and relay cost make it
|
||||
// meaningless over Nostr.
|
||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState:
|
||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame:
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -500,6 +529,47 @@ final class NostrInboundPipeline {
|
||||
}
|
||||
|
||||
private extension NostrInboundPipeline {
|
||||
@MainActor
|
||||
func shouldProcessPrivatePayload(
|
||||
_ payload: NoisePayload,
|
||||
senderPubkey: String,
|
||||
recipientPubkey: String,
|
||||
envelopeKind: Int
|
||||
) -> Bool {
|
||||
let digest = payload.encode().sha256Fingerprint()
|
||||
let key = "\(recipientPubkey.lowercased()):\(senderPubkey.lowercased()):\(digest)"
|
||||
let formatBit: UInt8
|
||||
switch envelopeKind {
|
||||
case NostrProtocol.EventKind.privateEnvelope.rawValue:
|
||||
formatBit = 1 << 0
|
||||
case NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue:
|
||||
formatBit = 1 << 1
|
||||
default:
|
||||
return true
|
||||
}
|
||||
|
||||
if let observedFormats = recentPrivatePayloadFormats[key] {
|
||||
if observedFormats & formatBit != 0 {
|
||||
// A same-format re-envelope is a delivery retry. Let it reach
|
||||
// the coordinator so a lost DELIVERED acknowledgement can be
|
||||
// sent again; downstream message-ID dedup prevents rerendering.
|
||||
return true
|
||||
}
|
||||
// The same authenticated payload under the other migration format
|
||||
// is the compatibility twin, not a new message or acknowledgement.
|
||||
recentPrivatePayloadFormats[key] = observedFormats | formatBit
|
||||
return false
|
||||
}
|
||||
|
||||
recentPrivatePayloadFormats[key] = formatBit
|
||||
recentPrivatePayloadKeyOrder.append(key)
|
||||
if recentPrivatePayloadKeyOrder.count > Self.privatePayloadDedupCapacity {
|
||||
let evicted = recentPrivatePayloadKeyOrder.removeFirst()
|
||||
recentPrivatePayloadFormats.removeValue(forKey: evicted)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {
|
||||
guard content.hasPrefix("bitchat1:") else { return nil }
|
||||
|
||||
@@ -36,7 +36,6 @@ struct ContentPeopleSheetView: View {
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
let legacyConsentRequest = conversationUIModel.legacyPrivateMediaConsentRequest
|
||||
NavigationStack {
|
||||
Group {
|
||||
if privateConversationModel.selectedPeerID != nil {
|
||||
@@ -98,63 +97,6 @@ struct ContentPeopleSheetView: View {
|
||||
}
|
||||
.themedSheetBackground()
|
||||
.foregroundColor(palette.primary)
|
||||
.confirmationDialog(
|
||||
String(
|
||||
localized: "content.private_media.legacy_warning.title",
|
||||
defaultValue: "Send without end-to-end encryption?",
|
||||
comment: "Title warning before sending private media to an older client in a clear signed envelope"
|
||||
),
|
||||
isPresented: Binding(
|
||||
get: { legacyConsentRequest != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented, let requestID = legacyConsentRequest?.id {
|
||||
conversationUIModel.resolveLegacyPrivateMediaConsent(
|
||||
requestID: requestID,
|
||||
approved: false
|
||||
)
|
||||
}
|
||||
}
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(
|
||||
String(
|
||||
localized: "content.private_media.legacy_warning.send",
|
||||
defaultValue: "send visible file",
|
||||
comment: "Destructive confirmation action for one legacy clear private-media send"
|
||||
),
|
||||
role: .destructive
|
||||
) {
|
||||
if let requestID = legacyConsentRequest?.id {
|
||||
conversationUIModel.resolveLegacyPrivateMediaConsent(
|
||||
requestID: requestID,
|
||||
approved: true
|
||||
)
|
||||
}
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {
|
||||
if let requestID = legacyConsentRequest?.id {
|
||||
conversationUIModel.resolveLegacyPrivateMediaConsent(
|
||||
requestID: requestID,
|
||||
approved: false
|
||||
)
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
if let request = legacyConsentRequest {
|
||||
Text(
|
||||
String(
|
||||
format: String(
|
||||
localized: "content.private_media.legacy_warning.message",
|
||||
defaultValue: "%@'s client does not advertise encrypted private media. This file will be signed but not end-to-end encrypted, so mesh relays can see it. Send this file anyway?",
|
||||
comment: "Warning explaining the confidentiality loss for one legacy private-media send; parameter is the peer name"
|
||||
),
|
||||
locale: .current,
|
||||
request.peerName
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 420, minHeight: 520)
|
||||
#endif
|
||||
@@ -504,7 +446,8 @@ private struct ContentPrivateChatSheetView: View {
|
||||
if privateConversationModel.selectedPeerID?.isGroup == true {
|
||||
return String(localized: "content.private.caption_group", comment: "Caption above the group chat composer noting messages are encrypted to group members")
|
||||
}
|
||||
// Geohash DMs are NIP-17 gift-wrapped — always end-to-end encrypted,
|
||||
// Geohash DMs use BitChat's private-envelope transport over Nostr —
|
||||
// always end-to-end encrypted,
|
||||
// even though they carry no Noise session status. Mesh DMs earn the
|
||||
// "encrypted" claim only once the Noise handshake has secured.
|
||||
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
||||
|
||||
@@ -99,95 +99,6 @@ struct BLEServiceCoreTests {
|
||||
#expect(ble.currentPeerSnapshots().isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func unsignedAndBadSignatureLeaveDoNotEvictOrRelayClaimedPeer() async throws {
|
||||
let ble = makeService()
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||
let outbound = OutboundPacketTap()
|
||||
ble._test_onOutboundPacket = outbound.record
|
||||
|
||||
let unsigned = makeLeavePacket(sender: alicePeerID, marker: "unsigned")
|
||||
ble._test_handlePacket(
|
||||
unsigned,
|
||||
fromPeerID: alicePeerID,
|
||||
signingPublicKey: alice.getSigningPublicKeyData()
|
||||
)
|
||||
|
||||
let unsignedRelayed = await TestHelpers.waitUntil(
|
||||
{ outbound.count(ofType: .leave) > 0 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!unsignedRelayed)
|
||||
#expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })
|
||||
|
||||
let badSignature = try #require(
|
||||
mallory.signPacket(makeLeavePacket(sender: alicePeerID, marker: "bad-signature"))
|
||||
)
|
||||
ble._test_handlePacket(
|
||||
badSignature,
|
||||
fromPeerID: alicePeerID,
|
||||
signingPublicKey: alice.getSigningPublicKeyData()
|
||||
)
|
||||
|
||||
let badSignatureRelayed = await TestHelpers.waitUntil(
|
||||
{ outbound.count(ofType: .leave) > 0 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!badSignatureRelayed)
|
||||
#expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })
|
||||
}
|
||||
|
||||
@Test
|
||||
func validSignedLeaveEvictsSessionAndRelays() async throws {
|
||||
let ble = makeService()
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||
|
||||
// Establish a real session so the leave regression also verifies that
|
||||
// stale secure-delivery state is retired, not just the peer-list row.
|
||||
let message1 = try ble._test_noiseInitiateHandshake(with: alicePeerID)
|
||||
let message2 = try #require(
|
||||
try alice.processHandshakeMessage(from: ble.myPeerID, message: message1)
|
||||
)
|
||||
let message3 = try #require(
|
||||
try ble._test_noiseProcessHandshakeMessage(from: alicePeerID, message: message2)
|
||||
)
|
||||
_ = try alice.processHandshakeMessage(from: ble.myPeerID, message: message3)
|
||||
#expect(ble.canDeliverSecurely(to: alicePeerID))
|
||||
let centralUUID = "central-valid-leave"
|
||||
ble._test_bindCentral(centralUUID, to: alicePeerID)
|
||||
ble._test_markNoiseAuthenticatedCentral(centralUUID, to: alicePeerID)
|
||||
#expect(ble._test_isNoiseAuthenticatedCentral(centralUUID, for: alicePeerID))
|
||||
|
||||
let outbound = OutboundPacketTap()
|
||||
ble._test_onOutboundPacket = outbound.record
|
||||
let signedLeave = try #require(
|
||||
alice.signPacket(makeLeavePacket(sender: alicePeerID, marker: "valid"))
|
||||
)
|
||||
ble._test_handlePacket(
|
||||
signedLeave,
|
||||
fromPeerID: alicePeerID,
|
||||
signingPublicKey: alice.getSigningPublicKeyData()
|
||||
)
|
||||
|
||||
let evicted = await TestHelpers.waitUntil(
|
||||
{
|
||||
!ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID }
|
||||
&& !ble.canDeliverSecurely(to: alicePeerID)
|
||||
&& !ble._test_isNoiseAuthenticatedCentral(centralUUID, for: alicePeerID)
|
||||
},
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(evicted)
|
||||
let relayed = await TestHelpers.waitUntil(
|
||||
{ outbound.count(ofType: .leave) == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(relayed)
|
||||
}
|
||||
|
||||
@Test
|
||||
func ingressAllowsRelayedSenderOnBoundLink() async throws {
|
||||
let ble = makeService()
|
||||
@@ -779,18 +690,6 @@ private func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64
|
||||
)
|
||||
}
|
||||
|
||||
private func makeLeavePacket(sender: PeerID, marker: String) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.leave.rawValue,
|
||||
senderID: Data(hexString: sender.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data(marker.utf8),
|
||||
signature: nil,
|
||||
ttl: TransportConfig.messageTTLDefault
|
||||
)
|
||||
}
|
||||
|
||||
private final class PublicCaptureDelegate: BitchatDelegate {
|
||||
private let lock = NSLock()
|
||||
private(set) var publicMessages: [BitchatMessage] = []
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
|
||||
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
|
||||
//
|
||||
// Real file/codec work remains covered by `ChatMediaPreparationTests`. These
|
||||
// tests inject a paused voice-note preparer to exercise cancellation ownership
|
||||
// across the detached-preparation/MainActor boundary deterministically.
|
||||
// Scope note: the async media-preparation pipelines (`ImageUtils`,
|
||||
// `ChatMediaPreparation`) run real file/codec work and remain covered by
|
||||
// `ChatMediaPreparationTests`; here we cover message enqueueing, transfer
|
||||
// bookkeeping, and the blocked-context guards.
|
||||
//
|
||||
|
||||
import Testing
|
||||
@@ -85,72 +86,11 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
||||
|
||||
// Mesh file transfer
|
||||
private(set) var privateFileSends: [(peerID: PeerID, transferId: String)] = []
|
||||
private(set) var privateFileLegacyAllowances: [Bool] = []
|
||||
private(set) var broadcastFileSends: [String] = []
|
||||
private(set) var cancelledTransfers: [String] = []
|
||||
var privateMediaPolicy: PrivateMediaSendPolicy = .encrypted
|
||||
var resolvedPrivateMediaPolicy: PrivateMediaSendPolicy?
|
||||
private(set) var legacyConsentRequests: [(
|
||||
id: UUID,
|
||||
peerID: PeerID,
|
||||
transferId: String,
|
||||
messageID: String
|
||||
)] = []
|
||||
private(set) var invalidatedLegacyConsents: [(transferId: String, messageID: String)] = []
|
||||
private var pendingLegacyConsentIDs: [UUID] = []
|
||||
private var legacyConsentCompletions: [UUID: @MainActor (Bool) -> Void] = [:]
|
||||
|
||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
|
||||
privateMediaPolicy
|
||||
}
|
||||
|
||||
func resolvePrivateMediaSendPolicy(
|
||||
to peerID: PeerID,
|
||||
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||
) {
|
||||
completion(resolvedPrivateMediaPolicy ?? privateMediaPolicy)
|
||||
}
|
||||
|
||||
func requestLegacyPrivateMediaConsent(
|
||||
for peerID: PeerID,
|
||||
transferId: String,
|
||||
messageID: String,
|
||||
completion: @escaping @MainActor (Bool) -> Void
|
||||
) {
|
||||
let id = UUID()
|
||||
legacyConsentRequests.append((id, peerID, transferId, messageID))
|
||||
pendingLegacyConsentIDs.append(id)
|
||||
legacyConsentCompletions[id] = completion
|
||||
}
|
||||
|
||||
func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String) {
|
||||
invalidatedLegacyConsents.append((transferId, messageID))
|
||||
let matchingIDs = Set(legacyConsentRequests.compactMap { request in
|
||||
request.transferId == transferId && request.messageID == messageID
|
||||
? request.id
|
||||
: nil
|
||||
})
|
||||
pendingLegacyConsentIDs.removeAll { matchingIDs.contains($0) }
|
||||
}
|
||||
|
||||
func resolveNextLegacyConsent(_ approved: Bool) {
|
||||
guard !pendingLegacyConsentIDs.isEmpty else { return }
|
||||
let id = pendingLegacyConsentIDs.removeFirst()
|
||||
legacyConsentCompletions[id]?(approved)
|
||||
}
|
||||
|
||||
func invokeLegacyConsentEvenIfInvalidated(id: UUID, approved: Bool) {
|
||||
legacyConsentCompletions[id]?(approved)
|
||||
}
|
||||
|
||||
func sendFilePrivate(
|
||||
_ packet: BitchatFilePacket,
|
||||
to peerID: PeerID,
|
||||
transferId: String,
|
||||
allowLegacyFallback: Bool
|
||||
) {
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
||||
privateFileSends.append((peerID, transferId))
|
||||
privateFileLegacyAllowances.append(allowLegacyFallback)
|
||||
}
|
||||
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
|
||||
@@ -162,56 +102,6 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
||||
}
|
||||
}
|
||||
|
||||
private final class PausedVoiceNotePreparer: @unchecked Sendable {
|
||||
private let condition = NSCondition()
|
||||
private var started = false
|
||||
private var released = false
|
||||
private var finished = false
|
||||
private let packet: BitchatFilePacket
|
||||
|
||||
init() {
|
||||
let content = Data("voice".utf8)
|
||||
packet = BitchatFilePacket(
|
||||
fileName: "paused.m4a",
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "audio/mp4",
|
||||
content: content
|
||||
)
|
||||
}
|
||||
|
||||
func prepare(_: URL) throws -> BitchatFilePacket {
|
||||
condition.lock()
|
||||
started = true
|
||||
condition.broadcast()
|
||||
while !released {
|
||||
condition.wait()
|
||||
}
|
||||
finished = true
|
||||
condition.broadcast()
|
||||
condition.unlock()
|
||||
return packet
|
||||
}
|
||||
|
||||
var hasStarted: Bool {
|
||||
condition.lock()
|
||||
defer { condition.unlock() }
|
||||
return started
|
||||
}
|
||||
|
||||
var hasFinished: Bool {
|
||||
condition.lock()
|
||||
defer { condition.unlock() }
|
||||
return finished
|
||||
}
|
||||
|
||||
func release() {
|
||||
condition.lock()
|
||||
released = true
|
||||
condition.broadcast()
|
||||
condition.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Coordinator Tests Against Mock Context
|
||||
|
||||
/// Exercises `ChatMediaTransferCoordinator` against
|
||||
@@ -276,14 +166,6 @@ struct ChatMediaTransferCoordinatorContextTests {
|
||||
#expect(context.removedMessages.count == 1)
|
||||
#expect(context.removedMessages.first?.messageID == "m2")
|
||||
#expect(context.removedMessages.first?.cleanupFile == true)
|
||||
|
||||
// A pre-start rejection keeps the placeholder visible and failed,
|
||||
// including queued post-handshake encryption failures.
|
||||
coordinator.registerTransfer(transferId: "t3", messageID: "m3")
|
||||
coordinator.handleTransferEvent(.rejected(id: "t3", reason: "encryption failed"))
|
||||
#expect(context.deliveryStatusUpdates.last?.messageID == "m3")
|
||||
#expect(context.deliveryStatusUpdates.last?.status == .failed(reason: "encryption failed"))
|
||||
#expect(coordinator.messageIDToTransferId["m3"] == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
@@ -306,20 +188,6 @@ struct ChatMediaTransferCoordinatorContextTests {
|
||||
#expect(coordinator.messageIDToTransferId.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func deleteMediaMessage_cancelsApprovedTransferBeforeRemovingMapping() {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||
coordinator.registerTransfer(transferId: "approved-delete", messageID: "message-delete")
|
||||
|
||||
coordinator.deleteMediaMessage(messageID: "message-delete")
|
||||
|
||||
#expect(context.cancelledTransfers == ["approved-delete"])
|
||||
#expect(coordinator.messageIDToTransferId["message-delete"] == nil)
|
||||
#expect(context.removedMessages.map(\.messageID) == ["message-delete"])
|
||||
#expect(context.removedMessages.first?.cleanupFile == true)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
@@ -338,249 +206,4 @@ struct ChatMediaTransferCoordinatorContextTests {
|
||||
#expect(context.appendedPublicMessages.isEmpty)
|
||||
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func cancelVoiceNoteDuringDetachedPreparationCannotSendOrRestoreMapping() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "5566778899aabbcc")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
let preparer = PausedVoiceNotePreparer()
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in try preparer.prepare(url) }
|
||||
)
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("paused-private-\(UUID().uuidString).m4a")
|
||||
try Data("voice".utf8).write(to: url)
|
||||
defer {
|
||||
preparer.release()
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
#expect(await TestHelpers.waitUntil({ preparer.hasStarted }, timeout: TestConstants.longTimeout))
|
||||
let messageID = try #require(context.privateChats[peerID]?.first?.id)
|
||||
let transferId = try #require(coordinator.messageIDToTransferId[messageID])
|
||||
|
||||
coordinator.cancelMediaSend(messageID: messageID)
|
||||
preparer.release()
|
||||
#expect(await TestHelpers.waitUntil({ preparer.hasFinished }, timeout: TestConstants.longTimeout))
|
||||
for _ in 0..<10 { await Task.yield() }
|
||||
|
||||
#expect(context.cancelledTransfers == [transferId])
|
||||
#expect(context.privateFileSends.isEmpty)
|
||||
#expect(context.broadcastFileSends.isEmpty)
|
||||
#expect(coordinator.messageIDToTransferId[messageID] == nil)
|
||||
#expect(coordinator.transferIdToMessageIDs[transferId] == nil)
|
||||
#expect(context.removedMessages.map(\.messageID) == [messageID])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func deletePublicVoiceNoteDuringDetachedPreparationCannotBroadcastOrRestoreMapping() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let preparer = PausedVoiceNotePreparer()
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in try preparer.prepare(url) }
|
||||
)
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("paused-public-\(UUID().uuidString).m4a")
|
||||
try Data("voice".utf8).write(to: url)
|
||||
defer {
|
||||
preparer.release()
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
#expect(await TestHelpers.waitUntil({ preparer.hasStarted }, timeout: TestConstants.longTimeout))
|
||||
let messageID = try #require(context.appendedPublicMessages.first?.message.id)
|
||||
let transferId = try #require(coordinator.messageIDToTransferId[messageID])
|
||||
|
||||
coordinator.deleteMediaMessage(messageID: messageID)
|
||||
preparer.release()
|
||||
#expect(await TestHelpers.waitUntil({ preparer.hasFinished }, timeout: TestConstants.longTimeout))
|
||||
for _ in 0..<10 { await Task.yield() }
|
||||
|
||||
#expect(context.cancelledTransfers == [transferId])
|
||||
#expect(context.broadcastFileSends.isEmpty)
|
||||
#expect(context.privateFileSends.isEmpty)
|
||||
#expect(coordinator.messageIDToTransferId[messageID] == nil)
|
||||
#expect(coordinator.transferIdToMessageIDs[transferId] == nil)
|
||||
#expect(context.removedMessages.map(\.messageID) == [messageID])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func voicePreparationFailureMarksPlaceholderFailedAndClearsEarlyMapping() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "66778899aabbccdd")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { _ in
|
||||
throw ChatMediaPreparationError.voiceNoteTooLarge(bytes: 999_999)
|
||||
}
|
||||
)
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("failing-private-\(UUID().uuidString).m4a")
|
||||
try Data("voice".utf8).write(to: url)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{
|
||||
context.deliveryStatusUpdates.contains { update in
|
||||
if case .failed = update.status { return true }
|
||||
return false
|
||||
}
|
||||
},
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
let messageID = try #require(context.privateChats[peerID]?.first?.id)
|
||||
|
||||
#expect(coordinator.messageIDToTransferId[messageID] == nil)
|
||||
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
||||
#expect(context.privateFileSends.isEmpty)
|
||||
#expect(context.broadcastFileSends.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func legacyPrivateVoiceNoteWaitsForPerSendConsent() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.privateMediaPolicy = .legacyRequiresConsent
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("legacy-consent-\(UUID().uuidString).m4a")
|
||||
try (Data([0x00, 0x00, 0x00, 0x18]) + Data("ftypM4A voice".utf8)).write(to: url)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
|
||||
let prompted = await TestHelpers.waitUntil(
|
||||
{ context.legacyConsentRequests.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(prompted)
|
||||
#expect(context.legacyConsentRequests.map { $0.peerID } == [peerID])
|
||||
#expect(context.privateFileSends.isEmpty)
|
||||
|
||||
context.resolveNextLegacyConsent(true)
|
||||
|
||||
#expect(context.privateFileSends.count == 1)
|
||||
#expect(context.privateFileLegacyAllowances == [true])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func capabilityProofTimeoutTransitionsToConsentWithoutAutomaticRawSend() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||
let peerID = PeerID(str: "1020304050607080")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.privateMediaPolicy = .awaitingCapabilityProof
|
||||
context.resolvedPrivateMediaPolicy = .legacyRequiresConsent
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("proof-timeout-consent-\(UUID().uuidString).m4a")
|
||||
try Data("voice".utf8).write(to: url)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
|
||||
let prompted = await TestHelpers.waitUntil(
|
||||
{ context.legacyConsentRequests.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(prompted)
|
||||
#expect(context.privateFileSends.isEmpty)
|
||||
context.resolveNextLegacyConsent(false)
|
||||
#expect(context.privateFileSends.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func legacyConsentApprovalAfterCancelCannotSend() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||
let peerID = PeerID(str: "2233445566778899")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.privateMediaPolicy = .legacyRequiresConsent
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("legacy-cancel-\(UUID().uuidString).m4a")
|
||||
try Data("voice".utf8).write(to: url)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
let prompted = await TestHelpers.waitUntil(
|
||||
{ context.legacyConsentRequests.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(prompted)
|
||||
let request = try #require(context.legacyConsentRequests.first)
|
||||
|
||||
coordinator.cancelMediaSend(messageID: request.messageID)
|
||||
#expect(context.invalidatedLegacyConsents.contains {
|
||||
$0.transferId == request.transferId && $0.messageID == request.messageID
|
||||
})
|
||||
|
||||
// Model a stale framework callback that escaped active invalidation.
|
||||
// The coordinator's transfer/message binding check is the final gate.
|
||||
context.invokeLegacyConsentEvenIfInvalidated(id: request.id, approved: true)
|
||||
#expect(context.privateFileSends.isEmpty)
|
||||
#expect(coordinator.messageIDToTransferId[request.messageID] == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func legacyConsentApprovalAfterDeleteCannotSend() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||
let peerID = PeerID(str: "33445566778899aa")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.privateMediaPolicy = .legacyRequiresConsent
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("legacy-delete-\(UUID().uuidString).m4a")
|
||||
try Data("voice".utf8).write(to: url)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
let prompted = await TestHelpers.waitUntil(
|
||||
{ context.legacyConsentRequests.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(prompted)
|
||||
let request = try #require(context.legacyConsentRequests.first)
|
||||
|
||||
coordinator.deleteMediaMessage(messageID: request.messageID)
|
||||
context.invokeLegacyConsentEvenIfInvalidated(id: request.id, approved: true)
|
||||
|
||||
#expect(context.invalidatedLegacyConsents.contains {
|
||||
$0.transferId == request.transferId && $0.messageID == request.messageID
|
||||
})
|
||||
#expect(context.privateFileSends.isEmpty)
|
||||
#expect(coordinator.messageIDToTransferId[request.messageID] == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func pinnedPrivateMediaDowngradeNeverPromptsOrSends() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.privateMediaPolicy = .blockedDowngrade
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("blocked-downgrade-\(UUID().uuidString).m4a")
|
||||
try Data("voice".utf8).write(to: url)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
|
||||
let failed = await TestHelpers.waitUntil(
|
||||
{ context.deliveryStatusUpdates.contains { update in
|
||||
if case .failed = update.status { return true }
|
||||
return false
|
||||
} },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(failed)
|
||||
#expect(context.legacyConsentRequests.isEmpty)
|
||||
#expect(context.privateFileSends.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,7 +243,7 @@ private func drainMainQueue() async {
|
||||
|
||||
/// Exercises `ChatNostrCoordinator` against `MockChatNostrContext` with no
|
||||
/// `ChatViewModel`. Scoped to the inbound event pipeline (dedup, presence,
|
||||
/// public-message ingest), gift-wrap DM ingest, key mapping, channel-switch
|
||||
/// public-message ingest), private-envelope ingest, key mapping, channel-switch
|
||||
/// teardown, embedded ack flows, and — now that favorites and notifications
|
||||
/// are injected through the context — the favorite-notification ingest and
|
||||
/// the sampled-geohash notification cooldown. Flows that hit live singletons
|
||||
@@ -335,7 +335,7 @@ struct ChatNostrCoordinatorContextTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleGiftWrap_routesEmbeddedPrivateMessageAndDeduplicates() async throws {
|
||||
func handlePrivateEnvelope_routesEmbeddedPrivateMessageAndDeduplicates() async throws {
|
||||
let context = MockChatNostrContext()
|
||||
let coordinator = ChatNostrCoordinator(context: context)
|
||||
|
||||
@@ -346,17 +346,21 @@ struct ChatNostrCoordinatorContextTests {
|
||||
messageID: "gm-1",
|
||||
senderPeerID: PeerID(str: "aabbccddeeff0011")
|
||||
))
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelopes = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||
content: embedded,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
|
||||
for envelope in envelopes {
|
||||
coordinator.inbound.handlePrivateEnvelope(envelope, id: recipient)
|
||||
}
|
||||
|
||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
||||
#expect(context.recordedNostrEventIDs == envelopes.map(\.id))
|
||||
#expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex)
|
||||
// The primary and compatibility envelopes carry the same authenticated
|
||||
// embedded payload and must invoke message side effects only once.
|
||||
#expect(context.handledPrivateMessages.count == 1)
|
||||
#expect(context.handledPrivateMessages.first?.senderPubkey == sender.publicKeyHex)
|
||||
#expect(context.handledPrivateMessages.first?.convKey == convKey)
|
||||
@@ -368,34 +372,84 @@ struct ChatNostrCoordinatorContextTests {
|
||||
#expect(pm.messageID == "gm-1")
|
||||
#expect(pm.content == "psst")
|
||||
|
||||
// The same gift wrap is dropped on replay.
|
||||
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
|
||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
||||
// The same private envelope is dropped on replay.
|
||||
coordinator.inbound.handlePrivateEnvelope(envelopes[0], id: recipient)
|
||||
#expect(context.recordedNostrEventIDs == envelopes.map(\.id))
|
||||
#expect(context.handledPrivateMessages.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func processNostrMessage_invalidSignatureDoesNotPoisonDedup() async throws {
|
||||
func migrationEnvelopePairs_processEachMessageAndAckOnlyOnce() throws {
|
||||
let context = MockChatNostrContext()
|
||||
let coordinator = ChatNostrCoordinator(context: context)
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let messageContent = try #require(NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
|
||||
content: "migration message",
|
||||
messageID: "migration-message-id",
|
||||
senderPeerID: PeerID(str: "aabbccddeeff0011")
|
||||
))
|
||||
let deliveredContent = try #require(NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(
|
||||
type: .delivered,
|
||||
messageID: "migration-ack-id",
|
||||
senderPeerID: PeerID(str: "aabbccddeeff0011")
|
||||
))
|
||||
let readContent = try #require(NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(
|
||||
type: .readReceipt,
|
||||
messageID: "migration-ack-id",
|
||||
senderPeerID: PeerID(str: "aabbccddeeff0011")
|
||||
))
|
||||
|
||||
for content in [messageContent, deliveredContent, readContent] {
|
||||
let envelopes = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
#expect(envelopes.count == 2)
|
||||
for envelope in envelopes {
|
||||
coordinator.inbound.handlePrivateEnvelope(envelope, id: recipient)
|
||||
}
|
||||
}
|
||||
|
||||
#expect(context.handledPrivateMessages.count == 1)
|
||||
#expect(context.handledDelivered.count == 1)
|
||||
#expect(context.handledReadReceipts.count == 1)
|
||||
|
||||
// A later same-format re-envelope is a delivery retry, not the
|
||||
// migration twin, so it must still reach downstream message-ID dedup
|
||||
// and acknowledgement resend logic.
|
||||
let primaryRetry = try NostrProtocol.createPrivateEnvelope(
|
||||
content: messageContent,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
coordinator.inbound.handlePrivateEnvelope(primaryRetry, id: recipient)
|
||||
#expect(context.handledPrivateMessages.count == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func processAccountPrivateEnvelope_invalidSignatureDoesNotPoisonDedup() async throws {
|
||||
let context = MockChatNostrContext()
|
||||
let coordinator = ChatNostrCoordinator(context: context)
|
||||
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: "verify:noop",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
var invalidGiftWrap = giftWrap
|
||||
invalidGiftWrap.sig = String(repeating: "0", count: 128)
|
||||
var invalidEnvelope = envelope
|
||||
invalidEnvelope.sig = String(repeating: "0", count: 128)
|
||||
|
||||
// A forged-signature copy is rejected WITHOUT entering the dedup set...
|
||||
await coordinator.inbound.processNostrMessage(invalidGiftWrap)
|
||||
await coordinator.inbound.processAccountPrivateEnvelope(invalidEnvelope)
|
||||
#expect(context.recordedNostrEventIDs.isEmpty)
|
||||
|
||||
// ...so the genuine event with the same ID still processes and records.
|
||||
await coordinator.inbound.processNostrMessage(giftWrap)
|
||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
||||
await coordinator.inbound.processAccountPrivateEnvelope(envelope)
|
||||
#expect(context.recordedNostrEventIDs == [envelope.id])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
|
||||
@@ -393,20 +393,25 @@ struct ChatViewModelNostrExtensionTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func subscribeGiftWrap_rejectsOversizedEmbeddedPacket() async throws {
|
||||
func privateEnvelope_rejectsOversizedEmbeddedPacketBeforePublication() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
|
||||
let oversized = Data(repeating: 0x41, count: FileTransferLimits.maxFramedFileBytes + 1)
|
||||
let content = "bitchat1:" + base64URLEncode(oversized)
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
|
||||
do {
|
||||
_ = try NostrProtocol.createPrivateEnvelope(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
Issue.record("Expected oversized private-envelope plaintext to be rejected")
|
||||
} catch NostrError.invalidCiphertext {
|
||||
// Rejected before encryption/publication, as intended.
|
||||
} catch {
|
||||
Issue.record("Expected NostrError.invalidCiphertext, got \(error)")
|
||||
}
|
||||
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
#expect(viewModel.privateChats.isEmpty)
|
||||
@@ -451,7 +456,7 @@ struct ChatViewModelNostrExtensionTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func subscribeGiftWrap_deliveredAckUpdatesExistingMessage() async throws {
|
||||
func subscribePrivateEnvelope_deliveredAckUpdatesExistingMessage() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
@@ -473,13 +478,13 @@ struct ChatViewModelNostrExtensionTests {
|
||||
], for: convKey)
|
||||
|
||||
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
|
||||
viewModel.subscribePrivateEnvelope(envelope, id: recipient)
|
||||
|
||||
let didUpdate = await TestHelpers.waitUntil(
|
||||
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||
@@ -489,7 +494,7 @@ struct ChatViewModelNostrExtensionTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func subscribeGiftWrap_readAckUpdatesExistingMessage() async throws {
|
||||
func subscribePrivateEnvelope_readAckUpdatesExistingMessage() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
@@ -511,13 +516,13 @@ struct ChatViewModelNostrExtensionTests {
|
||||
], for: convKey)
|
||||
|
||||
let content = try ackContent(type: .readReceipt, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
|
||||
viewModel.subscribePrivateEnvelope(envelope, id: recipient)
|
||||
|
||||
let didUpdate = await TestHelpers.waitUntil(
|
||||
{ isRead(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||
@@ -527,28 +532,28 @@ struct ChatViewModelNostrExtensionTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleGiftWrap_privateMessageStoresConversationAndMapping() async throws {
|
||||
func handlePrivateEnvelope_privateMessageStoresConversationAndMapping() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let messageID = "gift-private"
|
||||
let messageID = "envelope-private"
|
||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||
|
||||
let content = try privateMessageContent(
|
||||
text: "Hello from gift wrap",
|
||||
text: "Hello from private envelope",
|
||||
messageID: messageID,
|
||||
senderPeerID: PeerID(str: "0123456789abcdef")
|
||||
)
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
viewModel.handleGiftWrap(giftWrap, id: recipient)
|
||||
viewModel.handlePrivateEnvelope(envelope, id: recipient)
|
||||
|
||||
let didStore = await TestHelpers.waitUntil(
|
||||
{ viewModel.privateChats[convKey]?.first?.content == "Hello from gift wrap" },
|
||||
{ viewModel.privateChats[convKey]?.first?.content == "Hello from private envelope" },
|
||||
timeout: 5.0
|
||||
)
|
||||
#expect(didStore)
|
||||
@@ -557,11 +562,11 @@ struct ChatViewModelNostrExtensionTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleGiftWrap_blockedSenderSkipsMessageStorage() async throws {
|
||||
func handlePrivateEnvelope_blockedSenderSkipsMessageStorage() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let messageID = "gift-blocked"
|
||||
let messageID = "envelope-blocked"
|
||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||
|
||||
viewModel.identityManager.setNostrBlocked(sender.publicKeyHex, isBlocked: true)
|
||||
@@ -571,13 +576,13 @@ struct ChatViewModelNostrExtensionTests {
|
||||
messageID: messageID,
|
||||
senderPeerID: PeerID(str: "0123456789abcdef")
|
||||
)
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
viewModel.handleGiftWrap(giftWrap, id: recipient)
|
||||
viewModel.handlePrivateEnvelope(envelope, id: recipient)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
#expect(viewModel.privateChats[convKey] == nil)
|
||||
@@ -585,12 +590,12 @@ struct ChatViewModelNostrExtensionTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleGiftWrap_deliveredAckUpdatesExistingMessage() async throws {
|
||||
func handlePrivateEnvelope_deliveredAckUpdatesExistingMessage() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||
let messageID = "gift-delivered"
|
||||
let messageID = "envelope-delivered"
|
||||
|
||||
viewModel.seedPrivateChat([
|
||||
BitchatMessage(
|
||||
@@ -607,13 +612,13 @@ struct ChatViewModelNostrExtensionTests {
|
||||
], for: convKey)
|
||||
|
||||
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
viewModel.handleGiftWrap(giftWrap, id: recipient)
|
||||
viewModel.handlePrivateEnvelope(envelope, id: recipient)
|
||||
|
||||
let didUpdate = await TestHelpers.waitUntil(
|
||||
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||
@@ -1048,89 +1053,6 @@ struct ChatViewModelMediaTransferTests {
|
||||
#expect(viewModel.transferIdToMessageIDs.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func legacyPrivateMediaConsentRequestsArePerSendAndQueued() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let firstPeer = PeerID(str: "1111111111111111")
|
||||
let secondPeer = PeerID(str: "2222222222222222")
|
||||
var decisions: [Bool] = []
|
||||
|
||||
viewModel.enqueueLegacyPrivateMediaConsent(
|
||||
for: firstPeer,
|
||||
transferId: "transfer-1",
|
||||
messageID: "message-1"
|
||||
) { decisions.append($0) }
|
||||
viewModel.enqueueLegacyPrivateMediaConsent(
|
||||
for: secondPeer,
|
||||
transferId: "transfer-2",
|
||||
messageID: "message-2"
|
||||
) { decisions.append($0) }
|
||||
|
||||
#expect(viewModel.legacyPrivateMediaConsentRequest?.peerID == firstPeer)
|
||||
let firstRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
|
||||
viewModel.resolveLegacyPrivateMediaConsent(requestID: firstRequestID, approved: true)
|
||||
let showedSecond = await TestHelpers.waitUntil(
|
||||
{ viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(showedSecond)
|
||||
let secondRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
|
||||
|
||||
// A button action and the dialog binding may both resolve the first
|
||||
// ID. The stale second callback must not consume the queued request.
|
||||
viewModel.resolveLegacyPrivateMediaConsent(requestID: firstRequestID, approved: false)
|
||||
#expect(decisions == [true])
|
||||
#expect(viewModel.legacyPrivateMediaConsentRequest?.id == secondRequestID)
|
||||
|
||||
viewModel.resolveLegacyPrivateMediaConsent(requestID: secondRequestID, approved: false)
|
||||
|
||||
#expect(decisions == [true, false])
|
||||
#expect(viewModel.legacyPrivateMediaConsentRequest == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func invalidatingPresentedLegacyConsentAdvancesQueueAndStaleResolutionNoops() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let firstPeer = PeerID(str: "3333333333333333")
|
||||
let secondPeer = PeerID(str: "4444444444444444")
|
||||
var decisions: [String] = []
|
||||
|
||||
viewModel.enqueueLegacyPrivateMediaConsent(
|
||||
for: firstPeer,
|
||||
transferId: "transfer-cancelled",
|
||||
messageID: "message-cancelled"
|
||||
) { decisions.append("first:\($0)") }
|
||||
viewModel.enqueueLegacyPrivateMediaConsent(
|
||||
for: secondPeer,
|
||||
transferId: "transfer-kept",
|
||||
messageID: "message-kept"
|
||||
) { decisions.append("second:\($0)") }
|
||||
|
||||
let cancelledRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
|
||||
viewModel.invalidateLegacyPrivateMediaConsent(
|
||||
transferId: "transfer-cancelled",
|
||||
messageID: "message-cancelled"
|
||||
)
|
||||
let advanced = await TestHelpers.waitUntil(
|
||||
{ viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(advanced)
|
||||
#expect(decisions.isEmpty, "Invalidation drops the request rather than resolving its send")
|
||||
|
||||
viewModel.resolveLegacyPrivateMediaConsent(
|
||||
requestID: cancelledRequestID,
|
||||
approved: true
|
||||
)
|
||||
#expect(viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer)
|
||||
#expect(decisions.isEmpty)
|
||||
|
||||
let keptRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
|
||||
viewModel.resolveLegacyPrivateMediaConsent(requestID: keptRequestID, approved: true)
|
||||
#expect(decisions == ["second:true"])
|
||||
#expect(viewModel.legacyPrivateMediaConsentRequest == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendVoiceNote_oversizedFileFailsAndDeletesTempFile() async throws {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,8 +14,6 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
private var blockedFingerprints: Set<String> = []
|
||||
private var blockedNostrPubkeys: Set<String> = []
|
||||
private var socialIdentities: [String: SocialIdentity] = [:]
|
||||
private var privateMediaCapableFingerprints: Set<String> = []
|
||||
private var authenticatedSigningKeys: [String: Data] = [:]
|
||||
|
||||
init(_: KeychainManagerProtocol) {}
|
||||
|
||||
@@ -89,10 +87,7 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {}
|
||||
|
||||
func clearAllIdentityData() {
|
||||
privateMediaCapableFingerprints.removeAll()
|
||||
authenticatedSigningKeys.removeAll()
|
||||
}
|
||||
func clearAllIdentityData() {}
|
||||
|
||||
func removeEphemeralSession(peerID: PeerID) {}
|
||||
|
||||
@@ -106,22 +101,6 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
Set()
|
||||
}
|
||||
|
||||
func markPrivateMediaCapable(fingerprint: String) {
|
||||
privateMediaCapableFingerprints.insert(fingerprint)
|
||||
}
|
||||
|
||||
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool {
|
||||
privateMediaCapableFingerprints.contains(fingerprint)
|
||||
}
|
||||
|
||||
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) {
|
||||
authenticatedSigningKeys[fingerprint] = signingPublicKey
|
||||
}
|
||||
|
||||
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? {
|
||||
authenticatedSigningKeys[fingerprint]
|
||||
}
|
||||
|
||||
// MARK: Vouching (transitive verification)
|
||||
|
||||
private var vouchesByVouchee: [String: [VouchRecord]] = [:]
|
||||
|
||||
@@ -36,7 +36,6 @@ final class MockTransport: Transport {
|
||||
private(set) var sentFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
|
||||
private(set) var sentBroadcastFiles: [(packet: BitchatFilePacket, transferID: String)] = []
|
||||
private(set) var sentPrivateFiles: [(packet: BitchatFilePacket, peerID: PeerID, transferID: String)] = []
|
||||
private(set) var sentPrivateFileLegacyAllowances: [Bool] = []
|
||||
private(set) var cancelledTransfers: [String] = []
|
||||
private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||
@@ -59,7 +58,6 @@ final class MockTransport: Transport {
|
||||
var peerNicknames: [PeerID: String] = [:]
|
||||
var peerFingerprints: [PeerID: String] = [:]
|
||||
var peerNoiseStates: [PeerID: LazyHandshakeState] = [:]
|
||||
var privateMediaPolicies: [PeerID: PrivateMediaSendPolicy] = [:]
|
||||
private let mockKeychain = MockKeychain()
|
||||
|
||||
// MARK: - Transport Protocol Implementation
|
||||
@@ -188,29 +186,6 @@ final class MockTransport: Transport {
|
||||
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
||||
sentPrivateFiles.append((packet, peerID, transferId))
|
||||
sentPrivateFileLegacyAllowances.append(false)
|
||||
}
|
||||
|
||||
func sendFilePrivate(
|
||||
_ packet: BitchatFilePacket,
|
||||
to peerID: PeerID,
|
||||
transferId: String,
|
||||
allowLegacyFallback: Bool
|
||||
) {
|
||||
sentPrivateFiles.append((packet, peerID, transferId))
|
||||
sentPrivateFileLegacyAllowances.append(allowLegacyFallback)
|
||||
}
|
||||
|
||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
|
||||
privateMediaPolicies[peerID] ?? .encrypted
|
||||
}
|
||||
|
||||
func resolvePrivateMediaSendPolicy(
|
||||
to peerID: PeerID,
|
||||
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||
) {
|
||||
let policy = privateMediaPolicies[peerID] ?? .encrypted
|
||||
Task { @MainActor in completion(policy) }
|
||||
}
|
||||
|
||||
func cancelTransfer(_ transferId: String) {
|
||||
|
||||
@@ -12,15 +12,8 @@ struct NoiseCoverageTests {
|
||||
private let bobStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
private let charlieStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
|
||||
// Manager test dictionaries are keyed by the remote peer. Keep the
|
||||
// historical names, but derive each wire ID from the static key that the
|
||||
// corresponding manager authenticates during the handshake.
|
||||
private var alicePeerID: PeerID {
|
||||
PeerID(publicKey: bobStaticKey.publicKey.rawRepresentation)
|
||||
}
|
||||
private var bobPeerID: PeerID {
|
||||
PeerID(publicKey: aliceStaticKey.publicKey.rawRepresentation)
|
||||
}
|
||||
private let alicePeerID = PeerID(str: "0011223344556677")
|
||||
private let bobPeerID = PeerID(str: "8899aabbccddeeff")
|
||||
private let charliePeerID = PeerID(str: "fedcba9876543210")
|
||||
|
||||
@Test("Protocol metadata and handshake patterns expose expected values")
|
||||
@@ -542,12 +535,8 @@ struct NoiseCoverageTests {
|
||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
|
||||
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
|
||||
|
||||
aliceManager.onSessionEstablished = establishedRecorder.recordEstablished(
|
||||
peerID:remoteKey:sessionGeneration:
|
||||
)
|
||||
bobManager.onSessionEstablished = establishedRecorder.recordEstablished(
|
||||
peerID:remoteKey:sessionGeneration:
|
||||
)
|
||||
aliceManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:)
|
||||
bobManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:)
|
||||
|
||||
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||
|
||||
@@ -654,121 +643,13 @@ struct NoiseCoverageTests {
|
||||
try aliceManager.initiateHandshake(with: alicePeerID)
|
||||
}
|
||||
|
||||
let rekeyHandshake = try aliceManager.initiateRekey(for: alicePeerID)
|
||||
#expect(!rekeyHandshake.isEmpty)
|
||||
try aliceManager.initiateRekey(for: alicePeerID)
|
||||
let rekeyedSession = try #require(aliceManager.getSession(for: alicePeerID))
|
||||
|
||||
#expect(rekeyedSession !== establishedSession)
|
||||
#expect(rekeyedSession.getState() == .handshaking)
|
||||
}
|
||||
|
||||
@Test("A stale decrypt generation cannot commit across session promotion")
|
||||
func staleDecryptGenerationCannotCommitAcrossPromotion() throws {
|
||||
let aliceManager = NoiseSessionManager(
|
||||
localStaticKey: aliceStaticKey,
|
||||
keychain: keychain,
|
||||
sessionFactory: { peerID, role in
|
||||
BlockingDecryptNoiseSession(
|
||||
peerID: peerID,
|
||||
role: role,
|
||||
keychain: self.keychain,
|
||||
localStaticKey: self.aliceStaticKey
|
||||
)
|
||||
}
|
||||
)
|
||||
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
|
||||
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||
|
||||
let oldSession = try #require(
|
||||
aliceManager.getSession(for: alicePeerID) as? BlockingDecryptNoiseSession
|
||||
)
|
||||
let oldGeneration = try #require(aliceManager.sessionGeneration(for: alicePeerID))
|
||||
|
||||
// Prepare a fully authenticated responder candidate without promoting
|
||||
// it yet. Its final XX message is the exact operation that replaces
|
||||
// the old `sessions[peerID]` entry.
|
||||
let replacementInitiator = NoiseSession(
|
||||
peerID: bobPeerID,
|
||||
role: .initiator,
|
||||
keychain: keychain,
|
||||
localStaticKey: bobStaticKey
|
||||
)
|
||||
let message1 = try replacementInitiator.startHandshake()
|
||||
let message2 = try #require(
|
||||
try aliceManager.handleIncomingHandshake(from: alicePeerID, message: message1)
|
||||
)
|
||||
let message3 = try #require(try replacementInitiator.processHandshakeMessage(message2))
|
||||
|
||||
let ciphertext = try bobManager.encrypt(Data("old session".utf8), for: bobPeerID)
|
||||
oldSession.pauseNextDecrypt()
|
||||
|
||||
let decryptResult = ConcurrentTestResult<(plaintext: Data, sessionGeneration: UUID)>()
|
||||
var promotionResultForCleanup: ConcurrentTestResult<Data?>?
|
||||
defer {
|
||||
// A failed startup requirement must not strand a late thread in
|
||||
// the blocking test double after the test has returned.
|
||||
oldSession.resumeDecrypt()
|
||||
_ = decryptResult.wait(timeout: 5)
|
||||
if let promotionResultForCleanup {
|
||||
_ = promotionResultForCleanup.wait(timeout: 5)
|
||||
}
|
||||
}
|
||||
|
||||
let decryptThread = Thread {
|
||||
decryptResult.capture {
|
||||
try aliceManager.decryptWithSessionGeneration(ciphertext, from: self.alicePeerID)
|
||||
}
|
||||
}
|
||||
decryptThread.name = "NoiseCoverageTests.staleDecrypt.decrypt"
|
||||
decryptThread.qualityOfService = .userInitiated
|
||||
decryptThread.start()
|
||||
try #require(oldSession.waitForDecryptStart(timeout: 5))
|
||||
|
||||
let promotionStarted = DispatchSemaphore(value: 0)
|
||||
let promotionResult = ConcurrentTestResult<Data?>()
|
||||
promotionResultForCleanup = promotionResult
|
||||
let promotionThread = Thread {
|
||||
promotionStarted.signal()
|
||||
promotionResult.capture {
|
||||
try aliceManager.handleIncomingHandshake(from: self.alicePeerID, message: message3)
|
||||
}
|
||||
}
|
||||
promotionThread.name = "NoiseCoverageTests.staleDecrypt.promote"
|
||||
promotionThread.qualityOfService = .userInitiated
|
||||
promotionThread.start()
|
||||
try #require(promotionStarted.wait(timeout: .now() + 5) == .success)
|
||||
#expect(
|
||||
promotionResult.wait(timeout: 0.05) == nil,
|
||||
"Promotion must wait for the exact decrypting-session lease"
|
||||
)
|
||||
|
||||
oldSession.resumeDecrypt()
|
||||
let decrypted = try #require(decryptResult.wait(timeout: 5)).get()
|
||||
_ = try #require(promotionResult.wait(timeout: 5)).get()
|
||||
|
||||
#expect(decrypted.plaintext == Data("old session".utf8))
|
||||
#expect(decrypted.sessionGeneration == oldGeneration)
|
||||
#expect(aliceManager.sessionGeneration(for: alicePeerID) != oldGeneration)
|
||||
#expect(throws: NoiseEncryptionError.sessionNotEstablished) {
|
||||
try aliceManager.encrypt(
|
||||
Data("stale send".utf8),
|
||||
for: alicePeerID,
|
||||
expectedSessionGeneration: oldGeneration
|
||||
)
|
||||
}
|
||||
|
||||
var staleCommitRan = false
|
||||
let staleCommit = aliceManager.withCurrentSessionGeneration(
|
||||
for: alicePeerID,
|
||||
expected: decrypted.sessionGeneration
|
||||
) {
|
||||
staleCommitRan = true
|
||||
return true
|
||||
}
|
||||
#expect(staleCommit == nil)
|
||||
#expect(!staleCommitRan)
|
||||
}
|
||||
|
||||
@Test("Secure noise sessions enforce limits and renegotiation thresholds")
|
||||
func secureNoiseSessionsEnforceLimitsAndThresholds() throws {
|
||||
let initiator = SecureNoiseSession(
|
||||
@@ -963,11 +844,7 @@ private final class SessionCallbackRecorder: @unchecked Sendable {
|
||||
return establishedEntries.map(\.0)
|
||||
}
|
||||
|
||||
func recordEstablished(
|
||||
peerID: PeerID,
|
||||
remoteKey: Curve25519.KeyAgreement.PublicKey,
|
||||
sessionGeneration _: UUID
|
||||
) {
|
||||
func recordEstablished(peerID: PeerID, remoteKey: Curve25519.KeyAgreement.PublicKey) {
|
||||
lock.lock()
|
||||
establishedEntries.append((peerID, remoteKey.rawRepresentation))
|
||||
lock.unlock()
|
||||
@@ -989,62 +866,3 @@ private final class FailingNoiseSession: NoiseSession {
|
||||
throw Error.synthetic
|
||||
}
|
||||
}
|
||||
|
||||
private final class BlockingDecryptNoiseSession: NoiseSession, @unchecked Sendable {
|
||||
private let controlLock = NSLock()
|
||||
private var shouldPauseNextDecrypt = false
|
||||
private let decryptStarted = DispatchSemaphore(value: 0)
|
||||
private let resumeDecryptSemaphore = DispatchSemaphore(value: 0)
|
||||
|
||||
func pauseNextDecrypt() {
|
||||
controlLock.lock()
|
||||
shouldPauseNextDecrypt = true
|
||||
controlLock.unlock()
|
||||
}
|
||||
|
||||
func waitForDecryptStart(timeout: TimeInterval) -> Bool {
|
||||
decryptStarted.wait(timeout: .now() + timeout) == .success
|
||||
}
|
||||
|
||||
func resumeDecrypt() {
|
||||
resumeDecryptSemaphore.signal()
|
||||
}
|
||||
|
||||
override func decrypt(_ ciphertext: Data) throws -> Data {
|
||||
controlLock.lock()
|
||||
let shouldPause = shouldPauseNextDecrypt
|
||||
shouldPauseNextDecrypt = false
|
||||
controlLock.unlock()
|
||||
|
||||
if shouldPause {
|
||||
decryptStarted.signal()
|
||||
resumeDecryptSemaphore.wait()
|
||||
}
|
||||
return try super.decrypt(ciphertext)
|
||||
}
|
||||
}
|
||||
|
||||
private final class ConcurrentTestResult<Value>: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private let completed = DispatchGroup()
|
||||
private var storedResult: Result<Value, Error>?
|
||||
|
||||
init() {
|
||||
completed.enter()
|
||||
}
|
||||
|
||||
func capture(_ operation: () throws -> Value) {
|
||||
let result = Result(catching: operation)
|
||||
lock.lock()
|
||||
storedResult = result
|
||||
lock.unlock()
|
||||
completed.leave()
|
||||
}
|
||||
|
||||
func wait(timeout: TimeInterval) -> Result<Value, Error>? {
|
||||
guard completed.wait(timeout: .now() + timeout) == .success else { return nil }
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return storedResult
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"id":"8f4ac4680de5f972a586267bc7b6b5102ba548a34f618bdee47edd08929ee1e8","pubkey":"6981231b5745520fd982f66f485fa1b42f8f91ad25ab32242d4afb839d696b0a","created_at":1783727308,"kind":1059,"tags":[["p","c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"]],"content":"v2:ETvtGOrkDQ2JIiiMFMdxBlrYNLh07-QKmLds0xsjkgs-v54CP4E4uoKl1L_VvKvaREg-EviPQmksd3DOYHrBVAd5E6xuRn1o9vkoss4MYV7I71mu4RfADRt77ohZaNbc-KhrmgyRTgE-WnEsqErax8LUN6GUukjkKncVA9MAmC1WUB1MI1AR8c4BQ0IOc_ubrEqi640a8AeBZaCZOYutCb3ttqNSPBxR63XErE761KNg1uiq5pgBVo8iKvLO-N2ei7IWvQhmDTapBaEU7LexeIdHDEwMdXDoAtr5Nmd54H1VN12tBvk1wXvHnENgZCOLOR5J2E1eSwrFXXBto-ohrNpBaLKIXBTPGEoepa7x0gC0Vrh1OTf4tCI7JJ5UWnkUAQFGUgPGlTRYC8MdESgEthqmdgKT3Jc0N6sylTmv6zSVx5dXqO4fvSLHC6_7it8F_V_8-uAxUYstJz65oK4F9CwOEVglUuUdfn2mN_3cMBzASLOlKvL8jbkwwo5aMBVrShGiEwDix02hfGMNMKf7OKsLlfNiBAVSPh6MQSvAWhxbDCDnWN-yHKWF4TYxbnH70X2KGl_ZD9pXTphKVIpFuRmP7UNM-01oG53NKcv9puBSxAkLbTZd122uFL_zQebxid1ukOXT8WgSB_WYJYoAlQekeu4ITryB4I60vAAzMAa4AppCUNnf6T8jwWxuC-8G0TPf18MTxpeNkzcSpPVyVE0jtLaOwsJDXs_Pg82Id03Qc-b_fWMm9V07UzGmEnMqQ1gBMLmEXHb_4Ebg5X7TdzuXy87O36CJzau7Dm5ZfoalryF-16Z4MxzQOyXb1G61yFthRsGCT6sYi-68YkhPScMf7u_BobtMuGWiMiWoBqN_IrQ_ecMHVfaeEYvpCz5NYlrE26iAktNmzCBUDNcIr6P_nHdb6I3Q1rOOmWwEF7jsLbvnU_w_82_nXE_yfdGsoly24A2wB0L0SpdnyyEWgvKWjjfS3J3vkIVW4_iM0FT0jNelANc2X_ryb3EPTmGenlqm_qRGh86PYk_R07hKYu3ULNEgLzDTijeZ9-bP23tsMXUDdJS2VR7LP5063ygVuSC0J-GL1FmQ2c-DmJaWQSeqp0NN3sCND3pSIRzwQRwChnMNjVB65mJj","sig":"c53f339b19b9de021765588b0ee4f5f1e0c2423a34d35fe2cbcc6ce89e12e2fedbdf88a0b6a9d719c2c8580002c7574f1ace93aedeab1e255b6231d4ebb6f9b2"}
|
||||
@@ -0,0 +1,29 @@
|
||||
diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt
|
||||
index a5bd956..544a29b 100644
|
||||
--- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt
|
||||
+++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt
|
||||
@@ -10,0 +11,21 @@ class NostrProtocolTest {
|
||||
+ @Test
|
||||
+ fun emitCrossPlatformFixtures() {
|
||||
+ val sender = NostrIdentity.fromPrivateKey(
|
||||
+ "0000000000000000000000000000000000000000000000000000000000000001"
|
||||
+ )
|
||||
+ val recipient = NostrIdentity.fromPrivateKey(
|
||||
+ "0000000000000000000000000000000000000000000000000000000000000002"
|
||||
+ )
|
||||
+ val giftWrap = NostrProtocol.createPrivateMessage(
|
||||
+ content = "legacy fixture from Android b7f0b33d",
|
||||
+ recipientPubkey = recipient.publicKeyHex,
|
||||
+ senderIdentity = sender
|
||||
+ ).single()
|
||||
+
|
||||
+ assertEquals(NostrKind.GIFT_WRAP, giftWrap.kind)
|
||||
+ assertEquals(listOf(listOf("p", recipient.publicKeyHex)), giftWrap.tags)
|
||||
+ println("BITCHAT_FIXTURE_EVENT=${gson.toJson(giftWrap)}")
|
||||
+ println("BITCHAT_FIXTURE_RECIPIENT_PRIVATE_KEY=${recipient.privateKeyHex}")
|
||||
+ println("BITCHAT_FIXTURE_SENDER_PUBLIC_KEY=${sender.publicKeyHex}")
|
||||
+ }
|
||||
+
|
||||
@Test
|
||||
fun decryptPrivateMessage_acceptsAuthenticatedSeal() {
|
||||
val sender = NostrIdentity.generate()
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"android_commit": "b7f0b33d3a267c770d3d5a65ee2d8c7e755450db",
|
||||
"generator": "NostrProtocol.createPrivateMessage",
|
||||
"generator_patch": "AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch",
|
||||
"generator_patch_sha256": "e7ad29d6a247638cc16fb2ec06937266e6a03e7a115ae00913330a86a88fa56a",
|
||||
"gradle_test": "ANDROID_HOME=/opt/homebrew/share/android-commandlinetools ANDROID_SDK_ROOT=/opt/homebrew/share/android-commandlinetools JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home ./gradlew testDebugUnitTest --tests com.bitchat.android.nostr.NostrProtocolTest.emitCrossPlatformFixtures --info",
|
||||
"fixture_sha256": "d2df3d0b7ffd84c5cb25e0b3f4a89ad14f72a105bddadab77081f98c661b080e",
|
||||
"recipient_private_key": "0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"sender_public_key": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"content":"v2:SorfnTaoQ_Rv0XLKA8b3FZojgaFvnWgx66JR6Gj20ztnorQyL-hUYBZwAa5ohFC6ioR9hlJARJm0cgNTvWgSZuNTcfSAvoMdSG6mXK73kzsp99x351zUB_lc1_5ZXm0qqePAEz3Dl5jj5EsfAb8ZzsCd-BZENCsTwqjqC_hCFl7RitlRfIL2Tq_n4TUFEFandDCplNz3W4L7V07V89aGEoUlhzcwPU44BcxfvQjeqVKq0IRf2AetypoOduPrjSqkv674ubWaRcHtw3Asrqgo5XSYbpOlSk1PF_TttrQSPZGViNe5MuiK2P-7d-XqKXuDf_bUgAzW884KXogbct-wtIJZJbVM-utMd-dHrpC1mY81lgpS4_kPuhj0Z6Ro9hU5nCcEk2K4_vNoSM9m9QbcXP34h47qSPsw9ikmz8UoD00-1fXQVB4YJcBVUSVI06IzbZEWulo8SPXvQ4pJjV3nwPgYqRgwnrNWMNfeuKojlF8yA17UNOWvD2U7r1cs84HL8dxztzX9NdN0DGxvjMILvt3D4eWrMbcSrsIkBgyvV-uskEPd4eX3fc9GmX8MPkMgxRErcxbuq6JBUNXikOJXNH_qspOt4UIw5dCIajrHsKycKd8A_3rSgLEQteirOWMGaD2gOJEzpbe4iT72dmPkvRq7k-wDjLbO5dOSuUvpFM-ipkB07ATJz_1uUcRpl8fD_oSlcdAzdPjGKG5Y-tNv3AcUstkqCi52E5x-aO8EDUNBFi_OCbD86nkTUv1jOpAQwejowSiiOnCZ91zUEg5pRQyhBV0Ozib-j0Wf8S8VAz9M8bqQQaKedPCEowDn6csOKbFdBtqSeROf1XWKCkm1mSJGHWW9V44MiMiHebVrRUpbP0PqvxiIeJE0","created_at":1783710443,"id":"46425f8d4e8007af43abb67b3668b59604bd34c86dee1b1c3702559086927cb9","kind":1059,"pubkey":"960e391e314a7fb00bbdd85eccb0a93c17e981b6fed38487cf891f1ed6b66aeb","sig":"975c88c1c4d11f0b623603f8ecc7c181fea7385e8feacdb4a64a6b4f7536b1337ff556aee165ca337c9ec501cfab3e9703026c6df2b12bb8c702378875f00722","tags":[["p","1c108500bf53da288d30530718bac4bf80d661d1fe38854060c5b5c79eb77755"]]}
|
||||
@@ -0,0 +1 @@
|
||||
{"recipient_private_key":"8355a5c110cdfef2e644f4ad5d51c39f253b2c2c80ebb6856379fb16531dc1fa"}
|
||||
@@ -2,18 +2,17 @@
|
||||
// NostrProtocolTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for NIP-17 gift-wrapped private messages
|
||||
// Tests for BitChat's proprietary private-envelope transport over Nostr.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
struct NostrProtocolTests {
|
||||
|
||||
@Test func nip17MessageRoundTrip() throws {
|
||||
@Test func privateEnvelopeRoundTrip() throws {
|
||||
// Create sender and recipient identities
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
@@ -22,21 +21,19 @@ struct NostrProtocolTests {
|
||||
print("Recipient pubkey: \(recipient.publicKeyHex)")
|
||||
|
||||
// Create a test message
|
||||
let originalContent = "Hello from NIP-17 test!"
|
||||
let originalContent = "Hello from BitChat private-envelope test!"
|
||||
|
||||
// Create encrypted gift wrap
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: originalContent,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
print("Gift wrap created with ID: \(giftWrap.id)")
|
||||
print("Gift wrap pubkey: \(giftWrap.pubkey)")
|
||||
print("Private envelope created with ID: \(envelope.id)")
|
||||
print("Private envelope pubkey: \(envelope.pubkey)")
|
||||
|
||||
// Decrypt the gift wrap
|
||||
let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
|
||||
@@ -52,43 +49,389 @@ struct NostrProtocolTests {
|
||||
print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey) at \(messageDate)")
|
||||
}
|
||||
|
||||
@Test func giftWrapUsesUniqueEphemeralKeys() throws {
|
||||
@Test func privateEnvelopesUseUniqueEphemeralKeys() throws {
|
||||
// Create identities
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
|
||||
// Create two messages
|
||||
let message1 = try NostrProtocol.createPrivateMessage(
|
||||
let message1 = try NostrProtocol.createPrivateEnvelope(
|
||||
content: "Message 1",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
let message2 = try NostrProtocol.createPrivateMessage(
|
||||
let message2 = try NostrProtocol.createPrivateEnvelope(
|
||||
content: "Message 2",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
// Gift wrap pubkeys should be different (unique ephemeral keys)
|
||||
// Public envelope keys must be one-time use.
|
||||
#expect(message1.pubkey != message2.pubkey)
|
||||
|
||||
print("Message 1 gift wrap pubkey: \(message1.pubkey)")
|
||||
print("Message 2 gift wrap pubkey: \(message2.pubkey)")
|
||||
print("Message 1 envelope pubkey: \(message1.pubkey)")
|
||||
print("Message 2 envelope pubkey: \(message2.pubkey)")
|
||||
|
||||
// Both should decrypt successfully
|
||||
let (content1, _, _) = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: message1,
|
||||
let (content1, _, _) = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: message1,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
let (content2, _, _) = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: message2,
|
||||
let (content2, _, _) = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: message2,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
|
||||
#expect(content1 == "Message 1")
|
||||
#expect(content2 == "Message 2")
|
||||
}
|
||||
|
||||
@Test func privateEnvelopeUsesBitChatWireFormatAtEveryLayer() throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: "bitchat-specific",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
#expect(envelope.kind == NostrProtocol.EventKind.privateEnvelope.rawValue)
|
||||
#expect(envelope.kind != NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue)
|
||||
#expect(envelope.content.hasPrefix(NostrProtocol.privateEnvelopeContentPrefix))
|
||||
#expect(!envelope.content.hasPrefix("v2:"))
|
||||
#expect(envelope.tags == [["p", recipient.publicKeyHex]])
|
||||
#expect(envelope.created_at <= Int(Date().timeIntervalSince1970))
|
||||
|
||||
let layers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
#expect(layers.seal.kind == NostrProtocol.EventKind.privateSeal.rawValue)
|
||||
#expect(layers.seal.content.hasPrefix(NostrProtocol.privateEnvelopeContentPrefix))
|
||||
#expect(layers.seal.tags.isEmpty)
|
||||
#expect(layers.message.kind == NostrProtocol.EventKind.privateMessage.rawValue)
|
||||
#expect(layers.message.tags.isEmpty)
|
||||
#expect(layers.message.sig == nil)
|
||||
}
|
||||
|
||||
@Test func decryptAcceptsReceiveOnlyLegacyBitChatEnvelope() throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let envelope = try NostrProtocol.createLegacyPrivateEnvelopeForTesting(
|
||||
content: "legacy in-flight message",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
#expect(envelope.kind == NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue)
|
||||
#expect(envelope.content.hasPrefix("v2:"))
|
||||
|
||||
let result = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
#expect(result.content == "legacy in-flight message")
|
||||
#expect(result.senderPubkey == sender.publicKeyHex)
|
||||
|
||||
let layers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
#expect(layers.seal.kind == NostrProtocol.EventKind.legacyNIP59Seal.rawValue)
|
||||
#expect(layers.message.kind == NostrProtocol.EventKind.legacyNIP17DirectMessage.rawValue)
|
||||
#expect(layers.message.tags.isEmpty)
|
||||
}
|
||||
|
||||
@Test func decryptAcceptsCurrentAndroidLegacyInnerRecipientTag() throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
|
||||
// Current Android's `createPrivateMessage` emits an unsigned kind-14
|
||||
// inner event with exactly [["p", recipient]], while its outer/seal
|
||||
// layers use the deployed BitChat legacy v2 crypto. This isolated
|
||||
// generator reproduces that cross-platform wire shape without making
|
||||
// the production encoder depend on Android's historical tag choice.
|
||||
let envelope = try NostrProtocol.createLegacyPrivateEnvelopeForTesting(
|
||||
content: "legacy message from Android",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender,
|
||||
innerMessageTags: [["p", recipient.publicKeyHex]]
|
||||
)
|
||||
|
||||
let layers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
#expect(layers.message.tags == [["p", recipient.publicKeyHex]])
|
||||
|
||||
let result = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
#expect(result.content == "legacy message from Android")
|
||||
#expect(result.senderPubkey == sender.publicKeyHex)
|
||||
}
|
||||
|
||||
@Test func decryptsFrozenLegacyEnvelopeProducedByAndroidB7f0b33d() throws {
|
||||
let eventData = try Data(contentsOf: fixtureURL(
|
||||
name: "AndroidLegacyPrivateEnvelopeB7f0b33d"
|
||||
))
|
||||
let metadataData = try Data(contentsOf: fixtureURL(
|
||||
name: "AndroidLegacyPrivateEnvelopeB7f0b33dMetadata"
|
||||
))
|
||||
let envelope = try JSONDecoder().decode(NostrEvent.self, from: eventData)
|
||||
let metadata = try JSONDecoder().decode(AndroidLegacyEnvelopeFixture.self, from: metadataData)
|
||||
let recipientKey = try #require(Data(hexString: metadata.recipientPrivateKey))
|
||||
let recipient = try NostrIdentity(privateKeyData: recipientKey)
|
||||
|
||||
#expect(metadata.androidCommit == "b7f0b33d3a267c770d3d5a65ee2d8c7e755450db")
|
||||
#expect(metadata.generator == "NostrProtocol.createPrivateMessage")
|
||||
#expect(metadata.generatorPatch == "AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch")
|
||||
#expect(metadata.fixtureSHA256 == eventData.sha256Fingerprint())
|
||||
#expect(metadata.gradleTest.contains("NostrProtocolTest.emitCrossPlatformFixtures"))
|
||||
let generatorPatch = try String(contentsOf: fixtureURL(
|
||||
name: "AndroidLegacyPrivateEnvelopeB7f0b33dGenerator",
|
||||
extension: "patch"
|
||||
))
|
||||
#expect(metadata.generatorPatchSHA256 == Data(generatorPatch.utf8).sha256Fingerprint())
|
||||
#expect(generatorPatch.contains("fun emitCrossPlatformFixtures()"))
|
||||
#expect(generatorPatch.contains(metadata.recipientPrivateKey))
|
||||
#expect(envelope.isValidSignature())
|
||||
#expect(envelope.kind == NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue)
|
||||
#expect(envelope.tags == [["p", recipient.publicKeyHex]])
|
||||
|
||||
let layers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
#expect(layers.message.tags == [["p", recipient.publicKeyHex]])
|
||||
|
||||
let result = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
#expect(result.content == "legacy fixture from Android b7f0b33d")
|
||||
#expect(result.senderPubkey == metadata.senderPublicKey)
|
||||
}
|
||||
|
||||
@Test func decryptRejectsAlternateLegacyInnerTags() throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let otherRecipient = try NostrIdentity.generate()
|
||||
let invalidTagShapes = [
|
||||
[["p", otherRecipient.publicKeyHex]],
|
||||
[["p", recipient.publicKeyHex], ["p", recipient.publicKeyHex]],
|
||||
[["p", recipient.publicKeyHex, "unexpected"]],
|
||||
[["x", recipient.publicKeyHex]]
|
||||
]
|
||||
|
||||
for tags in invalidTagShapes {
|
||||
let envelope = try NostrProtocol.createLegacyPrivateEnvelopeForTesting(
|
||||
content: "invalid Android-shaped tags",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender,
|
||||
innerMessageTags: tags
|
||||
)
|
||||
expectInvalidEvent {
|
||||
_ = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func newPrivateEnvelopeRejectsInnerRecipientTag() throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let envelope = try NostrProtocol.createPrivateEnvelopeWithInnerTagsForTesting(
|
||||
content: "new format stays strict",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender,
|
||||
innerMessageTags: [["p", recipient.publicKeyHex]]
|
||||
)
|
||||
|
||||
expectInvalidEvent {
|
||||
_ = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func decryptsFrozenLegacyEnvelopeProducedByRelease733098bb() throws {
|
||||
let eventData = try Data(contentsOf: fixtureURL(
|
||||
name: "LegacyPrivateEnvelope733098bb"
|
||||
))
|
||||
let keyData = try Data(contentsOf: fixtureURL(
|
||||
name: "LegacyPrivateEnvelope733098bbRecipientKey"
|
||||
))
|
||||
let envelope = try JSONDecoder().decode(NostrEvent.self, from: eventData)
|
||||
let keyFixture = try JSONDecoder().decode(LegacyRecipientKeyFixture.self, from: keyData)
|
||||
let recipientKey = try #require(Data(hexString: keyFixture.recipientPrivateKey))
|
||||
let recipient = try NostrIdentity(privateKeyData: recipientKey)
|
||||
|
||||
#expect(envelope.isValidSignature())
|
||||
let result = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
#expect(result.content == "legacy fixture from 733098bb")
|
||||
#expect(result.senderPubkey == "2e3d79df7047204f02b726c574e256f8de1dd80510f7dcb8b0d12df13acb87e6")
|
||||
}
|
||||
|
||||
@Test func publicationBatchAlwaysDualPublishesForCoordinatedMigration() throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
|
||||
let migrationBatch = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||
content: "mixed-version",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
#expect(migrationBatch.map(\.kind) == [
|
||||
NostrProtocol.EventKind.privateEnvelope.rawValue,
|
||||
NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue
|
||||
])
|
||||
for envelope in migrationBatch {
|
||||
let result = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
#expect(result.content == "mixed-version")
|
||||
}
|
||||
|
||||
// The compatibility copy retains the exact released-iOS legacy shape,
|
||||
// which current Android also accepts: kinds 1059/13/14, v2 prefix, and
|
||||
// no inner tags. There is no wall-clock branch that can silently stop
|
||||
// old-client delivery.
|
||||
let compatibilityLayers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
|
||||
envelope: migrationBatch[1],
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
#expect(migrationBatch[1].content.hasPrefix("v2:"))
|
||||
#expect(compatibilityLayers.seal.kind == NostrProtocol.EventKind.legacyNIP59Seal.rawValue)
|
||||
#expect(compatibilityLayers.message.kind == NostrProtocol.EventKind.legacyNIP17DirectMessage.rawValue)
|
||||
#expect(compatibilityLayers.message.tags.isEmpty)
|
||||
}
|
||||
|
||||
@Test func mailboxLookbackCoversDeliveryWindowAndroidFuzzAndClockSkew() {
|
||||
let sentAt = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
let earliestAndroidTimestamp = sentAt.addingTimeInterval(
|
||||
-TransportConfig.nostrLegacyAndroidTimestampFuzzSeconds
|
||||
)
|
||||
let subscribeAtDeliveryBoundary = sentAt.addingTimeInterval(
|
||||
TransportConfig.nostrPrivateEnvelopeDeliveryWindowSeconds
|
||||
)
|
||||
let filterSince = subscribeAtDeliveryBoundary.addingTimeInterval(
|
||||
-TransportConfig.nostrDMSubscribeLookbackSeconds
|
||||
)
|
||||
|
||||
#expect(TransportConfig.nostrPrivateEnvelopeDeliveryWindowSeconds == 24 * 60 * 60)
|
||||
#expect(TransportConfig.nostrLegacyAndroidTimestampFuzzSeconds == 48 * 60 * 60)
|
||||
#expect(TransportConfig.nostrDMSubscribeClockSkewSeconds == 15 * 60)
|
||||
let expectedLookback: TimeInterval = 72 * 60 * 60 + 15 * 60
|
||||
#expect(TransportConfig.nostrDMSubscribeLookbackSeconds == expectedLookback)
|
||||
#expect(filterSince == earliestAndroidTimestamp.addingTimeInterval(-(15 * 60)))
|
||||
}
|
||||
|
||||
@Test func largePrivateEnvelopeFitsLayerSpecificExpansionLimits() throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
// Large enough that the nested Base64 seal exceeds the inner 32 KiB
|
||||
// cap, while the inner message JSON itself remains below that cap.
|
||||
let content = String(repeating: "A", count: 30 * 1024)
|
||||
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: content,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
let decrypted = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
|
||||
#expect(decrypted.content == content)
|
||||
#expect(envelope.content.utf8.count <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes)
|
||||
}
|
||||
|
||||
@Test func privateEnvelopeRejectsOversizedCiphertextBeforeDecoding() throws {
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let wrapper = try NostrIdentity.generate()
|
||||
let oversizedContent = NostrProtocol.privateEnvelopeContentPrefix
|
||||
+ String(
|
||||
repeating: "A",
|
||||
count: NostrProtocol.maximumPrivateEnvelopeCiphertextBytes
|
||||
)
|
||||
let event = NostrEvent(
|
||||
pubkey: wrapper.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .privateEnvelope,
|
||||
tags: [["p", recipient.publicKeyHex]],
|
||||
content: oversizedContent
|
||||
)
|
||||
let signed = try event.sign(with: wrapper.schnorrSigningKey())
|
||||
|
||||
expectInvalidCiphertext {
|
||||
_ = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: signed,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func privateEnvelopeRejectsOversizedPlaintextBeforeEncryption() throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let oversizedPlaintext = String(
|
||||
repeating: "x",
|
||||
count: NostrProtocol.maximumPrivateEnvelopePlaintextBytes + 1
|
||||
)
|
||||
|
||||
expectInvalidCiphertext {
|
||||
_ = try NostrProtocol.createPrivateEnvelope(
|
||||
content: oversizedPlaintext,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func privateEnvelopeRejectsOversizedNestedJSONBeforeParsing() {
|
||||
let oversizedJSON = String(
|
||||
repeating: "{",
|
||||
count: NostrProtocol.maximumPrivateEnvelopePlaintextBytes + 1
|
||||
)
|
||||
expectInvalidCiphertext {
|
||||
_ = try NostrProtocol.decodePrivateEnvelopeEventJSONForTesting(oversizedJSON)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func decryptDoesNotMisinterpretStandardNIP44PayloadAsLegacyBitChat() throws {
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let wrapper = try NostrIdentity.generate()
|
||||
// A valid NIP-44 v2 payload from the official test vectors. Its wire
|
||||
// format starts with a version byte in standard Base64, not BitChat's
|
||||
// historical `v2:` prefix.
|
||||
let standardPayload = "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb"
|
||||
let event = NostrEvent(
|
||||
pubkey: wrapper.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .legacyNIP59GiftWrap,
|
||||
tags: [["p", recipient.publicKeyHex]],
|
||||
content: standardPayload
|
||||
)
|
||||
let signed = try event.sign(with: wrapper.schnorrSigningKey())
|
||||
|
||||
expectInvalidCiphertext {
|
||||
_ = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: signed,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func decryptionFailsWithWrongRecipient() throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
@@ -96,68 +439,58 @@ struct NostrProtocolTests {
|
||||
let wrongRecipient = try NostrIdentity.generate()
|
||||
|
||||
// Create message for recipient
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: "Secret message",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
// Try to decrypt with wrong recipient
|
||||
if #available(macOS 14.4, iOS 17.4, *) {
|
||||
#expect(throws: CryptoKitError.authenticationFailure) {
|
||||
try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: wrongRecipient
|
||||
)
|
||||
}
|
||||
} else {
|
||||
#expect(throws: (any Error).self) {
|
||||
try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: wrongRecipient
|
||||
)
|
||||
}
|
||||
expectInvalidEvent {
|
||||
_ = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: wrongRecipient
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func decryptRejectsInvalidSealSignature() throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let giftWrap = try NostrProtocol.createPrivateMessageWithInvalidSealSignatureForTesting(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelopeWithInvalidSealSignatureForTesting(
|
||||
content: "forged signature",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
expectInvalidEvent {
|
||||
_ = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
_ = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func decryptRejectsSealRumorPubkeyMismatch() throws {
|
||||
@Test func decryptRejectsSealMessagePubkeyMismatch() throws {
|
||||
let claimedSender = try NostrIdentity.generate()
|
||||
let sealSigner = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let giftWrap = try NostrProtocol.createPrivateMessageWithMismatchedSealRumorPubkeyForTesting(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelopeWithMismatchedSealMessagePubkeyForTesting(
|
||||
content: "spoofed sender",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
rumorIdentity: claimedSender,
|
||||
messageIdentity: claimedSender,
|
||||
sealSignerIdentity: sealSigner
|
||||
)
|
||||
|
||||
expectInvalidEvent {
|
||||
_ = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
_ = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func testAckRoundTripNIP44V2_Delivered() throws {
|
||||
func deliveredAckRoundTripsInsidePrivateEnvelope() throws {
|
||||
// Identities
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
@@ -171,19 +504,17 @@ struct NostrProtocolTests {
|
||||
"Failed to embed delivered ack"
|
||||
)
|
||||
|
||||
// Create NIP-17 gift wrap to recipient (uses NIP-44 v2 internally)
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: embedded,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
// Ensure v2 format was used for ciphertext
|
||||
#expect(giftWrap.content.hasPrefix("v2:"))
|
||||
#expect(envelope.content.hasPrefix(NostrProtocol.privateEnvelopeContentPrefix))
|
||||
|
||||
// Decrypt as recipient
|
||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
|
||||
@@ -208,7 +539,7 @@ struct NostrProtocolTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test func ackRoundTripNIP44V2_ReadReceipt() throws {
|
||||
@Test func readReceiptRoundTripsInsidePrivateEnvelope() throws {
|
||||
// Identities
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
@@ -220,16 +551,16 @@ struct NostrProtocolTests {
|
||||
"Failed to embed read ack"
|
||||
)
|
||||
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||
content: embedded,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
#expect(giftWrap.content.hasPrefix("v2:"))
|
||||
#expect(envelope.content.hasPrefix(NostrProtocol.privateEnvelopeContentPrefix))
|
||||
|
||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: envelope,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
#expect(senderPubkey == sender.publicKeyHex)
|
||||
@@ -290,7 +621,73 @@ struct NostrProtocolTests {
|
||||
#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
|
||||
private struct LegacyRecipientKeyFixture: Decodable {
|
||||
let recipientPrivateKey: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case recipientPrivateKey = "recipient_private_key"
|
||||
}
|
||||
}
|
||||
|
||||
private struct AndroidLegacyEnvelopeFixture: Decodable {
|
||||
let androidCommit: String
|
||||
let generator: String
|
||||
let generatorPatch: String
|
||||
let generatorPatchSHA256: String
|
||||
let gradleTest: String
|
||||
let fixtureSHA256: String
|
||||
let recipientPrivateKey: String
|
||||
let senderPublicKey: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case androidCommit = "android_commit"
|
||||
case generator
|
||||
case generatorPatch = "generator_patch"
|
||||
case generatorPatchSHA256 = "generator_patch_sha256"
|
||||
case gradleTest = "gradle_test"
|
||||
case fixtureSHA256 = "fixture_sha256"
|
||||
case recipientPrivateKey = "recipient_private_key"
|
||||
case senderPublicKey = "sender_public_key"
|
||||
}
|
||||
}
|
||||
|
||||
private func fixtureURL(name: String, extension fileExtension: String = "json") throws -> URL {
|
||||
#if SWIFT_PACKAGE
|
||||
let bundle = Bundle.module
|
||||
#else
|
||||
let bundle = Bundle(for: MockKeychain.self)
|
||||
#endif
|
||||
return try #require(bundle.url(forResource: name, withExtension: fileExtension))
|
||||
}
|
||||
|
||||
private static func base64URLDecode(_ s: String) -> Data? {
|
||||
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||
let rem = str.count % 4
|
||||
@@ -308,4 +705,15 @@ struct NostrProtocolTests {
|
||||
Issue.record("Expected NostrError.invalidEvent, got \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func expectInvalidCiphertext(_ operation: () throws -> Void) {
|
||||
do {
|
||||
try operation()
|
||||
Issue.record("Expected NostrError.invalidCiphertext")
|
||||
} catch NostrError.invalidCiphertext {
|
||||
return
|
||||
} catch {
|
||||
Issue.record("Expected NostrError.invalidCiphertext, got \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,39 +145,6 @@ struct PacketsTests {
|
||||
#expect(decoded.capabilities?.rawValue == 0x0180)
|
||||
}
|
||||
|
||||
@Test
|
||||
func authenticatedPeerStateUsesVersionedCanonicalTLVs() throws {
|
||||
let signingKey = Data(repeating: 0xA5, count: 32)
|
||||
let packet = AuthenticatedPeerStatePacket(
|
||||
capabilities: [.privateMedia, .vouch],
|
||||
signingPublicKey: signingKey
|
||||
)
|
||||
|
||||
var encoded = try #require(packet.encode())
|
||||
#expect(encoded.prefix(5) == Data([0x01, 0x01, 0x02, 0x20, 0x01]))
|
||||
// Unknown TLVs are forward-compatible and do not alter v1 state.
|
||||
encoded.append(makeTLV(type: 0x7F, value: Data([0xCA, 0xFE])))
|
||||
|
||||
#expect(AuthenticatedPeerStatePacket.decode(from: encoded) == packet)
|
||||
}
|
||||
|
||||
@Test
|
||||
func authenticatedPeerStateRejectsMalformedAmbiguousOrUnknownVersion() {
|
||||
let key = Data(repeating: 0x44, count: 32)
|
||||
let capabilities = makeTLV(type: 0x01, value: Data([0x00, 0x01]))
|
||||
let signing = makeTLV(type: 0x02, value: key)
|
||||
|
||||
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x02]) + capabilities + signing) == nil)
|
||||
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + signing) == nil)
|
||||
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + capabilities + capabilities + signing) == nil)
|
||||
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01, 0x01, 0x00]) + signing) == nil)
|
||||
// 0x0001 is non-minimal little endian; the canonical form is [0x01].
|
||||
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + makeTLV(type: 0x01, value: Data([0x01, 0x00])) + signing) == nil)
|
||||
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + capabilities + makeTLV(type: 0x02, value: Data(key.dropLast()))) == nil)
|
||||
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + capabilities + Data(signing.dropLast())) == nil)
|
||||
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + makeTLV(type: 0x01, value: Data(repeating: 0x01, count: 9)) + signing) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func privateMessagePacketRejectsUnknownTypeAndTruncation() {
|
||||
let unknownTLV = Data([0x7F, 0x01, 0x41])
|
||||
|
||||
@@ -6,7 +6,6 @@ import Testing
|
||||
struct BLEAnnounceHandlerTests {
|
||||
private final class Recorder {
|
||||
var existingNoisePublicKey: Data?
|
||||
var authenticatedSigningPublicKey: Data?
|
||||
var signatureValid = true
|
||||
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
|
||||
var linkBoundToOtherPeer = false
|
||||
@@ -38,7 +37,6 @@ struct BLEAnnounceHandlerTests {
|
||||
messageTTL: TransportConfig.messageTTLDefault,
|
||||
now: { now },
|
||||
existingNoisePublicKey: { _ in recorder.existingNoisePublicKey },
|
||||
authenticatedSigningPublicKey: { _ in recorder.authenticatedSigningPublicKey },
|
||||
verifySignature: { packet, signingPublicKey in
|
||||
recorder.verifySignatureCalls.append((packet, signingPublicKey))
|
||||
return recorder.signatureValid
|
||||
|
||||
@@ -169,23 +169,6 @@ struct BLEAnnounceHandlingPolicyTests {
|
||||
#expect(decision.isVerified)
|
||||
}
|
||||
|
||||
@Test
|
||||
func trustPolicyRejectsSigningKeyReplacementAfterNoiseBinding() {
|
||||
let noiseKey = Data(repeating: 0xCC, count: 32)
|
||||
let boundSigningKey = Data(repeating: 0x11, count: 32)
|
||||
|
||||
let decision = BLEAnnounceTrustPolicy.evaluate(
|
||||
hasSignature: true,
|
||||
signatureValid: true,
|
||||
existingNoisePublicKey: noiseKey,
|
||||
announcedNoisePublicKey: noiseKey,
|
||||
authenticatedSigningPublicKey: boundSigningKey,
|
||||
announcedSigningPublicKey: Data(repeating: 0x22, count: 32)
|
||||
)
|
||||
|
||||
#expect(decision == .reject(.authenticatedSigningKeyMismatch))
|
||||
}
|
||||
|
||||
@Test
|
||||
func responsePolicyConnectsOnlyForDirectNewOrReconnectedPeers() {
|
||||
let directNew = BLEAnnounceResponsePolicy.plan(
|
||||
|
||||
@@ -33,7 +33,6 @@ struct BLEFileTransferHandlerTests {
|
||||
recorder.signatureVerifyCount += 1
|
||||
return recorder.signatureVerifies
|
||||
},
|
||||
localSigningPublicKey: { [sampleSigningKey] in sampleSigningKey },
|
||||
signedSenderDisplayName: { _, peerID in
|
||||
recorder.signedNameQueries.append(peerID)
|
||||
return recorder.signedName
|
||||
@@ -93,11 +92,12 @@ struct BLEFileTransferHandlerTests {
|
||||
@Test
|
||||
func selfEchoIsDropped() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.signatureVerifies = true
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = try makeFileTransferPacket(sender: localPeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8), ttl: 3)
|
||||
|
||||
#expect(!handler.handle(packet, from: localPeerID))
|
||||
// The relay pipeline already suppresses self-originated packets, so the
|
||||
// handler reports "relayable" rather than treating the echo as forged.
|
||||
#expect(handler.handle(packet, from: localPeerID))
|
||||
|
||||
expectNoSideEffects(recorder)
|
||||
}
|
||||
@@ -120,12 +120,7 @@ struct BLEFileTransferHandlerTests {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = try makeFileTransferPacket(
|
||||
sender: remotePeerID,
|
||||
mimeType: "application/pdf",
|
||||
content: Data("%PDF-1.7".utf8),
|
||||
hasSignature: false
|
||||
)
|
||||
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8))
|
||||
|
||||
// Failed sender authentication must also stop the packet from being
|
||||
// relayed to downstream nodes.
|
||||
@@ -134,7 +129,7 @@ struct BLEFileTransferHandlerTests {
|
||||
// Broadcast files carry an attacker-controllable senderID, so — like
|
||||
// public messages — a connected-but-unverified peer must present a valid
|
||||
// packet signature. No signing key + no signed identity means dropped.
|
||||
#expect(recorder.signedNameQueries.isEmpty)
|
||||
#expect(recorder.signedNameQueries == [remotePeerID])
|
||||
#expect(recorder.trackedPackets.isEmpty)
|
||||
#expect(recorder.deliveredMessages.isEmpty)
|
||||
}
|
||||
@@ -158,11 +153,12 @@ struct BLEFileTransferHandlerTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func signedSelfBroadcastReplayIsDelivered() throws {
|
||||
// Our own broadcast file replayed via gossip sync arrives with ttl==0;
|
||||
// it is verified against our local signing key before delivery.
|
||||
func selfBroadcastReplayIsDeliveredWithoutSignatureCheck() throws {
|
||||
// Our own broadcast file replayed via gossip sync arrives with ttl==0
|
||||
// (so it is not treated as a self-echo) and cannot be verified against
|
||||
// the peer registry — it must still be accepted, matching
|
||||
// BLEPublicMessageHandler's self exemption.
|
||||
let recorder = Recorder()
|
||||
recorder.signatureVerifies = true
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = try makeFileTransferPacket(
|
||||
sender: localPeerID,
|
||||
@@ -173,7 +169,7 @@ struct BLEFileTransferHandlerTests {
|
||||
|
||||
#expect(handler.handle(packet, from: localPeerID))
|
||||
|
||||
#expect(recorder.signatureVerifyCount == 1)
|
||||
#expect(recorder.signatureVerifyCount == 0)
|
||||
#expect(recorder.signedNameQueries.isEmpty)
|
||||
#expect(recorder.deliveredMessages.count == 1)
|
||||
#expect(recorder.deliveredMessages.first?.sender == "Me")
|
||||
@@ -209,8 +205,7 @@ struct BLEFileTransferHandlerTests {
|
||||
sender: remotePeerID,
|
||||
mimeType: "audio/mp4",
|
||||
content: m4a,
|
||||
fileName: "voice_1122334455667788",
|
||||
hasSignature: false
|
||||
fileName: "voice_1122334455667788"
|
||||
)
|
||||
|
||||
// The spoofed note must be dropped locally AND not relayed onward.
|
||||
@@ -220,7 +215,7 @@ struct BLEFileTransferHandlerTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func rawDirectedFileWithoutVerifiableSignatureIsDroppedWithoutWriteOrRelay() throws {
|
||||
func privateFileFromConnectedUnverifiedPeerIsAccepted() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
@@ -228,25 +223,23 @@ struct BLEFileTransferHandlerTests {
|
||||
sender: remotePeerID,
|
||||
mimeType: "application/pdf",
|
||||
content: Data("%PDF-1.7".utf8),
|
||||
recipientID: Data(hexString: localPeerID.id),
|
||||
hasSignature: false
|
||||
recipientID: Data(hexString: localPeerID.id)
|
||||
)
|
||||
|
||||
#expect(!handler.handle(packet, from: remotePeerID))
|
||||
#expect(handler.handle(packet, from: remotePeerID))
|
||||
|
||||
// Directed transfers keep the lenient connected-peer path (no broadcast
|
||||
// exposure); no signature check is required.
|
||||
#expect(recorder.signatureVerifyCount == 0)
|
||||
#expect(recorder.signedNameQueries.isEmpty)
|
||||
#expect(recorder.trackedPackets.isEmpty)
|
||||
#expect(recorder.quotaReservations.isEmpty)
|
||||
#expect(recorder.saveCalls.isEmpty)
|
||||
#expect(recorder.deliveredMessages.isEmpty)
|
||||
#expect(recorder.deliveredMessages.count == 1)
|
||||
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func fileDirectedToAnotherPeerIsIgnored() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)]
|
||||
recorder.signatureVerifies = true
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = try makeFileTransferPacket(
|
||||
sender: remotePeerID,
|
||||
@@ -267,8 +260,7 @@ struct BLEFileTransferHandlerTests {
|
||||
@Test
|
||||
func privateFileUpdatesLastSeenAndDeliversPrivateMessage() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)]
|
||||
recorder.signatureVerifies = true
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = try makeFileTransferPacket(
|
||||
sender: remotePeerID,
|
||||
@@ -290,56 +282,6 @@ struct BLEFileTransferHandlerTests {
|
||||
#expect(recorder.deliveredMessages.first?.deliveryStatus == .delivered(to: "Me", at: Date(timeIntervalSince1970: 900)))
|
||||
}
|
||||
|
||||
@Test
|
||||
func decryptedPrivateFileUsesValidationQuotaAndPrivateDeliveryWithoutRawSignature() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
||||
let file = BitchatFilePacket(
|
||||
fileName: "secret.jpg",
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "image/jpeg",
|
||||
content: content
|
||||
)
|
||||
let payload = try #require(file.encode())
|
||||
let timestamp = Date(timeIntervalSince1970: 1_234)
|
||||
|
||||
#expect(handler.handlePrivatePayload(payload, from: remotePeerID, timestamp: timestamp))
|
||||
|
||||
#expect(recorder.signatureVerifyCount == 0)
|
||||
#expect(recorder.signedNameQueries.isEmpty)
|
||||
#expect(recorder.trackedPackets.isEmpty)
|
||||
#expect(recorder.quotaReservations == [content.count])
|
||||
#expect(recorder.saveCalls.first?.data == content)
|
||||
#expect(recorder.lastSeenUpdates == [remotePeerID])
|
||||
#expect(recorder.deliveredMessages.count == 1)
|
||||
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
||||
#expect(recorder.deliveredMessages.first?.timestamp == timestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
func decryptedPrivateFileOverPayloadCapIsRejectedBeforeQuotaOrDiskWrite() {
|
||||
let recorder = Recorder()
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let oversizedCount = FileTransferLimits.maxPayloadBytes + 1
|
||||
var length = UInt32(oversizedCount).bigEndian
|
||||
var payload = Data([0x04]) // BitchatFilePacket CONTENT TLV
|
||||
withUnsafeBytes(of: &length) { payload.append(contentsOf: $0) }
|
||||
payload.append(Data(repeating: 0x41, count: oversizedCount))
|
||||
|
||||
#expect(!handler.handlePrivatePayload(
|
||||
payload,
|
||||
from: remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||
))
|
||||
|
||||
#expect(recorder.quotaReservations.isEmpty)
|
||||
#expect(recorder.saveCalls.isEmpty)
|
||||
#expect(recorder.lastSeenUpdates.isEmpty)
|
||||
#expect(recorder.deliveredMessages.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func malformedPayloadIsTrackedForSyncButDropped() {
|
||||
let recorder = Recorder()
|
||||
@@ -352,7 +294,7 @@ struct BLEFileTransferHandlerTests {
|
||||
recipientID: nil,
|
||||
timestamp: 900_000,
|
||||
payload: Data([0x01, 0x02, 0x03]),
|
||||
signature: Data(repeating: 0x5A, count: 64),
|
||||
signature: nil,
|
||||
ttl: TransportConfig.messageTTLDefault
|
||||
)
|
||||
|
||||
@@ -461,8 +403,7 @@ struct BLEFileTransferHandlerTests {
|
||||
content: Data,
|
||||
ttl: UInt8 = TransportConfig.messageTTLDefault,
|
||||
recipientID: Data? = nil,
|
||||
fileName: String = "sample",
|
||||
hasSignature: Bool = true
|
||||
fileName: String = "sample"
|
||||
) throws -> BitchatPacket {
|
||||
let filePacket = BitchatFilePacket(
|
||||
fileName: fileName,
|
||||
@@ -477,7 +418,7 @@ struct BLEFileTransferHandlerTests {
|
||||
recipientID: recipientID,
|
||||
timestamp: 900_000,
|
||||
payload: payload,
|
||||
signature: hasSignature ? Data(repeating: 0x5A, count: 64) : nil,
|
||||
signature: nil,
|
||||
ttl: ttl
|
||||
)
|
||||
}
|
||||
|
||||
@@ -117,35 +117,6 @@ struct BLEFragmentAssemblyBufferTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func encryptedPrivateFileAssemblyGetsFramedFileHeadroom() throws {
|
||||
var buffer = BLEFragmentAssemblyBuffer()
|
||||
let fragmentID = Data(repeating: 0x15, count: 8)
|
||||
let first = try #require(BLEFragmentHeader(packet: makeFragmentPacket(
|
||||
fragmentID: fragmentID,
|
||||
index: 0,
|
||||
total: 2,
|
||||
originalType: MessageType.noiseEncrypted.rawValue,
|
||||
fragmentData: Data(repeating: 0x01, count: FileTransferLimits.maxPayloadBytes)
|
||||
)))
|
||||
let second = try #require(BLEFragmentHeader(packet: makeFragmentPacket(
|
||||
fragmentID: fragmentID,
|
||||
index: 1,
|
||||
total: 2,
|
||||
originalType: MessageType.noiseEncrypted.rawValue,
|
||||
fragmentData: Data([0x02])
|
||||
)))
|
||||
|
||||
_ = buffer.append(first, maxInFlightAssemblies: 8)
|
||||
let result = buffer.append(second, maxInFlightAssemblies: 8)
|
||||
|
||||
if case let .complete(_, data, _) = result {
|
||||
#expect(data.count == FileTransferLimits.maxPayloadBytes + 1)
|
||||
} else {
|
||||
Issue.record("Expected encrypted private-file assembly to use framed-file limit")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func removeExpiredDropsOldAssemblies() throws {
|
||||
var buffer = BLEFragmentAssemblyBuffer()
|
||||
|
||||
@@ -9,7 +9,6 @@ struct BLENoisePacketHandlerTests {
|
||||
private final class Recorder {
|
||||
var handshakeResult: Result<Data?, Error> = .success(nil)
|
||||
var hasSession = false
|
||||
let sessionGeneration = UUID()
|
||||
var decryptResult: Result<Data, Error> = .success(Data())
|
||||
|
||||
var processedHandshakes: [(peerID: PeerID, message: Data)] = []
|
||||
@@ -19,7 +18,6 @@ struct BLENoisePacketHandlerTests {
|
||||
var lastSeenUpdates: [PeerID] = []
|
||||
var decryptCalls: [(payload: Data, peerID: PeerID)] = []
|
||||
var clearedSessions: [PeerID] = []
|
||||
var authenticatedPeerStates: [(peerID: PeerID, payload: Data, generation: UUID)] = []
|
||||
var deliveries: [(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)] = []
|
||||
/// Ordered side-effect log to assert recovery sequencing.
|
||||
var events: [String] = []
|
||||
@@ -58,18 +56,12 @@ struct BLENoisePacketHandlerTests {
|
||||
},
|
||||
decrypt: { payload, peerID in
|
||||
recorder.decryptCalls.append((payload, peerID))
|
||||
return BLENoiseDecryptionResult(
|
||||
plaintext: try recorder.decryptResult.get(),
|
||||
sessionGeneration: recorder.sessionGeneration
|
||||
)
|
||||
return try recorder.decryptResult.get()
|
||||
},
|
||||
clearSession: { peerID in
|
||||
recorder.clearedSessions.append(peerID)
|
||||
recorder.events.append("clearSession")
|
||||
},
|
||||
handleAuthenticatedPeerState: { peerID, payload, generation in
|
||||
recorder.authenticatedPeerStates.append((peerID, payload, generation))
|
||||
},
|
||||
deliverNoisePayload: { peerID, type, payload, timestamp in
|
||||
recorder.deliveries.append((peerID, type, payload, timestamp))
|
||||
}
|
||||
@@ -160,21 +152,6 @@ struct BLENoisePacketHandlerTests {
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func peerIdentityMismatchDoesNotRecreateHandshakeState() {
|
||||
let recorder = Recorder()
|
||||
recorder.handshakeResult = .failure(NoiseSessionError.peerIdentityMismatch)
|
||||
recorder.hasSession = false
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = makeHandshakePacket(recipientID: Data(hexString: localPeerID.id))
|
||||
|
||||
#expect(!handler.handleHandshake(packet, from: remotePeerID))
|
||||
|
||||
#expect(recorder.hasSessionQueries.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
#expect(recorder.broadcastPackets.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: Encrypted
|
||||
|
||||
@Test
|
||||
@@ -229,25 +206,6 @@ struct BLENoisePacketHandlerTests {
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func authenticatedPeerStateIsConsumedByTransportNotDeliveredToUI() {
|
||||
let recorder = Recorder()
|
||||
recorder.decryptResult = .success(Data([
|
||||
NoisePayloadType.authenticatedPeerState.rawValue,
|
||||
0x01, 0x02, 0x03
|
||||
]))
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = makeEncryptedPacket(recipientID: Data(hexString: localPeerID.id))
|
||||
|
||||
handler.handleEncrypted(packet, from: remotePeerID)
|
||||
|
||||
#expect(recorder.authenticatedPeerStates.count == 1)
|
||||
#expect(recorder.authenticatedPeerStates.first?.peerID == remotePeerID)
|
||||
#expect(recorder.authenticatedPeerStates.first?.payload == Data([0x01, 0x02, 0x03]))
|
||||
#expect(recorder.authenticatedPeerStates.first?.generation == recorder.sessionGeneration)
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func emptyDecryptedPayloadIsIgnored() {
|
||||
let recorder = Recorder()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
struct BLENoisePayloadFactoryTests {
|
||||
@@ -32,63 +31,4 @@ struct BLENoisePayloadFactoryTests {
|
||||
|
||||
#expect(payload == Data([NoisePayloadType.verifyChallenge.rawValue, 0xCA, 0xFE]))
|
||||
}
|
||||
|
||||
@Test
|
||||
func privateFilePayloadPrefixesCanonicalFilePacket() throws {
|
||||
let content = Data("%PDF-secret".utf8)
|
||||
let file = BitchatFilePacket(
|
||||
fileName: "secret.pdf",
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "application/pdf",
|
||||
content: content
|
||||
)
|
||||
|
||||
let payload = try #require(BLENoisePayloadFactory.privateFile(file))
|
||||
|
||||
#expect(payload.first == 0x20, "Encrypted files must use Android's deployed wire value")
|
||||
let decoded = try #require(BitchatFilePacket.decode(Data(payload.dropFirst())))
|
||||
#expect(decoded.fileName == "secret.pdf")
|
||||
#expect(decoded.mimeType == "application/pdf")
|
||||
#expect(decoded.content == content)
|
||||
}
|
||||
|
||||
@Test
|
||||
func androidB7f0b33PrivateFilePlaintextFixtureIsByteCompatible() throws {
|
||||
// Runtime-emitted by Android commit b7f0b33d from
|
||||
// BitchatFilePacket("a.txt", 3, "text/plain", [01, 02, 03]) and
|
||||
// NoisePayload(type = FILE_TRANSFER, data = file.encode()).encode().
|
||||
let fixtureHex = "20010005612e7478740200040000000303000a746578742f706c61696e0400000003010203"
|
||||
let fixture = try #require(Data(hexString: fixtureHex))
|
||||
|
||||
let typed = try #require(NoisePayload.decode(fixture))
|
||||
#expect(typed.type == .privateFile)
|
||||
let file = try #require(BitchatFilePacket.decode(typed.data))
|
||||
#expect(file.fileName == "a.txt")
|
||||
#expect(file.fileSize == 3)
|
||||
#expect(file.mimeType == "text/plain")
|
||||
#expect(file.content == Data([0x01, 0x02, 0x03]))
|
||||
#expect(BLENoisePayloadFactory.privateFile(file) == fixture)
|
||||
}
|
||||
|
||||
@Test
|
||||
func prereleasePrivateFileTypeCanonicalizesOnDecode() throws {
|
||||
let encoded = Data([NoisePayloadType.prereleasePrivateFileRawValue, 0xCA, 0xFE])
|
||||
let decoded = try #require(NoisePayload.decode(encoded))
|
||||
|
||||
#expect(decoded.type == .privateFile)
|
||||
#expect(decoded.data == Data([0xCA, 0xFE]))
|
||||
#expect(decoded.encode().first == 0x20)
|
||||
}
|
||||
|
||||
@Test
|
||||
func authenticatedPeerStateUsesPermanent0x21Type() throws {
|
||||
let state = AuthenticatedPeerStatePacket(
|
||||
capabilities: .privateMedia,
|
||||
signingPublicKey: Data(repeating: 0x77, count: 32)
|
||||
)
|
||||
let encoded = try #require(BLENoisePayloadFactory.authenticatedPeerState(state))
|
||||
|
||||
#expect(encoded.first == 0x21)
|
||||
#expect(AuthenticatedPeerStatePacket.decode(from: Data(encoded.dropFirst())) == state)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,10 +48,7 @@ struct BLENoiseSessionQueuesTests {
|
||||
queues.appendTypedPayload(Data([0x01]), for: peerID)
|
||||
queues.appendTypedPayload(Data([0x02]), for: peerID)
|
||||
|
||||
#expect(queues.takeTypedPayloads(for: peerID) == [
|
||||
BLEPendingTypedPayload(payload: Data([0x01]), transferId: nil),
|
||||
BLEPendingTypedPayload(payload: Data([0x02]), transferId: nil)
|
||||
])
|
||||
#expect(queues.takeTypedPayloads(for: peerID) == [Data([0x01]), Data([0x02])])
|
||||
#expect(queues.takeTypedPayloads(for: peerID).isEmpty)
|
||||
#expect(queues.takePrivateMessages(for: peerID).map(\.messageID) == ["m1"])
|
||||
}
|
||||
@@ -67,21 +64,4 @@ struct BLENoiseSessionQueuesTests {
|
||||
|
||||
#expect(queues.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func transferIDSurvivesHandshakeQueueAndCanBeCancelledBeforeDrain() {
|
||||
let peerID = PeerID(str: "aaaaaaaaaaaaaaaa")
|
||||
var queues = BLENoiseSessionQueues()
|
||||
|
||||
queues.appendTypedPayload(Data([0x20, 0xAA]), transferId: "media-1", for: peerID)
|
||||
queues.appendTypedPayload(Data([0x01, 0xBB]), for: peerID)
|
||||
|
||||
let removed = queues.removeTypedPayload(transferId: "media-1")
|
||||
let removedAgain = queues.removeTypedPayload(transferId: "media-1")
|
||||
#expect(removed)
|
||||
#expect(!removedAgain)
|
||||
#expect(queues.takeTypedPayloads(for: peerID) == [
|
||||
BLEPendingTypedPayload(payload: Data([0x01, 0xBB]), transferId: nil)
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,58 +108,6 @@ struct BLEOutboundFragmentPlannerTests {
|
||||
) == nil)
|
||||
}
|
||||
|
||||
@Test("private media v1 accepts exactly 256 fragments and rejects 257")
|
||||
func privateMediaCrossPlatformFragmentBoundary() throws {
|
||||
let maxPayload = makePayload(count: 160 * 1024, seed: 0xFACE_CAFE)
|
||||
|
||||
func plan(payloadCount: Int) throws -> BLEOutboundFragmentPlan {
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: "0011223344556677") ?? Data(),
|
||||
recipientID: Data(hexString: "8877665544332211"),
|
||||
timestamp: 0x0102030405,
|
||||
payload: Data(maxPayload.prefix(payloadCount)),
|
||||
signature: nil,
|
||||
ttl: 3,
|
||||
version: 2
|
||||
)
|
||||
return try #require(BLEOutboundFragmentPlanner.makePlan(
|
||||
for: BLEOutboundFragmentTransferRequest(
|
||||
packet: packet,
|
||||
pad: false,
|
||||
maxChunk: nil,
|
||||
directedPeer: PeerID(str: "8877665544332211"),
|
||||
transferId: "boundary"
|
||||
),
|
||||
defaultChunkSize: TransportConfig.bleDefaultFragmentSize,
|
||||
bleMaxMTU: 512,
|
||||
fragmentID: Data(repeating: 0xD4, count: 8)
|
||||
))
|
||||
}
|
||||
|
||||
func firstPlan(withAtLeast target: Int) throws -> BLEOutboundFragmentPlan {
|
||||
var low = 1
|
||||
var high = maxPayload.count
|
||||
while low < high {
|
||||
let mid = low + (high - low) / 2
|
||||
if try plan(payloadCount: mid).totalFragments >= target {
|
||||
high = mid
|
||||
} else {
|
||||
low = mid + 1
|
||||
}
|
||||
}
|
||||
return try plan(payloadCount: low)
|
||||
}
|
||||
|
||||
let at256 = try firstPlan(withAtLeast: 256)
|
||||
let at257 = try firstPlan(withAtLeast: 257)
|
||||
|
||||
#expect(at256.totalFragments == 256)
|
||||
#expect(BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible(at256))
|
||||
#expect(at257.totalFragments == 257)
|
||||
#expect(!BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible(at257))
|
||||
}
|
||||
|
||||
private func makePacket(
|
||||
payload: Data,
|
||||
route: [Data]? = nil,
|
||||
|
||||
@@ -20,24 +20,6 @@ struct BLEOutboundFragmentTransferSchedulerTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func explicitTransferIDReservesEncryptedPrivateFileFragments() {
|
||||
var scheduler = BLEOutboundFragmentTransferScheduler()
|
||||
let request = makeRequest(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
transferId: "private-media"
|
||||
)
|
||||
|
||||
let result = scheduler.submit(request, maxConcurrentTransfers: 1)
|
||||
|
||||
if case let .start(_, reservedTransferId) = result {
|
||||
#expect(reservedTransferId == "private-media")
|
||||
#expect(scheduler.activeCount == 1)
|
||||
} else {
|
||||
Issue.record("Expected encrypted private media to reserve its progress slot")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func submitQueuesFileTransferWhenSlotsAreFull() {
|
||||
var scheduler = BLEOutboundFragmentTransferScheduler()
|
||||
|
||||
@@ -42,37 +42,6 @@ struct BLEPeerRegistryTests {
|
||||
#expect(registry.info(for: peerID)?.nickname == "alice-renamed")
|
||||
}
|
||||
|
||||
@Test("registry preserves absent versus explicit empty capabilities")
|
||||
func capabilitiesPresenceIsPreserved() {
|
||||
var registry = BLEPeerRegistry()
|
||||
let oldPeer = PeerID(str: "1122334455667788")
|
||||
let modernPeer = PeerID(str: "8877665544332211")
|
||||
|
||||
_ = registry.upsertVerifiedAnnounce(
|
||||
peerID: oldPeer,
|
||||
nickname: "old",
|
||||
noisePublicKey: Data(repeating: 0x11, count: 32),
|
||||
signingPublicKey: Data(repeating: 0x12, count: 32),
|
||||
isConnected: true,
|
||||
now: Date(),
|
||||
capabilities: nil
|
||||
)
|
||||
_ = registry.upsertVerifiedAnnounce(
|
||||
peerID: modernPeer,
|
||||
nickname: "modern",
|
||||
noisePublicKey: Data(repeating: 0x21, count: 32),
|
||||
signingPublicKey: Data(repeating: 0x22, count: 32),
|
||||
isConnected: true,
|
||||
now: Date(),
|
||||
capabilities: []
|
||||
)
|
||||
|
||||
#expect(registry.capabilities(for: oldPeer).isEmpty)
|
||||
#expect(!registry.capabilitiesWereExplicitlyAdvertised(for: oldPeer))
|
||||
#expect(registry.capabilities(for: modernPeer).isEmpty)
|
||||
#expect(registry.capabilitiesWereExplicitlyAdvertised(for: modernPeer))
|
||||
}
|
||||
|
||||
@Test("reachability keeps recent verified offline peers only when mesh is attached")
|
||||
func reachabilityRequiresMeshAttachmentForOfflinePeers() {
|
||||
let offlinePeer = PeerID(str: "1122334455667788")
|
||||
|
||||
@@ -91,266 +91,39 @@ struct NoiseEncryptionServiceTests {
|
||||
func handshakeEncryptionAndFingerprintLifecycle() async throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||
let alicePeerID = PeerID(str: "0011223344556677")
|
||||
let bobPeerID = PeerID(str: "8899aabbccddeeff")
|
||||
let recorder = AuthenticationRecorder()
|
||||
|
||||
#expect(alice.onPeerAuthenticated == nil)
|
||||
#expect(bob.onPeerAuthenticatedWithGeneration == nil)
|
||||
alice.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
|
||||
bob.onPeerAuthenticated = recorder.record(peerID:fingerprint:)
|
||||
bob.onPeerAuthenticatedWithGeneration = recorder.record(
|
||||
peerID:fingerprint:sessionGeneration:
|
||||
)
|
||||
|
||||
try establishSessions(alice: alice, bob: bob)
|
||||
try establishSessions(alice: alice, bob: bob, alicePeerID: alicePeerID, bobPeerID: bobPeerID)
|
||||
|
||||
let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 5.0)
|
||||
#expect(authenticated)
|
||||
let generationAuthenticated = await TestHelpers.waitUntil(
|
||||
{ recorder.generationCount >= 1 },
|
||||
timeout: 5.0
|
||||
)
|
||||
#expect(generationAuthenticated)
|
||||
#expect(alice.hasEstablishedSession(with: bobPeerID))
|
||||
#expect(bob.hasEstablishedSession(with: alicePeerID))
|
||||
#expect(alice.hasSession(with: bobPeerID))
|
||||
#expect(bob.hasSession(with: alicePeerID))
|
||||
#expect(alice.getPeerPublicKeyData(bobPeerID)?.count == 32)
|
||||
#expect(bob.getPeerPublicKeyData(alicePeerID)?.count == 32)
|
||||
#expect(alice.getPeerFingerprint(bobPeerID) != nil)
|
||||
#expect(bob.getPeerFingerprint(alicePeerID) != nil)
|
||||
#expect(recorder.generation(for: alicePeerID) == bob.sessionGeneration(for: alicePeerID))
|
||||
#expect(alice.hasEstablishedSession(with: alicePeerID))
|
||||
#expect(bob.hasEstablishedSession(with: bobPeerID))
|
||||
#expect(alice.hasSession(with: alicePeerID))
|
||||
#expect(bob.hasSession(with: bobPeerID))
|
||||
#expect(alice.getPeerPublicKeyData(alicePeerID)?.count == 32)
|
||||
#expect(bob.getPeerPublicKeyData(bobPeerID)?.count == 32)
|
||||
#expect(alice.getPeerFingerprint(alicePeerID) != nil)
|
||||
#expect(bob.getPeerFingerprint(bobPeerID) != nil)
|
||||
|
||||
let plaintext = Data("secret payload".utf8)
|
||||
let ciphertext = try alice.encrypt(plaintext, for: bobPeerID)
|
||||
let decrypted = try bob.decrypt(ciphertext, from: alicePeerID)
|
||||
let ciphertext = try alice.encrypt(plaintext, for: alicePeerID)
|
||||
let decrypted = try bob.decrypt(ciphertext, from: bobPeerID)
|
||||
#expect(decrypted == plaintext)
|
||||
|
||||
alice.clearSession(for: bobPeerID)
|
||||
#expect(!alice.hasSession(with: bobPeerID))
|
||||
#expect(alice.getPeerFingerprint(bobPeerID) == nil)
|
||||
alice.clearSession(for: alicePeerID)
|
||||
#expect(!alice.hasSession(with: alicePeerID))
|
||||
#expect(alice.getPeerFingerprint(alicePeerID) == nil)
|
||||
|
||||
bob.clearEphemeralStateForPanic()
|
||||
#expect(!bob.hasSession(with: alicePeerID))
|
||||
#expect(bob.getPeerFingerprint(alicePeerID) == nil)
|
||||
}
|
||||
|
||||
@Test("Handshake rejects a claimed peer ID that does not match the authenticated static key")
|
||||
func handshakeRejectsClaimedPeerIDStaticKeyMismatch() async throws {
|
||||
let receiver = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let claimedAlice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let receiverPeerID = PeerID(publicKey: receiver.getStaticPublicKeyData())
|
||||
let claimedAlicePeerID = PeerID(publicKey: claimedAlice.getStaticPublicKeyData())
|
||||
let recorder = AuthenticationRecorder()
|
||||
receiver.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
|
||||
|
||||
let message1 = try mallory.initiateHandshake(with: receiverPeerID)
|
||||
let message2 = try #require(
|
||||
try receiver.processHandshakeMessage(from: claimedAlicePeerID, message: message1)
|
||||
)
|
||||
let message3 = try #require(
|
||||
try mallory.processHandshakeMessage(from: receiverPeerID, message: message2)
|
||||
)
|
||||
|
||||
do {
|
||||
_ = try receiver.processHandshakeMessage(from: claimedAlicePeerID, message: message3)
|
||||
Issue.record("Expected the authenticated Mallory key to be rejected for Alice's peer ID")
|
||||
} catch let error as NoiseSessionError {
|
||||
#expect(error == .peerIdentityMismatch)
|
||||
} catch {
|
||||
Issue.record("Unexpected mismatch error: \(error)")
|
||||
}
|
||||
|
||||
#expect(!receiver.hasSession(with: claimedAlicePeerID))
|
||||
let emittedAuthentication = await TestHelpers.waitUntil(
|
||||
{ recorder.count > 0 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!emittedAuthentication)
|
||||
}
|
||||
|
||||
@Test("Failed forged replacement preserves the established peer session")
|
||||
func forgedReplacementPreservesEstablishedSession() async throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let receiver = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||
let receiverPeerID = PeerID(publicKey: receiver.getStaticPublicKeyData())
|
||||
let recorder = AuthenticationRecorder()
|
||||
receiver.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
|
||||
|
||||
try establishSessions(alice: alice, bob: receiver)
|
||||
let initialAuthentication = await TestHelpers.waitUntil(
|
||||
{ recorder.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(initialAuthentication)
|
||||
|
||||
let before = try alice.encrypt(Data("before".utf8), for: receiverPeerID)
|
||||
#expect(try receiver.decrypt(before, from: alicePeerID) == Data("before".utf8))
|
||||
|
||||
let forgedMessage1 = try mallory.initiateHandshake(with: receiverPeerID)
|
||||
let forgedMessage2 = try #require(
|
||||
try receiver.processHandshakeMessage(from: alicePeerID, message: forgedMessage1)
|
||||
)
|
||||
// The replacement has not authenticated yet; the working Alice
|
||||
// transport session must remain available throughout the candidate.
|
||||
#expect(receiver.hasEstablishedSession(with: alicePeerID))
|
||||
let forgedMessage3 = try #require(
|
||||
try mallory.processHandshakeMessage(from: receiverPeerID, message: forgedMessage2)
|
||||
)
|
||||
|
||||
do {
|
||||
_ = try receiver.processHandshakeMessage(from: alicePeerID, message: forgedMessage3)
|
||||
Issue.record("Expected forged replacement to fail peer binding")
|
||||
} catch let error as NoiseSessionError {
|
||||
#expect(error == .peerIdentityMismatch)
|
||||
} catch {
|
||||
Issue.record("Unexpected replacement error: \(error)")
|
||||
}
|
||||
|
||||
#expect(receiver.hasEstablishedSession(with: alicePeerID))
|
||||
let after = try alice.encrypt(Data("after".utf8), for: receiverPeerID)
|
||||
#expect(try receiver.decrypt(after, from: alicePeerID) == Data("after".utf8))
|
||||
let emittedReplacementAuthentication = await TestHelpers.waitUntil(
|
||||
{ recorder.count > 1 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!emittedReplacementAuthentication)
|
||||
}
|
||||
|
||||
@Test("Valid rehandshake atomically replaces the established session")
|
||||
func validRehandshakeReplacesEstablishedSession() throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let receiver = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||
let receiverPeerID = PeerID(publicKey: receiver.getStaticPublicKeyData())
|
||||
|
||||
try establishSessions(alice: alice, bob: receiver)
|
||||
alice.clearSession(for: receiverPeerID)
|
||||
|
||||
let message1 = try alice.initiateHandshake(with: receiverPeerID)
|
||||
let message2 = try #require(
|
||||
try receiver.processHandshakeMessage(from: alicePeerID, message: message1)
|
||||
)
|
||||
#expect(receiver.hasEstablishedSession(with: alicePeerID))
|
||||
let message3 = try #require(
|
||||
try alice.processHandshakeMessage(from: receiverPeerID, message: message2)
|
||||
)
|
||||
_ = try receiver.processHandshakeMessage(from: alicePeerID, message: message3)
|
||||
|
||||
#expect(alice.hasEstablishedSession(with: receiverPeerID))
|
||||
#expect(receiver.hasEstablishedSession(with: alicePeerID))
|
||||
let ciphertext = try alice.encrypt(Data("new session".utf8), for: receiverPeerID)
|
||||
#expect(try receiver.decrypt(ciphertext, from: alicePeerID) == Data("new session".utf8))
|
||||
}
|
||||
|
||||
@Test("Automatic rekey exposes and completes its exact handshake bytes")
|
||||
func automaticRekeyHandshakeIsNotStranded() throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||
try establishSessions(alice: alice, bob: bob)
|
||||
let originalGeneration = try #require(alice.sessionGeneration(for: bobPeerID))
|
||||
var leaseRan = false
|
||||
let leased = alice.withCurrentSessionGeneration(
|
||||
for: bobPeerID,
|
||||
expected: originalGeneration
|
||||
) {
|
||||
leaseRan = true
|
||||
return true
|
||||
}
|
||||
#expect(leased == true)
|
||||
#expect(leaseRan)
|
||||
|
||||
var emittedPeerID: PeerID?
|
||||
var emittedMessage: Data?
|
||||
alice.onRekeyHandshakeReady = { peerID, message in
|
||||
emittedPeerID = peerID
|
||||
emittedMessage = message
|
||||
}
|
||||
try alice._test_initiateAutomaticRekey(for: bobPeerID)
|
||||
|
||||
#expect(emittedPeerID == bobPeerID)
|
||||
#expect(alice.sessionGeneration(for: bobPeerID) == nil)
|
||||
leaseRan = false
|
||||
let staleLease = alice.withCurrentSessionGeneration(
|
||||
for: bobPeerID,
|
||||
expected: originalGeneration
|
||||
) {
|
||||
leaseRan = true
|
||||
return true
|
||||
}
|
||||
#expect(staleLease == nil)
|
||||
#expect(!leaseRan)
|
||||
let message1 = try #require(emittedMessage)
|
||||
#expect(!message1.isEmpty)
|
||||
#expect(alice.hasSession(with: bobPeerID))
|
||||
#expect(!alice.hasEstablishedSession(with: bobPeerID))
|
||||
|
||||
let message2 = try #require(
|
||||
try bob.processHandshakeMessage(from: alicePeerID, message: message1)
|
||||
)
|
||||
let message3 = try #require(
|
||||
try alice.processHandshakeMessage(from: bobPeerID, message: message2)
|
||||
)
|
||||
_ = try bob.processHandshakeMessage(from: alicePeerID, message: message3)
|
||||
|
||||
#expect(alice.hasEstablishedSession(with: bobPeerID))
|
||||
#expect(bob.hasEstablishedSession(with: alicePeerID))
|
||||
#expect(alice.sessionGeneration(for: bobPeerID) != originalGeneration)
|
||||
}
|
||||
|
||||
@Test("Large private-file payloads use the bounded Noise extension")
|
||||
func largePrivateFileNoiseRoundTrip() throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||
try establishSessions(alice: alice, bob: bob)
|
||||
|
||||
let content = Data("%PDF-1.7\n".utf8) + Data(repeating: 0x51, count: 96 * 1024)
|
||||
let file = BitchatFilePacket(
|
||||
fileName: "large-private.pdf",
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "application/pdf",
|
||||
content: content
|
||||
)
|
||||
let typedPayload = try #require(BLENoisePayloadFactory.privateFile(file))
|
||||
#expect(typedPayload.count > NoiseSecurityConstants.maxMessageSize)
|
||||
#expect(typedPayload.first == NoisePayloadType.privateFile.rawValue)
|
||||
#expect(
|
||||
typedPayload.count <= NoiseSecurityConstants.maxPrivateFilePlaintextSize,
|
||||
"typedBytes=\(typedPayload.count) limit=\(NoiseSecurityConstants.maxPrivateFilePlaintextSize)"
|
||||
)
|
||||
|
||||
do {
|
||||
_ = try alice.encrypt(typedPayload, for: bobPeerID)
|
||||
Issue.record("Ordinary Noise payload path must retain its 64 KiB ceiling")
|
||||
} catch NoiseSecurityError.messageTooLarge {
|
||||
// Expected: only the purpose-specific private-file API may extend it.
|
||||
}
|
||||
|
||||
let ciphertext: Data
|
||||
do {
|
||||
ciphertext = try alice.encryptPrivateFilePayload(typedPayload, for: bobPeerID)
|
||||
} catch {
|
||||
Issue.record("Private-file encryption failed: \(error)")
|
||||
return
|
||||
}
|
||||
let decrypted: Data
|
||||
do {
|
||||
decrypted = try bob.decrypt(ciphertext, from: alicePeerID)
|
||||
} catch {
|
||||
Issue.record("Private-file decryption failed: \(error); ciphertextBytes=\(ciphertext.count)")
|
||||
return
|
||||
}
|
||||
|
||||
#expect(ciphertext.range(of: content) == nil)
|
||||
#expect(decrypted == typedPayload)
|
||||
#expect(!bob.hasSession(with: bobPeerID))
|
||||
#expect(bob.getPeerFingerprint(bobPeerID) == nil)
|
||||
}
|
||||
|
||||
@Test("Encrypt without a session requests handshake and decrypt without session fails")
|
||||
@@ -427,16 +200,16 @@ struct NoiseEncryptionServiceTests {
|
||||
|
||||
private func establishSessions(
|
||||
alice: NoiseEncryptionService,
|
||||
bob: NoiseEncryptionService
|
||||
bob: NoiseEncryptionService,
|
||||
alicePeerID: PeerID,
|
||||
bobPeerID: PeerID
|
||||
) throws {
|
||||
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||
let message1 = try alice.initiateHandshake(with: bobPeerID)
|
||||
let response = try bob.processHandshakeMessage(from: alicePeerID, message: message1)
|
||||
let message1 = try alice.initiateHandshake(with: alicePeerID)
|
||||
let response = try bob.processHandshakeMessage(from: bobPeerID, message: message1)
|
||||
let message2 = try #require(response, "Expected handshake response")
|
||||
let final = try alice.processHandshakeMessage(from: bobPeerID, message: message2)
|
||||
let final = try alice.processHandshakeMessage(from: alicePeerID, message: message2)
|
||||
let message3 = try #require(final, "Expected handshake final")
|
||||
let finalMessage = try bob.processHandshakeMessage(from: alicePeerID, message: message3)
|
||||
let finalMessage = try bob.processHandshakeMessage(from: bobPeerID, message: message3)
|
||||
#expect(finalMessage == nil)
|
||||
}
|
||||
}
|
||||
@@ -444,7 +217,6 @@ struct NoiseEncryptionServiceTests {
|
||||
private final class AuthenticationRecorder: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var entries: [(PeerID, String)] = []
|
||||
private var generationEntries: [(PeerID, UUID)] = []
|
||||
|
||||
var count: Int {
|
||||
lock.lock()
|
||||
@@ -452,27 +224,9 @@ private final class AuthenticationRecorder: @unchecked Sendable {
|
||||
return entries.count
|
||||
}
|
||||
|
||||
var generationCount: Int {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return generationEntries.count
|
||||
}
|
||||
|
||||
func record(peerID: PeerID, fingerprint: String) {
|
||||
lock.lock()
|
||||
entries.append((peerID, fingerprint))
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func record(peerID: PeerID, fingerprint _: String, sessionGeneration: UUID) {
|
||||
lock.lock()
|
||||
generationEntries.append((peerID, sessionGeneration))
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func generation(for peerID: PeerID) -> UUID? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return generationEntries.last { $0.0 == peerID }?.1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,6 +267,312 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, TransportConfig.nostrPendingSendQueueCap)
|
||||
}
|
||||
|
||||
func test_privateEnvelopeBatchEvictsEphemeralTrafficAndRemainsAtomic() async throws {
|
||||
let relayURL = "wss://private-batch-priority.example"
|
||||
let context = makeContext(
|
||||
permission: .denied,
|
||||
userTorEnabled: true,
|
||||
torEnforced: true,
|
||||
torIsReady: false
|
||||
)
|
||||
var ephemeralIDs: [String] = []
|
||||
for i in 0..<(TransportConfig.nostrPendingSendQueueCap - 1) {
|
||||
let event = try makeSignedEvent(
|
||||
content: "ephemeral-\(i)",
|
||||
kind: .ephemeralEvent
|
||||
)
|
||||
ephemeralIDs.append(event.id)
|
||||
context.manager.sendEvent(event, to: [relayURL])
|
||||
}
|
||||
let regular = try makeSignedEvent(content: "regular survives")
|
||||
context.manager.sendEvent(regular, to: [relayURL])
|
||||
XCTAssertEqual(
|
||||
context.manager.debugPendingMessageQueueCount,
|
||||
TransportConfig.nostrPendingSendQueueCap
|
||||
)
|
||||
|
||||
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
||||
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
||||
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch([primary, legacy], to: [relayURL]))
|
||||
|
||||
let batches = context.manager.debugPendingMessageQueueEventIDsByBatch
|
||||
let allIDs = Set(batches.flatMap { $0 })
|
||||
XCTAssertEqual(
|
||||
context.manager.debugPendingMessageQueueCount,
|
||||
TransportConfig.nostrPendingSendQueueCap
|
||||
)
|
||||
XCTAssertTrue(batches.contains([primary.id, legacy.id]))
|
||||
XCTAssertTrue(allIDs.contains(regular.id))
|
||||
XCTAssertFalse(allIDs.contains(ephemeralIDs[0]))
|
||||
XCTAssertFalse(allIDs.contains(ephemeralIDs[1]))
|
||||
|
||||
// Later low-priority traffic may evict another ephemeral event, but
|
||||
// never one half (or both halves) of the protected private batch.
|
||||
let lateEphemeral = try makeSignedEvent(content: "late", kind: .geohashPresence)
|
||||
context.manager.sendEvent(lateEphemeral, to: [relayURL])
|
||||
XCTAssertTrue(
|
||||
context.manager.debugPendingMessageQueueEventIDsByBatch
|
||||
.contains([primary.id, legacy.id])
|
||||
)
|
||||
}
|
||||
|
||||
func test_privateEnvelopeBatchDoesNotWriteStaleSocketUntilTorRecovers() async throws {
|
||||
let relayURL = "wss://private-batch-tor-gate.example"
|
||||
let context = makeContext(
|
||||
permission: .denied,
|
||||
userTorEnabled: true,
|
||||
torEnforced: true,
|
||||
torIsReady: true
|
||||
)
|
||||
context.manager.ensureConnections(to: [relayURL])
|
||||
let connected = await waitUntil {
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
let connection = try XCTUnwrap(context.sessionFactory.latestConnection(for: relayURL))
|
||||
|
||||
context.torWaiter.isReady = false
|
||||
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
||||
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
||||
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch([primary, legacy], to: [relayURL]))
|
||||
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
XCTAssertTrue(connection.sentStrings.isEmpty)
|
||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
|
||||
XCTAssertEqual(context.torWaiter.awaitCallCount, 1)
|
||||
|
||||
context.torWaiter.resolve(true)
|
||||
let flushed = await waitUntil {
|
||||
connection.sentStrings.count == 2 &&
|
||||
context.manager.debugPendingMessageQueueCount == 0
|
||||
}
|
||||
XCTAssertTrue(flushed)
|
||||
}
|
||||
|
||||
func test_privateEnvelopeBatchPrunesTerminalRelayAfterHealthyDelivery() async throws {
|
||||
let healthyURL = "wss://private-batch-healthy.example"
|
||||
let deadURL = "wss://private-batch-dead.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
context.sessionFactory.pingErrorByURL[deadURL] = NSError(
|
||||
domain: NSURLErrorDomain,
|
||||
code: NSURLErrorCannotFindHost
|
||||
)
|
||||
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
||||
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
||||
var terminalFailureCount = 0
|
||||
|
||||
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch(
|
||||
[primary, legacy],
|
||||
to: [healthyURL, deadURL],
|
||||
terminalFailure: { terminalFailureCount += 1 }
|
||||
))
|
||||
|
||||
let drained = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: healthyURL)?.sentStrings.count == 2 &&
|
||||
context.manager.debugPendingMessageQueueCount == 0
|
||||
}
|
||||
XCTAssertTrue(drained)
|
||||
XCTAssertEqual(terminalFailureCount, 0)
|
||||
|
||||
// The terminal target is excluded during cooldown. More than one full
|
||||
// protected-capacity worth of subsequent logical sends must continue
|
||||
// to drain through the healthy relay instead of wedging globally.
|
||||
for _ in 0...100 {
|
||||
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch(
|
||||
[primary, legacy],
|
||||
to: [healthyURL, deadURL]
|
||||
))
|
||||
let sent = await waitUntil {
|
||||
context.manager.debugPendingMessageQueueCount == 0
|
||||
}
|
||||
XCTAssertTrue(sent)
|
||||
}
|
||||
}
|
||||
|
||||
func test_privateEnvelopeBatchAllTerminalTargetsReportsWholeBatchFailure() async throws {
|
||||
let deadURL = "wss://private-batch-all-dead.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
context.sessionFactory.pingErrorByURL[deadURL] = NSError(
|
||||
domain: NSURLErrorDomain,
|
||||
code: NSURLErrorCannotFindHost
|
||||
)
|
||||
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
||||
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
||||
var terminalFailureCount = 0
|
||||
|
||||
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch(
|
||||
[primary, legacy],
|
||||
to: [deadURL],
|
||||
terminalFailure: { terminalFailureCount += 1 }
|
||||
))
|
||||
|
||||
let failed = await waitUntil {
|
||||
terminalFailureCount == 1 &&
|
||||
context.manager.debugPendingMessageQueueCount == 0
|
||||
}
|
||||
XCTAssertTrue(failed)
|
||||
}
|
||||
|
||||
func test_privateEnvelopeBatchRejectsBeforeWritingWhenProtectedCapacityIsFull() async throws {
|
||||
let stalledRelayURL = "wss://private-batch-full.example"
|
||||
let connectedRelayURL = "wss://private-batch-connected.example"
|
||||
let context = makeContext(
|
||||
permission: .denied,
|
||||
userTorEnabled: true,
|
||||
torEnforced: true,
|
||||
torIsReady: true,
|
||||
torIsForeground: true
|
||||
)
|
||||
context.manager.ensureConnections(to: [connectedRelayURL])
|
||||
let connected = await waitUntil {
|
||||
context.manager.relays.first(where: { $0.url == connectedRelayURL })?.isConnected == true
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
context.torForeground.value = false
|
||||
|
||||
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
||||
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
||||
let pair = [primary, legacy]
|
||||
|
||||
for _ in 0..<(TransportConfig.nostrPendingSendQueueCap / pair.count) {
|
||||
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch(pair, to: [stalledRelayURL]))
|
||||
}
|
||||
XCTAssertEqual(
|
||||
context.manager.debugPendingMessageQueueCount,
|
||||
TransportConfig.nostrPendingSendQueueCap
|
||||
)
|
||||
|
||||
let rejectedPrimary = try makeSignedEvent(content: "rejected-primary", kind: .privateEnvelope)
|
||||
let rejectedLegacy = try makeSignedEvent(content: "rejected-legacy", kind: .legacyNIP59GiftWrap)
|
||||
XCTAssertFalse(
|
||||
context.manager.sendPrivateEnvelopeBatch(
|
||||
[rejectedPrimary, rejectedLegacy],
|
||||
to: [connectedRelayURL]
|
||||
)
|
||||
)
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
XCTAssertTrue(
|
||||
context.sessionFactory.latestConnection(for: connectedRelayURL)?.sentStrings.isEmpty == true
|
||||
)
|
||||
let queuedIDs = Set(
|
||||
context.manager.debugPendingMessageQueueEventIDsByBatch.flatMap { $0 }
|
||||
)
|
||||
XCTAssertFalse(queuedIDs.contains(rejectedPrimary.id))
|
||||
XCTAssertFalse(queuedIDs.contains(rejectedLegacy.id))
|
||||
}
|
||||
|
||||
func test_privateEnvelopeBatchStaysQueuedAndRetriesAfterFirstWriteFails() async throws {
|
||||
try await assertPrivateEnvelopeBatchWriteFailure(
|
||||
failureSequence: [NSError(domain: "send", code: 1)],
|
||||
expectedFirstConnectionWrites: 1
|
||||
)
|
||||
}
|
||||
|
||||
func test_privateEnvelopeBatchStaysQueuedAndRetriesAfterSecondWriteFails() async throws {
|
||||
try await assertPrivateEnvelopeBatchWriteFailure(
|
||||
failureSequence: [nil, NSError(domain: "send", code: 2)],
|
||||
expectedFirstConnectionWrites: 2
|
||||
)
|
||||
}
|
||||
|
||||
func test_privateEnvelopeBatchRemainsPendingUntilBothWritesSucceed() async throws {
|
||||
let relayURL = "wss://private-batch-two-write-commit.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
||||
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
||||
|
||||
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch([primary, legacy], to: [relayURL]))
|
||||
let connection = try XCTUnwrap(context.sessionFactory.latestConnection(for: relayURL))
|
||||
connection.deferSendCompletions = true
|
||||
|
||||
let firstWriteStarted = await waitUntil { connection.sentStrings.count == 1 }
|
||||
XCTAssertTrue(firstWriteStarted)
|
||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
|
||||
|
||||
connection.flushDeferredSendCompletions()
|
||||
let secondWriteStarted = await waitUntil { connection.sentStrings.count == 2 }
|
||||
XCTAssertTrue(secondWriteStarted)
|
||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
|
||||
|
||||
connection.flushDeferredSendCompletions()
|
||||
let committed = await waitUntil {
|
||||
context.manager.debugPendingMessageQueueCount == 0
|
||||
}
|
||||
XCTAssertTrue(committed)
|
||||
}
|
||||
|
||||
func test_privateEnvelopeBatchDisconnectDuringWriteReplaysOnReplacementConnection() async throws {
|
||||
let relayURL = "wss://private-batch-background-reconnect.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
||||
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
||||
|
||||
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch([primary, legacy], to: [relayURL]))
|
||||
let firstConnection = try XCTUnwrap(
|
||||
context.sessionFactory.latestConnection(for: relayURL)
|
||||
)
|
||||
firstConnection.deferSendCompletions = true
|
||||
let firstWriteStarted = await waitUntil { firstConnection.sentStrings.count == 1 }
|
||||
XCTAssertTrue(firstWriteStarted)
|
||||
|
||||
// Backgrounding cancels the socket while its first write callback is
|
||||
// still outstanding. The pair must remain replayable in the queue.
|
||||
context.manager.disconnect()
|
||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
|
||||
context.manager.connect()
|
||||
|
||||
let replayed = await waitUntil {
|
||||
let connections = context.sessionFactory.connectionsByURL[relayURL] ?? []
|
||||
guard connections.count == 2, let replacement = connections.last else {
|
||||
return false
|
||||
}
|
||||
return replacement.sentStrings.count == 2 &&
|
||||
context.manager.debugPendingMessageQueueCount == 0
|
||||
}
|
||||
XCTAssertTrue(replayed)
|
||||
|
||||
// A late success callback from the canceled socket must neither send
|
||||
// the legacy half on that stale socket nor disturb the completed pair.
|
||||
firstConnection.flushDeferredSendCompletions()
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
XCTAssertEqual(firstConnection.sentStrings.count, 1)
|
||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 0)
|
||||
}
|
||||
|
||||
private func assertPrivateEnvelopeBatchWriteFailure(
|
||||
failureSequence: [Error?],
|
||||
expectedFirstConnectionWrites: Int
|
||||
) async throws {
|
||||
let relayURL = "wss://private-batch-write-failure.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
|
||||
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
|
||||
|
||||
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch([primary, legacy], to: [relayURL]))
|
||||
let firstConnection = try XCTUnwrap(context.sessionFactory.latestConnection(for: relayURL))
|
||||
firstConnection.sendErrorSequence = failureSequence
|
||||
|
||||
let stayedQueued = await waitUntil {
|
||||
firstConnection.sentStrings.count == expectedFirstConnectionWrites &&
|
||||
context.manager.debugPendingMessageQueueEventIDsByBatch.contains([primary.id, legacy.id])
|
||||
}
|
||||
XCTAssertTrue(stayedQueued)
|
||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
|
||||
|
||||
// The failed socket is disconnected with bounded backoff. Its queue
|
||||
// item is reused—not re-admitted—and the replacement retries both
|
||||
// copies before the pair is finally removed.
|
||||
XCTAssertFalse(context.scheduler.scheduled.isEmpty)
|
||||
context.scheduler.runNext()
|
||||
let retried = await waitUntil {
|
||||
guard let replacement = context.sessionFactory.latestConnection(for: relayURL),
|
||||
replacement !== firstConnection else { return false }
|
||||
return replacement.sentStrings.count == 2 &&
|
||||
context.manager.debugPendingMessageQueueCount == 0
|
||||
}
|
||||
XCTAssertTrue(retried)
|
||||
}
|
||||
|
||||
func test_sendEvent_waitsForTorReadinessBeforeSending() async throws {
|
||||
let relayURL = "wss://tor-ready.example"
|
||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
@@ -548,6 +854,52 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
XCTAssertEqual(context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count, 1)
|
||||
}
|
||||
|
||||
func test_subscribe_multiplePrivateEnvelopeFiltersEncodesIndependentLimits() async throws {
|
||||
let relayURL = "wss://private-recovery-filters.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
let filters = NostrFilter.privateEnvelopeFiltersFor(
|
||||
pubkey: "recipient",
|
||||
since: Date(timeIntervalSince1970: 1_234_567)
|
||||
)
|
||||
|
||||
context.manager.subscribe(
|
||||
filters: filters,
|
||||
id: "private-recovery",
|
||||
relayUrls: [relayURL],
|
||||
handler: { _ in }
|
||||
)
|
||||
|
||||
let sent = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
|
||||
}
|
||||
XCTAssertTrue(sent)
|
||||
let request = try XCTUnwrap(
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.first
|
||||
)
|
||||
let data = try XCTUnwrap(request.data(using: .utf8))
|
||||
let array = try XCTUnwrap(
|
||||
try JSONSerialization.jsonObject(with: data) as? [Any]
|
||||
)
|
||||
XCTAssertEqual(array.count, 4)
|
||||
XCTAssertEqual(array[0] as? String, "REQ")
|
||||
XCTAssertEqual(array[1] as? String, "private-recovery")
|
||||
let encodedFilters = try [array[2], array[3]].map {
|
||||
try XCTUnwrap($0 as? [String: Any])
|
||||
}
|
||||
XCTAssertEqual(encodedFilters.compactMap { $0["kinds"] as? [Int] }, [
|
||||
[NostrProtocol.EventKind.privateEnvelope.rawValue],
|
||||
[NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue]
|
||||
])
|
||||
for filter in encodedFilters {
|
||||
XCTAssertEqual(
|
||||
filter["limit"] as? Int,
|
||||
TransportConfig.nostrPrivateEnvelopeFetchLimitPerKind
|
||||
)
|
||||
XCTAssertEqual(filter["#p"] as? [String], ["recipient"])
|
||||
XCTAssertEqual(filter["since"] as? Int, 1_234_567)
|
||||
}
|
||||
}
|
||||
|
||||
func test_subscribe_coalescesDuplicateRequestsBeforeTorReadyAndDefersEOSE() async throws {
|
||||
let relayURL = "wss://tor-subscribe-coalesce.example"
|
||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
@@ -893,11 +1245,11 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
XCTAssertTrue(secondDelivered)
|
||||
}
|
||||
|
||||
func test_okMessages_clearPendingGiftWrapIDs() async throws {
|
||||
func test_okMessages_clearPendingPrivateEnvelopeIDs() async throws {
|
||||
let relayURL = "wss://ok.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
let successID = "gift-wrap-success"
|
||||
let failureID = "gift-wrap-failure"
|
||||
let successID = "private-envelope-success"
|
||||
let failureID = "private-envelope-failure"
|
||||
|
||||
context.manager.ensureConnections(to: [relayURL])
|
||||
let connected = await waitUntil {
|
||||
@@ -906,17 +1258,17 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
|
||||
NostrRelayManager.registerPendingGiftWrap(id: successID)
|
||||
NostrRelayManager.registerPendingPrivateEnvelope(id: successID)
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: successID, success: true, reason: "ok")
|
||||
let successCleared = await waitUntil {
|
||||
!NostrRelayManager.pendingGiftWrapIDs.contains(successID)
|
||||
!NostrRelayManager.pendingPrivateEnvelopeIDs.contains(successID)
|
||||
}
|
||||
XCTAssertTrue(successCleared)
|
||||
|
||||
NostrRelayManager.registerPendingGiftWrap(id: failureID)
|
||||
NostrRelayManager.registerPendingPrivateEnvelope(id: failureID)
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: failureID, success: false, reason: "rejected")
|
||||
let failureCleared = await waitUntil {
|
||||
!NostrRelayManager.pendingGiftWrapIDs.contains(failureID)
|
||||
!NostrRelayManager.pendingPrivateEnvelopeIDs.contains(failureID)
|
||||
}
|
||||
XCTAssertTrue(failureCleared)
|
||||
}
|
||||
@@ -1646,12 +1998,15 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
return filter
|
||||
}
|
||||
|
||||
private func makeSignedEvent(content: String) throws -> NostrEvent {
|
||||
private func makeSignedEvent(
|
||||
content: String,
|
||||
kind: NostrProtocol.EventKind = .textNote
|
||||
) throws -> NostrEvent {
|
||||
let identity = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .textNote,
|
||||
kind: kind,
|
||||
tags: [],
|
||||
content: content
|
||||
)
|
||||
@@ -1791,6 +2146,7 @@ private final class MockRelaySessionFactory: NostrRelaySessionProtocol {
|
||||
private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
||||
private let pingError: Error?
|
||||
var sendError: Error?
|
||||
var sendErrorSequence: [Error?] = []
|
||||
private var receiveHandler: ((Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
|
||||
private(set) var resumeCallCount = 0
|
||||
private(set) var cancelCallCount = 0
|
||||
@@ -1820,14 +2176,15 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
||||
}
|
||||
|
||||
var deferSendCompletions = false
|
||||
private var deferredSendCompletions: [(Error?) -> Void] = []
|
||||
private var deferredSendCompletions: [(error: Error?, completion: (Error?) -> Void)] = []
|
||||
|
||||
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) {
|
||||
sentMessages.append(message)
|
||||
let error = sendErrorSequence.isEmpty ? sendError : sendErrorSequence.removeFirst()
|
||||
if deferSendCompletions {
|
||||
deferredSendCompletions.append(completionHandler)
|
||||
deferredSendCompletions.append((error, completionHandler))
|
||||
} else {
|
||||
completionHandler(sendError)
|
||||
completionHandler(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1835,7 +2192,7 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
||||
let pending = deferredSendCompletions
|
||||
deferredSendCompletions = []
|
||||
pending.forEach {
|
||||
$0(sendError)
|
||||
$0.completion($0.error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ struct NostrTransportTests {
|
||||
#expect(undeliverable)
|
||||
}
|
||||
|
||||
@Test("Private message resolves short peer ID and emits decryptable packet")
|
||||
@Test("Private message resolves short peer ID and emits both migration formats")
|
||||
@MainActor
|
||||
func sendPrivateMessageResolvesShortPeerID() async throws {
|
||||
let keychain = MockKeychain()
|
||||
@@ -153,8 +153,8 @@ struct NostrTransportTests {
|
||||
favoriteStatusForNoiseKey: { _ in nil },
|
||||
favoriteStatusForPeerID: { $0 == shortPeerID ? relationship : nil },
|
||||
currentIdentity: { sender },
|
||||
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
||||
sendEvent: probe.record(event:),
|
||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
}
|
||||
@@ -164,8 +164,12 @@ struct NostrTransportTests {
|
||||
|
||||
transport.sendPrivateMessage("hello over nostr", to: shortPeerID, recipientNickname: "Carol", messageID: "pm-1")
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 5.0)
|
||||
#expect(didSend)
|
||||
#expect(probe.sentEvents.map(\.kind) == [
|
||||
NostrProtocol.EventKind.privateEnvelope.rawValue,
|
||||
NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue
|
||||
])
|
||||
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
||||
let privateMessage = try decodePrivateMessage(from: result.payload)
|
||||
|
||||
@@ -173,7 +177,332 @@ struct NostrTransportTests {
|
||||
#expect(privateMessage.messageID == "pm-1")
|
||||
#expect(privateMessage.content == "hello over nostr")
|
||||
#expect(result.packet.recipientID == shortPeerID.routingData)
|
||||
#expect(probe.pendingGiftWrapIDs.isEmpty)
|
||||
#expect(probe.pendingPrivateEnvelopeIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Coordinated migration always publishes primary and compatibility envelopes")
|
||||
@MainActor
|
||||
func migrationAlwaysDualPublishes() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let noiseKey = Data((192..<224).map(UInt8.init))
|
||||
let peerID = PeerID(hexData: noiseKey)
|
||||
let relationship = makeRelationship(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
peerNostrPublicKey: recipient.npub,
|
||||
peerNickname: "Migration peer"
|
||||
)
|
||||
|
||||
let migrationProbe = NostrTransportProbe()
|
||||
let migrationTransport = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
dependencies: makeDependencies(
|
||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||
currentIdentity: { sender },
|
||||
sendPrivateEnvelopeBatch: { events, _ in migrationProbe.record(batch: events) }
|
||||
)
|
||||
)
|
||||
migrationTransport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||
migrationTransport.sendPrivateMessage(
|
||||
"migration payload",
|
||||
to: peerID,
|
||||
recipientNickname: "Migration peer",
|
||||
messageID: "migration-pm"
|
||||
)
|
||||
|
||||
let sentPair = await TestHelpers.waitUntil(
|
||||
{ migrationProbe.sentEvents.count == 2 },
|
||||
timeout: 5.0
|
||||
)
|
||||
#expect(sentPair)
|
||||
#expect(migrationProbe.sentEvents.map(\.kind) == [
|
||||
NostrProtocol.EventKind.privateEnvelope.rawValue,
|
||||
NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue
|
||||
])
|
||||
for event in migrationProbe.sentEvents {
|
||||
let result = try decodeEmbeddedPayload(from: event, recipient: recipient)
|
||||
let message = try decodePrivateMessage(from: result.payload)
|
||||
#expect(message.messageID == "migration-pm")
|
||||
#expect(message.content == "migration payload")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Rejected migration batch does not register half-delivery state")
|
||||
@MainActor
|
||||
func rejectedMigrationBatchRegistersNothing() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let probe = NostrTransportProbe()
|
||||
var rejectedKinds: [Int] = []
|
||||
let transport = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
dependencies: makeDependencies(
|
||||
currentIdentity: { sender },
|
||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||
sendPrivateEnvelopeBatch: { events, _ in
|
||||
rejectedKinds = events.map(\.kind)
|
||||
return false
|
||||
}
|
||||
)
|
||||
)
|
||||
transport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||
|
||||
transport.sendPrivateMessageGeohash(
|
||||
content: "must stay atomic",
|
||||
toRecipientHex: recipient.publicKeyHex,
|
||||
from: sender,
|
||||
messageID: "atomic-reject"
|
||||
)
|
||||
|
||||
let attempted = await TestHelpers.waitUntil({ rejectedKinds.count == 2 }, timeout: 5.0)
|
||||
#expect(attempted)
|
||||
#expect(rejectedKinds == [
|
||||
NostrProtocol.EventKind.privateEnvelope.rawValue,
|
||||
NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue
|
||||
])
|
||||
#expect(probe.pendingPrivateEnvelopeIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Rejected user message emits a visible failed-delivery event")
|
||||
@MainActor
|
||||
func rejectedUserMessageEmitsFailureEvent() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let eventProbe = NostrTransportEventProbe()
|
||||
let transport = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
dependencies: makeDependencies(
|
||||
currentIdentity: { sender },
|
||||
sendPrivateEnvelopeBatch: { _, _ in false }
|
||||
)
|
||||
)
|
||||
transport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||
transport.eventDelegate = eventProbe
|
||||
|
||||
transport.sendPrivateMessageGeohash(
|
||||
content: "must fail visibly",
|
||||
toRecipientHex: recipient.publicKeyHex,
|
||||
from: sender,
|
||||
messageID: "visible-reject"
|
||||
)
|
||||
|
||||
let reported = await TestHelpers.waitUntil(
|
||||
{ eventProbe.failedMessageIDs == ["visible-reject"] },
|
||||
timeout: 5.0
|
||||
)
|
||||
#expect(reported)
|
||||
}
|
||||
|
||||
@Test("Rejected favorite notification retains and retries the exact pair")
|
||||
@MainActor
|
||||
func rejectedFavoriteNotificationRetriesExactPair() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let noiseKey = Data((96..<128).map(UInt8.init))
|
||||
let fullPeerID = PeerID(hexData: noiseKey)
|
||||
let relationship = makeRelationship(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
peerNostrPublicKey: recipient.npub,
|
||||
peerNickname: "Retry favorite"
|
||||
)
|
||||
let probe = NostrTransportProbe()
|
||||
var attempts: [[String]] = []
|
||||
weak var releasedTransport: NostrTransport?
|
||||
do {
|
||||
let transport = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
dependencies: makeDependencies(
|
||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||
currentIdentity: { sender },
|
||||
sendPrivateEnvelopeBatch: { events, _ in
|
||||
attempts.append(events.map(\.id))
|
||||
return attempts.count > 1
|
||||
},
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
}
|
||||
)
|
||||
)
|
||||
transport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||
releasedTransport = transport
|
||||
|
||||
transport.sendFavoriteNotification(to: fullPeerID, isFavorite: true)
|
||||
let retryScheduled = await TestHelpers.waitUntil(
|
||||
{ attempts.count == 1 && probe.scheduledActionCount == 1 },
|
||||
timeout: 5.0
|
||||
)
|
||||
#expect(retryScheduled)
|
||||
}
|
||||
#expect(releasedTransport == nil)
|
||||
#expect(probe.runNextScheduledAction())
|
||||
|
||||
let retried = await TestHelpers.waitUntil({ attempts.count == 2 }, timeout: 5.0)
|
||||
#expect(retried)
|
||||
#expect(attempts[0] == attempts[1])
|
||||
}
|
||||
|
||||
@Test("Rejected delivery acknowledgement remains queued for retry")
|
||||
@MainActor
|
||||
func rejectedDeliveryAckRetries() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let noiseKey = Data((128..<160).map(UInt8.init))
|
||||
let fullPeerID = PeerID(hexData: noiseKey)
|
||||
let relationship = makeRelationship(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
peerNostrPublicKey: recipient.npub,
|
||||
peerNickname: "Retry ack"
|
||||
)
|
||||
let probe = NostrTransportProbe()
|
||||
var attempts: [[String]] = []
|
||||
let transport = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
dependencies: makeDependencies(
|
||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||
currentIdentity: { sender },
|
||||
sendPrivateEnvelopeBatch: { events, _ in
|
||||
attempts.append(events.map(\.id))
|
||||
return attempts.count > 1
|
||||
},
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
}
|
||||
)
|
||||
)
|
||||
transport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||
|
||||
transport.sendDeliveryAck(for: "retry-ack", to: fullPeerID)
|
||||
let firstAttempt = await TestHelpers.waitUntil(
|
||||
{ attempts.count == 1 && probe.scheduledActionCount >= 1 },
|
||||
timeout: 5.0
|
||||
)
|
||||
#expect(firstAttempt)
|
||||
|
||||
for _ in 0..<3 where attempts.count < 2 {
|
||||
_ = probe.runNextScheduledAction()
|
||||
_ = await TestHelpers.waitUntil(
|
||||
{ attempts.count == 2 || probe.scheduledActionCount > 0 },
|
||||
timeout: 1.0
|
||||
)
|
||||
}
|
||||
#expect(attempts.count == 2)
|
||||
#expect(attempts[0] == attempts[1])
|
||||
}
|
||||
|
||||
@Test("Control retry queue is bounded and evicted callbacks are harmless")
|
||||
@MainActor
|
||||
func controlRetryQueueIsBounded() async throws {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let events = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||
content: "bounded retry fixture",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
let probe = NostrTransportProbe()
|
||||
var retryAttempts = 0
|
||||
let queue = NostrPrivateEnvelopeRetryQueue(
|
||||
sendPrivateEnvelopeBatch: { _, _ in
|
||||
retryAttempts += 1
|
||||
return false
|
||||
},
|
||||
registerPendingPrivateEnvelope: { _ in },
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
}
|
||||
)
|
||||
|
||||
for index in 0...TransportConfig.nostrPrivateEnvelopeRetryQueueCap {
|
||||
queue.enqueue(
|
||||
key: "control-\(index)",
|
||||
events: events,
|
||||
registerPending: false
|
||||
)
|
||||
}
|
||||
|
||||
#expect(queue.debugPendingCount == TransportConfig.nostrPrivateEnvelopeRetryQueueCap)
|
||||
#expect(!queue.debugContains(key: "control-0"))
|
||||
#expect(queue.debugContains(key: "control-1"))
|
||||
|
||||
// The oldest callback was scheduled before eviction. Running it must
|
||||
// observe the missing key and return without touching dependencies.
|
||||
#expect(probe.runNextScheduledAction())
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
#expect(retryAttempts == 0)
|
||||
|
||||
queue.removeAll()
|
||||
#expect(queue.debugPendingCount == 0)
|
||||
#expect(probe.runNextScheduledAction())
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
#expect(retryAttempts == 0)
|
||||
}
|
||||
|
||||
@Test("Multiple transports share one globally bounded control retry owner")
|
||||
@MainActor
|
||||
func multipleTransportsShareControlRetryQueue() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let events = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||
content: "shared retry fixture",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
let probe = NostrTransportProbe()
|
||||
var retryAttempts = 0
|
||||
let sharedQueue = NostrPrivateEnvelopeRetryQueue(
|
||||
sendPrivateEnvelopeBatch: { _, _ in
|
||||
retryAttempts += 1
|
||||
return false
|
||||
},
|
||||
registerPendingPrivateEnvelope: { _ in },
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
}
|
||||
)
|
||||
let first = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
dependencies: makeDependencies(envelopeRetryQueue: sharedQueue)
|
||||
)
|
||||
let second = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
dependencies: makeDependencies(envelopeRetryQueue: sharedQueue)
|
||||
)
|
||||
|
||||
first.debugEnqueueControlRetry(key: "shared", events: events)
|
||||
second.debugEnqueueControlRetry(key: "shared", events: events)
|
||||
#expect(first.debugControlRetryCount == 1)
|
||||
#expect(second.debugControlRetryCount == 1)
|
||||
|
||||
for index in 0...TransportConfig.nostrPrivateEnvelopeRetryQueueCap {
|
||||
let transport = index.isMultiple(of: 2) ? first : second
|
||||
transport.debugEnqueueControlRetry(key: "global-\(index)", events: events)
|
||||
}
|
||||
#expect(first.debugControlRetryCount == TransportConfig.nostrPrivateEnvelopeRetryQueueCap)
|
||||
#expect(second.debugControlRetryCount == TransportConfig.nostrPrivateEnvelopeRetryQueueCap)
|
||||
|
||||
// The first scheduled callback belongs to the now-evicted shared key.
|
||||
#expect(probe.runNextScheduledAction())
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
#expect(retryAttempts == 0)
|
||||
}
|
||||
|
||||
@Test("Favorite notification embeds current npub")
|
||||
@@ -198,8 +527,8 @@ struct NostrTransportTests {
|
||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||
favoriteStatusForPeerID: { _ in nil },
|
||||
currentIdentity: { sender },
|
||||
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
||||
sendEvent: probe.record(event:),
|
||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
}
|
||||
@@ -209,7 +538,7 @@ struct NostrTransportTests {
|
||||
|
||||
transport.sendFavoriteNotification(to: fullPeerID, isFavorite: true)
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 5.0)
|
||||
#expect(didSend)
|
||||
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
||||
let privateMessage = try decodePrivateMessage(from: result.payload)
|
||||
@@ -239,8 +568,8 @@ struct NostrTransportTests {
|
||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||
favoriteStatusForPeerID: { _ in nil },
|
||||
currentIdentity: { sender },
|
||||
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
||||
sendEvent: probe.record(event:),
|
||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
}
|
||||
@@ -250,7 +579,7 @@ struct NostrTransportTests {
|
||||
|
||||
transport.sendDeliveryAck(for: "ack-1", to: fullPeerID)
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 5.0)
|
||||
#expect(didSend)
|
||||
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
||||
|
||||
@@ -259,9 +588,9 @@ struct NostrTransportTests {
|
||||
#expect(result.packet.recipientID == fullPeerID.toShort().routingData)
|
||||
}
|
||||
|
||||
@Test("Geohash private message registers pending gift wrap")
|
||||
@Test("Geohash private message registers pending private envelope")
|
||||
@MainActor
|
||||
func sendPrivateMessageGeohashRegistersPendingGiftWrap() async throws {
|
||||
func sendPrivateMessageGeohashRegistersPendingPrivateEnvelope() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let sender = try NostrIdentity.generate()
|
||||
@@ -272,8 +601,8 @@ struct NostrTransportTests {
|
||||
idBridge: idBridge,
|
||||
dependencies: makeDependencies(
|
||||
currentIdentity: { sender },
|
||||
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
||||
sendEvent: probe.record(event:),
|
||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
}
|
||||
@@ -288,7 +617,7 @@ struct NostrTransportTests {
|
||||
messageID: "geo-1"
|
||||
)
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 5.0)
|
||||
#expect(didSend)
|
||||
let event = probe.sentEvents[0]
|
||||
let result = try decodeEmbeddedPayload(from: event, recipient: recipient)
|
||||
@@ -297,7 +626,7 @@ struct NostrTransportTests {
|
||||
#expect(privateMessage.messageID == "geo-1")
|
||||
#expect(privateMessage.content == "geo hello")
|
||||
#expect(result.packet.recipientID == nil)
|
||||
#expect(probe.pendingGiftWrapIDs == [event.id])
|
||||
#expect(probe.pendingPrivateEnvelopeIDs == probe.sentEvents.map(\.id))
|
||||
}
|
||||
|
||||
@Test("Read receipt queue sends in order and waits for scheduler")
|
||||
@@ -322,8 +651,8 @@ struct NostrTransportTests {
|
||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||
favoriteStatusForPeerID: { _ in nil },
|
||||
currentIdentity: { sender },
|
||||
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
||||
sendEvent: probe.record(event:),
|
||||
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
|
||||
scheduleAfter: { delay, action in
|
||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||
}
|
||||
@@ -338,20 +667,20 @@ struct NostrTransportTests {
|
||||
transport.sendReadReceipt(second, to: fullPeerID)
|
||||
|
||||
let readReceiptTimeout: TimeInterval = 5.0
|
||||
let sentFirst = await TestHelpers.waitUntil({ probe.sentEvents.count >= 1 }, timeout: readReceiptTimeout)
|
||||
try #require(sentFirst, "Expected first queued read receipt event")
|
||||
let sentFirst = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: readReceiptTimeout)
|
||||
try #require(sentFirst, "Expected first queued read receipt pair")
|
||||
let scheduledThrottle = await TestHelpers.waitUntil({ probe.scheduledActionCount == 1 }, timeout: readReceiptTimeout)
|
||||
try #require(scheduledThrottle, "Expected queued throttle action after first read receipt")
|
||||
let firstEvent = try #require(probe.sentEvents.first, "Expected first queued read receipt event")
|
||||
let firstEvent = try #require(probe.sentEvents.first, "Expected first queued read receipt pair")
|
||||
let firstPayload = try decodeEmbeddedPayload(from: firstEvent, recipient: recipient).payload
|
||||
#expect(firstPayload.type == .readReceipt)
|
||||
#expect(String(data: firstPayload.data, encoding: .utf8) == "read-1")
|
||||
|
||||
try #require(probe.runNextScheduledAction(), "Expected queued throttle action after first read receipt")
|
||||
|
||||
let sentSecond = await TestHelpers.waitUntil({ probe.sentEvents.count >= 2 }, timeout: readReceiptTimeout)
|
||||
try #require(sentSecond, "Expected second read receipt after running throttle action")
|
||||
let secondEvent = try #require(probe.sentEvents.last, "Expected second queued read receipt event")
|
||||
let sentSecond = await TestHelpers.waitUntil({ probe.sentEvents.count == 4 }, timeout: readReceiptTimeout)
|
||||
try #require(sentSecond, "Expected second read receipt pair after running throttle action")
|
||||
let secondEvent = probe.sentEvents[2]
|
||||
let secondPayload = try decodeEmbeddedPayload(from: secondEvent, recipient: recipient).payload
|
||||
#expect(secondPayload.type == .readReceipt)
|
||||
#expect(String(data: secondPayload.data, encoding: .utf8) == "read-2")
|
||||
@@ -419,10 +748,14 @@ struct NostrTransportTests {
|
||||
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoriteRelationship? = { _ in nil },
|
||||
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoriteRelationship? = { _ in nil },
|
||||
currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil },
|
||||
registerPendingGiftWrap: @escaping @MainActor (String) -> Void = { _ in },
|
||||
sendEvent: @escaping @MainActor (NostrEvent) -> Void = { _ in },
|
||||
registerPendingPrivateEnvelope: @escaping @MainActor (String) -> Void = { _ in },
|
||||
sendPrivateEnvelopeBatch: @escaping @MainActor (
|
||||
[NostrEvent],
|
||||
@escaping @MainActor () -> Void
|
||||
) -> Bool = { _, _ in true },
|
||||
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in },
|
||||
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never> = { Just(false).eraseToAnyPublisher() }
|
||||
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never> = { Just(false).eraseToAnyPublisher() },
|
||||
envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue? = nil
|
||||
) -> NostrTransport.Dependencies {
|
||||
NostrTransport.Dependencies(
|
||||
notificationCenter: notificationCenter,
|
||||
@@ -430,10 +763,11 @@ struct NostrTransportTests {
|
||||
favoriteStatusForNoiseKey: favoriteStatusForNoiseKey,
|
||||
favoriteStatusForPeerID: favoriteStatusForPeerID,
|
||||
currentIdentity: currentIdentity,
|
||||
registerPendingGiftWrap: registerPendingGiftWrap,
|
||||
sendEvent: sendEvent,
|
||||
registerPendingPrivateEnvelope: registerPendingPrivateEnvelope,
|
||||
sendPrivateEnvelopeBatch: sendPrivateEnvelopeBatch,
|
||||
scheduleAfter: scheduleAfter,
|
||||
relayConnectivity: relayConnectivity
|
||||
relayConnectivity: relayConnectivity,
|
||||
envelopeRetryQueue: envelopeRetryQueue
|
||||
)
|
||||
}
|
||||
|
||||
@@ -457,8 +791,8 @@ struct NostrTransportTests {
|
||||
from event: NostrEvent,
|
||||
recipient: NostrIdentity
|
||||
) throws -> (packet: BitchatPacket, payload: NoisePayload, senderPubkey: String) {
|
||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: event,
|
||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateEnvelope(
|
||||
envelope: event,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
guard content.hasPrefix("bitchat1:") else {
|
||||
@@ -488,6 +822,17 @@ private enum NostrTransportTestError: Error {
|
||||
case invalidPrivateMessage
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class NostrTransportEventProbe: TransportEventDelegate {
|
||||
private(set) var failedMessageIDs: [String] = []
|
||||
|
||||
func didReceiveTransportEvent(_ event: TransportEvent) {
|
||||
guard case .messageDeliveryStatusUpdated(let messageID, let status) = event,
|
||||
case .failed = status else { return }
|
||||
failedMessageIDs.append(messageID)
|
||||
}
|
||||
}
|
||||
|
||||
private func base64URLDecode(_ string: String) -> Data? {
|
||||
var candidate = string
|
||||
let padding = (4 - (candidate.count % 4)) % 4
|
||||
@@ -503,7 +848,7 @@ private func base64URLDecode(_ string: String) -> Data? {
|
||||
private final class NostrTransportProbe: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var sentEventsStorage: [NostrEvent] = []
|
||||
private var pendingGiftWrapIDsStorage: [String] = []
|
||||
private var pendingPrivateEnvelopeIDsStorage: [String] = []
|
||||
private var scheduledActionsStorage: [(@Sendable () -> Void)] = []
|
||||
|
||||
var sentEvents: [NostrEvent] {
|
||||
@@ -512,10 +857,10 @@ private final class NostrTransportProbe: @unchecked Sendable {
|
||||
return sentEventsStorage
|
||||
}
|
||||
|
||||
var pendingGiftWrapIDs: [String] {
|
||||
var pendingPrivateEnvelopeIDs: [String] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return pendingGiftWrapIDsStorage
|
||||
return pendingPrivateEnvelopeIDsStorage
|
||||
}
|
||||
|
||||
var scheduledActionCount: Int {
|
||||
@@ -524,15 +869,16 @@ private final class NostrTransportProbe: @unchecked Sendable {
|
||||
return scheduledActionsStorage.count
|
||||
}
|
||||
|
||||
func record(event: NostrEvent) {
|
||||
func record(batch: [NostrEvent]) -> Bool {
|
||||
lock.lock()
|
||||
sentEventsStorage.append(event)
|
||||
sentEventsStorage.append(contentsOf: batch)
|
||||
lock.unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
func recordPendingGiftWrap(id: String) {
|
||||
func recordPendingPrivateEnvelope(id: String) {
|
||||
lock.lock()
|
||||
pendingGiftWrapIDsStorage.append(id)
|
||||
pendingPrivateEnvelopeIDsStorage.append(id)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
|
||||
@@ -399,59 +399,6 @@ final class SecureIdentityStateManagerTests: XCTestCase {
|
||||
XCTAssertTrue(cleared)
|
||||
}
|
||||
|
||||
func test_privateMediaCapabilityPinPersistsMonotonicallyAndPanicClearRemovesIt() async {
|
||||
let keychain = MockKeychain()
|
||||
let fingerprint = Data(repeating: 0x42, count: 32).sha256Fingerprint()
|
||||
let manager = SecureIdentityStateManager(keychain)
|
||||
|
||||
XCTAssertFalse(manager.hasObservedPrivateMediaCapability(fingerprint: fingerprint))
|
||||
manager.markPrivateMediaCapable(fingerprint: fingerprint)
|
||||
XCTAssertTrue(
|
||||
manager.hasObservedPrivateMediaCapability(fingerprint: fingerprint),
|
||||
"pin insertion must be synchronously visible to the next downgrade decision"
|
||||
)
|
||||
|
||||
// Re-marking is idempotent, and the encrypted cache carries the pin
|
||||
// across launches.
|
||||
manager.markPrivateMediaCapable(fingerprint: fingerprint)
|
||||
manager.forceSave()
|
||||
let reloaded = SecureIdentityStateManager(keychain)
|
||||
XCTAssertTrue(reloaded.hasObservedPrivateMediaCapability(fingerprint: fingerprint))
|
||||
|
||||
// ChatViewModel's panic path calls this same wipe after deleting
|
||||
// keychain data; the in-memory pin must disappear immediately too.
|
||||
reloaded.clearAllIdentityData()
|
||||
let cleared = await waitUntil {
|
||||
!reloaded.hasObservedPrivateMediaCapability(fingerprint: fingerprint)
|
||||
}
|
||||
XCTAssertTrue(cleared)
|
||||
}
|
||||
|
||||
func test_noiseAuthenticatedSigningKeyBindingPersistsAndPanicClearRemovesIt() async {
|
||||
let keychain = MockKeychain()
|
||||
let fingerprint = Data(repeating: 0x31, count: 32).sha256Fingerprint()
|
||||
let firstKey = Data(repeating: 0x41, count: 32)
|
||||
let rotatedKey = Data(repeating: 0x42, count: 32)
|
||||
let manager = SecureIdentityStateManager(keychain)
|
||||
|
||||
manager.bindAuthenticatedSigningPublicKey(firstKey, fingerprint: fingerprint)
|
||||
XCTAssertEqual(manager.authenticatedSigningPublicKey(forFingerprint: fingerprint), firstKey)
|
||||
// A later authenticated Noise session may legitimately rotate the
|
||||
// announcement signing key.
|
||||
manager.bindAuthenticatedSigningPublicKey(rotatedKey, fingerprint: fingerprint)
|
||||
XCTAssertEqual(manager.authenticatedSigningPublicKey(forFingerprint: fingerprint), rotatedKey)
|
||||
|
||||
manager.forceSave()
|
||||
let reloaded = SecureIdentityStateManager(keychain)
|
||||
XCTAssertEqual(reloaded.authenticatedSigningPublicKey(forFingerprint: fingerprint), rotatedKey)
|
||||
|
||||
reloaded.clearAllIdentityData()
|
||||
let cleared = await waitUntil {
|
||||
reloaded.authenticatedSigningPublicKey(forFingerprint: fingerprint) == nil
|
||||
}
|
||||
XCTAssertTrue(cleared)
|
||||
}
|
||||
|
||||
func test_forceSave_withFailingCacheWriteDoesNotPersistCache() async {
|
||||
let keychain = FailingCacheSaveKeychain()
|
||||
let manager = SecureIdentityStateManager(keychain)
|
||||
|
||||
@@ -49,7 +49,7 @@ struct TransferProgressManagerTests {
|
||||
recorder.append("updated:\(id):\(sent):\(total)")
|
||||
case .completed(let id, let total):
|
||||
recorder.append("completed:\(id):\(total)")
|
||||
case .cancelled, .rejected:
|
||||
case .cancelled:
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@ struct TransferProgressManagerTests {
|
||||
recorder.append("started:\(id):\(total)")
|
||||
case .cancelled(let id, let sent, let total):
|
||||
recorder.append("cancelled:\(id):\(sent):\(total)")
|
||||
case .updated, .completed, .rejected:
|
||||
case .updated, .completed:
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -105,28 +105,6 @@ struct TransferProgressManagerTests {
|
||||
#expect(manager.snapshot(id: transferID) == nil)
|
||||
_ = cancellable
|
||||
}
|
||||
|
||||
@Test("Preflight policy rejection publishes a visible failure reason")
|
||||
@MainActor
|
||||
func rejectBeforeStartPublishesReason() async {
|
||||
let manager = TransferProgressManager()
|
||||
let transferID = "transfer-visible-reject"
|
||||
let recorder = EventRecorder()
|
||||
let cancellable = manager.publisher.sink { event in
|
||||
if case .rejected(let id, let reason) = event {
|
||||
recorder.append("rejected:\(id):\(reason)")
|
||||
}
|
||||
}
|
||||
|
||||
manager.rejectBeforeStart(id: transferID, reason: "upgrade required")
|
||||
|
||||
let didReceive = await TestHelpers.waitUntil({
|
||||
recorder.values == ["rejected:\(transferID):upgrade required"]
|
||||
}, timeout: 5.0)
|
||||
#expect(didReceive)
|
||||
#expect(manager.snapshot(id: transferID) == nil)
|
||||
_ = cancellable
|
||||
}
|
||||
}
|
||||
|
||||
private final class EventRecorder: @unchecked Sendable {
|
||||
|
||||
@@ -266,11 +266,6 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
verified.removeAll()
|
||||
}
|
||||
|
||||
func markPrivateMediaCapable(fingerprint: String) {}
|
||||
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool { false }
|
||||
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) {}
|
||||
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? { nil }
|
||||
|
||||
func removeEphemeralSession(peerID: PeerID) {}
|
||||
|
||||
func setVerified(fingerprint: String, verified: Bool) {
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
# Private-media wire migration
|
||||
|
||||
Private files use the `BitchatFilePacket` TLV shared by iOS and Android. The
|
||||
preferred direct-message wire form encrypts that complete TLV inside the
|
||||
peer's Noise session before BLE fragmentation.
|
||||
|
||||
## Wire values and capability
|
||||
|
||||
- `NoisePayloadType.privateFile` is `0x20`, the value already deployed by the
|
||||
Android client. New sends must use this value.
|
||||
- iOS temporarily accepts `0x09`, which appeared in prerelease builds of the
|
||||
private-media change. Decoders canonicalize it to `privateFile`; they never
|
||||
emit it.
|
||||
- `NoisePayloadType.authenticatedPeerState` is permanently assigned `0x21`.
|
||||
It is emitted after every completed/rekeyed Noise XX session and echoed at
|
||||
most once when the remote state arrives, so message-3/proof reordering over
|
||||
different mesh links converges. This type is part of the protocol security
|
||||
boundary and is not removed when the media migration ends.
|
||||
- The `0x21` payload starts with version `0x01`, followed by one-byte
|
||||
type/length/value fields. Version 1 requires canonical TLV `0x01` (the
|
||||
minimal little-endian `PeerCapabilities` bitfield, 1-8 bytes) and TLV `0x02`
|
||||
(the 32-byte Ed25519 announcement signing key). Duplicate required fields,
|
||||
non-minimal capabilities, malformed lengths, missing fields, and unknown
|
||||
versions are ignored without changing state. Unknown TLVs are skipped.
|
||||
- The public `PeerCapabilities.privateMedia` announce bit is a discovery hint:
|
||||
it starts a Noise handshake, but never selects encrypted sending or creates
|
||||
a pin. A private transfer waits boundedly for the exact session's encrypted
|
||||
`0x21`. A valid bit-8 proof selects Noise `0x20`; a valid no-bit proof or a
|
||||
no-proof timeout reaches the explicit legacy-consent path for an unpinned
|
||||
peer. No timeout automatically sends raw bytes.
|
||||
- An unpinned peer with a stable Noise key but without that capability is
|
||||
eligible for one signed, directed
|
||||
`fileTransfer`, matching the pre-migration wire form used by older iOS and
|
||||
accepted by current Android clients, only after the sender confirms a
|
||||
per-send warning that the file is not end-to-end encrypted and mesh relays
|
||||
can see it. The
|
||||
consent is consumed by that invocation and is never remembered.
|
||||
- A signed announce never creates a pin by itself: an attacker can copy a
|
||||
victim's public Noise key, supply its own Ed25519 key and capability bits,
|
||||
and self-sign an internally consistent announce. Only successfully
|
||||
decrypted `0x21` state pins the authenticated Noise fingerprint and binds
|
||||
the Ed25519 key used by later announces/public messages. A later valid
|
||||
no-bit `0x21` is treated as a downgrade, and raw fallback is blocked even if
|
||||
a caller presents legacy consent. Public no-bit announces cannot overwrite
|
||||
current session-authenticated state.
|
||||
- During migration, both an absent capabilities TLV and an explicit TLV
|
||||
without `privateMedia` are legacy-eligible when that stable fingerprint is
|
||||
not pinned. This supports clients that added capability advertisement before
|
||||
encrypted media. Neither shape bypasses a previously authenticated pin.
|
||||
|
||||
Older clients decrypt and ignore unknown inner type `0x21`; they do not need to
|
||||
understand it to continue using text or the warned legacy media path. They are
|
||||
never inferred capable merely because the handshake succeeded.
|
||||
|
||||
Removal gates are independent and must not share an arbitrary calendar date:
|
||||
|
||||
- Remove the `0x09` receive alias only after every TestFlight/internal build
|
||||
that emitted it has expired and minimum-supported-client policy excludes it.
|
||||
- Remove the signed directed raw `0x22` fallback only after minimum-supported
|
||||
iOS and Android clients emit authenticated bit-8 `0x21` state and the legacy
|
||||
population has aged out.
|
||||
- Nostr kind `1059` compatibility is a separate envelope migration. Its dual
|
||||
publish/removal gate is not evidence that either BLE compatibility shape can
|
||||
be removed.
|
||||
|
||||
## Security boundary
|
||||
|
||||
The encrypted form provides Noise confidentiality and peer authentication.
|
||||
The fallback is signed and its signature is required on receive, so relays
|
||||
cannot forge its sender or contents. It is not confidential: relays can see
|
||||
the raw file TLV. The UI says this explicitly and asks on every send. A peer
|
||||
without a stable Noise key from a verified registry entry cannot use the
|
||||
fallback. Keep it only for the mixed-version migration, and remove it only
|
||||
after minimum-supported Android and iOS releases emit authenticated bit-8
|
||||
`0x21` state and the legacy population has aged out. Never replace it with an
|
||||
unsigned fallback, persist blanket consent, or send both forms.
|
||||
|
||||
Incoming clients accept all three migration-era shapes:
|
||||
|
||||
| Sender | Inbound form | Result |
|
||||
| --- | --- | --- |
|
||||
| Current Android | Noise `0x20` | Decrypt and deliver |
|
||||
| Prerelease iOS | Noise `0x09` | Decrypt, canonicalize, and deliver |
|
||||
| Older client | Signed directed `fileTransfer` | Verify signature and deliver |
|
||||
| Forged/unsigned raw sender | Directed `fileTransfer` | Reject |
|
||||
|
||||
Panic wipe clears the persistent capability pins together with the rest of
|
||||
the encrypted identity cache.
|
||||
|
||||
This migration path is mesh-Noise-only (BLE and compatible direct mesh links).
|
||||
Nostr private-media transport is unchanged and remains a follow-up. Nostr
|
||||
inbound paths explicitly ignore `0x21`; do not infer the mesh consent fallback
|
||||
or capability-pin semantics for Nostr delivery.
|
||||
|
||||
## Size interoperability
|
||||
|
||||
iOS bounds inbound file content at 1 MiB and applies the expanded allocation
|
||||
budget only after a large Noise ciphertext authenticates to `0x20` or the
|
||||
temporary `0x09` alias. Ordinary Noise messages retain their 64 KiB limit.
|
||||
|
||||
Current Android builds cap each reassembly at 256 fragments. Depending on the
|
||||
negotiated BLE packet size and routing overhead, that is roughly 110-120 KiB,
|
||||
well below iOS's absolute inbound ceiling. Private-media v1 therefore runs the
|
||||
actual route-aware BLE fragment planner before both encrypted and consented
|
||||
legacy sends and rejects any plan above 256 fragments with a visible failure.
|
||||
This fragment-count contract, rather than a guessed byte threshold, stays
|
||||
correct as route overhead changes.
|
||||
@@ -54,9 +54,9 @@ Public archives contain content already intended for public mesh/board distribut
|
||||
|
||||
## Nostr and Mesh Bridge
|
||||
|
||||
- NIP-17/NIP-44 v2 private fallback protects plaintext with secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305. Relays still see event and network metadata.
|
||||
- BitChat's proprietary private-envelope fallback protects plaintext with secp256k1 key agreement, HKDF-SHA256 with a BitChat-specific domain separator, and XChaCha20-Poly1305. It is not NIP-17, NIP-44, or NIP-59 compatible and does not provide forward secrecy against later compromise of the recipient's static Nostr private key. Relays still see the recipient public-key tag, event timing and size, and network metadata.
|
||||
- Bridge courier drops use a throwaway publisher key, an opaque Noise-sealed envelope, and a day-rotating recipient tag. Only a party already holding the recipient's Noise static key can compute candidate tags.
|
||||
- Relay publication is considered successful only after an explicit NIP-20 `OK true` from at least one target relay. Rejected, disconnected, timed-out, or merely socket-written events stay retryable.
|
||||
- Relay publication is considered successful only after an explicit NIP-01 `OK true` from at least one target relay. Rejected, disconnected, timed-out, or merely socket-written events stay retryable.
|
||||
- When mesh bridge is enabled, public mesh messages not marked “nearby only” are signed under a per-cell Nostr identity and published to a neighborhood rendezvous geohash. Presence and public bridge traffic therefore expose a coarse area to relays and participants.
|
||||
- A bridge gateway can carry signed bridge/location events and opaque courier drops for nearby mesh-only peers. It cannot validly publish a neighbor's radio-only message because the author must first sign the bridge event.
|
||||
|
||||
|
||||
@@ -24,10 +24,6 @@ public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable {
|
||||
/// (uplink/downlink carriers for mesh-only peers). Advertised alongside
|
||||
/// a `bridgeGeohash` TLV carrying the rendezvous cell.
|
||||
public static let bridge = PeerCapabilities(rawValue: 1 << 7)
|
||||
/// Finalized direct-message media encrypted as Noise payload `0x20`
|
||||
/// before outer BLE fragmentation. Peers that omit this bit require the
|
||||
/// signed directed raw-file migration fallback.
|
||||
public static let privateMedia = PeerCapabilities(rawValue: 1 << 8)
|
||||
|
||||
/// Minimal little-endian byte encoding; always at least one byte so an
|
||||
/// empty set is distinguishable from an absent TLV.
|
||||
|
||||
@@ -16,12 +16,11 @@ struct PeerCapabilitiesTests {
|
||||
#expect(PeerCapabilities([]).encoded() == Data([0x00]))
|
||||
#expect(PeerCapabilities.prekeys.encoded() == Data([0x01]))
|
||||
#expect(PeerCapabilities.meshDiagnostics.encoded() == Data([0x40]))
|
||||
#expect(PeerCapabilities.privateMedia.encoded() == Data([0x00, 0x01]))
|
||||
|
||||
let high = PeerCapabilities(rawValue: 1 << 9)
|
||||
#expect(high.encoded() == Data([0x00, 0x02]))
|
||||
|
||||
let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics, .privateMedia]
|
||||
let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics]
|
||||
#expect(PeerCapabilities(encoded: all.encoded()) == all)
|
||||
#expect(PeerCapabilities(encoded: high.encoded()) == high)
|
||||
#expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == [])
|
||||
|
||||
Reference in New Issue
Block a user