Relabel private Nostr envelopes honestly

This commit is contained in:
jack
2026-07-26 14:47:43 +02:00
parent c72bb4ca2e
commit 1a411970f9
16 changed files with 1006 additions and 662 deletions
+6 -3
View File
@@ -49,9 +49,12 @@ BitChat uses a **hybrid messaging architecture** with two complementary transpor
BitChat's private-envelope format is proprietary and is **not** NIP-17, 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 NIP-44, or NIP-59 compatible. It uses Nostr as a relay transport but only
interoperates with BitChat clients: private payloads travel inside kind-1059 interoperates with BitChat clients. New envelopes use provisional,
events whose `v2:`-prefixed content is a BitChat-specific XChaCha20-Poly1305 BitChat-specific public kind 1402 (not a formally reserved Nostr kind),
construction, not NIP-44 encryption. 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
+3 -3
View File
@@ -78,9 +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 use BitChat's proprietary private-envelope protocol. An unsigned inner message (kind 14) is encrypted and placed in a sender-signed seal (kind 13); that seal is encrypted again inside a public envelope (kind 1059) signed by a one-time key, so relays learn neither the stable sender identity nor the content. Each encrypted content field is `v2:` followed by base64url of a 24-byte nonce, XChaCha20-Poly1305 ciphertext, and its 16-byte tag. Keys come from secp256k1 ECDH and HKDF-SHA256 (the derivation reuses a "nip44-v2" info label but is not the NIP-44 key schedule). 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 reuses NIP-17/NIP-59 kind numbers but 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 plaintext and stable sender identity remain inside authenticated ciphertext. Public seal and envelope timestamps are randomized by up to ±15 minutes, 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 addressed to that key. 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
@@ -107,7 +107,7 @@ Public broadcast messages are cached (1000 packets) and reconciled between peers
### 6.4 Nostr Mailboxes ### 6.4 Nostr Mailboxes
BitChat private envelopes rest on Nostr relays; clients re-subscribe 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
+363 -290
View File
@@ -9,24 +9,28 @@ import Security
/// BitChat's private-envelope protocol transported over Nostr relays. /// BitChat's private-envelope protocol transported over Nostr relays.
/// ///
/// This construction is deliberately BitChat-specific and is **not** NIP-17, /// This is deliberately BitChat-specific and is not NIP-17, NIP-44, or NIP-59.
/// NIP-44, or NIP-59 compatible, even though it historically reuses those /// It uses Nostr events and secp256k1 identities, but its XChaCha20-Poly1305
/// NIPs' kind numbers (1059/13/14) and a `v2:` content prefix. It uses Nostr /// payload layout is proprietary and interoperates only with BitChat clients.
/// events and secp256k1 identities, but the XChaCha20-Poly1305 payload layout
/// and key derivation are proprietary and interoperate 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
// BitChat's proprietary private-envelope layers. These reuse the // Bounded compatibility for BitChat releases that incorrectly emitted
// NIP-17/NIP-59 kind numbers (14/13/1059) for historical reasons, but // the proprietary payload under standard NIP kinds. Only kind 1059 is
// the encrypted payloads are BitChat-specific and not NIP-compatible. // temporarily published during migration; all three remain readable.
case dm = 14 // unsigned inner message (inside ciphertext) case legacyNIP59Seal = 13
case seal = 13 // sender-signed seal (inside ciphertext) case legacyNIP17DirectMessage = 14
case giftWrap = 1059 // public outer envelope (one-time key) 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
@@ -36,209 +40,272 @@ struct NostrProtocol {
case courierDrop = 1401 case courierDrop = 1401
} }
/// Bound work before Base64-decoding either encrypted layer of an inbound /// Prefix for BitChat private-envelope ciphertext. The suffix is
/// private envelope, and before parsing each decrypted nested JSON layer. /// base64url(nonce24 || ciphertext || poly1305Tag).
/// Real envelopes are normally a few KiB; 64 KiB leaves ample headroom static let privateEnvelopeContentPrefix = "bitchat-pm-v1:"
/// without letting an addressed relay event drive unbounded allocation.
/// 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 static let maximumPrivateEnvelopeCiphertextBytes = 64 * 1024
/// Create a BitChat private envelope for relay transport (outer kind 1059). /// Bound the inner authenticated message JSON before allocation/parsing.
static func createPrivateMessage( 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 createPrivateMessage( try createPrivateEnvelope(
content: content, content: content,
recipientPubkey: recipientPubkey, recipientPubkey: recipientPubkey,
senderIdentity: senderIdentity, senderIdentity: senderIdentity,
messageTags: [] format: .bitchatV1
) )
} }
private static func createPrivateMessage( /// 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, content: String,
recipientPubkey: String, recipientPubkey: String,
senderIdentity: NostrIdentity, senderIdentity: NostrIdentity,
messageTags: [[String]] 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]
}
private static func createPrivateEnvelope(
content: String,
recipientPubkey: String,
senderIdentity: NostrIdentity,
format: PrivateEnvelopeWireFormat
) throws -> NostrEvent { ) throws -> NostrEvent {
// 1. Create the rumor (unsigned inner event) // 1. Create the unsigned inner BitChat message.
let rumor = NostrEvent( let message = NostrEvent(
pubkey: senderIdentity.publicKeyHex, pubkey: senderIdentity.publicKeyHex,
createdAt: Date(), createdAt: Date(),
kind: .dm, kind: format.messageKind,
tags: messageTags, 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 so the recipient can authenticate who sent the // with the sender's stable Nostr identity for sender authentication.
// 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. 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
) )
return giftWrap
} }
/// Decrypt a received BitChat private envelope. /// Decrypt a BitChat private envelope. Legacy proprietary envelopes that
/// Returns the content, sender pubkey, and the actual message timestamp (not the randomized outer 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(
// 0. Validate the untrusted outer envelope before any decryption work. envelope: envelope,
// Every BitChat client (released iOS and current Android) publishes recipientIdentity: recipientIdentity
// exactly one outer recipient `p` tag on a validly signed kind-1059 )
// wrap; anything else is malformed or misbound. return (
guard giftWrap.content.utf8.count <= maximumPrivateEnvelopeCiphertextBytes else { content: layers.message.content,
SecureLogger.error("❌ Rejecting DM: oversized outer envelope ciphertext", category: .session) senderPubkey: layers.seal.pubkey,
throw NostrError.invalidCiphertext timestamp: layers.message.created_at
} )
guard giftWrap.kind == EventKind.giftWrap.rawValue,
giftWrap.tags == [["p", recipientIdentity.publicKeyHex]],
giftWrap.isValidSignature() else {
SecureLogger.error("❌ Rejecting DM: malformed or misbound outer envelope", category: .session)
throw NostrError.invalidEvent
}
// 1. Unwrap the gift wrap
let seal: NostrEvent
do {
seal = try unwrapGiftWrap(
giftWrap: giftWrap,
recipientKey: recipientIdentity.schnorrSigningKey()
)
// Successfully unwrapped gift wrap
} catch {
SecureLogger.error("❌ Failed to unwrap gift wrap: \(error)", category: .session)
throw error
}
// 2. Authenticate the seal. The seal MUST be signed by the sender's real
// identity key; without this check a DM is forgeable by anyone who
// knows the recipient's npub. Every BitChat sender emits a tagless
// kind-13 seal, so bind the decrypted layer to that exact shape.
guard seal.kind == EventKind.seal.rawValue,
seal.tags.isEmpty,
seal.isValidSignature() else {
SecureLogger.error("❌ Rejecting DM: seal is malformed or its signature is missing/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 rumor is intentionally unsigned; sender authentication comes
// from the seal. The sender claimed inside the rumor must match the
// key that actually signed the seal, otherwise the sender field is
// unauthenticated and spoofable. Also bind the inner kind and tag
// shape to what BitChat clients actually emit.
guard rumor.kind == EventKind.dm.rawValue,
validInnerMessageTags(rumor.tags, recipientPubkey: recipientIdentity.publicKeyHex),
rumor.sig == nil,
seal.pubkey == rumor.pubkey else {
SecureLogger.error("❌ Rejecting DM: rumor is malformed or 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)
}
/// Released iOS envelopes use no inner tags, while current Android
/// envelopes place exactly the authenticated recipient's `p` tag on the
/// unsigned inner event. Accept only those two historical shapes;
/// alternate recipients, duplicate tags, and extra tags are rejected.
private static func validInnerMessageTags(
_ tags: [[String]],
recipientPubkey: String
) -> Bool {
tags.isEmpty || tags == [["p", recipientPubkey]]
} }
#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 createPrivateEnvelopeEvent(
seal: seal,
recipientPubkey: recipientPubkey,
format: format
) )
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
} }
/// Reproduces historical wire shapes (current Android places exactly one static func createLegacyPrivateEnvelopeForTesting(
/// recipient `p` tag on the unsigned inner event) without making the
/// production encoder depend on that quirk.
static func createPrivateMessageWithInnerTagsForTesting(
content: String, content: String,
recipientPubkey: String, recipientPubkey: String,
senderIdentity: NostrIdentity, senderIdentity: NostrIdentity
innerMessageTags: [[String]]
) throws -> NostrEvent { ) throws -> NostrEvent {
try createPrivateMessage( try createPrivateEnvelope(
content: content, content: content,
recipientPubkey: recipientPubkey, recipientPubkey: recipientPubkey,
senderIdentity: senderIdentity, senderIdentity: senderIdentity,
messageTags: innerMessageTags 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
/// Create a geohash-scoped ephemeral public message (kind 20000) /// Create a geohash-scoped ephemeral public message (kind 20000)
@@ -474,170 +541,190 @@ 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(
seal: NostrEvent,
recipientPubkey: String
) throws -> NostrEvent {
let sealJSON = try seal.jsonString() private static func createPrivateEnvelopeEvent(
seal: NostrEvent,
// Create new ephemeral key for gift wrap recipientPubkey: String,
let wrapKey = try P256K.Schnorr.PrivateKey() format: PrivateEnvelopeWireFormat
// Creating gift wrap with ephemeral key ) throws -> NostrEvent {
// A fresh signing/encryption key for every public envelope keeps the
// Encrypt the seal with the new ephemeral key (not the seal's key) // stable sender identity inside ciphertext.
let envelopeKey = try P256K.Schnorr.PrivateKey()
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 SecureLogger.error("❌ Rejecting DM: oversized outer envelope ciphertext", category: .session)
let decrypted = try decrypt(
ciphertext: giftWrap.content,
senderPubkey: giftWrap.pubkey,
recipientKey: recipientKey
)
// Check UTF-8 size before allocating Data or invoking the general
// JSON parser on attacker-influenced plaintext.
guard decrypted.utf8.count <= maximumPrivateEnvelopeCiphertextBytes else {
throw NostrError.invalidCiphertext throw NostrError.invalidCiphertext
} }
guard let data = decrypted.data(using: .utf8), guard let format = PrivateEnvelopeWireFormat(outerKind: envelope.kind),
let sealDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { envelope.tags == [["p", recipientIdentity.publicKeyHex]],
envelope.isValidSignature() else {
SecureLogger.error("❌ Rejecting DM: malformed or misbound outer envelope", category: .session)
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 {
SecureLogger.error("❌ Rejecting DM: seal is malformed or its signature is missing/invalid", category: .session)
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(
guard decrypted.utf8.count <= maximumPrivateEnvelopeCiphertextBytes else { messageJSON,
throw NostrError.invalidCiphertext maximumBytes: maximumPrivateEnvelopePlaintextBytes
} )
guard let data = decrypted.data(using: .utf8),
let rumorDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { // The inner message is intentionally unsigned; sender authentication
// comes from the seal. Bind its claimed sender and custom kind to that
// authenticated layer before exposing content.
guard message.kind == format.messageKind.rawValue,
message.tags.isEmpty,
message.sig == nil,
seal.pubkey == message.pubkey else {
SecureLogger.error("❌ Rejecting DM: inner message is malformed or does not match seal signer", category: .session)
throw NostrError.invalidEvent throw NostrError.invalidEvent
} }
return try NostrEvent(from: rumorDict) return (seal, message)
}
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 // MARK: - BitChat private-envelope encryption
//
// Not NIP-44: the `v2:` prefix, base64url(nonce24 || ciphertext || tag)
// layout, XChaCha20-Poly1305 cipher, and HKDF parameters are all
// BitChat-specific.
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
} }
// Derive shared secret
let sharedSecret = try deriveSharedSecret( let sharedSecret = try deriveSharedSecret(
privateKey: senderKey, privateKey: senderKey,
publicKey: recipientPubkeyData publicKey: recipientPubkeyData
) )
// Derive the BitChat private-envelope symmetric key (HKDF-SHA256) let key = derivePrivateEnvelopeKey(from: sharedSecret, format: format)
let key = try derivePrivateEnvelopeKey(from: sharedSecret)
// 24-byte random nonce for XChaCha20-Poly1305
var nonce24 = Data(count: 24) var nonce24 = Data(count: 24)
let randomStatus = nonce24.withUnsafeMutableBytes { ptr in let randomStatus = nonce24.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!) SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
} }
// Never encrypt with an unrandomized nonce: nonce reuse under the same
// key breaks XChaCha20-Poly1305 confidentiality and authenticity.
guard randomStatus == errSecSuccess else { guard randomStatus == errSecSuccess else {
throw NostrError.cryptographicFailure 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
// v2: base64url(nonce24 || ciphertext || tag) }
let sealed = try XChaCha20Poly1305Compat.seal(
plaintext: plaintextData,
key: key,
nonce24: nonce24
)
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 BitChat's historical `v2:` private-envelope framing, and
// bound work before Base64 decoding attacker-sized input.
guard ciphertext.utf8.count <= maximumPrivateEnvelopeCiphertextBytes, guard ciphertext.utf8.count <= maximumPrivateEnvelopeCiphertextBytes,
ciphertext.hasPrefix("v2:") else { ciphertext.hasPrefix(format.contentPrefix) else {
throw NostrError.invalidCiphertext throw NostrError.invalidCiphertext
} }
let encoded = String(ciphertext.dropFirst(3)) let encoded = String(ciphertext.dropFirst(format.contentPrefix.count))
guard let data = Base64URLCoding.decode(encoded), guard let data = Base64URLCoding.decode(encoded),
data.count > (24 + 16), data.count > (24 + 16),
let senderPubkeyData = Data(hexString: senderPubkey) else { let senderPubkeyData = Data(hexString: senderPubkey) else {
@@ -647,37 +734,37 @@ 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 derivePrivateEnvelopeKey(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 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) {
plaintext = pt plaintext = opened
} else { } else {
let odd = Data([0x03]) + senderPubkeyData let oddKey = Data([0x03]) + senderPubkeyData
plaintext = try attemptDecrypt(using: odd) plaintext = try attemptDecrypt(using: oddKey)
} }
} else { } else {
plaintext = try attemptDecrypt(using: senderPubkeyData) plaintext = try attemptDecrypt(using: senderPubkeyData)
} }
// Authenticated plaintext that is not valid UTF-8 is a malformed guard plaintext.count <= maximumPlaintextBytes,
// envelope, not an empty message. let decoded = String(data: plaintext, encoding: .utf8) else {
guard let decoded = String(data: plaintext, encoding: .utf8) else {
throw NostrError.invalidCiphertext throw NostrError.invalidCiphertext
} }
return decoded return decoded
@@ -740,30 +827,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 // Return raw ECDH shared secret; the wire-format-specific HKDF is
// derivePrivateEnvelopeKey // 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
} }
} }
@@ -901,15 +975,14 @@ enum NostrError: Error {
// MARK: - BitChat private-envelope key derivation // MARK: - BitChat private-envelope key derivation
private extension NostrProtocol { private extension NostrProtocol {
/// The HKDF info string retains the historical "nip44-v2" label for wire private static func derivePrivateEnvelopeKey(
/// compatibility with deployed clients, but this is not the NIP-44 key from sharedSecretData: Data,
/// schedule: NIP-44 derives a conversation key via HKDF-extract with that format: PrivateEnvelopeWireFormat
/// label as the *salt* and uses ChaCha20 with per-message expanded keys. ) -> Data {
static func derivePrivateEnvelopeKey(from sharedSecretData: Data) throws -> 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) }
+22 -15
View File
@@ -113,13 +113,14 @@ 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
// reported without misclassifying ordinary public-event failures.
// Entries are removed only on OK acks (or panic wipe); relays that never // Entries are removed only on OK acks (or panic wipe); relays that never
// ack leave entries behind for the process lifetime. Observability-only // ack leave entries behind for the process lifetime. Observability-only
// state, bounded in practice by outbound DM volume. // state, bounded in practice by outbound DM volume.
private(set) static var pendingGiftWrapIDs = Set<String>() private(set) static var pendingPrivateEnvelopeIDs = Set<String>()
static func registerPendingGiftWrap(id: String) { static func registerPendingPrivateEnvelope(id: String) {
pendingGiftWrapIDs.insert(id) pendingPrivateEnvelopeIDs.insert(id)
} }
struct Relay: Identifiable { struct Relay: Identifiable {
@@ -134,7 +135,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",
@@ -147,7 +148,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
@@ -458,7 +459,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()
@@ -1255,7 +1256,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) {
@@ -1294,11 +1295,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)
@@ -1809,13 +1810,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
} }
+29 -19
View File
@@ -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)
} }
} }
} }
+7 -1
View File
@@ -215,7 +215,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
@@ -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).
@@ -558,7 +558,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)
} }
} }
} }
+101 -40
View File
@@ -86,13 +86,21 @@ extension ChatViewModel: NostrInboundPipelineContext {
/// pipeline (which records events into its own dedup cache only AFTER /// pipeline (which records events into its own dedup cache only AFTER
/// verification, so forged copies can't suppress genuine events). This /// verification, so forged copies can't suppress genuine events). This
/// pipeline therefore never re-verifies; it keeps its own event-ID dedup /// pipeline therefore never re-verifies; it keeps its own event-ID dedup
/// (cheap main-actor lookups) and moves NIP-17 gift-wrap decryption two /// (cheap main-actor lookups) and moves private-envelope decryption two
/// ECDH+ChaCha layers off the main actor with an atomic main-actor /// ECDH+ChaCha layers off the main actor with an atomic main-actor
/// check-and-record. /// 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
/// Monotonic panic-wipe generation for this pipeline. A panic wipe clears /// Monotonic panic-wipe generation for this pipeline. A panic wipe clears
/// relay handlers so no NEW events flow, but a detached decrypt task /// relay handlers so no NEW events flow, but a detached decrypt task
@@ -286,13 +294,13 @@ 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 }
// Cheap dedup pre-check only; processGeohashGiftWrap does the // Cheap dedup pre-check only; processGeohashPrivateEnvelope does the
// authoritative main-actor check-and-record before the off-main // authoritative main-actor check-and-record before the off-main
// NIP-17 unwrap. The outer signature was already verified (exactly // private-envelope unwrap. The outer signature was already verified
// once, off the main actor) by NostrRelayManager. // (exactly once, off the main actor) by NostrRelayManager.
guard !context.hasProcessedNostrEvent(giftWrap.id) else { return } guard !context.hasProcessedNostrEvent(envelope.id) else { return }
// Capture the wipe generation at spawn, alongside the per-geohash // Capture the wipe generation at spawn, alongside the per-geohash
// identity (private key) the detached task strongly captures. A panic // identity (private key) the detached task strongly captures. A panic
@@ -300,29 +308,29 @@ final class NostrInboundPipeline {
// drops its result instead of delivering plaintext post-wipe. // drops its result instead of delivering plaintext post-wipe.
let wipeGeneration = self.wipeGeneration let wipeGeneration = self.wipeGeneration
Task.detached(priority: .userInitiated) { [weak self] in Task.detached(priority: .userInitiated) { [weak self] in
await self?.processGeohashGiftWrap(giftWrap, id: id, verbose: false, wipeGeneration: wipeGeneration) await self?.processGeohashPrivateEnvelope(envelope, id: id, verbose: false, wipeGeneration: wipeGeneration)
} }
} }
@MainActor @MainActor
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { func handlePrivateEnvelope(_ envelope: NostrEvent, id: NostrIdentity) {
guard let context else { return } guard let context else { return }
// Cheap dedup pre-check only; see subscribeGiftWrap. // Cheap dedup pre-check only; see subscribePrivateEnvelope.
if context.hasProcessedNostrEvent(giftWrap.id) { if context.hasProcessedNostrEvent(envelope.id) {
return return
} }
// Spawn-time wipe-generation capture; see subscribeGiftWrap. // Spawn-time wipe-generation capture; see subscribePrivateEnvelope.
let wipeGeneration = self.wipeGeneration let wipeGeneration = self.wipeGeneration
Task.detached(priority: .userInitiated) { [weak self] in Task.detached(priority: .userInitiated) { [weak self] in
await self?.processGeohashGiftWrap(giftWrap, id: id, verbose: true, wipeGeneration: wipeGeneration) await self?.processGeohashPrivateEnvelope(envelope, id: id, verbose: true, wipeGeneration: wipeGeneration)
} }
} }
/// Geohash-DM gift wrap ingest. The NIP-17 unwrap (two ECDH+ChaCha /// Geohash-DM private-envelope ingest. The envelope unwrap (two
/// layers) runs off the main actor; results hop back for state updates. /// ECDH+ChaCha layers) runs off the main actor; results hop back for
/// `verbose` keeps `handleGiftWrap`'s decrypt logging without adding it /// state updates. `verbose` keeps `handlePrivateEnvelope`'s decrypt
/// to the sampling path. /// logging without adding it to the sampling path.
/// ///
/// `wipeGeneration` is this pipeline's generation captured at spawn (the /// `wipeGeneration` is this pipeline's generation captured at spawn (the
/// moment the pre-wipe `id` was captured); a mismatch at either main-actor /// moment the pre-wipe `id` was captured); a mismatch at either main-actor
@@ -330,8 +338,8 @@ final class NostrInboundPipeline {
/// decrypting (first hop) or without delivering the plaintext (second /// decrypting (first hop) or without delivering the plaintext (second
/// hop) the captured identity and any decrypted material are simply /// hop) the captured identity and any decrypted material are simply
/// dropped with the task. /// dropped with the task.
private func processGeohashGiftWrap( private func processGeohashPrivateEnvelope(
_ giftWrap: NostrEvent, _ envelope: NostrEvent,
id: NostrIdentity, id: NostrIdentity,
verbose: Bool, verbose: Bool,
wipeGeneration: UInt64 wipeGeneration: UInt64
@@ -341,25 +349,25 @@ final class NostrInboundPipeline {
// 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 {
guard self.wipeGeneration == wipeGeneration else { return true } guard self.wipeGeneration == wipeGeneration else { return true }
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 }
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 {
if verbose { if verbose {
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
} }
if verbose { if verbose {
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
) )
} }
@@ -375,13 +383,19 @@ final class NostrInboundPipeline {
else { else {
return return
} }
guard self.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,
@@ -404,44 +418,44 @@ 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; processNostrMessage does the // Cheap dedup pre-check only; processAccountPrivateEnvelope does the
// authoritative check-and-record before the off-main NIP-17 unwrap. // authoritative check-and-record before the off-main private-envelope
// The outer signature was already verified (exactly once, off the // unwrap. The outer signature was already verified (exactly once, off
// main actor) by NostrRelayManager, and only verified events are // the main actor) by NostrRelayManager, and only verified events are
// recorded, so a forged-signature copy can never poison the dedup // recorded, so a forged-signature copy can never poison the dedup
// set and suppress the genuine event. // set and suppress the genuine event.
if context.hasProcessedNostrEvent(giftWrap.id) { return } if context.hasProcessedNostrEvent(envelope.id) { return }
Task.detached(priority: .userInitiated) { [weak self] in 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 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 }
// Fetch the identity and the wipe generation in ONE main-actor hop: // Fetch the identity and the wipe generation in ONE main-actor hop:
// the generation then vouches for exactly this identity. A wipe after // the generation then vouches for exactly this identity. A wipe after
// this point bumps the generation and the delivery hop below drops // this point bumps the generation and the delivery hop below drops
// the decrypted result (same guard as processGeohashGiftWrap; this // the decrypted result (same guard as processGeohashPrivateEnvelope;
// account-mailbox path had the identical hazard). // this account-mailbox path had the identical hazard).
let (currentIdentity, wipeGeneration): (NostrIdentity?, UInt64) = await MainActor.run { let (currentIdentity, wipeGeneration): (NostrIdentity?, UInt64) = await MainActor.run {
(context.currentNostrIdentity(), self.wipeGeneration) (context.currentNostrIdentity(), self.wipeGeneration)
} }
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
) )
@@ -465,11 +479,17 @@ 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 {
// Drop pre-wipe plaintext if a panic wipe landed // Drop pre-wipe plaintext if a panic wipe landed
// during the off-main decrypt (see above). // during the off-main decrypt (see above).
guard self.wipeGeneration == wipeGeneration else { return } guard self.wipeGeneration == wipeGeneration else { return }
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 {
@@ -543,6 +563,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 }
@@ -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,20 +346,30 @@ struct ChatNostrCoordinatorContextTests {
messageID: "gm-1", messageID: "gm-1",
senderPeerID: PeerID(str: "aabbccddeeff0011") senderPeerID: PeerID(str: "aabbccddeeff0011")
)) ))
let giftWrap = try NostrProtocol.createPrivateMessage( let envelopes = try NostrProtocol.createPrivateEnvelopePublicationBatch(
content: embedded, content: embedded,
recipientPubkey: recipient.publicKeyHex, recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender senderIdentity: sender
) )
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient) for envelope in envelopes {
coordinator.inbound.handlePrivateEnvelope(envelope, id: recipient)
}
// The NIP-17 unwrap runs off the main actor; wait for the hop back. // The envelope unwrap runs off the main actor; wait for the hop back.
let convKey = PeerID(nostr_: sender.publicKeyHex) let convKey = PeerID(nostr_: sender.publicKeyHex)
let routed = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 }) let routed = await TestHelpers.waitUntil({
context.handledPrivateMessages.count >= 1
&& context.recordedNostrEventIDs.count == envelopes.count
})
#expect(routed) #expect(routed)
#expect(context.recordedNostrEventIDs == [giftWrap.id]) #expect(Set(context.recordedNostrEventIDs) == Set(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.
try? await Task.sleep(nanoseconds: 200_000_000)
await drainMainQueue()
#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)
@@ -370,15 +380,78 @@ 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)
await drainMainQueue() await drainMainQueue()
#expect(context.recordedNostrEventIDs == [giftWrap.id]) #expect(Set(context.recordedNostrEventIDs) == Set(envelopes.map(\.id)))
#expect(context.handledPrivateMessages.count == 1) #expect(context.handledPrivateMessages.count == 1)
} }
@Test @MainActor @Test @MainActor
func handleGiftWrap_panicWipeAfterSpawnDropsDecryptedResult() async throws { func migrationEnvelopePairs_processEachMessageAndAckOnlyOnce() async throws {
let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context)
let recipient = try NostrIdentity.generate()
let sender = try NostrIdentity.generate()
let messageContent = try #require(NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
content: "migration message",
messageID: "migration-message-id",
senderPeerID: PeerID(str: "aabbccddeeff0011")
))
let deliveredContent = try #require(NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(
type: .delivered,
messageID: "migration-ack-id",
senderPeerID: PeerID(str: "aabbccddeeff0011")
))
let readContent = try #require(NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(
type: .readReceipt,
messageID: "migration-ack-id",
senderPeerID: PeerID(str: "aabbccddeeff0011")
))
var publishedIDs: [String] = []
for content in [messageContent, deliveredContent, readContent] {
let envelopes = try NostrProtocol.createPrivateEnvelopePublicationBatch(
content: content,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
#expect(envelopes.count == 2)
publishedIDs.append(contentsOf: envelopes.map(\.id))
for envelope in envelopes {
coordinator.inbound.handlePrivateEnvelope(envelope, id: recipient)
}
}
// Envelope unwrap runs off the main actor; wait until every published
// event has been recorded, then let any (incorrect) twin deliveries
// land before asserting exact side-effect counts.
let processed = await TestHelpers.waitUntil({
context.recordedNostrEventIDs.count == publishedIDs.count
})
#expect(processed)
try? await Task.sleep(nanoseconds: 200_000_000)
await drainMainQueue()
#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)
let retried = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 2 })
#expect(retried)
}
@Test @MainActor
func handlePrivateEnvelope_panicWipeAfterSpawnDropsDecryptedResult() async throws {
let context = MockChatNostrContext() let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context) let coordinator = ChatNostrCoordinator(context: context)
@@ -389,7 +462,7 @@ struct ChatNostrCoordinatorContextTests {
messageID: "gm-wipe-1", messageID: "gm-wipe-1",
senderPeerID: PeerID(str: "aabbccddeeff0011") senderPeerID: PeerID(str: "aabbccddeeff0011")
)) ))
let giftWrap = try NostrProtocol.createPrivateMessage( let envelope = try NostrProtocol.createPrivateEnvelope(
content: embedded, content: embedded,
recipientPubkey: recipient.publicKeyHex, recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender senderIdentity: sender
@@ -398,7 +471,7 @@ struct ChatNostrCoordinatorContextTests {
// Spawn the detached decrypt (it strongly captures the pre-wipe // Spawn the detached decrypt (it strongly captures the pre-wipe
// identity), then panic-wipe in the SAME main-actor turn guaranteed // identity), then panic-wipe in the SAME main-actor turn guaranteed
// to land before the task's first main-actor hop. // to land before the task's first main-actor hop.
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient) coordinator.inbound.handlePrivateEnvelope(envelope, id: recipient)
coordinator.inbound.invalidateInFlightDecrypts() coordinator.inbound.invalidateInFlightDecrypts()
// Give the detached task ample time to have delivered if the wipe // Give the detached task ample time to have delivered if the wipe
@@ -409,9 +482,9 @@ struct ChatNostrCoordinatorContextTests {
#expect(context.handledPrivateMessages.isEmpty) #expect(context.handledPrivateMessages.isEmpty)
#expect(context.recordedNostrEventIDs.isEmpty) #expect(context.recordedNostrEventIDs.isEmpty)
// The pipeline itself stays usable: a gift wrap spawned AFTER the // The pipeline itself stays usable: an envelope spawned AFTER the
// wipe (new generation) still decrypts and delivers. // wipe (new generation) still decrypts and delivers.
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient) coordinator.inbound.handlePrivateEnvelope(envelope, id: recipient)
let delivered = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 }) let delivered = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 })
#expect(delivered) #expect(delivered)
} }
@@ -424,26 +497,26 @@ struct ChatNostrCoordinatorContextTests {
// The inbound pipeline only ever sees verified events. // The inbound pipeline only ever sees verified events.
@Test @MainActor @Test @MainActor
func processNostrMessage_duplicateDeliveryProcessesOnce() async throws { func processAccountPrivateEnvelope_duplicateDeliveryProcessesOnce() 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()
context.nostrIdentity = recipient context.nostrIdentity = recipient
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
) )
// Fan-in of the same (already verified) gift wrap from several relays // Fan-in of the same (already verified) envelope from several relays
// records and processes exactly once. // records and processes exactly once.
await coordinator.inbound.processNostrMessage(giftWrap) await coordinator.inbound.processAccountPrivateEnvelope(envelope)
#expect(context.recordedNostrEventIDs == [giftWrap.id]) #expect(context.recordedNostrEventIDs == [envelope.id])
await coordinator.inbound.processNostrMessage(giftWrap) await coordinator.inbound.processAccountPrivateEnvelope(envelope)
#expect(context.recordedNostrEventIDs == [giftWrap.id]) #expect(context.recordedNostrEventIDs == [envelope.id])
} }
@Test @MainActor @Test @MainActor
+33 -28
View File
@@ -373,20 +373,25 @@ struct ChatViewModelNostrExtensionTests {
// `test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup`. // `test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup`.
@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)
@@ -431,7 +436,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()
@@ -453,13 +458,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)) },
@@ -469,7 +474,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()
@@ -491,13 +496,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)) },
@@ -507,28 +512,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)
@@ -537,11 +542,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)
@@ -551,13 +556,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)
// Gift-wrap decryption runs off the main actor; wait for the ack // Gift-wrap decryption runs off the main actor; wait for the ack
// (sent even for blocked senders) to know processing finished. // (sent even for blocked senders) to know processing finished.
@@ -570,12 +575,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(
@@ -592,13 +597,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)) },
+218 -196
View File
@@ -12,7 +12,7 @@ import BitFoundation
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()
@@ -21,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
) )
@@ -51,156 +49,97 @@ 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
) )
#expect(content1 == "Message 1") #expect(content1 == "Message 1")
#expect(content2 == "Message 2") #expect(content2 == "Message 2")
} }
@Test func decryptionFailsWithWrongRecipient() throws { @Test func privateEnvelopeUsesBitChatWireFormatAtEveryLayer() 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 envelope = try NostrProtocol.createPrivateEnvelope(
content: "bitchat-specific",
// Create message for recipient
let giftWrap = try NostrProtocol.createPrivateMessage(
content: "Secret message",
recipientPubkey: recipient.publicKeyHex, recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender senderIdentity: sender
) )
// Try to decrypt with wrong recipient. The outer envelope is bound to
// the addressed recipient's `p` tag, so this now fails validation
// before any decryption is attempted.
expectInvalidEvent {
_ = try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: wrongRecipient
)
}
}
@Test func decryptAcceptsCurrentAndroidInnerRecipientTag() throws { #expect(envelope.kind == NostrProtocol.EventKind.privateEnvelope.rawValue)
let sender = try NostrIdentity.generate() #expect(envelope.kind != NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue)
let recipient = try NostrIdentity.generate() #expect(envelope.content.hasPrefix(NostrProtocol.privateEnvelopeContentPrefix))
#expect(!envelope.content.hasPrefix("v2:"))
// Current Android's `createPrivateMessage` emits an unsigned kind-14
// inner event with exactly [["p", recipient]], while released iOS
// uses no inner tags. This isolated generator reproduces the Android
// wire shape without making the production encoder depend on it.
let giftWrap = try NostrProtocol.createPrivateMessageWithInnerTagsForTesting(
content: "legacy message from Android",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender,
innerMessageTags: [["p", recipient.publicKeyHex]]
)
let result = try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: recipient
)
#expect(result.content == "legacy message from Android")
#expect(result.senderPubkey == sender.publicKeyHex)
}
@Test func decryptRejectsAlternateInnerTagShapes() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let otherRecipient = try NostrIdentity.generate()
let invalidTagShapes = [
[["p", otherRecipient.publicKeyHex]],
[["p", recipient.publicKeyHex], ["p", recipient.publicKeyHex]],
[["p", recipient.publicKeyHex, "unexpected"]],
[["x", recipient.publicKeyHex]]
]
for tags in invalidTagShapes {
let giftWrap = try NostrProtocol.createPrivateMessageWithInnerTagsForTesting(
content: "invalid inner tag shape",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender,
innerMessageTags: tags
)
expectInvalidEvent {
_ = try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: recipient
)
}
}
}
@Test func decryptsFrozenLegacyEnvelopeProducedByAndroidB7f0b33d() throws {
let eventData = try Data(contentsOf: fixtureURL(
name: "AndroidLegacyPrivateEnvelopeB7f0b33d"
))
let metadataData = try Data(contentsOf: fixtureURL(
name: "AndroidLegacyPrivateEnvelopeB7f0b33dMetadata"
))
let envelope = try JSONDecoder().decode(NostrEvent.self, from: eventData)
let metadata = try JSONDecoder().decode(AndroidLegacyEnvelopeFixture.self, from: metadataData)
let recipientKey = try #require(Data(hexString: metadata.recipientPrivateKey))
let recipient = try NostrIdentity(privateKeyData: recipientKey)
#expect(metadata.androidCommit == "b7f0b33d3a267c770d3d5a65ee2d8c7e755450db")
#expect(metadata.generator == "NostrProtocol.createPrivateMessage")
// The generator patch ships as .patch.txt so the Xcode synchronized
// test group reliably copies it as a text resource; its pinned SHA-256
// covers the unchanged patch content.
#expect(metadata.generatorPatch == "AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch.txt")
#expect(metadata.fixtureSHA256 == eventData.sha256Fingerprint())
#expect(metadata.gradleTest.contains("NostrProtocolTest.emitCrossPlatformFixtures"))
let generatorPatch = try String(contentsOf: fixtureURL(
name: "AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch",
extension: "txt"
))
#expect(metadata.generatorPatchSHA256 == Data(generatorPatch.utf8).sha256Fingerprint())
#expect(generatorPatch.contains("fun emitCrossPlatformFixtures()"))
#expect(generatorPatch.contains(metadata.recipientPrivateKey))
#expect(envelope.isValidSignature())
#expect(envelope.kind == NostrProtocol.EventKind.giftWrap.rawValue)
#expect(envelope.tags == [["p", recipient.publicKeyHex]]) #expect(envelope.tags == [["p", recipient.publicKeyHex]])
#expect(envelope.created_at <= Int(Date().timeIntervalSince1970))
// The Android inner event carries exactly the recipient `p` tag, so a let layers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
// successful decrypt also proves the Android inner-tag acceptance. envelope: envelope,
let result = try NostrProtocol.decryptPrivateMessage(
giftWrap: envelope,
recipientIdentity: recipient recipientIdentity: recipient
) )
#expect(result.content == "legacy fixture from Android b7f0b33d") #expect(layers.seal.kind == NostrProtocol.EventKind.privateSeal.rawValue)
#expect(result.senderPubkey == metadata.senderPublicKey) #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 { @Test func decryptsFrozenLegacyEnvelopeProducedByRelease733098bb() throws {
@@ -216,18 +155,88 @@ struct NostrProtocolTests {
let recipient = try NostrIdentity(privateKeyData: recipientKey) let recipient = try NostrIdentity(privateKeyData: recipientKey)
#expect(envelope.isValidSignature()) #expect(envelope.isValidSignature())
let result = try NostrProtocol.decryptPrivateMessage( let result = try NostrProtocol.decryptPrivateEnvelope(
giftWrap: envelope, envelope: envelope,
recipientIdentity: recipient recipientIdentity: recipient
) )
#expect(result.content == "legacy fixture from 733098bb") #expect(result.content == "legacy fixture from 733098bb")
#expect(result.senderPubkey == "2e3d79df7047204f02b726c574e256f8de1dd80510f7dcb8b0d12df13acb87e6") #expect(result.senderPubkey == "2e3d79df7047204f02b726c574e256f8de1dd80510f7dcb8b0d12df13acb87e6")
} }
@Test func decryptRejectsOversizedCiphertextBeforeDecoding() throws { @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 recipient = try NostrIdentity.generate()
let wrapper = try NostrIdentity.generate() let wrapper = try NostrIdentity.generate()
let oversizedContent = "v2:" let oversizedContent = NostrProtocol.privateEnvelopeContentPrefix
+ String( + String(
repeating: "A", repeating: "A",
count: NostrProtocol.maximumPrivateEnvelopeCiphertextBytes count: NostrProtocol.maximumPrivateEnvelopeCiphertextBytes
@@ -235,21 +244,48 @@ struct NostrProtocolTests {
let event = NostrEvent( let event = NostrEvent(
pubkey: wrapper.publicKeyHex, pubkey: wrapper.publicKeyHex,
createdAt: Date(), createdAt: Date(),
kind: .giftWrap, kind: .privateEnvelope,
tags: [["p", recipient.publicKeyHex]], tags: [["p", recipient.publicKeyHex]],
content: oversizedContent content: oversizedContent
) )
let signed = try event.sign(with: wrapper.schnorrSigningKey()) let signed = try event.sign(with: wrapper.schnorrSigningKey())
expectInvalidCiphertext { expectInvalidCiphertext {
_ = try NostrProtocol.decryptPrivateMessage( _ = try NostrProtocol.decryptPrivateEnvelope(
giftWrap: signed, envelope: signed,
recipientIdentity: recipient recipientIdentity: recipient
) )
} }
} }
@Test func decryptDoesNotMisinterpretStandardNIP44Payload() throws { @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 recipient = try NostrIdentity.generate()
let wrapper = try NostrIdentity.generate() let wrapper = try NostrIdentity.generate()
// A valid NIP-44 v2 payload from the official test vectors. Its wire // A valid NIP-44 v2 payload from the official test vectors. Its wire
@@ -259,40 +295,36 @@ struct NostrProtocolTests {
let event = NostrEvent( let event = NostrEvent(
pubkey: wrapper.publicKeyHex, pubkey: wrapper.publicKeyHex,
createdAt: Date(), createdAt: Date(),
kind: .giftWrap, kind: .legacyNIP59GiftWrap,
tags: [["p", recipient.publicKeyHex]], tags: [["p", recipient.publicKeyHex]],
content: standardPayload content: standardPayload
) )
let signed = try event.sign(with: wrapper.schnorrSigningKey()) let signed = try event.sign(with: wrapper.schnorrSigningKey())
expectInvalidCiphertext { expectInvalidCiphertext {
_ = try NostrProtocol.decryptPrivateMessage( _ = try NostrProtocol.decryptPrivateEnvelope(
giftWrap: signed, envelope: signed,
recipientIdentity: recipient recipientIdentity: recipient
) )
} }
} }
@Test func decryptRejectsWrongOuterKind() 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()
var giftWrap = try NostrProtocol.createPrivateMessage( let wrongRecipient = try NostrIdentity.generate()
content: "wrong outer kind",
// Create message for recipient
let envelope = try NostrProtocol.createPrivateEnvelope(
content: "Secret message",
recipientPubkey: recipient.publicKeyHex, recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender senderIdentity: sender
) )
giftWrap = NostrEvent(
pubkey: giftWrap.pubkey,
createdAt: Date(timeIntervalSince1970: TimeInterval(giftWrap.created_at)),
kind: .textNote,
tags: giftWrap.tags,
content: giftWrap.content
)
expectInvalidEvent { expectInvalidEvent {
_ = try NostrProtocol.decryptPrivateMessage( _ = try NostrProtocol.decryptPrivateEnvelope(
giftWrap: giftWrap, envelope: envelope,
recipientIdentity: recipient recipientIdentity: wrongRecipient
) )
} }
} }
@@ -300,41 +332,41 @@ struct NostrProtocolTests {
@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()
@@ -348,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
) )
@@ -385,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()
@@ -397,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)
@@ -467,7 +497,6 @@ struct NostrProtocolTests {
#expect(object["limit"] as? Int == 42) #expect(object["limit"] as? Int == 42)
} }
@Test func inboundNostrEventRejectsTooManyTags() throws { @Test func inboundNostrEventRejectsTooManyTags() throws {
var eventDict = Self.validInboundEventDict() var eventDict = Self.validInboundEventDict()
eventDict["tags"] = Array( eventDict["tags"] = Array(
@@ -513,6 +542,24 @@ struct NostrProtocolTests {
#expect(event.tags.count == 2) #expect(event.tags.count == 2)
} }
@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 static func validInboundEventDict() -> [String: Any] { private static func validInboundEventDict() -> [String: Any] {
[ [
@@ -534,38 +581,13 @@ struct NostrProtocolTests {
} }
} }
private struct AndroidLegacyEnvelopeFixture: Decodable { private func fixtureURL(name: String) throws -> URL {
let androidCommit: String
let generator: String
let generatorPatch: String
let generatorPatchSHA256: String
let gradleTest: String
let fixtureSHA256: String
let recipientPrivateKey: String
let senderPublicKey: String
enum CodingKeys: String, CodingKey {
case androidCommit = "android_commit"
case generator
case generatorPatch = "generator_patch"
case generatorPatchSHA256 = "generator_patch_sha256"
case gradleTest = "gradle_test"
case fixtureSHA256 = "fixture_sha256"
case recipientPrivateKey = "recipient_private_key"
case senderPublicKey = "sender_public_key"
}
}
private func fixtureURL(name: String, extension fileExtension: String = "json") throws -> URL {
// Bundle.module only exists under SwiftPM; the Xcode test targets
// resolve resources through the test bundle (same pattern as
// NoiseProtocolTests' NoiseTestVectors.json loader).
#if SWIFT_PACKAGE #if SWIFT_PACKAGE
let bundle = Bundle.module let bundle = Bundle.module
#else #else
let bundle = Bundle(for: MockKeychain.self) let bundle = Bundle(for: MockKeychain.self)
#endif #endif
return try #require(bundle.url(forResource: name, withExtension: fileExtension)) return try #require(bundle.url(forResource: name, withExtension: "json"))
} }
private static func base64URLDecode(_ s: String) -> Data? { private static func base64URLDecode(_ s: String) -> Data? {
@@ -854,7 +854,7 @@ final class NostrRelayManagerTests: XCTestCase {
/// The relay boundary is the single signature-verification point for the /// The relay boundary is the single signature-verification point for the
/// whole inbound path (downstream pipelines no longer re-verify), so a /// whole inbound path (downstream pipelines no longer re-verify), so a
/// tampered gift wrap (kind 1059, the DM/mailbox path) must be dropped /// tampered private envelope (the DM/mailbox path) must be dropped
/// here and must not poison the dedup cache against the genuine copy. /// here and must not poison the dedup cache against the genuine copy.
func test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup() async throws { func test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup() async throws {
let firstRelayURL = "wss://giftwrap-one.example" let firstRelayURL = "wss://giftwrap-one.example"
@@ -862,7 +862,7 @@ final class NostrRelayManagerTests: XCTestCase {
let context = makeContext(permission: .denied) let context = makeContext(permission: .denied)
let sender = try NostrIdentity.generate() let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate() let recipient = try NostrIdentity.generate()
let giftWrap = try NostrProtocol.createPrivateMessage( let giftWrap = try NostrProtocol.createPrivateEnvelope(
content: "psst", content: "psst",
recipientPubkey: recipient.publicKeyHex, recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender senderIdentity: sender
@@ -1039,11 +1039,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 {
@@ -1052,17 +1052,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)
} }
+104 -20
View File
@@ -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()
} }