mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 05:25:19 +00:00
Relabel private Nostr envelopes honestly
This commit is contained in:
+2
-2
@@ -73,7 +73,7 @@ Private group members receive the group's name, roster, key epoch, and encrypted
|
|||||||
|
|
||||||
Internet-backed features are optional. When enabled or used:
|
Internet-backed features are optional. When enabled or used:
|
||||||
|
|
||||||
- Private fallback messages use encrypted NIP-17 gift wraps. Relays can observe event and network metadata but not the message plaintext.
|
- Private fallback messages use BitChat's app-specific encrypted envelopes. This format is not NIP-17, NIP-44, or NIP-59 compatible. Relays can observe the recipient public-key tag, event timing and size, and network metadata, but not the message plaintext or stable sender identity.
|
||||||
- Public location-channel messages, notes, notices, and presence include a geohash tag, event kind, timestamp, and a public key. A geohash reveals an approximate area; finer precision reveals a smaller area.
|
- Public location-channel messages, notes, notices, and presence include a geohash tag, event kind, timestamp, and a public key. A geohash reveals an approximate area; finer precision reveals a smaller area.
|
||||||
- The optional mesh bridge publishes bridge-enabled public mesh messages and presence to a neighborhood rendezvous cell. Those messages are public to participants and relays for that cell. A per-message “nearby only” choice prevents that message from crossing the bridge.
|
- The optional mesh bridge publishes bridge-enabled public mesh messages and presence to a neighborhood rendezvous cell. Those messages are public to participants and relays for that cell. A per-message “nearby only” choice prevents that message from crossing the bridge.
|
||||||
- Bridge courier drops contain opaque end-to-end encrypted envelopes and a rotating recipient tag. Relays still observe timing and network metadata.
|
- Bridge courier drops contain opaque end-to-end encrypted envelopes and a rotating recipient tag. Relays still observe timing and network metadata.
|
||||||
@@ -103,7 +103,7 @@ Private and public features use different protections:
|
|||||||
|
|
||||||
- Mesh private sessions use Noise XX with X25519, ChaCha20-Poly1305, and SHA-256.
|
- Mesh private sessions use Noise XX with X25519, ChaCha20-Poly1305, and SHA-256.
|
||||||
- Private group messages use ChaCha20-Poly1305; group state and relevant mesh packets use Ed25519 signatures.
|
- Private group messages use ChaCha20-Poly1305; group state and relevant mesh packets use Ed25519 signatures.
|
||||||
- Nostr events use secp256k1 Schnorr signatures. NIP-44 v2 private payloads use secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305.
|
- Nostr events use secp256k1 Schnorr signatures. BitChat private envelopes use secp256k1 key agreement, HKDF-SHA256 with a BitChat-specific domain separator, and XChaCha20-Poly1305. The envelope format is proprietary, only interoperates with BitChat clients, and does not provide forward secrecy against later compromise of the recipient's static Nostr private key.
|
||||||
- The persistent private-message outbox uses ChaCha20-Poly1305 with a key held in the keychain. Some other protected local identity state uses AES-GCM.
|
- The persistent private-message outbox uses ChaCha20-Poly1305 with a key held in the keychain. Some other protected local identity state uses AES-GCM.
|
||||||
- Public mesh, bridge, geohash, and board content is signed or authenticated as appropriate but is intentionally not confidential.
|
- Public mesh, bridge, geohash, and board content is signed or authenticated as appropriate but is intentionally not confidential.
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -63,7 +63,11 @@ let package = Package(
|
|||||||
// Only the vector fixture: declaring the whole "Noise"
|
// Only the vector fixture: declaring the whole "Noise"
|
||||||
// directory would claim its .swift test files as resources
|
// directory would claim its .swift test files as resources
|
||||||
// and silently drop them from compilation.
|
// and silently drop them from compilation.
|
||||||
.process("Noise/NoiseTestVectors.json")
|
.process("Noise/NoiseTestVectors.json"),
|
||||||
|
// Frozen output produced by the released 733098bb private-DM
|
||||||
|
// implementation; proves receive compatibility independently
|
||||||
|
// of the refactored legacy generator.
|
||||||
|
.process("Nostr/Fixtures")
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file
|
|||||||
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
|
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
|
||||||
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
|
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
|
||||||
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
|
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
|
||||||
- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, NIP-17 for Nostr
|
- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, BitChat private envelopes for Nostr fallback
|
||||||
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
|
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
|
||||||
- **Universal App**: Native support for iOS and macOS
|
- **Universal App**: Native support for iOS and macOS
|
||||||
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
||||||
@@ -44,9 +44,18 @@ BitChat uses a **hybrid messaging architecture** with two complementary transpor
|
|||||||
- **Global Reach**: Connect with users worldwide via internet relays
|
- **Global Reach**: Connect with users worldwide via internet relays
|
||||||
- **Location Channels**: Geographic chat rooms using geohash coordinates
|
- **Location Channels**: Geographic chat rooms using geohash coordinates
|
||||||
- **290+ Relay Network**: Distributed across the globe for reliability
|
- **290+ Relay Network**: Distributed across the globe for reliability
|
||||||
- **NIP-17 Encryption**: Gift-wrapped private messages for internet privacy
|
- **BitChat Private Envelopes**: App-specific encrypted private messages over Nostr relays
|
||||||
- **Ephemeral Keys**: Fresh cryptographic identity per geohash area
|
- **Ephemeral Keys**: Fresh cryptographic identity per geohash area
|
||||||
|
|
||||||
|
BitChat's private-envelope format is proprietary and is **not** NIP-17,
|
||||||
|
NIP-44, or NIP-59 compatible. It uses Nostr as a relay transport but only
|
||||||
|
interoperates with BitChat clients. New envelopes use provisional,
|
||||||
|
BitChat-specific public kind 1402 (not a formally reserved Nostr kind),
|
||||||
|
encrypted inner kinds 1403/1404, and the `bitchat-pm-v1:` content prefix.
|
||||||
|
For mixed-version delivery, clients also publish a compatibility-only legacy
|
||||||
|
kind-1059 copy through October 15, 2026 at 00:00 UTC; kind 1402 remains the
|
||||||
|
primary format and is the only format published after that deadline.
|
||||||
|
|
||||||
### Channel Types
|
### Channel Types
|
||||||
|
|
||||||
#### `mesh #bluetooth`
|
#### `mesh #bluetooth`
|
||||||
@@ -80,7 +89,7 @@ Private messages use **intelligent transport selection**:
|
|||||||
2. **Nostr Fallback** (when Bluetooth unavailable)
|
2. **Nostr Fallback** (when Bluetooth unavailable)
|
||||||
|
|
||||||
- Uses recipient's Nostr public key
|
- Uses recipient's Nostr public key
|
||||||
- NIP-17 gift-wrapping for privacy
|
- BitChat's app-specific private-envelope encryption
|
||||||
- Routes through global relay network
|
- Routes through global relay network
|
||||||
|
|
||||||
3. **Smart Queuing** (when neither available)
|
3. **Smart Queuing** (when neither available)
|
||||||
|
|||||||
+6
-4
@@ -25,7 +25,7 @@ bitchat is a decentralized, peer-to-peer messaging application for secure, priva
|
|||||||
Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`:
|
Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`:
|
||||||
|
|
||||||
* **BLE mesh** — every device is simultaneously a GATT central and peripheral, relaying packets in a controlled flood. No infrastructure, pairing, or accounts.
|
* **BLE mesh** — every device is simultaneously a GATT central and peripheral, relaying packets in a controlled flood. No infrastructure, pairing, or accounts.
|
||||||
* **Nostr** — private messages to mutual favorites travel as NIP-17 gift-wrapped events over public relays (over Tor where enabled), bridging separate meshes through the internet.
|
* **Nostr** — private messages to mutual favorites travel in BitChat's app-specific encrypted envelopes over public relays (over Tor where enabled), bridging separate meshes through the internet.
|
||||||
|
|
||||||
The router prefers a live mesh link, falls back to Nostr, and engages the courier system when neither can deliver promptly.
|
The router prefers a live mesh link, falls back to Nostr, and engages the courier system when neither can deliver promptly.
|
||||||
|
|
||||||
@@ -78,7 +78,9 @@ Courier envelopes are sealed to the recipient's *static* key with the one-way No
|
|||||||
|
|
||||||
### 5.3 Nostr Path
|
### 5.3 Nostr Path
|
||||||
|
|
||||||
Private messages to mutual favorites are wrapped per NIP-17/NIP-59: a rumor (kind 14) sealed (kind 13) and gift-wrapped (kind 1059) under a throwaway ephemeral key, so relays learn neither sender nor content.
|
Private messages to mutual favorites use BitChat's proprietary private-envelope protocol. An unsigned inner message (kind 1404) is encrypted and placed in a sender-signed seal (kind 1403); that seal is encrypted again inside a public envelope (kind 1402) signed by a one-time key. Kind 1402 is a provisional BitChat-specific assignment, not a formally reserved Nostr kind. Each encrypted content field is `bitchat-pm-v1:` followed by base64url of a 24-byte nonce, XChaCha20-Poly1305 ciphertext, and its 16-byte tag. Keys come from secp256k1 ECDH and HKDF-SHA256 with a BitChat-specific domain separator.
|
||||||
|
|
||||||
|
This format is **not NIP-17, NIP-44, or NIP-59 compatible** and interoperates only with BitChat clients. The outer `p` tag exposes the recipient's Nostr public key to relays; the stable sender identity and plaintext remain inside authenticated ciphertext. Seal and envelope timestamps are randomized up to 15 minutes into the past, while the actual message timestamp is encrypted. The protocol does not provide forward secrecy: compromise of the recipient's static Nostr private key can expose stored envelopes.
|
||||||
|
|
||||||
## 6. Store and Forward
|
## 6. Store and Forward
|
||||||
|
|
||||||
@@ -105,7 +107,7 @@ Public broadcast messages are cached (1000 packets) and reconciled between peers
|
|||||||
|
|
||||||
### 6.4 Nostr Mailboxes
|
### 6.4 Nostr Mailboxes
|
||||||
|
|
||||||
Gift-wrapped messages rest on Nostr relays; clients re-subscribe with a 24-hour lookback on reconnect, covering the both-devices-offline case for mutual favorites whenever either side touches the internet.
|
BitChat private envelopes rest on Nostr relays; clients re-subscribe across the 24-hour retention window plus the full 15-minute timestamp fuzz. During the rolling format migration, clients subscribe to both the provisional BitChat-specific kind 1402 and historical kind 1059. Through October 15, 2026 at 00:00 UTC, each logical payload is published first in the primary kind-1402 format and then as a compatibility-only legacy copy for older BitChat clients; after that deadline clients publish only kind 1402. Bounded dedup of the authenticated embedded payload collapses the migration pair at receivers.
|
||||||
|
|
||||||
### 6.5 Delivery Metrics
|
### 6.5 Delivery Metrics
|
||||||
|
|
||||||
@@ -127,7 +129,7 @@ Bare local counters (deposits, handovers, sprays, opens, outbox flushes and drop
|
|||||||
* **Flooding abuse** is bounded by TTL clamps, deduplication, per-depositor quotas, connect-rate limits, and announce-rate limiting.
|
* **Flooding abuse** is bounded by TTL clamps, deduplication, per-depositor quotas, connect-rate limits, and announce-rate limiting.
|
||||||
* **Replay** of public broadcasts is bounded by the 6-hour acceptance window plus deduplication; private payloads are protected by Noise nonces.
|
* **Replay** of public broadcasts is bounded by the 6-hour acceptance window plus deduplication; private payloads are protected by Noise nonces.
|
||||||
* **Metadata.** BLE proximity is inherently observable; ephemeral IDs and daily-rotating courier tags limit long-term correlation. Nostr traffic can ride Tor.
|
* **Metadata.** BLE proximity is inherently observable; ephemeral IDs and daily-rotating courier tags limit long-term correlation. Nostr traffic can ride Tor.
|
||||||
* **No forward secrecy for sealed mail** (§5.2) is the main cryptographic trade-off of the offline path.
|
* **No forward secrecy for sealed mail or Nostr envelopes** (§5.2–5.3) means compromise of a recipient's static key can expose retained ciphertext addressed to that key.
|
||||||
|
|
||||||
## 9. Future Work
|
## 9. Future Work
|
||||||
|
|
||||||
|
|||||||
@@ -265,10 +265,10 @@ final class PrivateConversationModel: ObservableObject {
|
|||||||
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
||||||
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
||||||
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
||||||
// Geo DMs are always routed over Nostr (NIP-17); their nostr_ keys
|
// Geo DMs are always routed through BitChat private envelopes over
|
||||||
// never resolve to a reachable mesh peer, so resolveAvailability would
|
// Nostr; their nostr_ keys never resolve to a reachable mesh peer, so
|
||||||
// report .offline. Report .nostrAvailable so the header shows the
|
// resolveAvailability would report .offline. Report .nostrAvailable
|
||||||
// globe instead of a misleading "offline" tag.
|
// so the header shows the globe instead of a misleading "offline" tag.
|
||||||
let availability = conversationPeerID.isGeoDM
|
let availability = conversationPeerID.isGeoDM
|
||||||
? .nostrAvailable
|
? .nostrAvailable
|
||||||
: resolveAvailability(for: headerPeerID, peer: peer)
|
: resolveAvailability(for: headerPeerID, peer: peer)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import P256K
|
import P256K
|
||||||
|
|
||||||
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
|
/// Manages the secp256k1 identity used by BitChat's Nostr relay features,
|
||||||
|
/// including the proprietary private-envelope transport.
|
||||||
struct NostrIdentity: Codable {
|
struct NostrIdentity: Codable {
|
||||||
let privateKey: Data
|
let privateKey: Data
|
||||||
let publicKey: Data
|
let publicKey: Data
|
||||||
|
|||||||
+386
-212
@@ -1,4 +1,3 @@
|
|||||||
import BitLogger
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import P256K
|
import P256K
|
||||||
@@ -7,16 +6,30 @@ import Security
|
|||||||
// Note: This file depends on Data extension from BinaryEncodingUtils.swift
|
// Note: This file depends on Data extension from BinaryEncodingUtils.swift
|
||||||
// Make sure BinaryEncodingUtils.swift is included in the target
|
// Make sure BinaryEncodingUtils.swift is included in the target
|
||||||
|
|
||||||
/// NIP-17 Protocol Implementation for Private Direct Messages
|
/// BitChat's private-envelope protocol transported over Nostr relays.
|
||||||
|
///
|
||||||
|
/// This is deliberately BitChat-specific and is not NIP-17, NIP-44, or NIP-59.
|
||||||
|
/// It uses Nostr events and secp256k1 identities, but its XChaCha20-Poly1305
|
||||||
|
/// payload layout is proprietary and interoperates only with BitChat clients.
|
||||||
struct NostrProtocol {
|
struct NostrProtocol {
|
||||||
|
|
||||||
/// Nostr event kinds
|
/// Nostr event kinds
|
||||||
enum EventKind: Int {
|
enum EventKind: Int {
|
||||||
case metadata = 0
|
case metadata = 0
|
||||||
case textNote = 1
|
case textNote = 1
|
||||||
case dm = 14 // NIP-17 DM rumor kind
|
// Bounded compatibility for BitChat releases that incorrectly emitted
|
||||||
case seal = 13 // NIP-17 sealed event
|
// the proprietary payload under standard NIP kinds. Only kind 1059 is
|
||||||
case giftWrap = 1059 // NIP-59 gift wrap
|
// temporarily published during migration; all three remain readable.
|
||||||
|
case legacyNIP59Seal = 13
|
||||||
|
case legacyNIP17DirectMessage = 14
|
||||||
|
case legacyNIP59GiftWrap = 1059
|
||||||
|
// Provisional BitChat-specific regular event kinds. These are not
|
||||||
|
// formally reserved by the Nostr kind registry. Only
|
||||||
|
// `privateEnvelope` is published; message and seal exist solely
|
||||||
|
// inside ciphertext.
|
||||||
|
case privateEnvelope = 1402
|
||||||
|
case privateSeal = 1403
|
||||||
|
case privateMessage = 1404
|
||||||
case ephemeralEvent = 20000
|
case ephemeralEvent = 20000
|
||||||
case geohashPresence = 20001
|
case geohashPresence = 20001
|
||||||
case deletion = 5 // NIP-09 event deletion request
|
case deletion = 5 // NIP-09 event deletion request
|
||||||
@@ -26,144 +39,271 @@ struct NostrProtocol {
|
|||||||
case courierDrop = 1401
|
case courierDrop = 1401
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a NIP-17 private message
|
/// Prefix for BitChat private-envelope ciphertext. The suffix is
|
||||||
static func createPrivateMessage(
|
/// base64url(nonce24 || ciphertext || poly1305Tag).
|
||||||
|
static let privateEnvelopeContentPrefix = "bitchat-pm-v1:"
|
||||||
|
|
||||||
|
/// Bound work before Base64 decoding either encrypted layer. Current
|
||||||
|
/// private messages are normally only a few KiB; 64 KiB leaves ample
|
||||||
|
/// migration headroom without allowing an addressed relay event to drive
|
||||||
|
/// unbounded allocation.
|
||||||
|
static let maximumPrivateEnvelopeCiphertextBytes = 64 * 1024
|
||||||
|
|
||||||
|
/// Bound the inner authenticated message JSON before allocation/parsing.
|
||||||
|
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
|
||||||
|
|
||||||
|
/// Compatibility-only publication stops at this instant. New-format kind
|
||||||
|
/// 1402 remains first/primary throughout the window; the legacy kind-1059
|
||||||
|
/// copy exists solely so pre-migration BitChat clients can receive it.
|
||||||
|
static let legacyPrivateEnvelopePublicationDeadline = Date(
|
||||||
|
timeIntervalSince1970: 1_792_022_400 // 2026-10-15T00:00:00Z
|
||||||
|
)
|
||||||
|
|
||||||
|
/// New clients subscribe to the provisional BitChat-specific kind and the
|
||||||
|
/// compatibility-only legacy kind so both sides of a rolling rollout can
|
||||||
|
/// recover stored messages.
|
||||||
|
static let acceptedPrivateEnvelopeKinds = [
|
||||||
|
EventKind.privateEnvelope.rawValue,
|
||||||
|
EventKind.legacyNIP59GiftWrap.rawValue
|
||||||
|
]
|
||||||
|
|
||||||
|
private enum PrivateEnvelopeWireFormat {
|
||||||
|
case bitchatV1
|
||||||
|
case legacyMislabelledV2
|
||||||
|
|
||||||
|
init?(outerKind: Int) {
|
||||||
|
switch outerKind {
|
||||||
|
case EventKind.privateEnvelope.rawValue:
|
||||||
|
self = .bitchatV1
|
||||||
|
case EventKind.legacyNIP59GiftWrap.rawValue:
|
||||||
|
self = .legacyMislabelledV2
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var messageKind: EventKind {
|
||||||
|
switch self {
|
||||||
|
case .bitchatV1: .privateMessage
|
||||||
|
case .legacyMislabelledV2: .legacyNIP17DirectMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sealKind: EventKind {
|
||||||
|
switch self {
|
||||||
|
case .bitchatV1: .privateSeal
|
||||||
|
case .legacyMislabelledV2: .legacyNIP59Seal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var envelopeKind: EventKind {
|
||||||
|
switch self {
|
||||||
|
case .bitchatV1: .privateEnvelope
|
||||||
|
case .legacyMislabelledV2: .legacyNIP59GiftWrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var contentPrefix: String {
|
||||||
|
switch self {
|
||||||
|
case .bitchatV1: NostrProtocol.privateEnvelopeContentPrefix
|
||||||
|
case .legacyMislabelledV2: "v2:"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hkdfSalt: Data {
|
||||||
|
switch self {
|
||||||
|
case .bitchatV1: Data("bitchat-private-envelope-v1".utf8)
|
||||||
|
case .legacyMislabelledV2: Data()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hkdfInfo: Data {
|
||||||
|
switch self {
|
||||||
|
case .bitchatV1: Data()
|
||||||
|
case .legacyMislabelledV2: Data("nip44-v2".utf8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a BitChat private envelope for relay transport.
|
||||||
|
static func createPrivateEnvelope(
|
||||||
content: String,
|
content: String,
|
||||||
recipientPubkey: String,
|
recipientPubkey: String,
|
||||||
senderIdentity: NostrIdentity
|
senderIdentity: NostrIdentity
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
|
try createPrivateEnvelope(
|
||||||
|
content: content,
|
||||||
|
recipientPubkey: recipientPubkey,
|
||||||
|
senderIdentity: senderIdentity,
|
||||||
|
format: .bitchatV1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Creating private message
|
/// Events to publish for one logical private payload. The primary
|
||||||
|
/// BitChat-specific format is always first. Until the explicit migration
|
||||||
|
/// deadline, a legacy copy follows for clients that still subscribe only
|
||||||
|
/// to kind 1059. 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,
|
||||||
|
now: Date = Date()
|
||||||
|
) throws -> [NostrEvent] {
|
||||||
|
let primary = try createPrivateEnvelope(
|
||||||
|
content: content,
|
||||||
|
recipientPubkey: recipientPubkey,
|
||||||
|
senderIdentity: senderIdentity
|
||||||
|
)
|
||||||
|
guard now < legacyPrivateEnvelopePublicationDeadline else {
|
||||||
|
return [primary]
|
||||||
|
}
|
||||||
|
let compatibilityCopy = try createPrivateEnvelope(
|
||||||
|
content: content,
|
||||||
|
recipientPubkey: recipientPubkey,
|
||||||
|
senderIdentity: senderIdentity,
|
||||||
|
format: .legacyMislabelledV2
|
||||||
|
)
|
||||||
|
return [primary, compatibilityCopy]
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Create the rumor (unsigned event)
|
private static func createPrivateEnvelope(
|
||||||
let rumor = NostrEvent(
|
content: String,
|
||||||
|
recipientPubkey: String,
|
||||||
|
senderIdentity: NostrIdentity,
|
||||||
|
format: PrivateEnvelopeWireFormat
|
||||||
|
) throws -> NostrEvent {
|
||||||
|
// 1. Create the unsigned inner BitChat message.
|
||||||
|
let message = NostrEvent(
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
kind: .dm, // NIP-17: DM rumor kind 14
|
kind: format.messageKind,
|
||||||
tags: [],
|
tags: [],
|
||||||
content: content
|
content: content
|
||||||
)
|
)
|
||||||
|
|
||||||
// 2. Seal the rumor (encrypt to recipient) and sign it with the SENDER'S
|
// 2. Encrypt the message to the recipient and sign the private seal
|
||||||
// real identity key. NIP-17 requires the seal be signed by the sender
|
// with the sender's stable Nostr identity for sender authentication.
|
||||||
// so the recipient can authenticate who sent the message; signing with
|
|
||||||
// a throwaway key leaves DMs forgeable/impersonatable.
|
|
||||||
let senderKey = try senderIdentity.schnorrSigningKey()
|
let senderKey = try senderIdentity.schnorrSigningKey()
|
||||||
let sealedEvent = try createSeal(
|
let sealedEvent = try createPrivateSeal(
|
||||||
rumor: rumor,
|
message: message,
|
||||||
recipientPubkey: recipientPubkey,
|
recipientPubkey: recipientPubkey,
|
||||||
senderKey: senderKey
|
senderKey: senderKey,
|
||||||
|
format: format
|
||||||
)
|
)
|
||||||
|
|
||||||
// 3. Gift wrap the sealed event with a throwaway ephemeral key (the wrap
|
// 3. Encrypt the seal under a one-time key so the public envelope does
|
||||||
// layer hides the sender's identity from relays; createGiftWrap mints
|
// not reveal the stable sender identity.
|
||||||
// its own ephemeral key internally).
|
return try createPrivateEnvelopeEvent(
|
||||||
let giftWrap = try createGiftWrap(
|
|
||||||
seal: sealedEvent,
|
seal: sealedEvent,
|
||||||
recipientPubkey: recipientPubkey
|
recipientPubkey: recipientPubkey,
|
||||||
|
format: format
|
||||||
)
|
)
|
||||||
|
|
||||||
// Created gift wrap
|
|
||||||
|
|
||||||
return giftWrap
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt a received NIP-17 message
|
/// Decrypt a BitChat private envelope. Legacy proprietary envelopes that
|
||||||
/// Returns the content, sender pubkey, and the actual message timestamp (not the randomized gift wrap timestamp)
|
/// older BitChat releases placed under kinds 1059/13/14 are accepted only
|
||||||
static func decryptPrivateMessage(
|
/// through the format-isolated receive path.
|
||||||
giftWrap: NostrEvent,
|
static func decryptPrivateEnvelope(
|
||||||
|
envelope: NostrEvent,
|
||||||
recipientIdentity: NostrIdentity
|
recipientIdentity: NostrIdentity
|
||||||
) throws -> (content: String, senderPubkey: String, timestamp: Int) {
|
) throws -> (content: String, senderPubkey: String, timestamp: Int) {
|
||||||
|
let layers = try decodePrivateEnvelopeLayers(
|
||||||
// Starting decryption
|
envelope: envelope,
|
||||||
|
recipientIdentity: recipientIdentity
|
||||||
// 1. Unwrap the gift wrap
|
)
|
||||||
let seal: NostrEvent
|
return (
|
||||||
do {
|
content: layers.message.content,
|
||||||
seal = try unwrapGiftWrap(
|
senderPubkey: layers.seal.pubkey,
|
||||||
giftWrap: giftWrap,
|
timestamp: layers.message.created_at
|
||||||
recipientKey: recipientIdentity.schnorrSigningKey()
|
)
|
||||||
)
|
|
||||||
// Successfully unwrapped gift wrap
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("❌ Failed to unwrap gift wrap: \(error)", category: .session)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Authenticate the seal. The seal MUST be signed by the sender's real
|
|
||||||
// identity key (NIP-17); without this check a DM is forgeable by anyone
|
|
||||||
// who knows the recipient's npub. Verify the seal's own signature.
|
|
||||||
guard seal.isValidSignature() else {
|
|
||||||
SecureLogger.error("❌ Rejecting DM: seal signature is missing or invalid", category: .session)
|
|
||||||
throw NostrError.invalidEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Open the seal
|
|
||||||
let rumor: NostrEvent
|
|
||||||
do {
|
|
||||||
rumor = try openSeal(
|
|
||||||
seal: seal,
|
|
||||||
recipientKey: recipientIdentity.schnorrSigningKey()
|
|
||||||
)
|
|
||||||
// Successfully opened seal
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("❌ Failed to open seal: \(error)", category: .session)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. The sender claimed inside the rumor must match the key that actually
|
|
||||||
// signed the seal, otherwise the sender field is unauthenticated and
|
|
||||||
// spoofable.
|
|
||||||
guard seal.pubkey == rumor.pubkey else {
|
|
||||||
SecureLogger.error("❌ Rejecting DM: rumor pubkey does not match seal signer", category: .session)
|
|
||||||
throw NostrError.invalidEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the seal signer's pubkey as the authenticated sender.
|
|
||||||
return (content: rumor.content, senderPubkey: seal.pubkey, timestamp: rumor.created_at)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
static func createPrivateMessageWithInvalidSealSignatureForTesting(
|
static func createPrivateEnvelopeWithInvalidSealSignatureForTesting(
|
||||||
content: String,
|
content: String,
|
||||||
recipientPubkey: String,
|
recipientPubkey: String,
|
||||||
senderIdentity: NostrIdentity
|
senderIdentity: NostrIdentity
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
let rumor = NostrEvent(
|
let format = PrivateEnvelopeWireFormat.bitchatV1
|
||||||
|
let message = NostrEvent(
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
kind: .dm,
|
kind: format.messageKind,
|
||||||
tags: [],
|
tags: [],
|
||||||
content: content
|
content: content
|
||||||
)
|
)
|
||||||
var seal = try createSeal(
|
var seal = try createPrivateSeal(
|
||||||
rumor: rumor,
|
message: message,
|
||||||
recipientPubkey: recipientPubkey,
|
recipientPubkey: recipientPubkey,
|
||||||
senderKey: senderIdentity.schnorrSigningKey()
|
senderKey: senderIdentity.schnorrSigningKey(),
|
||||||
|
format: format
|
||||||
)
|
)
|
||||||
seal.sig = String(repeating: "0", count: 128)
|
seal.sig = String(repeating: "0", count: 128)
|
||||||
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
|
return try createPrivateEnvelopeEvent(
|
||||||
|
seal: seal,
|
||||||
|
recipientPubkey: recipientPubkey,
|
||||||
|
format: format
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func createPrivateMessageWithMismatchedSealRumorPubkeyForTesting(
|
static func createPrivateEnvelopeWithMismatchedSealMessagePubkeyForTesting(
|
||||||
content: String,
|
content: String,
|
||||||
recipientPubkey: String,
|
recipientPubkey: String,
|
||||||
rumorIdentity: NostrIdentity,
|
messageIdentity: NostrIdentity,
|
||||||
sealSignerIdentity: NostrIdentity
|
sealSignerIdentity: NostrIdentity
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
let rumor = NostrEvent(
|
let format = PrivateEnvelopeWireFormat.bitchatV1
|
||||||
pubkey: rumorIdentity.publicKeyHex,
|
let message = NostrEvent(
|
||||||
|
pubkey: messageIdentity.publicKeyHex,
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
kind: .dm,
|
kind: format.messageKind,
|
||||||
tags: [],
|
tags: [],
|
||||||
content: content
|
content: content
|
||||||
)
|
)
|
||||||
let seal = try createSeal(
|
let seal = try createPrivateSeal(
|
||||||
rumor: rumor,
|
message: message,
|
||||||
recipientPubkey: recipientPubkey,
|
recipientPubkey: recipientPubkey,
|
||||||
senderKey: sealSignerIdentity.schnorrSigningKey()
|
senderKey: sealSignerIdentity.schnorrSigningKey(),
|
||||||
|
format: format
|
||||||
)
|
)
|
||||||
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
|
return try createPrivateEnvelopeEvent(
|
||||||
|
seal: seal,
|
||||||
|
recipientPubkey: recipientPubkey,
|
||||||
|
format: format
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func createLegacyPrivateEnvelopeForTesting(
|
||||||
|
content: String,
|
||||||
|
recipientPubkey: String,
|
||||||
|
senderIdentity: NostrIdentity
|
||||||
|
) throws -> NostrEvent {
|
||||||
|
try createPrivateEnvelope(
|
||||||
|
content: content,
|
||||||
|
recipientPubkey: recipientPubkey,
|
||||||
|
senderIdentity: senderIdentity,
|
||||||
|
format: .legacyMislabelledV2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decodePrivateEnvelopeLayersForTesting(
|
||||||
|
envelope: NostrEvent,
|
||||||
|
recipientIdentity: NostrIdentity
|
||||||
|
) throws -> (seal: NostrEvent, message: NostrEvent) {
|
||||||
|
try decodePrivateEnvelopeLayers(
|
||||||
|
envelope: envelope,
|
||||||
|
recipientIdentity: recipientIdentity
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decodePrivateEnvelopeEventJSONForTesting(_ json: String) throws -> NostrEvent {
|
||||||
|
try decodePrivateEnvelopeEventJSON(json)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -400,151 +540,186 @@ struct NostrProtocol {
|
|||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
private static func createSeal(
|
private static func createPrivateSeal(
|
||||||
rumor: NostrEvent,
|
message: NostrEvent,
|
||||||
recipientPubkey: String,
|
recipientPubkey: String,
|
||||||
senderKey: P256K.Schnorr.PrivateKey
|
senderKey: P256K.Schnorr.PrivateKey,
|
||||||
|
format: PrivateEnvelopeWireFormat
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
|
|
||||||
let rumorJSON = try rumor.jsonString()
|
|
||||||
let encrypted = try encrypt(
|
let encrypted = try encrypt(
|
||||||
plaintext: rumorJSON,
|
plaintext: message.jsonString(),
|
||||||
recipientPubkey: recipientPubkey,
|
recipientPubkey: recipientPubkey,
|
||||||
senderKey: senderKey
|
senderKey: senderKey,
|
||||||
|
format: format,
|
||||||
|
maximumPlaintextBytes: maximumPrivateEnvelopePlaintextBytes
|
||||||
)
|
)
|
||||||
|
|
||||||
let seal = NostrEvent(
|
let seal = NostrEvent(
|
||||||
pubkey: Data(senderKey.xonly.bytes).hexEncodedString(),
|
pubkey: Data(senderKey.xonly.bytes).hexEncodedString(),
|
||||||
createdAt: randomizedTimestamp(),
|
createdAt: randomizedPastTimestamp(),
|
||||||
kind: .seal,
|
kind: format.sealKind,
|
||||||
tags: [],
|
tags: [],
|
||||||
content: encrypted
|
content: encrypted
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sign the seal with the sender's Schnorr private key
|
|
||||||
return try seal.sign(with: senderKey)
|
return try seal.sign(with: senderKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func createGiftWrap(
|
private static func createPrivateEnvelopeEvent(
|
||||||
seal: NostrEvent,
|
seal: NostrEvent,
|
||||||
recipientPubkey: String
|
recipientPubkey: String,
|
||||||
|
format: PrivateEnvelopeWireFormat
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
|
// A fresh signing/encryption key for every public envelope keeps the
|
||||||
let sealJSON = try seal.jsonString()
|
// stable sender identity inside ciphertext.
|
||||||
|
let envelopeKey = try P256K.Schnorr.PrivateKey()
|
||||||
// Create new ephemeral key for gift wrap
|
|
||||||
let wrapKey = try P256K.Schnorr.PrivateKey()
|
|
||||||
// Creating gift wrap with ephemeral key
|
|
||||||
|
|
||||||
// Encrypt the seal with the new ephemeral key (not the seal's key)
|
|
||||||
let encrypted = try encrypt(
|
let encrypted = try encrypt(
|
||||||
plaintext: sealJSON,
|
plaintext: seal.jsonString(),
|
||||||
recipientPubkey: recipientPubkey,
|
recipientPubkey: recipientPubkey,
|
||||||
senderKey: wrapKey // Use the gift wrap ephemeral key
|
senderKey: envelopeKey,
|
||||||
|
format: format,
|
||||||
|
maximumPlaintextBytes: maximumPrivateEnvelopeSealPlaintextBytes
|
||||||
)
|
)
|
||||||
|
|
||||||
let giftWrap = NostrEvent(
|
let envelope = NostrEvent(
|
||||||
pubkey: Data(wrapKey.xonly.bytes).hexEncodedString(),
|
pubkey: Data(envelopeKey.xonly.bytes).hexEncodedString(),
|
||||||
createdAt: randomizedTimestamp(),
|
createdAt: randomizedPastTimestamp(),
|
||||||
kind: .giftWrap,
|
kind: format.envelopeKind,
|
||||||
tags: [["p", recipientPubkey]], // Tag recipient
|
tags: [["p", recipientPubkey]],
|
||||||
content: encrypted
|
content: encrypted
|
||||||
)
|
)
|
||||||
|
return try envelope.sign(with: envelopeKey)
|
||||||
// Sign the gift wrap with the wrap Schnorr private key
|
|
||||||
return try giftWrap.sign(with: wrapKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func unwrapGiftWrap(
|
private static func decodePrivateEnvelopeLayers(
|
||||||
giftWrap: NostrEvent,
|
envelope: NostrEvent,
|
||||||
recipientKey: P256K.Schnorr.PrivateKey
|
recipientIdentity: NostrIdentity
|
||||||
) throws -> NostrEvent {
|
) throws -> (seal: NostrEvent, message: NostrEvent) {
|
||||||
|
guard envelope.content.utf8.count <= maximumPrivateEnvelopeCiphertextBytes else {
|
||||||
// Unwrapping gift wrap
|
throw NostrError.invalidCiphertext
|
||||||
|
}
|
||||||
let decrypted = try decrypt(
|
guard let format = PrivateEnvelopeWireFormat(outerKind: envelope.kind),
|
||||||
ciphertext: giftWrap.content,
|
envelope.tags == [["p", recipientIdentity.publicKeyHex]],
|
||||||
senderPubkey: giftWrap.pubkey,
|
envelope.isValidSignature() else {
|
||||||
recipientKey: recipientKey
|
|
||||||
)
|
|
||||||
|
|
||||||
guard let data = decrypted.data(using: .utf8),
|
|
||||||
let sealDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
||||||
throw NostrError.invalidEvent
|
throw NostrError.invalidEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
let seal = try NostrEvent(from: sealDict)
|
let recipientKey = try recipientIdentity.schnorrSigningKey()
|
||||||
// Unwrapped seal
|
let sealJSON = try decrypt(
|
||||||
|
ciphertext: envelope.content,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
return seal
|
let messageJSON = try decrypt(
|
||||||
}
|
|
||||||
|
|
||||||
private static func openSeal(
|
|
||||||
seal: NostrEvent,
|
|
||||||
recipientKey: P256K.Schnorr.PrivateKey
|
|
||||||
) throws -> NostrEvent {
|
|
||||||
|
|
||||||
let decrypted = try decrypt(
|
|
||||||
ciphertext: seal.content,
|
ciphertext: seal.content,
|
||||||
senderPubkey: seal.pubkey,
|
senderPubkey: seal.pubkey,
|
||||||
recipientKey: recipientKey
|
recipientKey: recipientKey,
|
||||||
|
format: format,
|
||||||
|
maximumPlaintextBytes: maximumPrivateEnvelopePlaintextBytes
|
||||||
|
)
|
||||||
|
let message = try decodePrivateEnvelopeEventJSON(
|
||||||
|
messageJSON,
|
||||||
|
maximumBytes: maximumPrivateEnvelopePlaintextBytes
|
||||||
)
|
)
|
||||||
|
|
||||||
guard let data = decrypted.data(using: .utf8),
|
// The inner message is intentionally unsigned; sender authentication
|
||||||
let rumorDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
// comes from the seal. Bind its claimed sender and custom kind to that
|
||||||
|
// authenticated layer before exposing content.
|
||||||
|
guard message.kind == format.messageKind.rawValue,
|
||||||
|
message.tags.isEmpty,
|
||||||
|
message.sig == nil,
|
||||||
|
seal.pubkey == message.pubkey else {
|
||||||
throw NostrError.invalidEvent
|
throw NostrError.invalidEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
return try NostrEvent(from: rumorDict)
|
return (seal, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Encryption (NIP-44 v2)
|
private static func decodePrivateEnvelopeEventJSON(
|
||||||
|
_ json: String,
|
||||||
|
maximumBytes: Int = maximumPrivateEnvelopePlaintextBytes
|
||||||
|
) throws -> NostrEvent {
|
||||||
|
// Check UTF-8 size before allocating Data or invoking the general JSON
|
||||||
|
// parser. `decrypt` enforces the same cap on authenticated bytes; this
|
||||||
|
// local guard keeps the parser boundary explicit and independently
|
||||||
|
// testable.
|
||||||
|
guard json.utf8.count <= maximumBytes else {
|
||||||
|
throw NostrError.invalidCiphertext
|
||||||
|
}
|
||||||
|
guard let data = json.data(using: .utf8),
|
||||||
|
let dictionary = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
|
throw NostrError.invalidEvent
|
||||||
|
}
|
||||||
|
return try NostrEvent(from: dictionary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - BitChat private-envelope encryption
|
||||||
|
|
||||||
private static func encrypt(
|
private static func encrypt(
|
||||||
plaintext: String,
|
plaintext: String,
|
||||||
recipientPubkey: String,
|
recipientPubkey: String,
|
||||||
senderKey: P256K.Schnorr.PrivateKey
|
senderKey: P256K.Schnorr.PrivateKey,
|
||||||
|
format: PrivateEnvelopeWireFormat,
|
||||||
|
maximumPlaintextBytes: Int
|
||||||
) throws -> String {
|
) throws -> String {
|
||||||
|
|
||||||
guard let recipientPubkeyData = Data(hexString: recipientPubkey) else {
|
guard let recipientPubkeyData = Data(hexString: recipientPubkey) else {
|
||||||
throw NostrError.invalidPublicKey
|
throw NostrError.invalidPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
|
|
||||||
|
|
||||||
// Derive shared secret
|
|
||||||
let sharedSecret = try deriveSharedSecret(
|
let sharedSecret = try deriveSharedSecret(
|
||||||
privateKey: senderKey,
|
privateKey: senderKey,
|
||||||
publicKey: recipientPubkeyData
|
publicKey: recipientPubkeyData
|
||||||
)
|
)
|
||||||
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info)
|
let key = derivePrivateEnvelopeKey(from: sharedSecret, format: format)
|
||||||
let key = try deriveNIP44V2Key(from: sharedSecret)
|
|
||||||
|
|
||||||
// 24-byte random nonce for XChaCha20-Poly1305
|
|
||||||
var nonce24 = Data(count: 24)
|
var nonce24 = Data(count: 24)
|
||||||
_ = nonce24.withUnsafeMutableBytes { ptr in
|
let randomStatus = nonce24.withUnsafeMutableBytes { ptr in
|
||||||
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
|
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
|
||||||
}
|
}
|
||||||
|
guard randomStatus == errSecSuccess else {
|
||||||
|
throw NostrError.cryptographicFailure
|
||||||
|
}
|
||||||
|
|
||||||
let pt = Data(plaintext.utf8)
|
let plaintextData = Data(plaintext.utf8)
|
||||||
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
|
guard plaintextData.count <= maximumPlaintextBytes else {
|
||||||
|
throw NostrError.invalidCiphertext
|
||||||
|
}
|
||||||
|
let sealed = try XChaCha20Poly1305Compat.seal(
|
||||||
|
plaintext: plaintextData,
|
||||||
|
key: key,
|
||||||
|
nonce24: nonce24
|
||||||
|
)
|
||||||
|
|
||||||
// v2: base64url(nonce24 || ciphertext || tag)
|
|
||||||
var combined = Data()
|
var combined = Data()
|
||||||
combined.append(nonce24)
|
combined.append(nonce24)
|
||||||
combined.append(sealed.ciphertext)
|
combined.append(sealed.ciphertext)
|
||||||
combined.append(sealed.tag)
|
combined.append(sealed.tag)
|
||||||
return "v2:" + Base64URLCoding.encode(combined)
|
return format.contentPrefix + Base64URLCoding.encode(combined)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func decrypt(
|
private static func decrypt(
|
||||||
ciphertext: String,
|
ciphertext: String,
|
||||||
senderPubkey: String,
|
senderPubkey: String,
|
||||||
recipientKey: P256K.Schnorr.PrivateKey
|
recipientKey: P256K.Schnorr.PrivateKey,
|
||||||
|
format: PrivateEnvelopeWireFormat,
|
||||||
|
maximumPlaintextBytes: Int
|
||||||
) throws -> String {
|
) throws -> String {
|
||||||
// Expect NIP-44 v2 format
|
guard ciphertext.utf8.count <= maximumPrivateEnvelopeCiphertextBytes,
|
||||||
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
|
ciphertext.hasPrefix(format.contentPrefix) else {
|
||||||
let encoded = String(ciphertext.dropFirst(3))
|
throw NostrError.invalidCiphertext
|
||||||
|
}
|
||||||
|
let encoded = String(ciphertext.dropFirst(format.contentPrefix.count))
|
||||||
guard let data = Base64URLCoding.decode(encoded),
|
guard let data = Base64URLCoding.decode(encoded),
|
||||||
data.count > (24 + 16),
|
data.count > (24 + 16),
|
||||||
let senderPubkeyData = Data(hexString: senderPubkey) else {
|
let senderPubkeyData = Data(hexString: senderPubkey) else {
|
||||||
@@ -554,33 +729,40 @@ struct NostrProtocol {
|
|||||||
let nonce24 = data.prefix(24)
|
let nonce24 = data.prefix(24)
|
||||||
let rest = data.dropFirst(24)
|
let rest = data.dropFirst(24)
|
||||||
let tag = rest.suffix(16)
|
let tag = rest.suffix(16)
|
||||||
let ct = rest.dropLast(16)
|
let ciphertextBytes = rest.dropLast(16)
|
||||||
|
|
||||||
// Try decryption with even-Y then odd-Y when sender pubkey is x-only
|
func attemptDecrypt(using publicKeyData: Data) throws -> Data {
|
||||||
func attemptDecrypt(using pubKeyData: Data) throws -> Data {
|
let sharedSecret = try deriveSharedSecret(
|
||||||
let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData)
|
privateKey: recipientKey,
|
||||||
let key = try deriveNIP44V2Key(from: ss)
|
publicKey: publicKeyData
|
||||||
|
)
|
||||||
|
let key = derivePrivateEnvelopeKey(from: sharedSecret, format: format)
|
||||||
return try XChaCha20Poly1305Compat.open(
|
return try XChaCha20Poly1305Compat.open(
|
||||||
ciphertext: Data(ct),
|
ciphertext: Data(ciphertextBytes),
|
||||||
tag: Data(tag),
|
tag: Data(tag),
|
||||||
key: key,
|
key: key,
|
||||||
nonce24: Data(nonce24)
|
nonce24: Data(nonce24)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If 32 bytes (x-only) try both parities, otherwise single try
|
let plaintext: Data
|
||||||
if senderPubkeyData.count == 32 {
|
if senderPubkeyData.count == 32 {
|
||||||
let even = Data([0x02]) + senderPubkeyData
|
let evenKey = Data([0x02]) + senderPubkeyData
|
||||||
if let pt = try? attemptDecrypt(using: even) {
|
if let opened = try? attemptDecrypt(using: evenKey) {
|
||||||
return String(data: pt, encoding: .utf8) ?? ""
|
plaintext = opened
|
||||||
|
} else {
|
||||||
|
let oddKey = Data([0x03]) + senderPubkeyData
|
||||||
|
plaintext = try attemptDecrypt(using: oddKey)
|
||||||
}
|
}
|
||||||
let odd = Data([0x03]) + senderPubkeyData
|
|
||||||
let pt = try attemptDecrypt(using: odd)
|
|
||||||
return String(data: pt, encoding: .utf8) ?? ""
|
|
||||||
} else {
|
} else {
|
||||||
let pt = try attemptDecrypt(using: senderPubkeyData)
|
plaintext = try attemptDecrypt(using: senderPubkeyData)
|
||||||
return String(data: pt, encoding: .utf8) ?? ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard plaintext.count <= maximumPlaintextBytes,
|
||||||
|
let decoded = String(data: plaintext, encoding: .utf8) else {
|
||||||
|
throw NostrError.invalidCiphertext
|
||||||
|
}
|
||||||
|
return decoded
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func deriveSharedSecret(
|
private static func deriveSharedSecret(
|
||||||
@@ -640,29 +822,17 @@ struct NostrProtocol {
|
|||||||
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
||||||
// ECDH shared secret derived
|
// ECDH shared secret derived
|
||||||
|
|
||||||
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
|
// Return raw ECDH shared secret; the wire-format-specific HKDF is
|
||||||
|
// applied by derivePrivateEnvelopeKey.
|
||||||
return sharedSecretData
|
return sharedSecretData
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func randomizedTimestamp() -> Date {
|
private static func randomizedPastTimestamp() -> Date {
|
||||||
// Add random offset to current time for privacy
|
// Keep public timestamps in the past: future-dated events are rejected
|
||||||
// This prevents timing correlation attacks while the actual message timestamp
|
// by some relays. The actual message timestamp remains encrypted.
|
||||||
// is preserved in the encrypted rumor
|
Date().addingTimeInterval(
|
||||||
let offset = TimeInterval.random(in: -900...900) // +/- 15 minutes
|
-TimeInterval.random(in: 0...TransportConfig.nostrPrivateEnvelopeTimestampFuzzSeconds)
|
||||||
let now = Date()
|
)
|
||||||
let randomized = now.addingTimeInterval(offset)
|
|
||||||
|
|
||||||
// Log with explicit UTC and local time for debugging
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
//
|
|
||||||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
|
||||||
formatter.timeZone = TimeZone(abbreviation: "UTC")
|
|
||||||
|
|
||||||
formatter.timeZone = TimeZone.current
|
|
||||||
|
|
||||||
// Timestamp randomized for privacy
|
|
||||||
|
|
||||||
return randomized
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -775,16 +945,20 @@ enum NostrError: Error {
|
|||||||
case invalidPublicKey
|
case invalidPublicKey
|
||||||
case invalidEvent
|
case invalidEvent
|
||||||
case invalidCiphertext
|
case invalidCiphertext
|
||||||
|
case cryptographicFailure
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
|
// MARK: - BitChat private-envelope key derivation
|
||||||
|
|
||||||
private extension NostrProtocol {
|
private extension NostrProtocol {
|
||||||
static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {
|
private static func derivePrivateEnvelopeKey(
|
||||||
|
from sharedSecretData: Data,
|
||||||
|
format: PrivateEnvelopeWireFormat
|
||||||
|
) -> Data {
|
||||||
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
||||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
||||||
salt: Data(),
|
salt: format.hkdfSalt,
|
||||||
info: Data("nip44-v2".utf8),
|
info: format.hkdfInfo,
|
||||||
outputByteCount: 32
|
outputByteCount: 32
|
||||||
)
|
)
|
||||||
return derivedKey.withUnsafeBytes { Data($0) }
|
return derivedKey.withUnsafeBytes { Data($0) }
|
||||||
|
|||||||
@@ -106,10 +106,11 @@ private extension NostrRelayManagerDependencies {
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class NostrRelayManager: ObservableObject {
|
final class NostrRelayManager: ObservableObject {
|
||||||
static let shared = NostrRelayManager()
|
static let shared = NostrRelayManager()
|
||||||
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info
|
// Track BitChat private envelopes we initiated so relay rejections can be
|
||||||
private(set) static var pendingGiftWrapIDs = Set<String>()
|
// reported without misclassifying ordinary public-event failures.
|
||||||
static func registerPendingGiftWrap(id: String) {
|
private(set) static var pendingPrivateEnvelopeIDs = Set<String>()
|
||||||
pendingGiftWrapIDs.insert(id)
|
static func registerPendingPrivateEnvelope(id: String) {
|
||||||
|
pendingPrivateEnvelopeIDs.insert(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Relay: Identifiable {
|
struct Relay: Identifiable {
|
||||||
@@ -124,7 +125,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
var nextReconnectTime: Date?
|
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 = [
|
private static let defaultRelays = [
|
||||||
"wss://relay.damus.io",
|
"wss://relay.damus.io",
|
||||||
"wss://nos.lol",
|
"wss://nos.lol",
|
||||||
@@ -137,7 +138,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
@Published private(set) var relays: [Relay] = []
|
@Published private(set) var relays: [Relay] = []
|
||||||
@Published private(set) var isConnected = false
|
@Published private(set) var isConnected = false
|
||||||
/// Whether a relay that carries private messages is connected. DMs
|
/// Whether a relay that carries private messages is connected. DMs
|
||||||
/// target the default (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.
|
/// geohash/custom relay alone must not count — sends would still queue.
|
||||||
@Published private(set) var isDMRelayConnected = false
|
@Published private(set) var isDMRelayConnected = false
|
||||||
|
|
||||||
@@ -217,7 +218,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private var messageQueue: [PendingSend] = []
|
private var messageQueue: [PendingSend] = []
|
||||||
private let messageQueueLock = NSLock()
|
private let messageQueueLock = NSLock()
|
||||||
/// Non-queued sends whose callers require relay durability. A WebSocket
|
/// Non-queued sends whose callers require relay durability. A WebSocket
|
||||||
/// write only proves bytes left this process; NIP-20 OK is the relay's
|
/// write only proves bytes left this process; NIP-01 `OK` is the relay's
|
||||||
/// accept/reject acknowledgment.
|
/// accept/reject acknowledgment.
|
||||||
private struct ConfirmedSendState {
|
private struct ConfirmedSendState {
|
||||||
let token: UUID
|
let token: UUID
|
||||||
@@ -356,7 +357,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
duplicateInboundEventDropCount = 0
|
duplicateInboundEventDropCount = 0
|
||||||
duplicateInboundEventDropCountBySubscription.removeAll()
|
duplicateInboundEventDropCountBySubscription.removeAll()
|
||||||
inboundEventLogCount = 0
|
inboundEventLogCount = 0
|
||||||
Self.pendingGiftWrapIDs.removeAll()
|
Self.pendingPrivateEnvelopeIDs.removeAll()
|
||||||
confirmedSends.removeAll()
|
confirmedSends.removeAll()
|
||||||
|
|
||||||
messageQueueLock.lock()
|
messageQueueLock.lock()
|
||||||
@@ -433,8 +434,8 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Attempts an event only on currently connected target relays and
|
/// Attempts an event only on currently connected target relays and
|
||||||
/// reports whether at least one relay explicitly accepted it via NIP-20
|
/// reports whether at least one relay explicitly accepted it via NIP-01
|
||||||
/// OK. A successful WebSocket write alone is not durable acceptance.
|
/// `OK`. A successful WebSocket write alone is not durable acceptance.
|
||||||
/// Unlike `sendEvent`, this never enters the process-local pending queue;
|
/// Unlike `sendEvent`, this never enters the process-local pending queue;
|
||||||
/// callers use it when success unlocks durable state or user-visible
|
/// callers use it when success unlocks durable state or user-visible
|
||||||
/// delivery progress.
|
/// delivery progress.
|
||||||
@@ -1139,7 +1140,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else {
|
guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if event.kind != 1059 {
|
if !NostrProtocol.acceptedPrivateEnvelopeKinds.contains(event.kind) {
|
||||||
// Per-event logging floods dev builds in busy geohashes; sample it.
|
// Per-event logging floods dev builds in busy geohashes; sample it.
|
||||||
inboundEventLogCount += 1
|
inboundEventLogCount += 1
|
||||||
if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
|
if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
|
||||||
@@ -1168,11 +1169,11 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
case .ok(let eventId, let success, let reason):
|
case .ok(let eventId, let success, let reason):
|
||||||
resolveConfirmedSend(eventID: eventId, relayURL: relayUrl, accepted: success)
|
resolveConfirmedSend(eventID: eventId, relayURL: relayUrl, accepted: success)
|
||||||
if success {
|
if success {
|
||||||
_ = Self.pendingGiftWrapIDs.remove(eventId)
|
_ = Self.pendingPrivateEnvelopeIDs.remove(eventId)
|
||||||
SecureLogger.debug("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", category: .session)
|
SecureLogger.debug("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", category: .session)
|
||||||
} else {
|
} else {
|
||||||
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
|
let isPrivateEnvelope = Self.pendingPrivateEnvelopeIDs.remove(eventId) != nil
|
||||||
if isGiftWrap {
|
if isPrivateEnvelope {
|
||||||
SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16))… relay=\(relayUrl) reason=\(reason)", category: .session)
|
SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16))… relay=\(relayUrl) reason=\(reason)", category: .session)
|
||||||
} else {
|
} else {
|
||||||
SecureLogger.error("📮 Rejected id=\(eventId.prefix(16))… relay=\(relayUrl) reason=\(reason)", category: .session)
|
SecureLogger.error("📮 Rejected id=\(eventId.prefix(16))… relay=\(relayUrl) reason=\(reason)", category: .session)
|
||||||
@@ -1602,13 +1603,19 @@ struct NostrFilter: Encodable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For NIP-17 gift wraps
|
// BitChat private envelopes, plus compatibility legacy envelopes emitted
|
||||||
static func giftWrapsFor(pubkey: String, since: Date? = nil) -> NostrFilter {
|
// during the bounded migration and stored by older releases as kind 1059.
|
||||||
|
static func privateEnvelopesFor(pubkey: String, since: Date? = nil) -> NostrFilter {
|
||||||
var filter = NostrFilter()
|
var filter = NostrFilter()
|
||||||
filter.kinds = [1059] // Gift wrap kind
|
filter.kinds = NostrProtocol.acceptedPrivateEnvelopeKinds
|
||||||
filter.since = since?.timeIntervalSince1970.toInt()
|
filter.since = since?.timeIntervalSince1970.toInt()
|
||||||
filter.tagFilters = ["p": [pubkey]]
|
filter.tagFilters = ["p": [pubkey]]
|
||||||
filter.limit = TransportConfig.nostrRelayDefaultFetchLimit // reasonable limit
|
// Before the migration deadline each logical payload is stored once
|
||||||
|
// per accepted wire kind. Scale the combined filter so compatibility
|
||||||
|
// copies do not halve the number of logical messages/acks recovered
|
||||||
|
// after a reconnect.
|
||||||
|
filter.limit = TransportConfig.nostrRelayDefaultFetchLimit
|
||||||
|
* NostrProtocol.acceptedPrivateEnvelopeKinds.count
|
||||||
return filter
|
return filter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -322,7 +322,7 @@ final class CourierStore {
|
|||||||
|
|
||||||
/// Envelopes eligible to park on relays as bridge courier drops. Merely
|
/// Envelopes eligible to park on relays as bridge courier drops. Merely
|
||||||
/// offering one does not start its cooldown: the caller commits that only
|
/// offering one does not start its cooldown: the caller commits that only
|
||||||
/// after a relay explicitly accepts the event via NIP-20 OK.
|
/// after a relay explicitly accepts the event via NIP-01 `OK`.
|
||||||
func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] {
|
func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] {
|
||||||
let date = now()
|
let date = now()
|
||||||
return queue.sync {
|
return queue.sync {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
var relaysConnected: (@MainActor () -> Bool)?
|
var relaysConnected: (@MainActor () -> Bool)?
|
||||||
/// Publishes a signed drop event directly to connected default (DM)
|
/// Publishes a signed drop event directly to connected default (DM)
|
||||||
/// relays. Completion is true only after at least one relay explicitly
|
/// relays. Completion is true only after at least one relay explicitly
|
||||||
/// accepts the event via NIP-20 OK; this must never mean "queued in RAM"
|
/// accepts the event via NIP-01 `OK`; this must never mean "queued in RAM"
|
||||||
/// or merely "written to a socket".
|
/// or merely "written to a socket".
|
||||||
var publishEvent: (@MainActor (NostrEvent, @escaping @MainActor (Bool) -> Void) -> Void)?
|
var publishEvent: (@MainActor (NostrEvent, @escaping @MainActor (Bool) -> Void) -> Void)?
|
||||||
/// (Re)opens the drop subscription for the given hex tags.
|
/// (Re)opens the drop subscription for the given hex tags.
|
||||||
@@ -129,7 +129,7 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
/// a newer attempt for the same message.
|
/// a newer attempt for the same message.
|
||||||
private var activeDropOperations: [String: ActiveDropOperation] = [:]
|
private var activeDropOperations: [String: ActiveDropOperation] = [:]
|
||||||
/// Held-envelope publishes have no sender message ID, but still need an
|
/// Held-envelope publishes have no sender message ID, but still need an
|
||||||
/// in-flight identity: repeated refreshes inside the NIP-20 wait window
|
/// in-flight identity: repeated refreshes inside the relay-OK wait window
|
||||||
/// must not mint duplicate relay events for the same opaque envelope.
|
/// must not mint duplicate relay events for the same opaque envelope.
|
||||||
private var heldDropOperations: [Data: UUID] = [:]
|
private var heldDropOperations: [Data: UUID] = [:]
|
||||||
/// Deterministically invalid envelopes are suppressed for this process,
|
/// Deterministically invalid envelopes are suppressed for this process,
|
||||||
@@ -275,7 +275,7 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
/// Publishes a drop, or queues it when relays are down. `messageID` is the
|
/// Publishes a drop, or queues it when relays are down. `messageID` is the
|
||||||
/// sender-side dedup key (nil for held/relayed envelopes we don't track);
|
/// sender-side dedup key (nil for held/relayed envelopes we don't track);
|
||||||
/// it rides the pending queue so an evicted or failed drop can release its
|
/// it rides the pending queue so an evicted or failed drop can release its
|
||||||
/// in-flight slot. Completion reports actual NIP-20 relay acceptance.
|
/// in-flight slot. Completion reports actual NIP-01 relay acceptance.
|
||||||
private func publishDrop(
|
private func publishDrop(
|
||||||
_ envelope: CourierEnvelope,
|
_ envelope: CourierEnvelope,
|
||||||
messageID: String? = nil,
|
messageID: String? = nil,
|
||||||
|
|||||||
@@ -172,11 +172,11 @@ final class MessageDeduplicationService {
|
|||||||
/// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format)
|
/// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format)
|
||||||
private let nostrAckCache: LRUDeduplicationCache<Bool>
|
private let nostrAckCache: LRUDeduplicationCache<Bool>
|
||||||
|
|
||||||
/// Optional cross-launch persistence for the Nostr event cache. NIP-59
|
/// Optional cross-launch persistence for the Nostr event cache. BitChat
|
||||||
/// randomizes gift-wrap timestamps, so DM subscriptions look back 24h and
|
/// randomizes private-envelope timestamps, so DM subscriptions look back
|
||||||
/// relays redeliver the same events on every launch; without this record
|
/// 24h and relays redeliver the same events on every launch; without this
|
||||||
/// each relaunch reprocesses old PMs and acks. Nil (tests, macOS callers
|
/// record each relaunch reprocesses old PMs and acks. Nil (tests, macOS
|
||||||
/// that don't opt in) keeps the cache purely in-memory.
|
/// callers that don't opt in) keeps the cache purely in-memory.
|
||||||
private let nostrEventStore: NostrProcessedEventStore?
|
private let nostrEventStore: NostrProcessedEventStore?
|
||||||
private let nostrEventCapacity: Int
|
private let nostrEventCapacity: Int
|
||||||
private var persistScheduled = false
|
private var persistScheduled = false
|
||||||
@@ -314,7 +314,7 @@ final class MessageDeduplicationService {
|
|||||||
// MARK: - Clear
|
// MARK: - Clear
|
||||||
|
|
||||||
/// Clears all caches. This is the wipe/panic path: the persisted
|
/// Clears all caches. This is the wipe/panic path: the persisted
|
||||||
/// gift-wrap record goes with everything else.
|
/// private-envelope record goes with everything else.
|
||||||
func clearAll() {
|
func clearAll() {
|
||||||
contentCache.clear()
|
contentCache.clear()
|
||||||
nostrEventCache.clear()
|
nostrEventCache.clear()
|
||||||
@@ -325,7 +325,7 @@ final class MessageDeduplicationService {
|
|||||||
|
|
||||||
/// Clears only the in-memory Nostr caches (events and ACKs). Runs on
|
/// Clears only the in-memory Nostr caches (events and ACKs). Runs on
|
||||||
/// every geohash channel switch, so the disk record deliberately
|
/// every geohash channel switch, so the disk record deliberately
|
||||||
/// survives — wiping it here would forfeit cross-launch gift-wrap dedup
|
/// survives — wiping it here would forfeit cross-launch private-envelope dedup
|
||||||
/// each time the user changes channels (flagged by Codex on #1398).
|
/// each time the user changes channels (flagged by Codex on #1398).
|
||||||
func clearNostrCaches() {
|
func clearNostrCaches() {
|
||||||
nostrEventCache.clear()
|
nostrEventCache.clear()
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Disk persistence for processed gift-wrap event IDs. NIP-59 randomizes
|
/// Disk persistence for processed private-envelope event IDs. BitChat
|
||||||
/// gift-wrap timestamps, so DM subscriptions must look back generously (24h)
|
/// randomizes envelope timestamps, so DM subscriptions must look back
|
||||||
/// and relays redeliver the same events on every launch — without a
|
/// generously (24h) and relays redeliver the same events on every launch —
|
||||||
/// cross-launch record, each relaunch reprocesses old PMs and acks
|
/// without a cross-launch record, each relaunch reprocesses old PMs and acks
|
||||||
/// (re-sent DELIVERED bursts, "delivered ack for unknown mid" noise).
|
/// (re-sent DELIVERED bursts, "delivered ack for unknown mid" noise).
|
||||||
///
|
///
|
||||||
/// Contents are event IDs already visible to every relay, so
|
/// Contents are event IDs already visible to every relay, so
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
let favoriteStatusForNoiseKey: @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?
|
let favoriteStatusForNoiseKey: @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||||
let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
|
let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||||
let currentIdentity: @MainActor () throws -> NostrIdentity?
|
let currentIdentity: @MainActor () throws -> NostrIdentity?
|
||||||
let registerPendingGiftWrap: @MainActor (String) -> Void
|
let registerPendingPrivateEnvelope: @MainActor (String) -> Void
|
||||||
let sendEvent: @MainActor (NostrEvent) -> Void
|
let sendEvent: @MainActor (NostrEvent) -> Void
|
||||||
|
let now: @MainActor () -> Date
|
||||||
/// Emits whether a relay that carries private messages is up
|
/// Emits whether a relay that carries private messages is up
|
||||||
/// (fail-closed behind Tor). A connected geohash/custom relay alone
|
/// (fail-closed behind Tor). A connected geohash/custom relay alone
|
||||||
/// doesn't count: DM sends target the default relay set and would
|
/// doesn't count: DM sends target the default relay set and would
|
||||||
@@ -28,19 +29,21 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?,
|
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?,
|
||||||
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?,
|
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?,
|
||||||
currentIdentity: @escaping @MainActor () throws -> NostrIdentity?,
|
currentIdentity: @escaping @MainActor () throws -> NostrIdentity?,
|
||||||
registerPendingGiftWrap: @escaping @MainActor (String) -> Void,
|
registerPendingPrivateEnvelope: @escaping @MainActor (String) -> Void,
|
||||||
sendEvent: @escaping @MainActor (NostrEvent) -> Void,
|
sendEvent: @escaping @MainActor (NostrEvent) -> Void,
|
||||||
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void,
|
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void,
|
||||||
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never>,
|
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never>,
|
||||||
ackPacer: AckPacer? = nil
|
ackPacer: AckPacer? = nil,
|
||||||
|
now: @escaping @MainActor () -> Date = Date.init
|
||||||
) {
|
) {
|
||||||
self.notificationCenter = notificationCenter
|
self.notificationCenter = notificationCenter
|
||||||
self.loadFavorites = loadFavorites
|
self.loadFavorites = loadFavorites
|
||||||
self.favoriteStatusForNoiseKey = favoriteStatusForNoiseKey
|
self.favoriteStatusForNoiseKey = favoriteStatusForNoiseKey
|
||||||
self.favoriteStatusForPeerID = favoriteStatusForPeerID
|
self.favoriteStatusForPeerID = favoriteStatusForPeerID
|
||||||
self.currentIdentity = currentIdentity
|
self.currentIdentity = currentIdentity
|
||||||
self.registerPendingGiftWrap = registerPendingGiftWrap
|
self.registerPendingPrivateEnvelope = registerPendingPrivateEnvelope
|
||||||
self.sendEvent = sendEvent
|
self.sendEvent = sendEvent
|
||||||
|
self.now = now
|
||||||
self.relayConnectivity = relayConnectivity
|
self.relayConnectivity = relayConnectivity
|
||||||
// Default pacer drives its throttle through the same injected
|
// Default pacer drives its throttle through the same injected
|
||||||
// scheduler, so tests that step scheduleAfter manually keep
|
// scheduler, so tests that step scheduleAfter manually keep
|
||||||
@@ -56,7 +59,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
favoriteStatusForNoiseKey: { FavoritesPersistenceService.shared.getFavoriteStatus(for: $0) },
|
favoriteStatusForNoiseKey: { FavoritesPersistenceService.shared.getFavoriteStatus(for: $0) },
|
||||||
favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) },
|
favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) },
|
||||||
currentIdentity: { try idBridge.getCurrentNostrIdentity() },
|
currentIdentity: { try idBridge.getCurrentNostrIdentity() },
|
||||||
registerPendingGiftWrap: { NostrRelayManager.registerPendingGiftWrap(id: $0) },
|
registerPendingPrivateEnvelope: { NostrRelayManager.registerPendingPrivateEnvelope(id: $0) },
|
||||||
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
|
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
|
||||||
scheduleAfter: { delay, action in
|
scheduleAfter: { delay, action in
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
||||||
@@ -261,7 +264,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
sendPrivateEnvelope(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +290,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
sendPrivateEnvelope(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,7 +322,7 @@ extension NostrTransport {
|
|||||||
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
sendPrivateEnvelope(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,17 +343,24 @@ extension NostrTransport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates and sends a gift-wrapped private message event
|
/// Creates and sends a BitChat private-envelope event over Nostr.
|
||||||
@MainActor
|
@MainActor
|
||||||
private func sendWrappedMessage(content: String, recipientHex: String, senderIdentity: NostrIdentity, registerPending: Bool = false) {
|
private func sendPrivateEnvelope(content: String, recipientHex: String, senderIdentity: NostrIdentity, registerPending: Bool = false) {
|
||||||
guard let event = try? NostrProtocol.createPrivateMessage(content: content, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
guard let events = try? NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||||
SecureLogger.error("NostrTransport: failed to build Nostr event", category: .session)
|
content: content,
|
||||||
|
recipientPubkey: recipientHex,
|
||||||
|
senderIdentity: senderIdentity,
|
||||||
|
now: dependencies.now()
|
||||||
|
) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to build Nostr private-envelope batch", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if registerPending {
|
for event in events {
|
||||||
dependencies.registerPendingGiftWrap(event.id)
|
if registerPending {
|
||||||
|
dependencies.registerPendingPrivateEnvelope(event.id)
|
||||||
|
}
|
||||||
|
dependencies.sendEvent(event)
|
||||||
}
|
}
|
||||||
dependencies.sendEvent(event)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -367,7 +377,7 @@ extension NostrTransport {
|
|||||||
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
sendPrivateEnvelope(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||||
|
|
||||||
case .deliveredDirect(let messageID, let peerID):
|
case .deliveredDirect(let messageID, let peerID):
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||||
@@ -378,17 +388,17 @@ extension NostrTransport {
|
|||||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
sendPrivateEnvelope(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
||||||
|
|
||||||
case .deliveredGeohash(let messageID, let recipientHex, let identity):
|
case .deliveredGeohash(let messageID, let recipientHex, let identity):
|
||||||
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))…", category: .session)
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
sendPrivateEnvelope(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||||
|
|
||||||
case .readGeohash(let messageID, let recipientHex, let identity):
|
case .readGeohash(let messageID, let recipientHex, let identity):
|
||||||
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))…", category: .session)
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
sendPrivateEnvelope(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,7 +171,13 @@ enum TransportConfig {
|
|||||||
static let nostrGeoRelayCount: Int = 5
|
static let nostrGeoRelayCount: Int = 5
|
||||||
static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300
|
static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300
|
||||||
static let nostrGeohashSampleLimit: Int = 100
|
static let nostrGeohashSampleLimit: Int = 100
|
||||||
static let nostrDMSubscribeLookbackSeconds: TimeInterval = 86400
|
/// Public envelope timestamps are deliberately shifted into the past for
|
||||||
|
/// privacy and relay compatibility. Mailbox queries must add the complete
|
||||||
|
/// shift to the 24-hour delivery window or boundary messages disappear
|
||||||
|
/// from `since` filters early.
|
||||||
|
static let nostrPrivateEnvelopeTimestampFuzzSeconds: TimeInterval = 15 * 60
|
||||||
|
static let nostrDMSubscribeLookbackSeconds: TimeInterval = (24 * 60 * 60)
|
||||||
|
+ nostrPrivateEnvelopeTimestampFuzzSeconds
|
||||||
// A sampled chat message this recent means "a conversation is happening
|
// A sampled chat message this recent means "a conversation is happening
|
||||||
// there" for the empty-timeline nearby-activity hint.
|
// there" for the empty-timeline nearby-activity hint.
|
||||||
static let uiGeohashChatActivityWindowSeconds: TimeInterval = 900
|
static let uiGeohashChatActivityWindowSeconds: TimeInterval = 900
|
||||||
@@ -215,7 +221,7 @@ enum TransportConfig {
|
|||||||
// Fallback deadline for treating a subscription's initial fetch as complete
|
// Fallback deadline for treating a subscription's initial fetch as complete
|
||||||
// when a relay never sends EOSE (generous to cover Tor circuit setup).
|
// when a relay never sends EOSE (generous to cover Tor circuit setup).
|
||||||
static let nostrSubscriptionEOSEFallbackSeconds: TimeInterval = 10.0
|
static let nostrSubscriptionEOSEFallbackSeconds: TimeInterval = 10.0
|
||||||
// A bridge drop is durable only after NIP-20 OK. Relays that omit OK must
|
// A bridge drop is durable only after NIP-01 `OK`. Relays that omit `OK` must
|
||||||
// not pin the router's in-flight state indefinitely.
|
// not pin the router's in-flight state indefinitely.
|
||||||
static let nostrConfirmedSendAckTimeoutSeconds: TimeInterval = 10.0
|
static let nostrConfirmedSendAckTimeoutSeconds: TimeInterval = 10.0
|
||||||
// After this long, a relay marked permanently failed gets another chance.
|
// After this long, a relay marked permanently failed gets another chance.
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ extension ChatViewModel: ChatPrivateConversationContext {
|
|||||||
final class ChatPrivateConversationCoordinator {
|
final class ChatPrivateConversationCoordinator {
|
||||||
private unowned let context: any ChatPrivateConversationContext
|
private unowned let context: any ChatPrivateConversationContext
|
||||||
|
|
||||||
// Outbox retries re-wrap the same message in fresh gift-wrap events, so
|
// Outbox retries re-envelope the same message in fresh private events, so
|
||||||
// relay-level event-ID dedup can't catch them; track inbound GeoDM
|
// relay-level event-ID dedup can't catch them; track inbound GeoDM
|
||||||
// message IDs so each copy past the first costs one (already-deduped)
|
// message IDs so each copy past the first costs one (already-deduped)
|
||||||
// ack check and nothing else.
|
// ack check and nothing else.
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ struct ChatViewModelServiceBundle {
|
|||||||
self.privateChatManager = privateChatManager
|
self.privateChatManager = privateChatManager
|
||||||
self.unifiedPeerService = unifiedPeerService
|
self.unifiedPeerService = unifiedPeerService
|
||||||
self.autocompleteService = AutocompleteService()
|
self.autocompleteService = AutocompleteService()
|
||||||
// Persist processed gift-wrap event IDs: NIP-59 randomizes their
|
// Persist processed private-envelope event IDs: BitChat randomizes their
|
||||||
// timestamps, so the 24h-lookback DM subscriptions redeliver the same
|
// timestamps, so the 24h-lookback DM subscriptions redeliver the same
|
||||||
// events on every launch and only a cross-launch record stops the
|
// events on every launch and only a cross-launch record stops the
|
||||||
// reprocessing (re-sent DELIVERED bursts, phantom-ack noise).
|
// reprocessing (re-sent DELIVERED bursts, phantom-ack noise).
|
||||||
@@ -547,7 +547,7 @@ private extension ChatViewModelBootstrapper {
|
|||||||
// Default (DM) relays: drops need the standing global relay set,
|
// Default (DM) relays: drops need the standing global relay set,
|
||||||
// not geo relays — sender and recipient share no cell.
|
// not geo relays — sender and recipient share no cell.
|
||||||
// This confirmed path never falls back to the volatile relay
|
// This confirmed path never falls back to the volatile relay
|
||||||
// queue; bridge dedup is committed only after NIP-20 OK.
|
// queue; bridge dedup is committed only after NIP-01 `OK`.
|
||||||
NostrRelayManager.shared.sendEventImmediately(event, completion: completion)
|
NostrRelayManager.shared.sendEventImmediately(event, completion: completion)
|
||||||
}
|
}
|
||||||
courier.openSubscription = { tagsHex in
|
courier.openSubscription = { tagsHex in
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ extension ChatViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
func subscribePrivateEnvelope(_ envelope: NostrEvent, id: NostrIdentity) {
|
||||||
nostrCoordinator.inbound.subscribeGiftWrap(giftWrap, id: id)
|
nostrCoordinator.inbound.subscribePrivateEnvelope(envelope, id: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -36,8 +36,8 @@ extension ChatViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
func handlePrivateEnvelope(_ envelope: NostrEvent, id: NostrIdentity) {
|
||||||
nostrCoordinator.inbound.handleGiftWrap(giftWrap, id: id)
|
nostrCoordinator.inbound.handlePrivateEnvelope(envelope, id: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ extension ChatViewModel: GeohashSubscriptionContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Owns subscription IDs and relay lifecycle for geohash channels, geohash
|
/// Owns subscription IDs and relay lifecycle for geohash channels, geohash
|
||||||
/// DMs, the account gift-wrap mailbox, and background geohash sampling. The
|
/// DMs, the account private-envelope mailbox, and background geohash sampling. The
|
||||||
/// only component that talks to `NostrRelayManager`; inbound events are
|
/// only component that talks to `NostrRelayManager`; inbound events are
|
||||||
/// forwarded to `NostrInboundPipeline` / `GeoPresenceTracker`.
|
/// forwarded to `NostrInboundPipeline` / `GeoPresenceTracker`.
|
||||||
final class GeohashSubscriptionManager {
|
final class GeohashSubscriptionManager {
|
||||||
@@ -162,13 +162,13 @@ final class GeohashSubscriptionManager {
|
|||||||
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
|
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
|
||||||
let dmSub = "geo-dm-\(channel.geohash)"
|
let dmSub = "geo-dm-\(channel.geohash)"
|
||||||
context.setGeoDmSubscriptionID(dmSub)
|
context.setGeoDmSubscriptionID(dmSub)
|
||||||
let dmFilter = NostrFilter.giftWrapsFor(
|
let dmFilter = NostrFilter.privateEnvelopesFor(
|
||||||
pubkey: identity.publicKeyHex,
|
pubkey: identity.publicKeyHex,
|
||||||
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
||||||
)
|
)
|
||||||
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
|
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] envelope in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
self?.inbound.subscribeGiftWrap(giftWrap, id: identity)
|
self?.inbound.subscribePrivateEnvelope(envelope, id: identity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,13 +260,13 @@ final class GeohashSubscriptionManager {
|
|||||||
if TorManager.shared.isReady {
|
if TorManager.shared.isReady {
|
||||||
SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
|
SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
|
||||||
}
|
}
|
||||||
let dmFilter = NostrFilter.giftWrapsFor(
|
let dmFilter = NostrFilter.privateEnvelopesFor(
|
||||||
pubkey: identity.publicKeyHex,
|
pubkey: identity.publicKeyHex,
|
||||||
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
||||||
)
|
)
|
||||||
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
|
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] envelope in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
self?.inbound.handleGiftWrap(giftWrap, id: identity)
|
self?.inbound.handlePrivateEnvelope(envelope, id: identity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -388,14 +388,14 @@ final class GeohashSubscriptionManager {
|
|||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
|
|
||||||
let filter = NostrFilter.giftWrapsFor(
|
let filter = NostrFilter.privateEnvelopesFor(
|
||||||
pubkey: currentIdentity.publicKeyHex,
|
pubkey: currentIdentity.publicKeyHex,
|
||||||
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
||||||
)
|
)
|
||||||
|
|
||||||
context.nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in
|
context.nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
self?.inbound.handleNostrMessage(event)
|
self?.inbound.handleAccountPrivateEnvelope(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,13 +84,21 @@ extension ChatViewModel: NostrInboundPipelineContext {
|
|||||||
/// Ordering is deliberate and performance-critical: cheap rejects (kind,
|
/// Ordering is deliberate and performance-critical: cheap rejects (kind,
|
||||||
/// dedup lookup) run BEFORE Schnorr signature verification because duplicates
|
/// dedup lookup) run BEFORE Schnorr signature verification because duplicates
|
||||||
/// dominate real relay traffic; events are recorded only AFTER verification so
|
/// dominate real relay traffic; events are recorded only AFTER verification so
|
||||||
/// a forged-signature copy can never poison the dedup set; gift-wrap
|
/// a forged-signature copy can never poison the dedup set; private-envelope
|
||||||
/// verification for the account mailbox runs off-main with an atomic
|
/// verification for the account mailbox runs off-main with an atomic
|
||||||
/// main-actor check-and-record.
|
/// main-actor check-and-record.
|
||||||
final class NostrInboundPipeline {
|
final class NostrInboundPipeline {
|
||||||
private weak var context: (any NostrInboundPipelineContext)?
|
private weak var context: (any NostrInboundPipelineContext)?
|
||||||
private let presence: GeoPresenceTracker
|
private let presence: GeoPresenceTracker
|
||||||
private var geoEventLogCount = 0
|
private var geoEventLogCount = 0
|
||||||
|
// During the bounded wire-format migration, one logical private payload
|
||||||
|
// is published under both the primary and compatibility formats. Outer
|
||||||
|
// event IDs differ, so collapse the authenticated embedded payload before
|
||||||
|
// invoking message/ack side effects. Keep this bounded like the outer-ID
|
||||||
|
// caches; the recipient and authenticated sender are part of the key.
|
||||||
|
private var recentPrivatePayloadFormats: [String: UInt8] = [:]
|
||||||
|
private var recentPrivatePayloadKeyOrder: [String] = []
|
||||||
|
private static let privatePayloadDedupCapacity = 2_048
|
||||||
|
|
||||||
init(context: any NostrInboundPipelineContext, presence: GeoPresenceTracker) {
|
init(context: any NostrInboundPipelineContext, presence: GeoPresenceTracker) {
|
||||||
self.context = context
|
self.context = context
|
||||||
@@ -268,15 +276,16 @@ final class NostrInboundPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
func subscribePrivateEnvelope(_ envelope: NostrEvent, id: NostrIdentity) {
|
||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
// Dedup lookup before Schnorr verification; record only after it passes.
|
// Dedup lookup before Schnorr verification; record only after it passes.
|
||||||
guard !context.hasProcessedNostrEvent(giftWrap.id) else { return }
|
guard !context.hasProcessedNostrEvent(envelope.id) else { return }
|
||||||
guard giftWrap.isValidSignature() else { return }
|
guard envelope.content.utf8.count <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes else { return }
|
||||||
context.recordProcessedNostrEvent(giftWrap.id)
|
guard envelope.isValidSignature() else { return }
|
||||||
|
context.recordProcessedNostrEvent(envelope.id)
|
||||||
|
|
||||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
|
guard let (content, senderPubkey, messageTs) = try? NostrProtocol.decryptPrivateEnvelope(
|
||||||
giftWrap: giftWrap,
|
envelope: envelope,
|
||||||
recipientIdentity: id
|
recipientIdentity: id
|
||||||
),
|
),
|
||||||
let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
||||||
@@ -285,8 +294,14 @@ final class NostrInboundPipeline {
|
|||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
guard shouldProcessPrivatePayload(
|
||||||
|
noisePayload,
|
||||||
|
senderPubkey: senderPubkey,
|
||||||
|
recipientPubkey: id.publicKeyHex,
|
||||||
|
envelopeKind: envelope.kind
|
||||||
|
) else { return }
|
||||||
|
|
||||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(messageTs))
|
||||||
let convKey = PeerID(nostr_: senderPubkey)
|
let convKey = PeerID(nostr_: senderPubkey)
|
||||||
context.registerNostrKeyMapping(senderPubkey, for: convKey)
|
context.registerNostrKeyMapping(senderPubkey, for: convKey)
|
||||||
|
|
||||||
@@ -313,25 +328,26 @@ final class NostrInboundPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
func handlePrivateEnvelope(_ envelope: NostrEvent, id: NostrIdentity) {
|
||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
// Dedup lookup before Schnorr verification; record only after it passes.
|
// Dedup lookup before Schnorr verification; record only after it passes.
|
||||||
if context.hasProcessedNostrEvent(giftWrap.id) {
|
if context.hasProcessedNostrEvent(envelope.id) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard giftWrap.isValidSignature() else { return }
|
guard envelope.content.utf8.count <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes else { return }
|
||||||
context.recordProcessedNostrEvent(giftWrap.id)
|
guard envelope.isValidSignature() else { return }
|
||||||
|
context.recordProcessedNostrEvent(envelope.id)
|
||||||
|
|
||||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
|
guard let (content, senderPubkey, messageTs) = try? NostrProtocol.decryptPrivateEnvelope(
|
||||||
giftWrap: giftWrap,
|
envelope: envelope,
|
||||||
recipientIdentity: id
|
recipientIdentity: id
|
||||||
) else {
|
) else {
|
||||||
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…", category: .session)
|
SecureLogger.warning("GeoDM: failed decrypt private envelope id=\(envelope.id.prefix(8))…", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.debug(
|
SecureLogger.debug(
|
||||||
"GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
|
"GeoDM: decrypted private envelope id=\(envelope.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
|
||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -341,13 +357,19 @@ final class NostrInboundPipeline {
|
|||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
guard shouldProcessPrivatePayload(
|
||||||
|
payload,
|
||||||
|
senderPubkey: senderPubkey,
|
||||||
|
recipientPubkey: id.publicKeyHex,
|
||||||
|
envelopeKind: envelope.kind
|
||||||
|
) else { return }
|
||||||
|
|
||||||
let convKey = PeerID(nostr_: senderPubkey)
|
let convKey = PeerID(nostr_: senderPubkey)
|
||||||
context.registerNostrKeyMapping(senderPubkey, for: convKey)
|
context.registerNostrKeyMapping(senderPubkey, for: convKey)
|
||||||
|
|
||||||
switch payload.type {
|
switch payload.type {
|
||||||
case .privateMessage:
|
case .privateMessage:
|
||||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(messageTs))
|
||||||
context.handlePrivateMessage(
|
context.handlePrivateMessage(
|
||||||
payload,
|
payload,
|
||||||
senderPubkey: senderPubkey,
|
senderPubkey: senderPubkey,
|
||||||
@@ -369,28 +391,29 @@ final class NostrInboundPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func handleNostrMessage(_ giftWrap: NostrEvent) {
|
func handleAccountPrivateEnvelope(_ envelope: NostrEvent) {
|
||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
// Cheap dedup pre-check only; Schnorr verification runs off-main in
|
// Cheap dedup pre-check only; Schnorr verification runs off-main in
|
||||||
// processNostrMessage, which then does the authoritative
|
// processAccountPrivateEnvelope, which then does the authoritative
|
||||||
// check-and-record. Recording stays after verification so a
|
// check-and-record. Recording stays after verification so a
|
||||||
// forged-signature copy can never poison the dedup set and suppress
|
// forged-signature copy can never poison the dedup set and suppress
|
||||||
// the genuine event.
|
// the genuine event.
|
||||||
if context.hasProcessedNostrEvent(giftWrap.id) { return }
|
if context.hasProcessedNostrEvent(envelope.id) { return }
|
||||||
|
|
||||||
Task.detached(priority: .userInitiated) { [weak self] in
|
Task.detached(priority: .userInitiated) { [weak self] in
|
||||||
await self?.processNostrMessage(giftWrap)
|
await self?.processAccountPrivateEnvelope(envelope)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func processNostrMessage(_ giftWrap: NostrEvent) async {
|
func processAccountPrivateEnvelope(_ envelope: NostrEvent) async {
|
||||||
guard giftWrap.isValidSignature() else { return }
|
guard envelope.content.utf8.count <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes else { return }
|
||||||
|
guard envelope.isValidSignature() else { return }
|
||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
// Authoritative check-and-record, atomic on the main actor so two
|
// Authoritative check-and-record, atomic on the main actor so two
|
||||||
// concurrent detached tasks can't both process the same event.
|
// concurrent detached tasks can't both process the same event.
|
||||||
let alreadyProcessed: Bool = await MainActor.run {
|
let alreadyProcessed: Bool = await MainActor.run {
|
||||||
if context.hasProcessedNostrEvent(giftWrap.id) { return true }
|
if context.hasProcessedNostrEvent(envelope.id) { return true }
|
||||||
context.recordProcessedNostrEvent(giftWrap.id)
|
context.recordProcessedNostrEvent(envelope.id)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if alreadyProcessed { return }
|
if alreadyProcessed { return }
|
||||||
@@ -400,8 +423,8 @@ final class NostrInboundPipeline {
|
|||||||
guard let currentIdentity else { return }
|
guard let currentIdentity else { return }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage(
|
let (content, senderPubkey, messageTimestampSeconds) = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
giftWrap: giftWrap,
|
envelope: envelope,
|
||||||
recipientIdentity: currentIdentity
|
recipientIdentity: currentIdentity
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -425,8 +448,14 @@ final class NostrInboundPipeline {
|
|||||||
|
|
||||||
if packet.type == MessageType.noiseEncrypted.rawValue,
|
if packet.type == MessageType.noiseEncrypted.rawValue,
|
||||||
let payload = NoisePayload.decode(packet.payload) {
|
let payload = NoisePayload.decode(packet.payload) {
|
||||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(messageTimestampSeconds))
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
guard self.shouldProcessPrivatePayload(
|
||||||
|
payload,
|
||||||
|
senderPubkey: senderPubkey,
|
||||||
|
recipientPubkey: currentIdentity.publicKeyHex,
|
||||||
|
envelopeKind: envelope.kind
|
||||||
|
) else { return }
|
||||||
context.registerNostrKeyMapping(senderPubkey, for: targetPeerID)
|
context.registerNostrKeyMapping(senderPubkey, for: targetPeerID)
|
||||||
|
|
||||||
switch payload.type {
|
switch payload.type {
|
||||||
@@ -500,6 +529,47 @@ final class NostrInboundPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extension NostrInboundPipeline {
|
private extension NostrInboundPipeline {
|
||||||
|
@MainActor
|
||||||
|
func shouldProcessPrivatePayload(
|
||||||
|
_ payload: NoisePayload,
|
||||||
|
senderPubkey: String,
|
||||||
|
recipientPubkey: String,
|
||||||
|
envelopeKind: Int
|
||||||
|
) -> Bool {
|
||||||
|
let digest = payload.encode().sha256Fingerprint()
|
||||||
|
let key = "\(recipientPubkey.lowercased()):\(senderPubkey.lowercased()):\(digest)"
|
||||||
|
let formatBit: UInt8
|
||||||
|
switch envelopeKind {
|
||||||
|
case NostrProtocol.EventKind.privateEnvelope.rawValue:
|
||||||
|
formatBit = 1 << 0
|
||||||
|
case NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue:
|
||||||
|
formatBit = 1 << 1
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if let observedFormats = recentPrivatePayloadFormats[key] {
|
||||||
|
if observedFormats & formatBit != 0 {
|
||||||
|
// A same-format re-envelope is a delivery retry. Let it reach
|
||||||
|
// the coordinator so a lost DELIVERED acknowledgement can be
|
||||||
|
// sent again; downstream message-ID dedup prevents rerendering.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// The same authenticated payload under the other migration format
|
||||||
|
// is the compatibility twin, not a new message or acknowledgement.
|
||||||
|
recentPrivatePayloadFormats[key] = observedFormats | formatBit
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
recentPrivatePayloadFormats[key] = formatBit
|
||||||
|
recentPrivatePayloadKeyOrder.append(key)
|
||||||
|
if recentPrivatePayloadKeyOrder.count > Self.privatePayloadDedupCapacity {
|
||||||
|
let evicted = recentPrivatePayloadKeyOrder.removeFirst()
|
||||||
|
recentPrivatePayloadFormats.removeValue(forKey: evicted)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {
|
static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {
|
||||||
guard content.hasPrefix("bitchat1:") else { return nil }
|
guard content.hasPrefix("bitchat1:") else { return nil }
|
||||||
|
|||||||
@@ -446,7 +446,8 @@ private struct ContentPrivateChatSheetView: View {
|
|||||||
if privateConversationModel.selectedPeerID?.isGroup == true {
|
if privateConversationModel.selectedPeerID?.isGroup == true {
|
||||||
return String(localized: "content.private.caption_group", comment: "Caption above the group chat composer noting messages are encrypted to group members")
|
return String(localized: "content.private.caption_group", comment: "Caption above the group chat composer noting messages are encrypted to group members")
|
||||||
}
|
}
|
||||||
// Geohash DMs are NIP-17 gift-wrapped — always end-to-end encrypted,
|
// Geohash DMs use BitChat's private-envelope transport over Nostr —
|
||||||
|
// always end-to-end encrypted,
|
||||||
// even though they carry no Noise session status. Mesh DMs earn the
|
// even though they carry no Noise session status. Mesh DMs earn the
|
||||||
// "encrypted" claim only once the Noise handshake has secured.
|
// "encrypted" claim only once the Noise handshake has secured.
|
||||||
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ private func drainMainQueue() async {
|
|||||||
|
|
||||||
/// Exercises `ChatNostrCoordinator` against `MockChatNostrContext` with no
|
/// Exercises `ChatNostrCoordinator` against `MockChatNostrContext` with no
|
||||||
/// `ChatViewModel`. Scoped to the inbound event pipeline (dedup, presence,
|
/// `ChatViewModel`. Scoped to the inbound event pipeline (dedup, presence,
|
||||||
/// public-message ingest), gift-wrap DM ingest, key mapping, channel-switch
|
/// public-message ingest), private-envelope ingest, key mapping, channel-switch
|
||||||
/// teardown, embedded ack flows, and — now that favorites and notifications
|
/// teardown, embedded ack flows, and — now that favorites and notifications
|
||||||
/// are injected through the context — the favorite-notification ingest and
|
/// are injected through the context — the favorite-notification ingest and
|
||||||
/// the sampled-geohash notification cooldown. Flows that hit live singletons
|
/// the sampled-geohash notification cooldown. Flows that hit live singletons
|
||||||
@@ -335,7 +335,7 @@ struct ChatNostrCoordinatorContextTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func handleGiftWrap_routesEmbeddedPrivateMessageAndDeduplicates() async throws {
|
func handlePrivateEnvelope_routesEmbeddedPrivateMessageAndDeduplicates() async throws {
|
||||||
let context = MockChatNostrContext()
|
let context = MockChatNostrContext()
|
||||||
let coordinator = ChatNostrCoordinator(context: context)
|
let coordinator = ChatNostrCoordinator(context: context)
|
||||||
|
|
||||||
@@ -346,17 +346,22 @@ struct ChatNostrCoordinatorContextTests {
|
|||||||
messageID: "gm-1",
|
messageID: "gm-1",
|
||||||
senderPeerID: PeerID(str: "aabbccddeeff0011")
|
senderPeerID: PeerID(str: "aabbccddeeff0011")
|
||||||
))
|
))
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
let envelopes = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||||
content: embedded,
|
content: embedded,
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender,
|
||||||
|
now: NostrProtocol.legacyPrivateEnvelopePublicationDeadline.addingTimeInterval(-1)
|
||||||
)
|
)
|
||||||
|
|
||||||
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
|
for envelope in envelopes {
|
||||||
|
coordinator.inbound.handlePrivateEnvelope(envelope, id: recipient)
|
||||||
|
}
|
||||||
|
|
||||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
#expect(context.recordedNostrEventIDs == envelopes.map(\.id))
|
||||||
#expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex)
|
#expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex)
|
||||||
|
// The primary and compatibility envelopes carry the same authenticated
|
||||||
|
// embedded payload and must invoke message side effects only once.
|
||||||
#expect(context.handledPrivateMessages.count == 1)
|
#expect(context.handledPrivateMessages.count == 1)
|
||||||
#expect(context.handledPrivateMessages.first?.senderPubkey == sender.publicKeyHex)
|
#expect(context.handledPrivateMessages.first?.senderPubkey == sender.publicKeyHex)
|
||||||
#expect(context.handledPrivateMessages.first?.convKey == convKey)
|
#expect(context.handledPrivateMessages.first?.convKey == convKey)
|
||||||
@@ -368,34 +373,88 @@ struct ChatNostrCoordinatorContextTests {
|
|||||||
#expect(pm.messageID == "gm-1")
|
#expect(pm.messageID == "gm-1")
|
||||||
#expect(pm.content == "psst")
|
#expect(pm.content == "psst")
|
||||||
|
|
||||||
// The same gift wrap is dropped on replay.
|
// The same private envelope is dropped on replay.
|
||||||
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
|
coordinator.inbound.handlePrivateEnvelope(envelopes[0], id: recipient)
|
||||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
#expect(context.recordedNostrEventIDs == envelopes.map(\.id))
|
||||||
#expect(context.handledPrivateMessages.count == 1)
|
#expect(context.handledPrivateMessages.count == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func processNostrMessage_invalidSignatureDoesNotPoisonDedup() async throws {
|
func migrationEnvelopePairs_processEachMessageAndAckOnlyOnce() throws {
|
||||||
|
let context = MockChatNostrContext()
|
||||||
|
let coordinator = ChatNostrCoordinator(context: context)
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let beforeDeadline = NostrProtocol.legacyPrivateEnvelopePublicationDeadline
|
||||||
|
.addingTimeInterval(-1)
|
||||||
|
|
||||||
|
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,
|
||||||
|
now: beforeDeadline
|
||||||
|
)
|
||||||
|
#expect(envelopes.count == 2)
|
||||||
|
for envelope in envelopes {
|
||||||
|
coordinator.inbound.handlePrivateEnvelope(envelope, id: recipient)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(context.handledPrivateMessages.count == 1)
|
||||||
|
#expect(context.handledDelivered.count == 1)
|
||||||
|
#expect(context.handledReadReceipts.count == 1)
|
||||||
|
|
||||||
|
// A later same-format re-envelope is a delivery retry, not the
|
||||||
|
// migration twin, so it must still reach downstream message-ID dedup
|
||||||
|
// and acknowledgement resend logic.
|
||||||
|
let primaryRetry = try NostrProtocol.createPrivateEnvelope(
|
||||||
|
content: messageContent,
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
senderIdentity: sender
|
||||||
|
)
|
||||||
|
coordinator.inbound.handlePrivateEnvelope(primaryRetry, id: recipient)
|
||||||
|
#expect(context.handledPrivateMessages.count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func processAccountPrivateEnvelope_invalidSignatureDoesNotPoisonDedup() async throws {
|
||||||
let context = MockChatNostrContext()
|
let context = MockChatNostrContext()
|
||||||
let coordinator = ChatNostrCoordinator(context: context)
|
let coordinator = ChatNostrCoordinator(context: context)
|
||||||
|
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||||
content: "verify:noop",
|
content: "verify:noop",
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
var invalidGiftWrap = giftWrap
|
var invalidEnvelope = envelope
|
||||||
invalidGiftWrap.sig = String(repeating: "0", count: 128)
|
invalidEnvelope.sig = String(repeating: "0", count: 128)
|
||||||
|
|
||||||
// A forged-signature copy is rejected WITHOUT entering the dedup set...
|
// A forged-signature copy is rejected WITHOUT entering the dedup set...
|
||||||
await coordinator.inbound.processNostrMessage(invalidGiftWrap)
|
await coordinator.inbound.processAccountPrivateEnvelope(invalidEnvelope)
|
||||||
#expect(context.recordedNostrEventIDs.isEmpty)
|
#expect(context.recordedNostrEventIDs.isEmpty)
|
||||||
|
|
||||||
// ...so the genuine event with the same ID still processes and records.
|
// ...so the genuine event with the same ID still processes and records.
|
||||||
await coordinator.inbound.processNostrMessage(giftWrap)
|
await coordinator.inbound.processAccountPrivateEnvelope(envelope)
|
||||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
#expect(context.recordedNostrEventIDs == [envelope.id])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
|
|||||||
@@ -393,20 +393,25 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func subscribeGiftWrap_rejectsOversizedEmbeddedPacket() async throws {
|
func privateEnvelope_rejectsOversizedEmbeddedPacketBeforePublication() async throws {
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
|
|
||||||
let oversized = Data(repeating: 0x41, count: FileTransferLimits.maxFramedFileBytes + 1)
|
let oversized = Data(repeating: 0x41, count: FileTransferLimits.maxFramedFileBytes + 1)
|
||||||
let content = "bitchat1:" + base64URLEncode(oversized)
|
let content = "bitchat1:" + base64URLEncode(oversized)
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
do {
|
||||||
content: content,
|
_ = try NostrProtocol.createPrivateEnvelope(
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
content: content,
|
||||||
senderIdentity: sender
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
)
|
senderIdentity: sender
|
||||||
|
)
|
||||||
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
|
Issue.record("Expected oversized private-envelope plaintext to be rejected")
|
||||||
|
} catch NostrError.invalidCiphertext {
|
||||||
|
// Rejected before encryption/publication, as intended.
|
||||||
|
} catch {
|
||||||
|
Issue.record("Expected NostrError.invalidCiphertext, got \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||||
#expect(viewModel.privateChats.isEmpty)
|
#expect(viewModel.privateChats.isEmpty)
|
||||||
@@ -451,7 +456,7 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func subscribeGiftWrap_deliveredAckUpdatesExistingMessage() async throws {
|
func subscribePrivateEnvelope_deliveredAckUpdatesExistingMessage() async throws {
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
@@ -473,13 +478,13 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
], for: convKey)
|
], for: convKey)
|
||||||
|
|
||||||
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||||
content: content,
|
content: content,
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
|
viewModel.subscribePrivateEnvelope(envelope, id: recipient)
|
||||||
|
|
||||||
let didUpdate = await TestHelpers.waitUntil(
|
let didUpdate = await TestHelpers.waitUntil(
|
||||||
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||||
@@ -489,7 +494,7 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func subscribeGiftWrap_readAckUpdatesExistingMessage() async throws {
|
func subscribePrivateEnvelope_readAckUpdatesExistingMessage() async throws {
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
@@ -511,13 +516,13 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
], for: convKey)
|
], for: convKey)
|
||||||
|
|
||||||
let content = try ackContent(type: .readReceipt, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
let content = try ackContent(type: .readReceipt, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||||
content: content,
|
content: content,
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
|
viewModel.subscribePrivateEnvelope(envelope, id: recipient)
|
||||||
|
|
||||||
let didUpdate = await TestHelpers.waitUntil(
|
let didUpdate = await TestHelpers.waitUntil(
|
||||||
{ isRead(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
{ isRead(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||||
@@ -527,28 +532,28 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func handleGiftWrap_privateMessageStoresConversationAndMapping() async throws {
|
func handlePrivateEnvelope_privateMessageStoresConversationAndMapping() async throws {
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
let messageID = "gift-private"
|
let messageID = "envelope-private"
|
||||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||||
|
|
||||||
let content = try privateMessageContent(
|
let content = try privateMessageContent(
|
||||||
text: "Hello from gift wrap",
|
text: "Hello from private envelope",
|
||||||
messageID: messageID,
|
messageID: messageID,
|
||||||
senderPeerID: PeerID(str: "0123456789abcdef")
|
senderPeerID: PeerID(str: "0123456789abcdef")
|
||||||
)
|
)
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||||
content: content,
|
content: content,
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
viewModel.handleGiftWrap(giftWrap, id: recipient)
|
viewModel.handlePrivateEnvelope(envelope, id: recipient)
|
||||||
|
|
||||||
let didStore = await TestHelpers.waitUntil(
|
let didStore = await TestHelpers.waitUntil(
|
||||||
{ viewModel.privateChats[convKey]?.first?.content == "Hello from gift wrap" },
|
{ viewModel.privateChats[convKey]?.first?.content == "Hello from private envelope" },
|
||||||
timeout: 5.0
|
timeout: 5.0
|
||||||
)
|
)
|
||||||
#expect(didStore)
|
#expect(didStore)
|
||||||
@@ -557,11 +562,11 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func handleGiftWrap_blockedSenderSkipsMessageStorage() async throws {
|
func handlePrivateEnvelope_blockedSenderSkipsMessageStorage() async throws {
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
let messageID = "gift-blocked"
|
let messageID = "envelope-blocked"
|
||||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||||
|
|
||||||
viewModel.identityManager.setNostrBlocked(sender.publicKeyHex, isBlocked: true)
|
viewModel.identityManager.setNostrBlocked(sender.publicKeyHex, isBlocked: true)
|
||||||
@@ -571,13 +576,13 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
messageID: messageID,
|
messageID: messageID,
|
||||||
senderPeerID: PeerID(str: "0123456789abcdef")
|
senderPeerID: PeerID(str: "0123456789abcdef")
|
||||||
)
|
)
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||||
content: content,
|
content: content,
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
viewModel.handleGiftWrap(giftWrap, id: recipient)
|
viewModel.handlePrivateEnvelope(envelope, id: recipient)
|
||||||
|
|
||||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||||
#expect(viewModel.privateChats[convKey] == nil)
|
#expect(viewModel.privateChats[convKey] == nil)
|
||||||
@@ -585,12 +590,12 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func handleGiftWrap_deliveredAckUpdatesExistingMessage() async throws {
|
func handlePrivateEnvelope_deliveredAckUpdatesExistingMessage() async throws {
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||||
let messageID = "gift-delivered"
|
let messageID = "envelope-delivered"
|
||||||
|
|
||||||
viewModel.seedPrivateChat([
|
viewModel.seedPrivateChat([
|
||||||
BitchatMessage(
|
BitchatMessage(
|
||||||
@@ -607,13 +612,13 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
], for: convKey)
|
], for: convKey)
|
||||||
|
|
||||||
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||||
content: content,
|
content: content,
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
viewModel.handleGiftWrap(giftWrap, id: recipient)
|
viewModel.handlePrivateEnvelope(envelope, id: recipient)
|
||||||
|
|
||||||
let didUpdate = await TestHelpers.waitUntil(
|
let didUpdate = await TestHelpers.waitUntil(
|
||||||
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"content":"v2:SorfnTaoQ_Rv0XLKA8b3FZojgaFvnWgx66JR6Gj20ztnorQyL-hUYBZwAa5ohFC6ioR9hlJARJm0cgNTvWgSZuNTcfSAvoMdSG6mXK73kzsp99x351zUB_lc1_5ZXm0qqePAEz3Dl5jj5EsfAb8ZzsCd-BZENCsTwqjqC_hCFl7RitlRfIL2Tq_n4TUFEFandDCplNz3W4L7V07V89aGEoUlhzcwPU44BcxfvQjeqVKq0IRf2AetypoOduPrjSqkv674ubWaRcHtw3Asrqgo5XSYbpOlSk1PF_TttrQSPZGViNe5MuiK2P-7d-XqKXuDf_bUgAzW884KXogbct-wtIJZJbVM-utMd-dHrpC1mY81lgpS4_kPuhj0Z6Ro9hU5nCcEk2K4_vNoSM9m9QbcXP34h47qSPsw9ikmz8UoD00-1fXQVB4YJcBVUSVI06IzbZEWulo8SPXvQ4pJjV3nwPgYqRgwnrNWMNfeuKojlF8yA17UNOWvD2U7r1cs84HL8dxztzX9NdN0DGxvjMILvt3D4eWrMbcSrsIkBgyvV-uskEPd4eX3fc9GmX8MPkMgxRErcxbuq6JBUNXikOJXNH_qspOt4UIw5dCIajrHsKycKd8A_3rSgLEQteirOWMGaD2gOJEzpbe4iT72dmPkvRq7k-wDjLbO5dOSuUvpFM-ipkB07ATJz_1uUcRpl8fD_oSlcdAzdPjGKG5Y-tNv3AcUstkqCi52E5x-aO8EDUNBFi_OCbD86nkTUv1jOpAQwejowSiiOnCZ91zUEg5pRQyhBV0Ozib-j0Wf8S8VAz9M8bqQQaKedPCEowDn6csOKbFdBtqSeROf1XWKCkm1mSJGHWW9V44MiMiHebVrRUpbP0PqvxiIeJE0","created_at":1783710443,"id":"46425f8d4e8007af43abb67b3668b59604bd34c86dee1b1c3702559086927cb9","kind":1059,"pubkey":"960e391e314a7fb00bbdd85eccb0a93c17e981b6fed38487cf891f1ed6b66aeb","sig":"975c88c1c4d11f0b623603f8ecc7c181fea7385e8feacdb4a64a6b4f7536b1337ff556aee165ca337c9ec501cfab3e9703026c6df2b12bb8c702378875f00722","tags":[["p","1c108500bf53da288d30530718bac4bf80d661d1fe38854060c5b5c79eb77755"]]}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"recipient_private_key":"8355a5c110cdfef2e644f4ad5d51c39f253b2c2c80ebb6856379fb16531dc1fa"}
|
||||||
@@ -2,18 +2,17 @@
|
|||||||
// NostrProtocolTests.swift
|
// NostrProtocolTests.swift
|
||||||
// bitchatTests
|
// bitchatTests
|
||||||
//
|
//
|
||||||
// Tests for NIP-17 gift-wrapped private messages
|
// Tests for BitChat's proprietary private-envelope transport over Nostr.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
import CryptoKit
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
struct NostrProtocolTests {
|
struct NostrProtocolTests {
|
||||||
|
|
||||||
@Test func nip17MessageRoundTrip() throws {
|
@Test func privateEnvelopeRoundTrip() throws {
|
||||||
// Create sender and recipient identities
|
// Create sender and recipient identities
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
@@ -22,21 +21,19 @@ struct NostrProtocolTests {
|
|||||||
print("Recipient pubkey: \(recipient.publicKeyHex)")
|
print("Recipient pubkey: \(recipient.publicKeyHex)")
|
||||||
|
|
||||||
// Create a test message
|
// Create a test message
|
||||||
let originalContent = "Hello from NIP-17 test!"
|
let originalContent = "Hello from BitChat private-envelope test!"
|
||||||
|
|
||||||
// Create encrypted gift wrap
|
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
|
||||||
content: originalContent,
|
content: originalContent,
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
print("Gift wrap created with ID: \(giftWrap.id)")
|
print("Private envelope created with ID: \(envelope.id)")
|
||||||
print("Gift wrap pubkey: \(giftWrap.pubkey)")
|
print("Private envelope pubkey: \(envelope.pubkey)")
|
||||||
|
|
||||||
// Decrypt the gift wrap
|
let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateMessage(
|
envelope: envelope,
|
||||||
giftWrap: giftWrap,
|
|
||||||
recipientIdentity: recipient
|
recipientIdentity: recipient
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,37 +49,37 @@ struct NostrProtocolTests {
|
|||||||
print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey) at \(messageDate)")
|
print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey) at \(messageDate)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func giftWrapUsesUniqueEphemeralKeys() throws {
|
@Test func privateEnvelopesUseUniqueEphemeralKeys() throws {
|
||||||
// Create identities
|
// Create identities
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
|
|
||||||
// Create two messages
|
// Create two messages
|
||||||
let message1 = try NostrProtocol.createPrivateMessage(
|
let message1 = try NostrProtocol.createPrivateEnvelope(
|
||||||
content: "Message 1",
|
content: "Message 1",
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
let message2 = try NostrProtocol.createPrivateMessage(
|
let message2 = try NostrProtocol.createPrivateEnvelope(
|
||||||
content: "Message 2",
|
content: "Message 2",
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
// Gift wrap pubkeys should be different (unique ephemeral keys)
|
// Public envelope keys must be one-time use.
|
||||||
#expect(message1.pubkey != message2.pubkey)
|
#expect(message1.pubkey != message2.pubkey)
|
||||||
|
|
||||||
print("Message 1 gift wrap pubkey: \(message1.pubkey)")
|
print("Message 1 envelope pubkey: \(message1.pubkey)")
|
||||||
print("Message 2 gift wrap pubkey: \(message2.pubkey)")
|
print("Message 2 envelope pubkey: \(message2.pubkey)")
|
||||||
|
|
||||||
// Both should decrypt successfully
|
// Both should decrypt successfully
|
||||||
let (content1, _, _) = try NostrProtocol.decryptPrivateMessage(
|
let (content1, _, _) = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
giftWrap: message1,
|
envelope: message1,
|
||||||
recipientIdentity: recipient
|
recipientIdentity: recipient
|
||||||
)
|
)
|
||||||
let (content2, _, _) = try NostrProtocol.decryptPrivateMessage(
|
let (content2, _, _) = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
giftWrap: message2,
|
envelope: message2,
|
||||||
recipientIdentity: recipient
|
recipientIdentity: recipient
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -90,74 +87,286 @@ struct NostrProtocolTests {
|
|||||||
#expect(content2 == "Message 2")
|
#expect(content2 == "Message 2")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func privateEnvelopeUsesBitChatWireFormatAtEveryLayer() throws {
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||||
|
content: "bitchat-specific",
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
senderIdentity: sender
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(envelope.kind == NostrProtocol.EventKind.privateEnvelope.rawValue)
|
||||||
|
#expect(envelope.kind != NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue)
|
||||||
|
#expect(envelope.content.hasPrefix(NostrProtocol.privateEnvelopeContentPrefix))
|
||||||
|
#expect(!envelope.content.hasPrefix("v2:"))
|
||||||
|
#expect(envelope.tags == [["p", recipient.publicKeyHex]])
|
||||||
|
#expect(envelope.created_at <= Int(Date().timeIntervalSince1970))
|
||||||
|
|
||||||
|
let layers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
|
||||||
|
envelope: envelope,
|
||||||
|
recipientIdentity: recipient
|
||||||
|
)
|
||||||
|
#expect(layers.seal.kind == NostrProtocol.EventKind.privateSeal.rawValue)
|
||||||
|
#expect(layers.seal.content.hasPrefix(NostrProtocol.privateEnvelopeContentPrefix))
|
||||||
|
#expect(layers.seal.tags.isEmpty)
|
||||||
|
#expect(layers.message.kind == NostrProtocol.EventKind.privateMessage.rawValue)
|
||||||
|
#expect(layers.message.tags.isEmpty)
|
||||||
|
#expect(layers.message.sig == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func decryptAcceptsReceiveOnlyLegacyBitChatEnvelope() throws {
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let envelope = try NostrProtocol.createLegacyPrivateEnvelopeForTesting(
|
||||||
|
content: "legacy in-flight message",
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
senderIdentity: sender
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(envelope.kind == NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue)
|
||||||
|
#expect(envelope.content.hasPrefix("v2:"))
|
||||||
|
|
||||||
|
let result = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
|
envelope: envelope,
|
||||||
|
recipientIdentity: recipient
|
||||||
|
)
|
||||||
|
#expect(result.content == "legacy in-flight message")
|
||||||
|
#expect(result.senderPubkey == sender.publicKeyHex)
|
||||||
|
|
||||||
|
let layers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
|
||||||
|
envelope: envelope,
|
||||||
|
recipientIdentity: recipient
|
||||||
|
)
|
||||||
|
#expect(layers.seal.kind == NostrProtocol.EventKind.legacyNIP59Seal.rawValue)
|
||||||
|
#expect(layers.message.kind == NostrProtocol.EventKind.legacyNIP17DirectMessage.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 publicationBatchDualPublishesOnlyBeforeExplicitDeadline() throws {
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let beforeDeadline = NostrProtocol.legacyPrivateEnvelopePublicationDeadline
|
||||||
|
.addingTimeInterval(-1)
|
||||||
|
|
||||||
|
let migrationBatch = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||||
|
content: "mixed-version",
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
senderIdentity: sender,
|
||||||
|
now: beforeDeadline
|
||||||
|
)
|
||||||
|
#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")
|
||||||
|
}
|
||||||
|
|
||||||
|
let postMigrationBatch = try NostrProtocol.createPrivateEnvelopePublicationBatch(
|
||||||
|
content: "new-only",
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
senderIdentity: sender,
|
||||||
|
now: NostrProtocol.legacyPrivateEnvelopePublicationDeadline
|
||||||
|
)
|
||||||
|
#expect(postMigrationBatch.map(\.kind) == [
|
||||||
|
NostrProtocol.EventKind.privateEnvelope.rawValue
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func mailboxLookbackCoversFullRetentionWindowAndTimestampFuzz() {
|
||||||
|
let sentAt = Date(timeIntervalSince1970: 1_800_000_000)
|
||||||
|
let earliestPublicTimestamp = sentAt.addingTimeInterval(
|
||||||
|
-TransportConfig.nostrPrivateEnvelopeTimestampFuzzSeconds
|
||||||
|
)
|
||||||
|
let reconnectAtRetentionBoundary = sentAt.addingTimeInterval(24 * 60 * 60)
|
||||||
|
let filterSince = reconnectAtRetentionBoundary.addingTimeInterval(
|
||||||
|
-TransportConfig.nostrDMSubscribeLookbackSeconds
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(TransportConfig.nostrDMSubscribeLookbackSeconds == (24 * 60 * 60) + (15 * 60))
|
||||||
|
#expect(filterSince <= earliestPublicTimestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func largePrivateEnvelopeFitsLayerSpecificExpansionLimits() throws {
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
// Large enough that the nested Base64 seal exceeds the inner 32 KiB
|
||||||
|
// cap, while the inner message JSON itself remains below that cap.
|
||||||
|
let content = String(repeating: "A", count: 30 * 1024)
|
||||||
|
|
||||||
|
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||||
|
content: content,
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
senderIdentity: sender
|
||||||
|
)
|
||||||
|
let decrypted = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
|
envelope: envelope,
|
||||||
|
recipientIdentity: recipient
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(decrypted.content == content)
|
||||||
|
#expect(envelope.content.utf8.count <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func privateEnvelopeRejectsOversizedCiphertextBeforeDecoding() throws {
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let wrapper = try NostrIdentity.generate()
|
||||||
|
let oversizedContent = NostrProtocol.privateEnvelopeContentPrefix
|
||||||
|
+ String(
|
||||||
|
repeating: "A",
|
||||||
|
count: NostrProtocol.maximumPrivateEnvelopeCiphertextBytes
|
||||||
|
)
|
||||||
|
let event = NostrEvent(
|
||||||
|
pubkey: wrapper.publicKeyHex,
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .privateEnvelope,
|
||||||
|
tags: [["p", recipient.publicKeyHex]],
|
||||||
|
content: oversizedContent
|
||||||
|
)
|
||||||
|
let signed = try event.sign(with: wrapper.schnorrSigningKey())
|
||||||
|
|
||||||
|
expectInvalidCiphertext {
|
||||||
|
_ = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
|
envelope: signed,
|
||||||
|
recipientIdentity: recipient
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func privateEnvelopeRejectsOversizedPlaintextBeforeEncryption() throws {
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let oversizedPlaintext = String(
|
||||||
|
repeating: "x",
|
||||||
|
count: NostrProtocol.maximumPrivateEnvelopePlaintextBytes + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
expectInvalidCiphertext {
|
||||||
|
_ = try NostrProtocol.createPrivateEnvelope(
|
||||||
|
content: oversizedPlaintext,
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
senderIdentity: sender
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func privateEnvelopeRejectsOversizedNestedJSONBeforeParsing() {
|
||||||
|
let oversizedJSON = String(
|
||||||
|
repeating: "{",
|
||||||
|
count: NostrProtocol.maximumPrivateEnvelopePlaintextBytes + 1
|
||||||
|
)
|
||||||
|
expectInvalidCiphertext {
|
||||||
|
_ = try NostrProtocol.decodePrivateEnvelopeEventJSONForTesting(oversizedJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func decryptDoesNotMisinterpretStandardNIP44PayloadAsLegacyBitChat() throws {
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
let wrapper = try NostrIdentity.generate()
|
||||||
|
// A valid NIP-44 v2 payload from the official test vectors. Its wire
|
||||||
|
// format starts with a version byte in standard Base64, not BitChat's
|
||||||
|
// historical `v2:` prefix.
|
||||||
|
let standardPayload = "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb"
|
||||||
|
let event = NostrEvent(
|
||||||
|
pubkey: wrapper.publicKeyHex,
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .legacyNIP59GiftWrap,
|
||||||
|
tags: [["p", recipient.publicKeyHex]],
|
||||||
|
content: standardPayload
|
||||||
|
)
|
||||||
|
let signed = try event.sign(with: wrapper.schnorrSigningKey())
|
||||||
|
|
||||||
|
expectInvalidCiphertext {
|
||||||
|
_ = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
|
envelope: signed,
|
||||||
|
recipientIdentity: recipient
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test func decryptionFailsWithWrongRecipient() throws {
|
@Test func decryptionFailsWithWrongRecipient() throws {
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
let wrongRecipient = try NostrIdentity.generate()
|
let wrongRecipient = try NostrIdentity.generate()
|
||||||
|
|
||||||
// Create message for recipient
|
// Create message for recipient
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||||
content: "Secret message",
|
content: "Secret message",
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
// Try to decrypt with wrong recipient
|
expectInvalidEvent {
|
||||||
if #available(macOS 14.4, iOS 17.4, *) {
|
_ = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
#expect(throws: CryptoKitError.authenticationFailure) {
|
envelope: envelope,
|
||||||
try NostrProtocol.decryptPrivateMessage(
|
recipientIdentity: wrongRecipient
|
||||||
giftWrap: giftWrap,
|
)
|
||||||
recipientIdentity: wrongRecipient
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
#expect(throws: (any Error).self) {
|
|
||||||
try NostrProtocol.decryptPrivateMessage(
|
|
||||||
giftWrap: giftWrap,
|
|
||||||
recipientIdentity: wrongRecipient
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func decryptRejectsInvalidSealSignature() throws {
|
@Test func decryptRejectsInvalidSealSignature() throws {
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessageWithInvalidSealSignatureForTesting(
|
let envelope = try NostrProtocol.createPrivateEnvelopeWithInvalidSealSignatureForTesting(
|
||||||
content: "forged signature",
|
content: "forged signature",
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
expectInvalidEvent {
|
expectInvalidEvent {
|
||||||
_ = try NostrProtocol.decryptPrivateMessage(
|
_ = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
giftWrap: giftWrap,
|
envelope: envelope,
|
||||||
recipientIdentity: recipient
|
recipientIdentity: recipient
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func decryptRejectsSealRumorPubkeyMismatch() throws {
|
@Test func decryptRejectsSealMessagePubkeyMismatch() throws {
|
||||||
let claimedSender = try NostrIdentity.generate()
|
let claimedSender = try NostrIdentity.generate()
|
||||||
let sealSigner = try NostrIdentity.generate()
|
let sealSigner = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessageWithMismatchedSealRumorPubkeyForTesting(
|
let envelope = try NostrProtocol.createPrivateEnvelopeWithMismatchedSealMessagePubkeyForTesting(
|
||||||
content: "spoofed sender",
|
content: "spoofed sender",
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
rumorIdentity: claimedSender,
|
messageIdentity: claimedSender,
|
||||||
sealSignerIdentity: sealSigner
|
sealSignerIdentity: sealSigner
|
||||||
)
|
)
|
||||||
|
|
||||||
expectInvalidEvent {
|
expectInvalidEvent {
|
||||||
_ = try NostrProtocol.decryptPrivateMessage(
|
_ = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
giftWrap: giftWrap,
|
envelope: envelope,
|
||||||
recipientIdentity: recipient
|
recipientIdentity: recipient
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func testAckRoundTripNIP44V2_Delivered() throws {
|
func deliveredAckRoundTripsInsidePrivateEnvelope() throws {
|
||||||
// Identities
|
// Identities
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
@@ -171,19 +380,17 @@ struct NostrProtocolTests {
|
|||||||
"Failed to embed delivered ack"
|
"Failed to embed delivered ack"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create NIP-17 gift wrap to recipient (uses NIP-44 v2 internally)
|
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
|
||||||
content: embedded,
|
content: embedded,
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
// Ensure v2 format was used for ciphertext
|
#expect(envelope.content.hasPrefix(NostrProtocol.privateEnvelopeContentPrefix))
|
||||||
#expect(giftWrap.content.hasPrefix("v2:"))
|
|
||||||
|
|
||||||
// Decrypt as recipient
|
// Decrypt as recipient
|
||||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
giftWrap: giftWrap,
|
envelope: envelope,
|
||||||
recipientIdentity: recipient
|
recipientIdentity: recipient
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -208,7 +415,7 @@ struct NostrProtocolTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func ackRoundTripNIP44V2_ReadReceipt() throws {
|
@Test func readReceiptRoundTripsInsidePrivateEnvelope() throws {
|
||||||
// Identities
|
// Identities
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
@@ -220,16 +427,16 @@ struct NostrProtocolTests {
|
|||||||
"Failed to embed read ack"
|
"Failed to embed read ack"
|
||||||
)
|
)
|
||||||
|
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
let envelope = try NostrProtocol.createPrivateEnvelope(
|
||||||
content: embedded,
|
content: embedded,
|
||||||
recipientPubkey: recipient.publicKeyHex,
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(giftWrap.content.hasPrefix("v2:"))
|
#expect(envelope.content.hasPrefix(NostrProtocol.privateEnvelopeContentPrefix))
|
||||||
|
|
||||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
giftWrap: giftWrap,
|
envelope: envelope,
|
||||||
recipientIdentity: recipient
|
recipientIdentity: recipient
|
||||||
)
|
)
|
||||||
#expect(senderPubkey == sender.publicKeyHex)
|
#expect(senderPubkey == sender.publicKeyHex)
|
||||||
@@ -290,7 +497,42 @@ struct NostrProtocolTests {
|
|||||||
#expect(object["limit"] as? Int == 42)
|
#expect(object["limit"] as? Int == 42)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func privateEnvelopeFilterIncludesPrimaryAndCompatibilityKinds() throws {
|
||||||
|
let since = Date(timeIntervalSince1970: 1_234_567)
|
||||||
|
let filter = NostrFilter.privateEnvelopesFor(pubkey: "recipient", since: since)
|
||||||
|
let data = try JSONEncoder().encode(filter)
|
||||||
|
let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||||
|
|
||||||
|
#expect(object["kinds"] as? [Int] == [
|
||||||
|
NostrProtocol.EventKind.privateEnvelope.rawValue,
|
||||||
|
NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue
|
||||||
|
])
|
||||||
|
#expect(object["#p"] as? [String] == ["recipient"])
|
||||||
|
#expect(object["since"] as? Int == 1_234_567)
|
||||||
|
#expect(object["limit"] as? Int ==
|
||||||
|
TransportConfig.nostrRelayDefaultFetchLimit
|
||||||
|
* NostrProtocol.acceptedPrivateEnvelopeKinds.count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
private struct LegacyRecipientKeyFixture: Decodable {
|
||||||
|
let recipientPrivateKey: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case recipientPrivateKey = "recipient_private_key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fixtureURL(name: String) 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: "json"))
|
||||||
|
}
|
||||||
|
|
||||||
private static func base64URLDecode(_ s: String) -> Data? {
|
private static func base64URLDecode(_ s: String) -> Data? {
|
||||||
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||||
let rem = str.count % 4
|
let rem = str.count % 4
|
||||||
@@ -308,4 +550,15 @@ struct NostrProtocolTests {
|
|||||||
Issue.record("Expected NostrError.invalidEvent, got \(error)")
|
Issue.record("Expected NostrError.invalidEvent, got \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func expectInvalidCiphertext(_ operation: () throws -> Void) {
|
||||||
|
do {
|
||||||
|
try operation()
|
||||||
|
Issue.record("Expected NostrError.invalidCiphertext")
|
||||||
|
} catch NostrError.invalidCiphertext {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
Issue.record("Expected NostrError.invalidCiphertext, got \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -893,11 +893,11 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
XCTAssertTrue(secondDelivered)
|
XCTAssertTrue(secondDelivered)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_okMessages_clearPendingGiftWrapIDs() async throws {
|
func test_okMessages_clearPendingPrivateEnvelopeIDs() async throws {
|
||||||
let relayURL = "wss://ok.example"
|
let relayURL = "wss://ok.example"
|
||||||
let context = makeContext(permission: .denied)
|
let context = makeContext(permission: .denied)
|
||||||
let successID = "gift-wrap-success"
|
let successID = "private-envelope-success"
|
||||||
let failureID = "gift-wrap-failure"
|
let failureID = "private-envelope-failure"
|
||||||
|
|
||||||
context.manager.ensureConnections(to: [relayURL])
|
context.manager.ensureConnections(to: [relayURL])
|
||||||
let connected = await waitUntil {
|
let connected = await waitUntil {
|
||||||
@@ -906,17 +906,17 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
XCTAssertTrue(connected)
|
XCTAssertTrue(connected)
|
||||||
|
|
||||||
NostrRelayManager.registerPendingGiftWrap(id: successID)
|
NostrRelayManager.registerPendingPrivateEnvelope(id: successID)
|
||||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: successID, success: true, reason: "ok")
|
try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: successID, success: true, reason: "ok")
|
||||||
let successCleared = await waitUntil {
|
let successCleared = await waitUntil {
|
||||||
!NostrRelayManager.pendingGiftWrapIDs.contains(successID)
|
!NostrRelayManager.pendingPrivateEnvelopeIDs.contains(successID)
|
||||||
}
|
}
|
||||||
XCTAssertTrue(successCleared)
|
XCTAssertTrue(successCleared)
|
||||||
|
|
||||||
NostrRelayManager.registerPendingGiftWrap(id: failureID)
|
NostrRelayManager.registerPendingPrivateEnvelope(id: failureID)
|
||||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: failureID, success: false, reason: "rejected")
|
try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: failureID, success: false, reason: "rejected")
|
||||||
let failureCleared = await waitUntil {
|
let failureCleared = await waitUntil {
|
||||||
!NostrRelayManager.pendingGiftWrapIDs.contains(failureID)
|
!NostrRelayManager.pendingPrivateEnvelopeIDs.contains(failureID)
|
||||||
}
|
}
|
||||||
XCTAssertTrue(failureCleared)
|
XCTAssertTrue(failureCleared)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ struct NostrTransportTests {
|
|||||||
favoriteStatusForNoiseKey: { _ in nil },
|
favoriteStatusForNoiseKey: { _ in nil },
|
||||||
favoriteStatusForPeerID: { $0 == shortPeerID ? relationship : nil },
|
favoriteStatusForPeerID: { $0 == shortPeerID ? relationship : nil },
|
||||||
currentIdentity: { sender },
|
currentIdentity: { sender },
|
||||||
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||||
sendEvent: probe.record(event:),
|
sendEvent: probe.record(event:),
|
||||||
scheduleAfter: { delay, action in
|
scheduleAfter: { delay, action in
|
||||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||||
@@ -173,7 +173,87 @@ struct NostrTransportTests {
|
|||||||
#expect(privateMessage.messageID == "pm-1")
|
#expect(privateMessage.messageID == "pm-1")
|
||||||
#expect(privateMessage.content == "hello over nostr")
|
#expect(privateMessage.content == "hello over nostr")
|
||||||
#expect(result.packet.recipientID == shortPeerID.routingData)
|
#expect(result.packet.recipientID == shortPeerID.routingData)
|
||||||
#expect(probe.pendingGiftWrapIDs.isEmpty)
|
#expect(probe.pendingPrivateEnvelopeIDs.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Migration window publishes primary and compatibility envelopes, then stops")
|
||||||
|
@MainActor
|
||||||
|
func migrationWindowDualPublishesUntilDeadline() 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 },
|
||||||
|
sendEvent: migrationProbe.record(event:),
|
||||||
|
now: {
|
||||||
|
NostrProtocol.legacyPrivateEnvelopePublicationDeadline
|
||||||
|
.addingTimeInterval(-1)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
let postMigrationProbe = NostrTransportProbe()
|
||||||
|
let postMigrationTransport = NostrTransport(
|
||||||
|
keychain: keychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
dependencies: makeDependencies(
|
||||||
|
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||||
|
currentIdentity: { sender },
|
||||||
|
sendEvent: postMigrationProbe.record(event:),
|
||||||
|
now: { NostrProtocol.legacyPrivateEnvelopePublicationDeadline }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
postMigrationTransport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||||
|
postMigrationTransport.sendPrivateMessage(
|
||||||
|
"post migration",
|
||||||
|
to: peerID,
|
||||||
|
recipientNickname: "Migration peer",
|
||||||
|
messageID: "post-migration-pm"
|
||||||
|
)
|
||||||
|
|
||||||
|
let sentPrimaryOnly = await TestHelpers.waitUntil(
|
||||||
|
{ postMigrationProbe.sentEvents.count == 1 },
|
||||||
|
timeout: 5.0
|
||||||
|
)
|
||||||
|
#expect(sentPrimaryOnly)
|
||||||
|
#expect(postMigrationProbe.sentEvents.first?.kind == NostrProtocol.EventKind.privateEnvelope.rawValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Favorite notification embeds current npub")
|
@Test("Favorite notification embeds current npub")
|
||||||
@@ -198,7 +278,7 @@ struct NostrTransportTests {
|
|||||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||||
favoriteStatusForPeerID: { _ in nil },
|
favoriteStatusForPeerID: { _ in nil },
|
||||||
currentIdentity: { sender },
|
currentIdentity: { sender },
|
||||||
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||||
sendEvent: probe.record(event:),
|
sendEvent: probe.record(event:),
|
||||||
scheduleAfter: { delay, action in
|
scheduleAfter: { delay, action in
|
||||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||||
@@ -239,7 +319,7 @@ struct NostrTransportTests {
|
|||||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||||
favoriteStatusForPeerID: { _ in nil },
|
favoriteStatusForPeerID: { _ in nil },
|
||||||
currentIdentity: { sender },
|
currentIdentity: { sender },
|
||||||
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||||
sendEvent: probe.record(event:),
|
sendEvent: probe.record(event:),
|
||||||
scheduleAfter: { delay, action in
|
scheduleAfter: { delay, action in
|
||||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||||
@@ -259,9 +339,9 @@ struct NostrTransportTests {
|
|||||||
#expect(result.packet.recipientID == fullPeerID.toShort().routingData)
|
#expect(result.packet.recipientID == fullPeerID.toShort().routingData)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Geohash private message registers pending gift wrap")
|
@Test("Geohash private message registers pending private envelope")
|
||||||
@MainActor
|
@MainActor
|
||||||
func sendPrivateMessageGeohashRegistersPendingGiftWrap() async throws {
|
func sendPrivateMessageGeohashRegistersPendingPrivateEnvelope() async throws {
|
||||||
let keychain = MockKeychain()
|
let keychain = MockKeychain()
|
||||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
@@ -272,7 +352,7 @@ struct NostrTransportTests {
|
|||||||
idBridge: idBridge,
|
idBridge: idBridge,
|
||||||
dependencies: makeDependencies(
|
dependencies: makeDependencies(
|
||||||
currentIdentity: { sender },
|
currentIdentity: { sender },
|
||||||
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||||
sendEvent: probe.record(event:),
|
sendEvent: probe.record(event:),
|
||||||
scheduleAfter: { delay, action in
|
scheduleAfter: { delay, action in
|
||||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||||
@@ -297,7 +377,7 @@ struct NostrTransportTests {
|
|||||||
#expect(privateMessage.messageID == "geo-1")
|
#expect(privateMessage.messageID == "geo-1")
|
||||||
#expect(privateMessage.content == "geo hello")
|
#expect(privateMessage.content == "geo hello")
|
||||||
#expect(result.packet.recipientID == nil)
|
#expect(result.packet.recipientID == nil)
|
||||||
#expect(probe.pendingGiftWrapIDs == [event.id])
|
#expect(probe.pendingPrivateEnvelopeIDs == [event.id])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Read receipt queue sends in order and waits for scheduler")
|
@Test("Read receipt queue sends in order and waits for scheduler")
|
||||||
@@ -322,7 +402,7 @@ struct NostrTransportTests {
|
|||||||
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
|
||||||
favoriteStatusForPeerID: { _ in nil },
|
favoriteStatusForPeerID: { _ in nil },
|
||||||
currentIdentity: { sender },
|
currentIdentity: { sender },
|
||||||
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
|
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
|
||||||
sendEvent: probe.record(event:),
|
sendEvent: probe.record(event:),
|
||||||
scheduleAfter: { delay, action in
|
scheduleAfter: { delay, action in
|
||||||
probe.enqueueScheduledAction(delay: delay, action: action)
|
probe.enqueueScheduledAction(delay: delay, action: action)
|
||||||
@@ -419,10 +499,13 @@ struct NostrTransportTests {
|
|||||||
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoriteRelationship? = { _ in nil },
|
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoriteRelationship? = { _ in nil },
|
||||||
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoriteRelationship? = { _ in nil },
|
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoriteRelationship? = { _ in nil },
|
||||||
currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil },
|
currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil },
|
||||||
registerPendingGiftWrap: @escaping @MainActor (String) -> Void = { _ in },
|
registerPendingPrivateEnvelope: @escaping @MainActor (String) -> Void = { _ in },
|
||||||
sendEvent: @escaping @MainActor (NostrEvent) -> Void = { _ in },
|
sendEvent: @escaping @MainActor (NostrEvent) -> Void = { _ in },
|
||||||
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in },
|
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in },
|
||||||
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never> = { Just(false).eraseToAnyPublisher() }
|
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never> = { Just(false).eraseToAnyPublisher() },
|
||||||
|
now: @escaping @MainActor () -> Date = {
|
||||||
|
NostrProtocol.legacyPrivateEnvelopePublicationDeadline.addingTimeInterval(1)
|
||||||
|
}
|
||||||
) -> NostrTransport.Dependencies {
|
) -> NostrTransport.Dependencies {
|
||||||
NostrTransport.Dependencies(
|
NostrTransport.Dependencies(
|
||||||
notificationCenter: notificationCenter,
|
notificationCenter: notificationCenter,
|
||||||
@@ -430,10 +513,11 @@ struct NostrTransportTests {
|
|||||||
favoriteStatusForNoiseKey: favoriteStatusForNoiseKey,
|
favoriteStatusForNoiseKey: favoriteStatusForNoiseKey,
|
||||||
favoriteStatusForPeerID: favoriteStatusForPeerID,
|
favoriteStatusForPeerID: favoriteStatusForPeerID,
|
||||||
currentIdentity: currentIdentity,
|
currentIdentity: currentIdentity,
|
||||||
registerPendingGiftWrap: registerPendingGiftWrap,
|
registerPendingPrivateEnvelope: registerPendingPrivateEnvelope,
|
||||||
sendEvent: sendEvent,
|
sendEvent: sendEvent,
|
||||||
scheduleAfter: scheduleAfter,
|
scheduleAfter: scheduleAfter,
|
||||||
relayConnectivity: relayConnectivity
|
relayConnectivity: relayConnectivity,
|
||||||
|
now: now
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,8 +541,8 @@ struct NostrTransportTests {
|
|||||||
from event: NostrEvent,
|
from event: NostrEvent,
|
||||||
recipient: NostrIdentity
|
recipient: NostrIdentity
|
||||||
) throws -> (packet: BitchatPacket, payload: NoisePayload, senderPubkey: String) {
|
) throws -> (packet: BitchatPacket, payload: NoisePayload, senderPubkey: String) {
|
||||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateEnvelope(
|
||||||
giftWrap: event,
|
envelope: event,
|
||||||
recipientIdentity: recipient
|
recipientIdentity: recipient
|
||||||
)
|
)
|
||||||
guard content.hasPrefix("bitchat1:") else {
|
guard content.hasPrefix("bitchat1:") else {
|
||||||
@@ -503,7 +587,7 @@ private func base64URLDecode(_ string: String) -> Data? {
|
|||||||
private final class NostrTransportProbe: @unchecked Sendable {
|
private final class NostrTransportProbe: @unchecked Sendable {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var sentEventsStorage: [NostrEvent] = []
|
private var sentEventsStorage: [NostrEvent] = []
|
||||||
private var pendingGiftWrapIDsStorage: [String] = []
|
private var pendingPrivateEnvelopeIDsStorage: [String] = []
|
||||||
private var scheduledActionsStorage: [(@Sendable () -> Void)] = []
|
private var scheduledActionsStorage: [(@Sendable () -> Void)] = []
|
||||||
|
|
||||||
var sentEvents: [NostrEvent] {
|
var sentEvents: [NostrEvent] {
|
||||||
@@ -512,10 +596,10 @@ private final class NostrTransportProbe: @unchecked Sendable {
|
|||||||
return sentEventsStorage
|
return sentEventsStorage
|
||||||
}
|
}
|
||||||
|
|
||||||
var pendingGiftWrapIDs: [String] {
|
var pendingPrivateEnvelopeIDs: [String] {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
return pendingGiftWrapIDsStorage
|
return pendingPrivateEnvelopeIDsStorage
|
||||||
}
|
}
|
||||||
|
|
||||||
var scheduledActionCount: Int {
|
var scheduledActionCount: Int {
|
||||||
@@ -530,9 +614,9 @@ private final class NostrTransportProbe: @unchecked Sendable {
|
|||||||
lock.unlock()
|
lock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func recordPendingGiftWrap(id: String) {
|
func recordPendingPrivateEnvelope(id: String) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
pendingGiftWrapIDsStorage.append(id)
|
pendingPrivateEnvelopeIDsStorage.append(id)
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ Public archives contain content already intended for public mesh/board distribut
|
|||||||
|
|
||||||
## Nostr and Mesh Bridge
|
## Nostr and Mesh Bridge
|
||||||
|
|
||||||
- NIP-17/NIP-44 v2 private fallback protects plaintext with secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305. Relays still see event and network metadata.
|
- BitChat's proprietary private-envelope fallback protects plaintext with secp256k1 key agreement, HKDF-SHA256 with a BitChat-specific domain separator, and XChaCha20-Poly1305. It is not NIP-17, NIP-44, or NIP-59 compatible and does not provide forward secrecy against later compromise of the recipient's static Nostr private key. Relays still see the recipient public-key tag, event timing and size, and network metadata.
|
||||||
- Bridge courier drops use a throwaway publisher key, an opaque Noise-sealed envelope, and a day-rotating recipient tag. Only a party already holding the recipient's Noise static key can compute candidate tags.
|
- Bridge courier drops use a throwaway publisher key, an opaque Noise-sealed envelope, and a day-rotating recipient tag. Only a party already holding the recipient's Noise static key can compute candidate tags.
|
||||||
- Relay publication is considered successful only after an explicit NIP-20 `OK true` from at least one target relay. Rejected, disconnected, timed-out, or merely socket-written events stay retryable.
|
- Relay publication is considered successful only after an explicit NIP-01 `OK true` from at least one target relay. Rejected, disconnected, timed-out, or merely socket-written events stay retryable.
|
||||||
- When mesh bridge is enabled, public mesh messages not marked “nearby only” are signed under a per-cell Nostr identity and published to a neighborhood rendezvous geohash. Presence and public bridge traffic therefore expose a coarse area to relays and participants.
|
- When mesh bridge is enabled, public mesh messages not marked “nearby only” are signed under a per-cell Nostr identity and published to a neighborhood rendezvous geohash. Presence and public bridge traffic therefore expose a coarse area to relays and participants.
|
||||||
- A bridge gateway can carry signed bridge/location events and opaque courier drops for nearby mesh-only peers. It cannot validly publish a neighbor's radio-only message because the author must first sign the bridge event.
|
- A bridge gateway can carry signed bridge/location events and opaque courier drops for nearby mesh-only peers. It cannot validly publish a neighbor's radio-only message because the author must first sign the bridge event.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user