From e9275cb3d81505f60ec1e2192982d00696bda4e3 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:50:17 +0200 Subject: [PATCH] Relabel private Nostr envelopes honestly in docs; harden legacy envelope validation (#1480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split-out safe half of #1437 (the kind-1402 wire migration stays held for Android coordination). Docs (README/WHITEPAPER/PRIVACY_POLICY/privacy-assessment) now describe the actual proprietary DM construction — kind 1059 gift wrap carrying XChaCha20-Poly1305 with a 24-byte nonce, base64url v2: framing, and an HKDF that borrows the nip44-v2 info label but is not the NIP-44 key schedule — instead of claiming NIP-17/44/59. Hardens the existing legacy inbound path: 64 KiB ciphertext cap before decode (~46x the largest producible legacy envelope), outer kind/recipient-tag/signature binding, tagless kind-13 seal binding, unsigned kind-14 inner binding, inner tags restricted to the two shapes deployed clients emit (verified against every historical iOS release and a fixture frozen from Android production), SecRandomCopyBytes failure now throws, non-UTF-8 plaintext now throws instead of returning empty. Adds frozen cross-platform fixtures with hash-pinned generators; interop-reviewed with no rejection surface for deployed clients. --- PRIVACY_POLICY.md | 4 +- Package.swift | 6 +- README.md | 12 +- WHITEPAPER.md | 10 +- bitchat/App/PrivateConversationModels.swift | 8 +- bitchat/Nostr/NostrIdentity.swift | 3 +- bitchat/Nostr/NostrProtocol.swift | 228 +++++++++++----- bitchat/Nostr/NostrRelayManager.swift | 6 +- bitchat/Services/Courier/CourierStore.swift | 2 +- .../Gateway/BridgeCourierService.swift | 6 +- .../MessageDeduplicationService.swift | 8 +- .../Services/NostrProcessedEventStore.swift | 5 +- bitchat/Services/TransportConfig.swift | 2 +- bitchat/Views/ContentSheetViews.swift | 3 +- .../AndroidLegacyPrivateEnvelopeB7f0b33d.json | 1 + ...PrivateEnvelopeB7f0b33dGenerator.patch.txt | 29 ++ ...LegacyPrivateEnvelopeB7f0b33dMetadata.json | 10 + .../LegacyPrivateEnvelope733098bb.json | 1 + ...cyPrivateEnvelope733098bbRecipientKey.json | 1 + bitchatTests/NostrProtocolTests.swift | 258 +++++++++++++++++- docs/privacy-assessment.md | 4 +- 21 files changed, 500 insertions(+), 107 deletions(-) create mode 100644 bitchatTests/Nostr/Fixtures/AndroidLegacyPrivateEnvelopeB7f0b33d.json create mode 100644 bitchatTests/Nostr/Fixtures/AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch.txt create mode 100644 bitchatTests/Nostr/Fixtures/AndroidLegacyPrivateEnvelopeB7f0b33dMetadata.json create mode 100644 bitchatTests/Nostr/Fixtures/LegacyPrivateEnvelope733098bb.json create mode 100644 bitchatTests/Nostr/Fixtures/LegacyPrivateEnvelope733098bbRecipientKey.json diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md index 8a35485a..8a6f8128 100644 --- a/PRIVACY_POLICY.md +++ b/PRIVACY_POLICY.md @@ -73,7 +73,7 @@ Private group members receive the group's name, roster, key epoch, and encrypted Internet-backed features are optional. When enabled or used: -- Private fallback messages use encrypted NIP-17 gift wraps. Relays can observe event and network metadata but not the message plaintext. +- Private fallback messages use BitChat's app-specific encrypted envelopes. This format is not NIP-17, NIP-44, or NIP-59 compatible. Relays can observe the recipient public-key tag, event timing and size, and network metadata, but not the message plaintext or stable sender identity. - Public location-channel messages, notes, notices, and presence include a geohash tag, event kind, timestamp, and a public key. A geohash reveals an approximate area; finer precision reveals a smaller area. - The optional mesh bridge publishes bridge-enabled public mesh messages and presence to a neighborhood rendezvous cell. Those messages are public to participants and relays for that cell. A per-message “nearby only” choice prevents that message from crossing the bridge. - Bridge courier drops contain opaque end-to-end encrypted envelopes and a rotating recipient tag. Relays still observe timing and network metadata. @@ -103,7 +103,7 @@ Private and public features use different protections: - Mesh private sessions use Noise XX with X25519, ChaCha20-Poly1305, and SHA-256. - Private group messages use ChaCha20-Poly1305; group state and relevant mesh packets use Ed25519 signatures. -- Nostr events use secp256k1 Schnorr signatures. NIP-44 v2 private payloads use secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305. +- Nostr events use secp256k1 Schnorr signatures. BitChat private envelopes use secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305. The envelope format is proprietary, only interoperates with BitChat clients, and does not provide forward secrecy against later compromise of the recipient's static Nostr private key. - The persistent private-message outbox uses ChaCha20-Poly1305 with a key held in the keychain. Some other protected local identity state uses AES-GCM. - Public mesh, bridge, geohash, and board content is signed or authenticated as appropriate but is intentionally not confidential. diff --git a/Package.swift b/Package.swift index 7d447630..2bb83521 100644 --- a/Package.swift +++ b/Package.swift @@ -63,7 +63,11 @@ let package = Package( // Only the vector fixture: declaring the whole "Noise" // directory would claim its .swift test files as resources // and silently drop them from compilation. - .process("Noise/NoiseTestVectors.json") + .process("Noise/NoiseTestVectors.json"), + // Frozen envelopes produced by the released iOS (733098bb) + // and Android (b7f0b33d) private-DM implementations; prove + // receive compatibility independently of the local generator. + .process("Nostr/Fixtures") ] ) ] diff --git a/README.md b/README.md index 9ad608f0..7c4f6a45 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file - **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback) - **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE - **Privacy First**: No accounts, no phone numbers, no persistent identifiers -- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, NIP-17 for Nostr +- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, BitChat private envelopes for Nostr fallback - **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface - **Universal App**: Native support for iOS and macOS - **Emergency Wipe**: Triple-tap to instantly clear all data @@ -44,9 +44,15 @@ BitChat uses a **hybrid messaging architecture** with two complementary transpor - **Global Reach**: Connect with users worldwide via internet relays - **Location Channels**: Geographic chat rooms using geohash coordinates - **290+ Relay Network**: Distributed across the globe for reliability -- **NIP-17 Encryption**: Gift-wrapped private messages for internet privacy +- **BitChat Private Envelopes**: App-specific encrypted private messages over Nostr relays - **Ephemeral Keys**: Fresh cryptographic identity per geohash area +BitChat's private-envelope format is proprietary and is **not** NIP-17, +NIP-44, or NIP-59 compatible. It uses Nostr as a relay transport but only +interoperates with BitChat clients: private payloads travel inside kind-1059 +events whose `v2:`-prefixed content is a BitChat-specific XChaCha20-Poly1305 +construction, not NIP-44 encryption. + ### Channel Types #### `mesh #bluetooth` @@ -80,7 +86,7 @@ Private messages use **intelligent transport selection**: 2. **Nostr Fallback** (when Bluetooth unavailable) - Uses recipient's Nostr public key - - NIP-17 gift-wrapping for privacy + - BitChat's app-specific private-envelope encryption - Routes through global relay network 3. **Smart Queuing** (when neither available) diff --git a/WHITEPAPER.md b/WHITEPAPER.md index 7716850c..3d1a2a13 100644 --- a/WHITEPAPER.md +++ b/WHITEPAPER.md @@ -25,7 +25,7 @@ bitchat is a decentralized, peer-to-peer messaging application for secure, priva Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`: * **BLE mesh** — every device is simultaneously a GATT central and peripheral, relaying packets in a controlled flood. No infrastructure, pairing, or accounts. -* **Nostr** — private messages to mutual favorites travel as NIP-17 gift-wrapped events over public relays (over Tor where enabled), bridging separate meshes through the internet. +* **Nostr** — private messages to mutual favorites travel in BitChat's app-specific encrypted envelopes over public relays (over Tor where enabled), bridging separate meshes through the internet. The router prefers a live mesh link, falls back to Nostr, and engages the courier system when neither can deliver promptly. @@ -78,7 +78,9 @@ Courier envelopes are sealed to the recipient's *static* key with the one-way No ### 5.3 Nostr Path -Private messages to mutual favorites are wrapped per NIP-17/NIP-59: a rumor (kind 14) sealed (kind 13) and gift-wrapped (kind 1059) under a throwaway ephemeral key, so relays learn neither sender nor content. +Private messages to mutual favorites use BitChat's proprietary private-envelope protocol. An unsigned inner message (kind 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). + +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. ## 6. Store and Forward @@ -105,7 +107,7 @@ Public broadcast messages are cached (1000 packets) and reconciled between peers ### 6.4 Nostr Mailboxes -Gift-wrapped messages rest on Nostr relays; clients re-subscribe with a 24-hour lookback on reconnect, covering the both-devices-offline case for mutual favorites whenever either side touches the internet. +BitChat private envelopes rest on Nostr relays; clients re-subscribe with a 24-hour lookback on reconnect, covering the both-devices-offline case for mutual favorites whenever either side touches the internet. ### 6.5 Delivery Metrics @@ -127,7 +129,7 @@ Bare local counters (deposits, handovers, sprays, opens, outbox flushes and drop * **Flooding abuse** is bounded by TTL clamps, deduplication, per-depositor quotas, connect-rate limits, and announce-rate limiting. * **Replay** of public broadcasts is bounded by the 6-hour acceptance window plus deduplication; private payloads are protected by Noise nonces. * **Metadata.** BLE proximity is inherently observable; ephemeral IDs and daily-rotating courier tags limit long-term correlation. Nostr traffic can ride Tor. -* **No forward secrecy for sealed mail** (§5.2) is the main cryptographic trade-off of the offline path. +* **No forward secrecy for sealed mail or Nostr private envelopes** (§5.2–5.3) means compromise of a recipient's static key can expose retained ciphertext addressed to that key. ## 9. Future Work diff --git a/bitchat/App/PrivateConversationModels.swift b/bitchat/App/PrivateConversationModels.swift index d9920646..57c93afd 100644 --- a/bitchat/App/PrivateConversationModels.swift +++ b/bitchat/App/PrivateConversationModels.swift @@ -265,10 +265,10 @@ final class PrivateConversationModel: ObservableObject { let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID) let peer = chatViewModel.getPeer(byID: headerPeerID) let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer) - // Geo DMs are always routed over Nostr (NIP-17); their nostr_ keys - // never resolve to a reachable mesh peer, so resolveAvailability would - // report .offline. Report .nostrAvailable so the header shows the - // globe instead of a misleading "offline" tag. + // Geo DMs are always routed through BitChat private envelopes over + // Nostr; their nostr_ keys never resolve to a reachable mesh peer, so + // resolveAvailability would report .offline. Report .nostrAvailable + // so the header shows the globe instead of a misleading "offline" tag. let availability = conversationPeerID.isGeoDM ? .nostrAvailable : resolveAvailability(for: headerPeerID, peer: peer) diff --git a/bitchat/Nostr/NostrIdentity.swift b/bitchat/Nostr/NostrIdentity.swift index 7dedfd08..9d41ca54 100644 --- a/bitchat/Nostr/NostrIdentity.swift +++ b/bitchat/Nostr/NostrIdentity.swift @@ -1,7 +1,8 @@ import Foundation import P256K -/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging +/// Manages the secp256k1 identity used by BitChat's Nostr relay features, +/// including the proprietary private-envelope transport. struct NostrIdentity: Codable { let privateKey: Data let publicKey: Data diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index 46aa7c07..bfef85e2 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -7,16 +7,26 @@ import Security // Note: This file depends on Data extension from BinaryEncodingUtils.swift // Make sure BinaryEncodingUtils.swift is included in the target -/// NIP-17 Protocol Implementation for Private Direct Messages +/// BitChat's private-envelope protocol transported over Nostr relays. +/// +/// This construction is deliberately BitChat-specific and is **not** NIP-17, +/// NIP-44, or NIP-59 compatible, even though it historically reuses those +/// NIPs' kind numbers (1059/13/14) and a `v2:` content prefix. It uses Nostr +/// events and secp256k1 identities, but the XChaCha20-Poly1305 payload layout +/// and key derivation are proprietary and interoperate only with BitChat +/// clients. struct NostrProtocol { - + /// Nostr event kinds enum EventKind: Int { case metadata = 0 case textNote = 1 - case dm = 14 // NIP-17 DM rumor kind - case seal = 13 // NIP-17 sealed event - case giftWrap = 1059 // NIP-59 gift wrap + // BitChat's proprietary private-envelope layers. These reuse the + // NIP-17/NIP-59 kind numbers (14/13/1059) for historical reasons, but + // the encrypted payloads are BitChat-specific and not NIP-compatible. + case dm = 14 // unsigned inner message (inside ciphertext) + case seal = 13 // sender-signed seal (inside ciphertext) + case giftWrap = 1059 // public outer envelope (one-time key) case ephemeralEvent = 20000 case geohashPresence = 20001 case deletion = 5 // NIP-09 event deletion request @@ -25,29 +35,46 @@ struct NostrProtocol { /// its NIP-40 expiration — the whole point is store-and-forward. case courierDrop = 1401 } - - /// Create a NIP-17 private message + + /// Bound work before Base64-decoding either encrypted layer of an inbound + /// private envelope, and before parsing each decrypted nested JSON layer. + /// Real envelopes are normally a few KiB; 64 KiB leaves ample headroom + /// without letting an addressed relay event drive unbounded allocation. + static let maximumPrivateEnvelopeCiphertextBytes = 64 * 1024 + + /// Create a BitChat private envelope for relay transport (outer kind 1059). static func createPrivateMessage( content: String, recipientPubkey: String, senderIdentity: NostrIdentity ) throws -> NostrEvent { - - // Creating private message - - // 1. Create the rumor (unsigned event) + try createPrivateMessage( + content: content, + recipientPubkey: recipientPubkey, + senderIdentity: senderIdentity, + messageTags: [] + ) + } + + private static func createPrivateMessage( + content: String, + recipientPubkey: String, + senderIdentity: NostrIdentity, + messageTags: [[String]] + ) throws -> NostrEvent { + // 1. Create the rumor (unsigned inner event) let rumor = NostrEvent( pubkey: senderIdentity.publicKeyHex, createdAt: Date(), - kind: .dm, // NIP-17: DM rumor kind 14 - tags: [], + kind: .dm, + tags: messageTags, content: content ) - + // 2. Seal the rumor (encrypt to recipient) and sign it with the SENDER'S - // real identity key. NIP-17 requires the seal be signed by the sender - // so the recipient can authenticate who sent the message; signing with - // a throwaway key leaves DMs forgeable/impersonatable. + // real identity key so the recipient can authenticate who sent the + // message; signing with a throwaway key leaves DMs + // forgeable/impersonatable. let senderKey = try senderIdentity.schnorrSigningKey() let sealedEvent = try createSeal( rumor: rumor, @@ -55,28 +82,39 @@ struct NostrProtocol { senderKey: senderKey ) - // 3. Gift wrap the sealed event with a throwaway ephemeral key (the wrap + // 3. Wrap the sealed event with a throwaway ephemeral key (the wrap // layer hides the sender's identity from relays; createGiftWrap mints // its own ephemeral key internally). let giftWrap = try createGiftWrap( seal: sealedEvent, recipientPubkey: recipientPubkey ) - - // Created gift wrap - + return giftWrap } - - /// Decrypt a received NIP-17 message - /// Returns the content, sender pubkey, and the actual message timestamp (not the randomized gift wrap timestamp) + + /// Decrypt a received BitChat private envelope. + /// Returns the content, sender pubkey, and the actual message timestamp (not the randomized outer timestamp) static func decryptPrivateMessage( giftWrap: NostrEvent, recipientIdentity: NostrIdentity ) throws -> (content: String, senderPubkey: String, timestamp: Int) { - - // Starting decryption - + + // 0. Validate the untrusted outer envelope before any decryption work. + // Every BitChat client (released iOS and current Android) publishes + // exactly one outer recipient `p` tag on a validly signed kind-1059 + // wrap; anything else is malformed or misbound. + guard giftWrap.content.utf8.count <= maximumPrivateEnvelopeCiphertextBytes else { + SecureLogger.error("❌ Rejecting DM: oversized outer envelope ciphertext", category: .session) + throw NostrError.invalidCiphertext + } + 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 { @@ -91,10 +129,13 @@ struct NostrProtocol { } // 2. Authenticate the seal. The seal MUST be signed by the sender's real - // identity key (NIP-17); without this check a DM is forgeable by anyone - // who knows the recipient's npub. Verify the seal's own signature. - guard seal.isValidSignature() else { - SecureLogger.error("❌ Rejecting DM: seal signature is missing or invalid", category: .session) + // 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 } @@ -111,11 +152,16 @@ struct NostrProtocol { throw error } - // 4. The sender claimed inside the rumor must match the key that actually - // signed the seal, otherwise the sender field is unauthenticated and - // spoofable. - guard seal.pubkey == rumor.pubkey else { - SecureLogger.error("❌ Rejecting DM: rumor pubkey does not match seal signer", category: .session) + // 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 } @@ -123,6 +169,17 @@ struct NostrProtocol { 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 static func createPrivateMessageWithInvalidSealSignatureForTesting( content: String, @@ -165,6 +222,23 @@ struct NostrProtocol { ) return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey) } + + /// Reproduces historical wire shapes (current Android places exactly one + /// recipient `p` tag on the unsigned inner event) without making the + /// production encoder depend on that quirk. + static func createPrivateMessageWithInnerTagsForTesting( + content: String, + recipientPubkey: String, + senderIdentity: NostrIdentity, + innerMessageTags: [[String]] + ) throws -> NostrEvent { + try createPrivateMessage( + content: content, + recipientPubkey: recipientPubkey, + senderIdentity: senderIdentity, + messageTags: innerMessageTags + ) + } #endif /// Create a geohash-scoped ephemeral public message (kind 20000) @@ -468,14 +542,19 @@ struct NostrProtocol { 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 + } guard let data = decrypted.data(using: .utf8), let sealDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw NostrError.invalidEvent } - + let seal = try NostrEvent(from: sealDict) // Unwrapped seal - + return seal } @@ -490,16 +569,23 @@ struct NostrProtocol { recipientKey: recipientKey ) + guard decrypted.utf8.count <= maximumPrivateEnvelopeCiphertextBytes else { + throw NostrError.invalidCiphertext + } guard let data = decrypted.data(using: .utf8), let rumorDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw NostrError.invalidEvent } - + return try NostrEvent(from: rumorDict) } - - // MARK: - Encryption (NIP-44 v2) - + + // 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( plaintext: String, recipientPubkey: String, @@ -510,22 +596,25 @@ struct NostrProtocol { throw NostrError.invalidPublicKey } - // Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned) - // Derive shared secret let sharedSecret = try deriveSharedSecret( privateKey: senderKey, publicKey: recipientPubkeyData ) - // Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info) - let key = try deriveNIP44V2Key(from: sharedSecret) - + // Derive the BitChat private-envelope symmetric key (HKDF-SHA256) + let key = try derivePrivateEnvelopeKey(from: sharedSecret) + // 24-byte random nonce for XChaCha20-Poly1305 var nonce24 = Data(count: 24) - _ = nonce24.withUnsafeMutableBytes { ptr in + let randomStatus = nonce24.withUnsafeMutableBytes { ptr in 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 { + throw NostrError.cryptographicFailure + } + let pt = Data(plaintext.utf8) let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24) @@ -542,8 +631,12 @@ struct NostrProtocol { senderPubkey: String, recipientKey: P256K.Schnorr.PrivateKey ) throws -> String { - // Expect NIP-44 v2 format - guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext } + // Expect BitChat's historical `v2:` private-envelope framing, and + // bound work before Base64 decoding attacker-sized input. + guard ciphertext.utf8.count <= maximumPrivateEnvelopeCiphertextBytes, + ciphertext.hasPrefix("v2:") else { + throw NostrError.invalidCiphertext + } let encoded = String(ciphertext.dropFirst(3)) guard let data = Base64URLCoding.decode(encoded), data.count > (24 + 16), @@ -559,7 +652,7 @@ struct NostrProtocol { // Try decryption with even-Y then odd-Y when sender pubkey is x-only func attemptDecrypt(using pubKeyData: Data) throws -> Data { let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData) - let key = try deriveNIP44V2Key(from: ss) + let key = try derivePrivateEnvelopeKey(from: ss) return try XChaCha20Poly1305Compat.open( ciphertext: Data(ct), tag: Data(tag), @@ -569,18 +662,25 @@ struct NostrProtocol { } // If 32 bytes (x-only) try both parities, otherwise single try + let plaintext: Data if senderPubkeyData.count == 32 { let even = Data([0x02]) + senderPubkeyData if let pt = try? attemptDecrypt(using: even) { - return String(data: pt, encoding: .utf8) ?? "" + plaintext = pt + } else { + let odd = Data([0x03]) + senderPubkeyData + plaintext = try attemptDecrypt(using: odd) } - let odd = Data([0x03]) + senderPubkeyData - let pt = try attemptDecrypt(using: odd) - return String(data: pt, encoding: .utf8) ?? "" } else { - let pt = try attemptDecrypt(using: senderPubkeyData) - return String(data: pt, encoding: .utf8) ?? "" + plaintext = try attemptDecrypt(using: senderPubkeyData) } + + // Authenticated plaintext that is not valid UTF-8 is a malformed + // envelope, not an empty message. + guard let decoded = String(data: plaintext, encoding: .utf8) else { + throw NostrError.invalidCiphertext + } + return decoded } private static func deriveSharedSecret( @@ -640,7 +740,8 @@ struct NostrProtocol { let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) } // ECDH shared secret derived - // Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key + // Return raw ECDH shared secret; HKDF is applied by + // derivePrivateEnvelopeKey return sharedSecretData } @@ -794,12 +895,17 @@ enum NostrError: Error { case invalidPublicKey case invalidEvent case invalidCiphertext + case cryptographicFailure } -// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305) +// MARK: - BitChat private-envelope key derivation private extension NostrProtocol { - static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data { + /// The HKDF info string retains the historical "nip44-v2" label for wire + /// compatibility with deployed clients, but this is not the NIP-44 key + /// schedule: NIP-44 derives a conversation key via HKDF-extract with that + /// label as the *salt* and uses ChaCha20 with per-message expanded keys. + static func derivePrivateEnvelopeKey(from sharedSecretData: Data) throws -> Data { let derivedKey = HKDF.deriveKey( inputKeyMaterial: SymmetricKey(data: sharedSecretData), salt: Data(), diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index eaa04bc6..c1485819 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -227,7 +227,7 @@ final class NostrRelayManager: ObservableObject { private var messageQueue: [PendingSend] = [] private let messageQueueLock = NSLock() /// Non-queued sends whose callers require relay durability. A WebSocket - /// write only proves bytes left this process; NIP-20 OK is the relay's + /// write only proves bytes left this process; NIP-01 `OK` is the relay's /// accept/reject acknowledgment. private struct ConfirmedSendState { let token: UUID @@ -535,8 +535,8 @@ final class NostrRelayManager: ObservableObject { } /// Attempts an event only on currently connected target relays and - /// reports whether at least one relay explicitly accepted it via NIP-20 - /// OK. A successful WebSocket write alone is not durable acceptance. + /// reports whether at least one relay explicitly accepted it via NIP-01 + /// `OK`. A successful WebSocket write alone is not durable acceptance. /// Unlike `sendEvent`, this never enters the process-local pending queue; /// callers use it when success unlocks durable state or user-visible /// delivery progress. diff --git a/bitchat/Services/Courier/CourierStore.swift b/bitchat/Services/Courier/CourierStore.swift index 77c7d100..565eef17 100644 --- a/bitchat/Services/Courier/CourierStore.swift +++ b/bitchat/Services/Courier/CourierStore.swift @@ -322,7 +322,7 @@ final class CourierStore { /// Envelopes eligible to park on relays as bridge courier drops. Merely /// offering one does not start its cooldown: the caller commits that only - /// after a relay explicitly accepts the event via NIP-20 OK. + /// after a relay explicitly accepts the event via NIP-01 `OK`. func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] { let date = now() return queue.sync { diff --git a/bitchat/Services/Gateway/BridgeCourierService.swift b/bitchat/Services/Gateway/BridgeCourierService.swift index f1f72c31..552255a8 100644 --- a/bitchat/Services/Gateway/BridgeCourierService.swift +++ b/bitchat/Services/Gateway/BridgeCourierService.swift @@ -67,7 +67,7 @@ final class BridgeCourierService: ObservableObject { var relaysConnected: (@MainActor () -> Bool)? /// Publishes a signed drop event directly to connected default (DM) /// relays. Completion is true only after at least one relay explicitly - /// accepts the event via NIP-20 OK; this must never mean "queued in RAM" + /// accepts the event via NIP-01 `OK`; this must never mean "queued in RAM" /// or merely "written to a socket". var publishEvent: (@MainActor (NostrEvent, @escaping @MainActor (Bool) -> Void) -> Void)? /// (Re)opens the drop subscription for the given hex tags. @@ -129,7 +129,7 @@ final class BridgeCourierService: ObservableObject { /// a newer attempt for the same message. private var activeDropOperations: [String: ActiveDropOperation] = [:] /// Held-envelope publishes have no sender message ID, but still need an - /// in-flight identity: repeated refreshes inside the NIP-20 wait window + /// in-flight identity: repeated refreshes inside the relay-OK wait window /// must not mint duplicate relay events for the same opaque envelope. private var heldDropOperations: [Data: UUID] = [:] /// Deterministically invalid envelopes are suppressed for this process, @@ -275,7 +275,7 @@ final class BridgeCourierService: ObservableObject { /// Publishes a drop, or queues it when relays are down. `messageID` is the /// sender-side dedup key (nil for held/relayed envelopes we don't track); /// it rides the pending queue so an evicted or failed drop can release its - /// in-flight slot. Completion reports actual NIP-20 relay acceptance. + /// in-flight slot. Completion reports actual NIP-01 relay acceptance. private func publishDrop( _ envelope: CourierEnvelope, messageID: String? = nil, diff --git a/bitchat/Services/MessageDeduplicationService.swift b/bitchat/Services/MessageDeduplicationService.swift index 66d7e57b..c6773461 100644 --- a/bitchat/Services/MessageDeduplicationService.swift +++ b/bitchat/Services/MessageDeduplicationService.swift @@ -172,8 +172,8 @@ final class MessageDeduplicationService { /// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format) private let nostrAckCache: LRUDeduplicationCache - /// Optional cross-launch persistence for the Nostr event cache. NIP-59 - /// randomizes gift-wrap timestamps, so DM subscriptions look back 24h and + /// Optional cross-launch persistence for the Nostr event cache. BitChat + /// randomizes private-envelope timestamps, so DM subscriptions look back 24h and /// relays redeliver the same events on every launch; without this record /// each relaunch reprocesses old PMs and acks. Nil (tests, macOS callers /// that don't opt in) keeps the cache purely in-memory. @@ -314,7 +314,7 @@ final class MessageDeduplicationService { // MARK: - Clear /// Clears all caches. This is the wipe/panic path: the persisted - /// gift-wrap record goes with everything else. + /// private-envelope record goes with everything else. func clearAll() { contentCache.clear() nostrEventCache.clear() @@ -325,7 +325,7 @@ final class MessageDeduplicationService { /// Clears only the in-memory Nostr caches (events and ACKs). Runs on /// every geohash channel switch, so the disk record deliberately - /// survives — wiping it here would forfeit cross-launch gift-wrap dedup + /// survives — wiping it here would forfeit cross-launch private-envelope dedup /// each time the user changes channels (flagged by Codex on #1398). func clearNostrCaches() { nostrEventCache.clear() diff --git a/bitchat/Services/NostrProcessedEventStore.swift b/bitchat/Services/NostrProcessedEventStore.swift index 167d29d9..ee70f0e7 100644 --- a/bitchat/Services/NostrProcessedEventStore.swift +++ b/bitchat/Services/NostrProcessedEventStore.swift @@ -9,8 +9,9 @@ import BitLogger import Foundation -/// Disk persistence for processed gift-wrap event IDs. NIP-59 randomizes -/// gift-wrap timestamps, so DM subscriptions must look back generously (24h) +/// Disk persistence for processed private-envelope event IDs. BitChat +/// randomizes envelope timestamps, so DM subscriptions must look back +/// generously (24h) /// and relays redeliver the same events on every launch — without a /// cross-launch record, each relaunch reprocesses old PMs and acks /// (re-sent DELIVERED bursts, "delivered ack for unknown mid" noise). diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index c6b6ba3e..7b65a758 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -252,7 +252,7 @@ enum TransportConfig { // Fallback deadline for treating a subscription's initial fetch as complete // when a relay never sends EOSE (generous to cover Tor circuit setup). static let nostrSubscriptionEOSEFallbackSeconds: TimeInterval = 10.0 - // A bridge drop is durable only after NIP-20 OK. Relays that omit OK must + // A bridge drop is durable only after NIP-01 `OK`. Relays that omit OK must // not pin the router's in-flight state indefinitely. static let nostrConfirmedSendAckTimeoutSeconds: TimeInterval = 10.0 // After this long, a relay marked permanently failed gets another chance. diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index b0e9507d..6eaa12ad 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -504,7 +504,8 @@ private struct ContentPrivateChatSheetView: View { if privateConversationModel.selectedPeerID?.isGroup == true { return String(localized: "content.private.caption_group", comment: "Caption above the group chat composer noting messages are encrypted to group members") } - // Geohash DMs are NIP-17 gift-wrapped — always end-to-end encrypted, + // Geohash DMs use BitChat's private-envelope encryption over Nostr — + // always end-to-end encrypted, // even though they carry no Noise session status. Mesh DMs earn the // "encrypted" claim only once the Noise handshake has secured. let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true diff --git a/bitchatTests/Nostr/Fixtures/AndroidLegacyPrivateEnvelopeB7f0b33d.json b/bitchatTests/Nostr/Fixtures/AndroidLegacyPrivateEnvelopeB7f0b33d.json new file mode 100644 index 00000000..692739cf --- /dev/null +++ b/bitchatTests/Nostr/Fixtures/AndroidLegacyPrivateEnvelopeB7f0b33d.json @@ -0,0 +1 @@ +{"id":"8f4ac4680de5f972a586267bc7b6b5102ba548a34f618bdee47edd08929ee1e8","pubkey":"6981231b5745520fd982f66f485fa1b42f8f91ad25ab32242d4afb839d696b0a","created_at":1783727308,"kind":1059,"tags":[["p","c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"]],"content":"v2:ETvtGOrkDQ2JIiiMFMdxBlrYNLh07-QKmLds0xsjkgs-v54CP4E4uoKl1L_VvKvaREg-EviPQmksd3DOYHrBVAd5E6xuRn1o9vkoss4MYV7I71mu4RfADRt77ohZaNbc-KhrmgyRTgE-WnEsqErax8LUN6GUukjkKncVA9MAmC1WUB1MI1AR8c4BQ0IOc_ubrEqi640a8AeBZaCZOYutCb3ttqNSPBxR63XErE761KNg1uiq5pgBVo8iKvLO-N2ei7IWvQhmDTapBaEU7LexeIdHDEwMdXDoAtr5Nmd54H1VN12tBvk1wXvHnENgZCOLOR5J2E1eSwrFXXBto-ohrNpBaLKIXBTPGEoepa7x0gC0Vrh1OTf4tCI7JJ5UWnkUAQFGUgPGlTRYC8MdESgEthqmdgKT3Jc0N6sylTmv6zSVx5dXqO4fvSLHC6_7it8F_V_8-uAxUYstJz65oK4F9CwOEVglUuUdfn2mN_3cMBzASLOlKvL8jbkwwo5aMBVrShGiEwDix02hfGMNMKf7OKsLlfNiBAVSPh6MQSvAWhxbDCDnWN-yHKWF4TYxbnH70X2KGl_ZD9pXTphKVIpFuRmP7UNM-01oG53NKcv9puBSxAkLbTZd122uFL_zQebxid1ukOXT8WgSB_WYJYoAlQekeu4ITryB4I60vAAzMAa4AppCUNnf6T8jwWxuC-8G0TPf18MTxpeNkzcSpPVyVE0jtLaOwsJDXs_Pg82Id03Qc-b_fWMm9V07UzGmEnMqQ1gBMLmEXHb_4Ebg5X7TdzuXy87O36CJzau7Dm5ZfoalryF-16Z4MxzQOyXb1G61yFthRsGCT6sYi-68YkhPScMf7u_BobtMuGWiMiWoBqN_IrQ_ecMHVfaeEYvpCz5NYlrE26iAktNmzCBUDNcIr6P_nHdb6I3Q1rOOmWwEF7jsLbvnU_w_82_nXE_yfdGsoly24A2wB0L0SpdnyyEWgvKWjjfS3J3vkIVW4_iM0FT0jNelANc2X_ryb3EPTmGenlqm_qRGh86PYk_R07hKYu3ULNEgLzDTijeZ9-bP23tsMXUDdJS2VR7LP5063ygVuSC0J-GL1FmQ2c-DmJaWQSeqp0NN3sCND3pSIRzwQRwChnMNjVB65mJj","sig":"c53f339b19b9de021765588b0ee4f5f1e0c2423a34d35fe2cbcc6ce89e12e2fedbdf88a0b6a9d719c2c8580002c7574f1ace93aedeab1e255b6231d4ebb6f9b2"} diff --git a/bitchatTests/Nostr/Fixtures/AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch.txt b/bitchatTests/Nostr/Fixtures/AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch.txt new file mode 100644 index 00000000..36f57ee7 --- /dev/null +++ b/bitchatTests/Nostr/Fixtures/AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch.txt @@ -0,0 +1,29 @@ +diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt +index a5bd956..544a29b 100644 +--- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt ++++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt +@@ -10,0 +11,21 @@ class NostrProtocolTest { ++ @Test ++ fun emitCrossPlatformFixtures() { ++ val sender = NostrIdentity.fromPrivateKey( ++ "0000000000000000000000000000000000000000000000000000000000000001" ++ ) ++ val recipient = NostrIdentity.fromPrivateKey( ++ "0000000000000000000000000000000000000000000000000000000000000002" ++ ) ++ val giftWrap = NostrProtocol.createPrivateMessage( ++ content = "legacy fixture from Android b7f0b33d", ++ recipientPubkey = recipient.publicKeyHex, ++ senderIdentity = sender ++ ).single() ++ ++ assertEquals(NostrKind.GIFT_WRAP, giftWrap.kind) ++ assertEquals(listOf(listOf("p", recipient.publicKeyHex)), giftWrap.tags) ++ println("BITCHAT_FIXTURE_EVENT=${gson.toJson(giftWrap)}") ++ println("BITCHAT_FIXTURE_RECIPIENT_PRIVATE_KEY=${recipient.privateKeyHex}") ++ println("BITCHAT_FIXTURE_SENDER_PUBLIC_KEY=${sender.publicKeyHex}") ++ } ++ + @Test + fun decryptPrivateMessage_acceptsAuthenticatedSeal() { + val sender = NostrIdentity.generate() diff --git a/bitchatTests/Nostr/Fixtures/AndroidLegacyPrivateEnvelopeB7f0b33dMetadata.json b/bitchatTests/Nostr/Fixtures/AndroidLegacyPrivateEnvelopeB7f0b33dMetadata.json new file mode 100644 index 00000000..7538f946 --- /dev/null +++ b/bitchatTests/Nostr/Fixtures/AndroidLegacyPrivateEnvelopeB7f0b33dMetadata.json @@ -0,0 +1,10 @@ +{ + "android_commit": "b7f0b33d3a267c770d3d5a65ee2d8c7e755450db", + "generator": "NostrProtocol.createPrivateMessage", + "generator_patch": "AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch.txt", + "generator_patch_sha256": "e7ad29d6a247638cc16fb2ec06937266e6a03e7a115ae00913330a86a88fa56a", + "gradle_test": "ANDROID_HOME=/opt/homebrew/share/android-commandlinetools ANDROID_SDK_ROOT=/opt/homebrew/share/android-commandlinetools JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home ./gradlew testDebugUnitTest --tests com.bitchat.android.nostr.NostrProtocolTest.emitCrossPlatformFixtures --info", + "fixture_sha256": "d2df3d0b7ffd84c5cb25e0b3f4a89ad14f72a105bddadab77081f98c661b080e", + "recipient_private_key": "0000000000000000000000000000000000000000000000000000000000000002", + "sender_public_key": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" +} diff --git a/bitchatTests/Nostr/Fixtures/LegacyPrivateEnvelope733098bb.json b/bitchatTests/Nostr/Fixtures/LegacyPrivateEnvelope733098bb.json new file mode 100644 index 00000000..21dc126a --- /dev/null +++ b/bitchatTests/Nostr/Fixtures/LegacyPrivateEnvelope733098bb.json @@ -0,0 +1 @@ +{"content":"v2:SorfnTaoQ_Rv0XLKA8b3FZojgaFvnWgx66JR6Gj20ztnorQyL-hUYBZwAa5ohFC6ioR9hlJARJm0cgNTvWgSZuNTcfSAvoMdSG6mXK73kzsp99x351zUB_lc1_5ZXm0qqePAEz3Dl5jj5EsfAb8ZzsCd-BZENCsTwqjqC_hCFl7RitlRfIL2Tq_n4TUFEFandDCplNz3W4L7V07V89aGEoUlhzcwPU44BcxfvQjeqVKq0IRf2AetypoOduPrjSqkv674ubWaRcHtw3Asrqgo5XSYbpOlSk1PF_TttrQSPZGViNe5MuiK2P-7d-XqKXuDf_bUgAzW884KXogbct-wtIJZJbVM-utMd-dHrpC1mY81lgpS4_kPuhj0Z6Ro9hU5nCcEk2K4_vNoSM9m9QbcXP34h47qSPsw9ikmz8UoD00-1fXQVB4YJcBVUSVI06IzbZEWulo8SPXvQ4pJjV3nwPgYqRgwnrNWMNfeuKojlF8yA17UNOWvD2U7r1cs84HL8dxztzX9NdN0DGxvjMILvt3D4eWrMbcSrsIkBgyvV-uskEPd4eX3fc9GmX8MPkMgxRErcxbuq6JBUNXikOJXNH_qspOt4UIw5dCIajrHsKycKd8A_3rSgLEQteirOWMGaD2gOJEzpbe4iT72dmPkvRq7k-wDjLbO5dOSuUvpFM-ipkB07ATJz_1uUcRpl8fD_oSlcdAzdPjGKG5Y-tNv3AcUstkqCi52E5x-aO8EDUNBFi_OCbD86nkTUv1jOpAQwejowSiiOnCZ91zUEg5pRQyhBV0Ozib-j0Wf8S8VAz9M8bqQQaKedPCEowDn6csOKbFdBtqSeROf1XWKCkm1mSJGHWW9V44MiMiHebVrRUpbP0PqvxiIeJE0","created_at":1783710443,"id":"46425f8d4e8007af43abb67b3668b59604bd34c86dee1b1c3702559086927cb9","kind":1059,"pubkey":"960e391e314a7fb00bbdd85eccb0a93c17e981b6fed38487cf891f1ed6b66aeb","sig":"975c88c1c4d11f0b623603f8ecc7c181fea7385e8feacdb4a64a6b4f7536b1337ff556aee165ca337c9ec501cfab3e9703026c6df2b12bb8c702378875f00722","tags":[["p","1c108500bf53da288d30530718bac4bf80d661d1fe38854060c5b5c79eb77755"]]} diff --git a/bitchatTests/Nostr/Fixtures/LegacyPrivateEnvelope733098bbRecipientKey.json b/bitchatTests/Nostr/Fixtures/LegacyPrivateEnvelope733098bbRecipientKey.json new file mode 100644 index 00000000..2b57bf90 --- /dev/null +++ b/bitchatTests/Nostr/Fixtures/LegacyPrivateEnvelope733098bbRecipientKey.json @@ -0,0 +1 @@ +{"recipient_private_key":"8355a5c110cdfef2e644f4ad5d51c39f253b2c2c80ebb6856379fb16531dc1fa"} diff --git a/bitchatTests/NostrProtocolTests.swift b/bitchatTests/NostrProtocolTests.swift index da24b4ec..4a8d33e7 100644 --- a/bitchatTests/NostrProtocolTests.swift +++ b/bitchatTests/NostrProtocolTests.swift @@ -2,11 +2,10 @@ // NostrProtocolTests.swift // bitchatTests // -// Tests for NIP-17 gift-wrapped private messages +// Tests for BitChat's proprietary private-envelope transport over Nostr. // import Testing -import CryptoKit import Foundation import BitFoundation @testable import bitchat @@ -102,24 +101,202 @@ struct NostrProtocolTests { senderIdentity: sender ) - // Try to decrypt with wrong recipient - if #available(macOS 14.4, iOS 17.4, *) { - #expect(throws: CryptoKitError.authenticationFailure) { - try NostrProtocol.decryptPrivateMessage( + // 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 { + let sender = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + + // Current Android's `createPrivateMessage` emits an unsigned kind-14 + // inner event with exactly [["p", recipient]], while 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: wrongRecipient - ) - } - } else { - #expect(throws: (any Error).self) { - try NostrProtocol.decryptPrivateMessage( - giftWrap: giftWrap, - recipientIdentity: wrongRecipient + 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]]) + + // The Android inner event carries exactly the recipient `p` tag, so a + // successful decrypt also proves the Android inner-tag acceptance. + let result = try NostrProtocol.decryptPrivateMessage( + giftWrap: envelope, + recipientIdentity: recipient + ) + #expect(result.content == "legacy fixture from Android b7f0b33d") + #expect(result.senderPubkey == metadata.senderPublicKey) + } + + @Test func decryptsFrozenLegacyEnvelopeProducedByRelease733098bb() throws { + let eventData = try Data(contentsOf: fixtureURL( + name: "LegacyPrivateEnvelope733098bb" + )) + let keyData = try Data(contentsOf: fixtureURL( + name: "LegacyPrivateEnvelope733098bbRecipientKey" + )) + let envelope = try JSONDecoder().decode(NostrEvent.self, from: eventData) + let keyFixture = try JSONDecoder().decode(LegacyRecipientKeyFixture.self, from: keyData) + let recipientKey = try #require(Data(hexString: keyFixture.recipientPrivateKey)) + let recipient = try NostrIdentity(privateKeyData: recipientKey) + + #expect(envelope.isValidSignature()) + let result = try NostrProtocol.decryptPrivateMessage( + giftWrap: envelope, + recipientIdentity: recipient + ) + #expect(result.content == "legacy fixture from 733098bb") + #expect(result.senderPubkey == "2e3d79df7047204f02b726c574e256f8de1dd80510f7dcb8b0d12df13acb87e6") + } + + @Test func decryptRejectsOversizedCiphertextBeforeDecoding() throws { + let recipient = try NostrIdentity.generate() + let wrapper = try NostrIdentity.generate() + let oversizedContent = "v2:" + + String( + repeating: "A", + count: NostrProtocol.maximumPrivateEnvelopeCiphertextBytes + ) + let event = NostrEvent( + pubkey: wrapper.publicKeyHex, + createdAt: Date(), + kind: .giftWrap, + tags: [["p", recipient.publicKeyHex]], + content: oversizedContent + ) + let signed = try event.sign(with: wrapper.schnorrSigningKey()) + + expectInvalidCiphertext { + _ = try NostrProtocol.decryptPrivateMessage( + giftWrap: signed, + recipientIdentity: recipient + ) + } + } + + @Test func decryptDoesNotMisinterpretStandardNIP44Payload() throws { + let recipient = try NostrIdentity.generate() + let wrapper = try NostrIdentity.generate() + // A valid NIP-44 v2 payload from the official test vectors. Its wire + // format starts with a version byte in standard Base64, not BitChat's + // historical `v2:` prefix. + let standardPayload = "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb" + let event = NostrEvent( + pubkey: wrapper.publicKeyHex, + createdAt: Date(), + kind: .giftWrap, + tags: [["p", recipient.publicKeyHex]], + content: standardPayload + ) + let signed = try event.sign(with: wrapper.schnorrSigningKey()) + + expectInvalidCiphertext { + _ = try NostrProtocol.decryptPrivateMessage( + giftWrap: signed, + recipientIdentity: recipient + ) + } + } + + @Test func decryptRejectsWrongOuterKind() throws { + let sender = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + var giftWrap = try NostrProtocol.createPrivateMessage( + content: "wrong outer kind", + recipientPubkey: recipient.publicKeyHex, + senderIdentity: sender + ) + giftWrap = NostrEvent( + pubkey: giftWrap.pubkey, + createdAt: Date(timeIntervalSince1970: TimeInterval(giftWrap.created_at)), + kind: .textNote, + tags: giftWrap.tags, + content: giftWrap.content + ) + + expectInvalidEvent { + _ = try NostrProtocol.decryptPrivateMessage( + giftWrap: giftWrap, + recipientIdentity: recipient + ) + } + } + @Test func decryptRejectsInvalidSealSignature() throws { let sender = try NostrIdentity.generate() let recipient = try NostrIdentity.generate() @@ -349,6 +526,48 @@ struct NostrProtocolTests { ] } + private struct LegacyRecipientKeyFixture: Decodable { + let recipientPrivateKey: String + + enum CodingKeys: String, CodingKey { + case recipientPrivateKey = "recipient_private_key" + } + } + + private struct AndroidLegacyEnvelopeFixture: Decodable { + let androidCommit: String + let generator: String + let generatorPatch: String + let generatorPatchSHA256: String + let gradleTest: String + let fixtureSHA256: String + let recipientPrivateKey: String + let senderPublicKey: String + + enum CodingKeys: String, CodingKey { + case androidCommit = "android_commit" + case generator + case generatorPatch = "generator_patch" + case generatorPatchSHA256 = "generator_patch_sha256" + case gradleTest = "gradle_test" + case fixtureSHA256 = "fixture_sha256" + case recipientPrivateKey = "recipient_private_key" + case senderPublicKey = "sender_public_key" + } + } + + private func fixtureURL(name: String, extension fileExtension: String = "json") throws -> URL { + // 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 + let bundle = Bundle.module + #else + let bundle = Bundle(for: MockKeychain.self) + #endif + return try #require(bundle.url(forResource: name, withExtension: fileExtension)) + } + private static func base64URLDecode(_ s: String) -> Data? { var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") let rem = str.count % 4 @@ -366,4 +585,15 @@ struct NostrProtocolTests { Issue.record("Expected NostrError.invalidEvent, got \(error)") } } + + private func expectInvalidCiphertext(_ operation: () throws -> Void) { + do { + try operation() + Issue.record("Expected NostrError.invalidCiphertext") + } catch NostrError.invalidCiphertext { + return + } catch { + Issue.record("Expected NostrError.invalidCiphertext, got \(error)") + } + } } diff --git a/docs/privacy-assessment.md b/docs/privacy-assessment.md index 84b969da..561bfb18 100644 --- a/docs/privacy-assessment.md +++ b/docs/privacy-assessment.md @@ -54,9 +54,9 @@ Public archives contain content already intended for public mesh/board distribut ## Nostr and Mesh Bridge -- NIP-17/NIP-44 v2 private fallback protects plaintext with secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305. Relays still see event and network metadata. +- BitChat's proprietary private-envelope fallback protects plaintext with secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305. It is not NIP-17, NIP-44, or NIP-59 compatible and does not provide forward secrecy against later compromise of the recipient's static Nostr private key. Relays still see the recipient public-key tag, event timing and size, and network metadata. - Bridge courier drops use a throwaway publisher key, an opaque Noise-sealed envelope, and a day-rotating recipient tag. Only a party already holding the recipient's Noise static key can compute candidate tags. -- Relay publication is considered successful only after an explicit NIP-20 `OK true` from at least one target relay. Rejected, disconnected, timed-out, or merely socket-written events stay retryable. +- Relay publication is considered successful only after an explicit NIP-01 `OK true` from at least one target relay. Rejected, disconnected, timed-out, or merely socket-written events stay retryable. - When mesh bridge is enabled, public mesh messages not marked “nearby only” are signed under a per-cell Nostr identity and published to a neighborhood rendezvous geohash. Presence and public bridge traffic therefore expose a coarse area to relays and participants. - A bridge gateway can carry signed bridge/location events and opaque courier drops for nearby mesh-only peers. It cannot validly publish a neighbor's radio-only message because the author must first sign the bridge event.