mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 18:25:21 +00:00
Relabel private Nostr envelopes honestly in docs; harden legacy envelope validation (#1480)
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.
This commit is contained in:
+2
-2
@@ -73,7 +73,7 @@ Private group members receive the group's name, roster, key epoch, and encrypted
|
|||||||
|
|
||||||
Internet-backed features are optional. When enabled or used:
|
Internet-backed features are optional. When enabled or used:
|
||||||
|
|
||||||
- Private fallback messages use encrypted NIP-17 gift wraps. Relays can observe event and network metadata but not the message plaintext.
|
- Private fallback messages use BitChat's app-specific encrypted envelopes. This format is not NIP-17, NIP-44, or NIP-59 compatible. Relays can observe the recipient public-key tag, event timing and size, and network metadata, but not the message plaintext or stable sender identity.
|
||||||
- Public location-channel messages, notes, notices, and presence include a geohash tag, event kind, timestamp, and a public key. A geohash reveals an approximate area; finer precision reveals a smaller area.
|
- Public location-channel messages, notes, notices, and presence include a geohash tag, event kind, timestamp, and a public key. A geohash reveals an approximate area; finer precision reveals a smaller area.
|
||||||
- The optional mesh bridge publishes bridge-enabled public mesh messages and presence to a neighborhood rendezvous cell. Those messages are public to participants and relays for that cell. A per-message “nearby only” choice prevents that message from crossing the bridge.
|
- The optional mesh bridge publishes bridge-enabled public mesh messages and presence to a neighborhood rendezvous cell. Those messages are public to participants and relays for that cell. A per-message “nearby only” choice prevents that message from crossing the bridge.
|
||||||
- Bridge courier drops contain opaque end-to-end encrypted envelopes and a rotating recipient tag. Relays still observe timing and network metadata.
|
- Bridge courier drops contain opaque end-to-end encrypted envelopes and a rotating recipient tag. Relays still observe timing and network metadata.
|
||||||
@@ -103,7 +103,7 @@ Private and public features use different protections:
|
|||||||
|
|
||||||
- Mesh private sessions use Noise XX with X25519, ChaCha20-Poly1305, and SHA-256.
|
- Mesh private sessions use Noise XX with X25519, ChaCha20-Poly1305, and SHA-256.
|
||||||
- Private group messages use ChaCha20-Poly1305; group state and relevant mesh packets use Ed25519 signatures.
|
- Private group messages use ChaCha20-Poly1305; group state and relevant mesh packets use Ed25519 signatures.
|
||||||
- Nostr events use secp256k1 Schnorr signatures. NIP-44 v2 private payloads use secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305.
|
- Nostr events use secp256k1 Schnorr signatures. BitChat private envelopes use secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305. The envelope format is proprietary, only interoperates with BitChat clients, and does not provide forward secrecy against later compromise of the recipient's static Nostr private key.
|
||||||
- The persistent private-message outbox uses ChaCha20-Poly1305 with a key held in the keychain. Some other protected local identity state uses AES-GCM.
|
- The persistent private-message outbox uses ChaCha20-Poly1305 with a key held in the keychain. Some other protected local identity state uses AES-GCM.
|
||||||
- Public mesh, bridge, geohash, and board content is signed or authenticated as appropriate but is intentionally not confidential.
|
- Public mesh, bridge, geohash, and board content is signed or authenticated as appropriate but is intentionally not confidential.
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -63,7 +63,11 @@ let package = Package(
|
|||||||
// Only the vector fixture: declaring the whole "Noise"
|
// Only the vector fixture: declaring the whole "Noise"
|
||||||
// directory would claim its .swift test files as resources
|
// directory would claim its .swift test files as resources
|
||||||
// and silently drop them from compilation.
|
// and silently drop them from compilation.
|
||||||
.process("Noise/NoiseTestVectors.json")
|
.process("Noise/NoiseTestVectors.json"),
|
||||||
|
// Frozen envelopes produced by the released iOS (733098bb)
|
||||||
|
// and Android (b7f0b33d) private-DM implementations; prove
|
||||||
|
// receive compatibility independently of the local generator.
|
||||||
|
.process("Nostr/Fixtures")
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file
|
|||||||
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
|
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
|
||||||
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
|
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
|
||||||
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
|
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
|
||||||
- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, NIP-17 for Nostr
|
- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, BitChat private envelopes for Nostr fallback
|
||||||
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
|
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
|
||||||
- **Universal App**: Native support for iOS and macOS
|
- **Universal App**: Native support for iOS and macOS
|
||||||
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
||||||
@@ -44,9 +44,15 @@ BitChat uses a **hybrid messaging architecture** with two complementary transpor
|
|||||||
- **Global Reach**: Connect with users worldwide via internet relays
|
- **Global Reach**: Connect with users worldwide via internet relays
|
||||||
- **Location Channels**: Geographic chat rooms using geohash coordinates
|
- **Location Channels**: Geographic chat rooms using geohash coordinates
|
||||||
- **290+ Relay Network**: Distributed across the globe for reliability
|
- **290+ Relay Network**: Distributed across the globe for reliability
|
||||||
- **NIP-17 Encryption**: Gift-wrapped private messages for internet privacy
|
- **BitChat Private Envelopes**: App-specific encrypted private messages over Nostr relays
|
||||||
- **Ephemeral Keys**: Fresh cryptographic identity per geohash area
|
- **Ephemeral Keys**: Fresh cryptographic identity per geohash area
|
||||||
|
|
||||||
|
BitChat's private-envelope format is proprietary and is **not** NIP-17,
|
||||||
|
NIP-44, or NIP-59 compatible. It uses Nostr as a relay transport but only
|
||||||
|
interoperates with BitChat clients: private payloads travel inside kind-1059
|
||||||
|
events whose `v2:`-prefixed content is a BitChat-specific XChaCha20-Poly1305
|
||||||
|
construction, not NIP-44 encryption.
|
||||||
|
|
||||||
### Channel Types
|
### Channel Types
|
||||||
|
|
||||||
#### `mesh #bluetooth`
|
#### `mesh #bluetooth`
|
||||||
@@ -80,7 +86,7 @@ Private messages use **intelligent transport selection**:
|
|||||||
2. **Nostr Fallback** (when Bluetooth unavailable)
|
2. **Nostr Fallback** (when Bluetooth unavailable)
|
||||||
|
|
||||||
- Uses recipient's Nostr public key
|
- Uses recipient's Nostr public key
|
||||||
- NIP-17 gift-wrapping for privacy
|
- BitChat's app-specific private-envelope encryption
|
||||||
- Routes through global relay network
|
- Routes through global relay network
|
||||||
|
|
||||||
3. **Smart Queuing** (when neither available)
|
3. **Smart Queuing** (when neither available)
|
||||||
|
|||||||
+6
-4
@@ -25,7 +25,7 @@ bitchat is a decentralized, peer-to-peer messaging application for secure, priva
|
|||||||
Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`:
|
Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`:
|
||||||
|
|
||||||
* **BLE mesh** — every device is simultaneously a GATT central and peripheral, relaying packets in a controlled flood. No infrastructure, pairing, or accounts.
|
* **BLE mesh** — every device is simultaneously a GATT central and peripheral, relaying packets in a controlled flood. No infrastructure, pairing, or accounts.
|
||||||
* **Nostr** — private messages to mutual favorites travel as NIP-17 gift-wrapped events over public relays (over Tor where enabled), bridging separate meshes through the internet.
|
* **Nostr** — private messages to mutual favorites travel in BitChat's app-specific encrypted envelopes over public relays (over Tor where enabled), bridging separate meshes through the internet.
|
||||||
|
|
||||||
The router prefers a live mesh link, falls back to Nostr, and engages the courier system when neither can deliver promptly.
|
The router prefers a live mesh link, falls back to Nostr, and engages the courier system when neither can deliver promptly.
|
||||||
|
|
||||||
@@ -78,7 +78,9 @@ Courier envelopes are sealed to the recipient's *static* key with the one-way No
|
|||||||
|
|
||||||
### 5.3 Nostr Path
|
### 5.3 Nostr Path
|
||||||
|
|
||||||
Private messages to mutual favorites are wrapped per NIP-17/NIP-59: a rumor (kind 14) sealed (kind 13) and gift-wrapped (kind 1059) under a throwaway ephemeral key, so relays learn neither sender nor content.
|
Private messages to mutual favorites use BitChat's proprietary private-envelope protocol. An unsigned inner message (kind 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
|
## 6. Store and Forward
|
||||||
|
|
||||||
@@ -105,7 +107,7 @@ Public broadcast messages are cached (1000 packets) and reconciled between peers
|
|||||||
|
|
||||||
### 6.4 Nostr Mailboxes
|
### 6.4 Nostr Mailboxes
|
||||||
|
|
||||||
Gift-wrapped messages rest on Nostr relays; clients re-subscribe with a 24-hour lookback on reconnect, covering the both-devices-offline case for mutual favorites whenever either side touches the internet.
|
BitChat private envelopes rest on Nostr relays; clients re-subscribe 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
|
### 6.5 Delivery Metrics
|
||||||
|
|
||||||
@@ -127,7 +129,7 @@ Bare local counters (deposits, handovers, sprays, opens, outbox flushes and drop
|
|||||||
* **Flooding abuse** is bounded by TTL clamps, deduplication, per-depositor quotas, connect-rate limits, and announce-rate limiting.
|
* **Flooding abuse** is bounded by TTL clamps, deduplication, per-depositor quotas, connect-rate limits, and announce-rate limiting.
|
||||||
* **Replay** of public broadcasts is bounded by the 6-hour acceptance window plus deduplication; private payloads are protected by Noise nonces.
|
* **Replay** of public broadcasts is bounded by the 6-hour acceptance window plus deduplication; private payloads are protected by Noise nonces.
|
||||||
* **Metadata.** BLE proximity is inherently observable; ephemeral IDs and daily-rotating courier tags limit long-term correlation. Nostr traffic can ride Tor.
|
* **Metadata.** BLE proximity is inherently observable; ephemeral IDs and daily-rotating courier tags limit long-term correlation. Nostr traffic can ride Tor.
|
||||||
* **No forward secrecy for sealed mail** (§5.2) is the main cryptographic trade-off of the offline path.
|
* **No forward secrecy for sealed mail or Nostr 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
|
## 9. Future Work
|
||||||
|
|
||||||
|
|||||||
@@ -265,10 +265,10 @@ final class PrivateConversationModel: ObservableObject {
|
|||||||
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
||||||
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
||||||
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
||||||
// Geo DMs are always routed over Nostr (NIP-17); their nostr_ keys
|
// Geo DMs are always routed through BitChat private envelopes over
|
||||||
// never resolve to a reachable mesh peer, so resolveAvailability would
|
// Nostr; their nostr_ keys never resolve to a reachable mesh peer, so
|
||||||
// report .offline. Report .nostrAvailable so the header shows the
|
// resolveAvailability would report .offline. Report .nostrAvailable
|
||||||
// globe instead of a misleading "offline" tag.
|
// so the header shows the globe instead of a misleading "offline" tag.
|
||||||
let availability = conversationPeerID.isGeoDM
|
let availability = conversationPeerID.isGeoDM
|
||||||
? .nostrAvailable
|
? .nostrAvailable
|
||||||
: resolveAvailability(for: headerPeerID, peer: peer)
|
: resolveAvailability(for: headerPeerID, peer: peer)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import P256K
|
import P256K
|
||||||
|
|
||||||
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
|
/// Manages the secp256k1 identity used by BitChat's Nostr relay features,
|
||||||
|
/// including the proprietary private-envelope transport.
|
||||||
struct NostrIdentity: Codable {
|
struct NostrIdentity: Codable {
|
||||||
let privateKey: Data
|
let privateKey: Data
|
||||||
let publicKey: Data
|
let publicKey: Data
|
||||||
|
|||||||
@@ -7,16 +7,26 @@ import Security
|
|||||||
// Note: This file depends on Data extension from BinaryEncodingUtils.swift
|
// Note: This file depends on Data extension from BinaryEncodingUtils.swift
|
||||||
// Make sure BinaryEncodingUtils.swift is included in the target
|
// Make sure BinaryEncodingUtils.swift is included in the target
|
||||||
|
|
||||||
/// NIP-17 Protocol Implementation for Private Direct Messages
|
/// BitChat's private-envelope protocol transported over Nostr relays.
|
||||||
|
///
|
||||||
|
/// This 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 {
|
struct NostrProtocol {
|
||||||
|
|
||||||
/// Nostr event kinds
|
/// Nostr event kinds
|
||||||
enum EventKind: Int {
|
enum EventKind: Int {
|
||||||
case metadata = 0
|
case metadata = 0
|
||||||
case textNote = 1
|
case textNote = 1
|
||||||
case dm = 14 // NIP-17 DM rumor kind
|
// BitChat's proprietary private-envelope layers. These reuse the
|
||||||
case seal = 13 // NIP-17 sealed event
|
// NIP-17/NIP-59 kind numbers (14/13/1059) for historical reasons, but
|
||||||
case giftWrap = 1059 // NIP-59 gift wrap
|
// 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 ephemeralEvent = 20000
|
||||||
case geohashPresence = 20001
|
case geohashPresence = 20001
|
||||||
case deletion = 5 // NIP-09 event deletion request
|
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.
|
/// its NIP-40 expiration — the whole point is store-and-forward.
|
||||||
case courierDrop = 1401
|
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(
|
static func createPrivateMessage(
|
||||||
content: String,
|
content: String,
|
||||||
recipientPubkey: String,
|
recipientPubkey: String,
|
||||||
senderIdentity: NostrIdentity
|
senderIdentity: NostrIdentity
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
|
try createPrivateMessage(
|
||||||
// Creating private message
|
content: content,
|
||||||
|
recipientPubkey: recipientPubkey,
|
||||||
// 1. Create the rumor (unsigned event)
|
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(
|
let rumor = NostrEvent(
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
kind: .dm, // NIP-17: DM rumor kind 14
|
kind: .dm,
|
||||||
tags: [],
|
tags: messageTags,
|
||||||
content: content
|
content: content
|
||||||
)
|
)
|
||||||
|
|
||||||
// 2. Seal the rumor (encrypt to recipient) and sign it with the SENDER'S
|
// 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
|
// real identity key so the recipient can authenticate who sent the
|
||||||
// so the recipient can authenticate who sent the message; signing with
|
// message; signing with a throwaway key leaves DMs
|
||||||
// a throwaway key leaves DMs forgeable/impersonatable.
|
// forgeable/impersonatable.
|
||||||
let senderKey = try senderIdentity.schnorrSigningKey()
|
let senderKey = try senderIdentity.schnorrSigningKey()
|
||||||
let sealedEvent = try createSeal(
|
let sealedEvent = try createSeal(
|
||||||
rumor: rumor,
|
rumor: rumor,
|
||||||
@@ -55,28 +82,39 @@ struct NostrProtocol {
|
|||||||
senderKey: senderKey
|
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
|
// layer hides the sender's identity from relays; createGiftWrap mints
|
||||||
// its own ephemeral key internally).
|
// its own ephemeral key internally).
|
||||||
let giftWrap = try createGiftWrap(
|
let giftWrap = try createGiftWrap(
|
||||||
seal: sealedEvent,
|
seal: sealedEvent,
|
||||||
recipientPubkey: recipientPubkey
|
recipientPubkey: recipientPubkey
|
||||||
)
|
)
|
||||||
|
|
||||||
// Created gift wrap
|
|
||||||
|
|
||||||
return giftWrap
|
return giftWrap
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt a received NIP-17 message
|
/// Decrypt a received BitChat private envelope.
|
||||||
/// Returns the content, sender pubkey, and the actual message timestamp (not the randomized gift wrap timestamp)
|
/// Returns the content, sender pubkey, and the actual message timestamp (not the randomized outer timestamp)
|
||||||
static func decryptPrivateMessage(
|
static func decryptPrivateMessage(
|
||||||
giftWrap: NostrEvent,
|
giftWrap: NostrEvent,
|
||||||
recipientIdentity: NostrIdentity
|
recipientIdentity: NostrIdentity
|
||||||
) throws -> (content: String, senderPubkey: String, timestamp: Int) {
|
) 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
|
// 1. Unwrap the gift wrap
|
||||||
let seal: NostrEvent
|
let seal: NostrEvent
|
||||||
do {
|
do {
|
||||||
@@ -91,10 +129,13 @@ struct NostrProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Authenticate the seal. The seal MUST be signed by the sender's real
|
// 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
|
// identity key; without this check a DM is forgeable by anyone who
|
||||||
// who knows the recipient's npub. Verify the seal's own signature.
|
// knows the recipient's npub. Every BitChat sender emits a tagless
|
||||||
guard seal.isValidSignature() else {
|
// kind-13 seal, so bind the decrypted layer to that exact shape.
|
||||||
SecureLogger.error("❌ Rejecting DM: seal signature is missing or invalid", category: .session)
|
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
|
throw NostrError.invalidEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,11 +152,16 @@ struct NostrProtocol {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. The sender claimed inside the rumor must match the key that actually
|
// 4. The rumor is intentionally unsigned; sender authentication comes
|
||||||
// signed the seal, otherwise the sender field is unauthenticated and
|
// from the seal. The sender claimed inside the rumor must match the
|
||||||
// spoofable.
|
// key that actually signed the seal, otherwise the sender field is
|
||||||
guard seal.pubkey == rumor.pubkey else {
|
// unauthenticated and spoofable. Also bind the inner kind and tag
|
||||||
SecureLogger.error("❌ Rejecting DM: rumor pubkey does not match seal signer", category: .session)
|
// 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
|
throw NostrError.invalidEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +169,17 @@ struct NostrProtocol {
|
|||||||
return (content: rumor.content, senderPubkey: seal.pubkey, timestamp: rumor.created_at)
|
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 createPrivateMessageWithInvalidSealSignatureForTesting(
|
||||||
content: String,
|
content: String,
|
||||||
@@ -165,6 +222,23 @@ struct NostrProtocol {
|
|||||||
)
|
)
|
||||||
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
|
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
|
#endif
|
||||||
|
|
||||||
/// Create a geohash-scoped ephemeral public message (kind 20000)
|
/// Create a geohash-scoped ephemeral public message (kind 20000)
|
||||||
@@ -468,14 +542,19 @@ struct NostrProtocol {
|
|||||||
recipientKey: recipientKey
|
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),
|
guard let data = decrypted.data(using: .utf8),
|
||||||
let sealDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
let sealDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
throw NostrError.invalidEvent
|
throw NostrError.invalidEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
let seal = try NostrEvent(from: sealDict)
|
let seal = try NostrEvent(from: sealDict)
|
||||||
// Unwrapped seal
|
// Unwrapped seal
|
||||||
|
|
||||||
return seal
|
return seal
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,16 +569,23 @@ struct NostrProtocol {
|
|||||||
recipientKey: recipientKey
|
recipientKey: recipientKey
|
||||||
)
|
)
|
||||||
|
|
||||||
|
guard decrypted.utf8.count <= maximumPrivateEnvelopeCiphertextBytes else {
|
||||||
|
throw NostrError.invalidCiphertext
|
||||||
|
}
|
||||||
guard let data = decrypted.data(using: .utf8),
|
guard let data = decrypted.data(using: .utf8),
|
||||||
let rumorDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
let rumorDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
throw NostrError.invalidEvent
|
throw NostrError.invalidEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
return try NostrEvent(from: rumorDict)
|
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(
|
private static func encrypt(
|
||||||
plaintext: String,
|
plaintext: String,
|
||||||
recipientPubkey: String,
|
recipientPubkey: String,
|
||||||
@@ -510,22 +596,25 @@ struct NostrProtocol {
|
|||||||
throw NostrError.invalidPublicKey
|
throw NostrError.invalidPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
|
|
||||||
|
|
||||||
// Derive shared secret
|
// Derive shared secret
|
||||||
let sharedSecret = try deriveSharedSecret(
|
let sharedSecret = try deriveSharedSecret(
|
||||||
privateKey: senderKey,
|
privateKey: senderKey,
|
||||||
publicKey: recipientPubkeyData
|
publicKey: recipientPubkeyData
|
||||||
)
|
)
|
||||||
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info)
|
// Derive the BitChat private-envelope symmetric key (HKDF-SHA256)
|
||||||
let key = try deriveNIP44V2Key(from: sharedSecret)
|
let key = try derivePrivateEnvelopeKey(from: sharedSecret)
|
||||||
|
|
||||||
// 24-byte random nonce for XChaCha20-Poly1305
|
// 24-byte random nonce for XChaCha20-Poly1305
|
||||||
var nonce24 = Data(count: 24)
|
var nonce24 = Data(count: 24)
|
||||||
_ = nonce24.withUnsafeMutableBytes { ptr in
|
let randomStatus = nonce24.withUnsafeMutableBytes { ptr in
|
||||||
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
|
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
|
||||||
}
|
}
|
||||||
|
// 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 pt = Data(plaintext.utf8)
|
||||||
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
|
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
|
||||||
|
|
||||||
@@ -542,8 +631,12 @@ struct NostrProtocol {
|
|||||||
senderPubkey: String,
|
senderPubkey: String,
|
||||||
recipientKey: P256K.Schnorr.PrivateKey
|
recipientKey: P256K.Schnorr.PrivateKey
|
||||||
) throws -> String {
|
) throws -> String {
|
||||||
// Expect NIP-44 v2 format
|
// Expect BitChat's historical `v2:` private-envelope framing, and
|
||||||
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
|
// 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))
|
let encoded = String(ciphertext.dropFirst(3))
|
||||||
guard let data = Base64URLCoding.decode(encoded),
|
guard let data = Base64URLCoding.decode(encoded),
|
||||||
data.count > (24 + 16),
|
data.count > (24 + 16),
|
||||||
@@ -559,7 +652,7 @@ struct NostrProtocol {
|
|||||||
// Try decryption with even-Y then odd-Y when sender pubkey is x-only
|
// Try decryption with even-Y then odd-Y when sender pubkey is x-only
|
||||||
func attemptDecrypt(using pubKeyData: Data) throws -> Data {
|
func attemptDecrypt(using pubKeyData: Data) throws -> Data {
|
||||||
let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData)
|
let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData)
|
||||||
let key = try deriveNIP44V2Key(from: ss)
|
let key = try derivePrivateEnvelopeKey(from: ss)
|
||||||
return try XChaCha20Poly1305Compat.open(
|
return try XChaCha20Poly1305Compat.open(
|
||||||
ciphertext: Data(ct),
|
ciphertext: Data(ct),
|
||||||
tag: Data(tag),
|
tag: Data(tag),
|
||||||
@@ -569,18 +662,25 @@ struct NostrProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If 32 bytes (x-only) try both parities, otherwise single try
|
// If 32 bytes (x-only) try both parities, otherwise single try
|
||||||
|
let plaintext: Data
|
||||||
if senderPubkeyData.count == 32 {
|
if senderPubkeyData.count == 32 {
|
||||||
let even = Data([0x02]) + senderPubkeyData
|
let even = Data([0x02]) + senderPubkeyData
|
||||||
if let pt = try? attemptDecrypt(using: even) {
|
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 {
|
} else {
|
||||||
let pt = try attemptDecrypt(using: senderPubkeyData)
|
plaintext = try attemptDecrypt(using: senderPubkeyData)
|
||||||
return String(data: pt, encoding: .utf8) ?? ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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(
|
private static func deriveSharedSecret(
|
||||||
@@ -640,7 +740,8 @@ struct NostrProtocol {
|
|||||||
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
||||||
// ECDH shared secret derived
|
// ECDH shared secret derived
|
||||||
|
|
||||||
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
|
// Return raw ECDH shared secret; HKDF is applied by
|
||||||
|
// derivePrivateEnvelopeKey
|
||||||
return sharedSecretData
|
return sharedSecretData
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -794,12 +895,17 @@ enum NostrError: Error {
|
|||||||
case invalidPublicKey
|
case invalidPublicKey
|
||||||
case invalidEvent
|
case invalidEvent
|
||||||
case invalidCiphertext
|
case invalidCiphertext
|
||||||
|
case cryptographicFailure
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
|
// MARK: - BitChat private-envelope key derivation
|
||||||
|
|
||||||
private extension NostrProtocol {
|
private extension NostrProtocol {
|
||||||
static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {
|
/// 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<CryptoKit.SHA256>.deriveKey(
|
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
||||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
||||||
salt: Data(),
|
salt: Data(),
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private var messageQueue: [PendingSend] = []
|
private var messageQueue: [PendingSend] = []
|
||||||
private let messageQueueLock = NSLock()
|
private let messageQueueLock = NSLock()
|
||||||
/// Non-queued sends whose callers require relay durability. A WebSocket
|
/// Non-queued sends whose callers require relay durability. A WebSocket
|
||||||
/// write only proves bytes left this process; NIP-20 OK is the relay's
|
/// write only proves bytes left this process; NIP-01 `OK` is the relay's
|
||||||
/// accept/reject acknowledgment.
|
/// accept/reject acknowledgment.
|
||||||
private struct ConfirmedSendState {
|
private struct ConfirmedSendState {
|
||||||
let token: UUID
|
let token: UUID
|
||||||
@@ -535,8 +535,8 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Attempts an event only on currently connected target relays and
|
/// Attempts an event only on currently connected target relays and
|
||||||
/// reports whether at least one relay explicitly accepted it via NIP-20
|
/// reports whether at least one relay explicitly accepted it via NIP-01
|
||||||
/// OK. A successful WebSocket write alone is not durable acceptance.
|
/// `OK`. A successful WebSocket write alone is not durable acceptance.
|
||||||
/// Unlike `sendEvent`, this never enters the process-local pending queue;
|
/// Unlike `sendEvent`, this never enters the process-local pending queue;
|
||||||
/// callers use it when success unlocks durable state or user-visible
|
/// callers use it when success unlocks durable state or user-visible
|
||||||
/// delivery progress.
|
/// delivery progress.
|
||||||
|
|||||||
@@ -322,7 +322,7 @@ final class CourierStore {
|
|||||||
|
|
||||||
/// Envelopes eligible to park on relays as bridge courier drops. Merely
|
/// Envelopes eligible to park on relays as bridge courier drops. Merely
|
||||||
/// offering one does not start its cooldown: the caller commits that only
|
/// offering one does not start its cooldown: the caller commits that only
|
||||||
/// after a relay explicitly accepts the event via NIP-20 OK.
|
/// after a relay explicitly accepts the event via NIP-01 `OK`.
|
||||||
func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] {
|
func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] {
|
||||||
let date = now()
|
let date = now()
|
||||||
return queue.sync {
|
return queue.sync {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
var relaysConnected: (@MainActor () -> Bool)?
|
var relaysConnected: (@MainActor () -> Bool)?
|
||||||
/// Publishes a signed drop event directly to connected default (DM)
|
/// Publishes a signed drop event directly to connected default (DM)
|
||||||
/// relays. Completion is true only after at least one relay explicitly
|
/// relays. Completion is true only after at least one relay explicitly
|
||||||
/// accepts the event via NIP-20 OK; this must never mean "queued in RAM"
|
/// accepts the event via NIP-01 `OK`; this must never mean "queued in RAM"
|
||||||
/// or merely "written to a socket".
|
/// or merely "written to a socket".
|
||||||
var publishEvent: (@MainActor (NostrEvent, @escaping @MainActor (Bool) -> Void) -> Void)?
|
var publishEvent: (@MainActor (NostrEvent, @escaping @MainActor (Bool) -> Void) -> Void)?
|
||||||
/// (Re)opens the drop subscription for the given hex tags.
|
/// (Re)opens the drop subscription for the given hex tags.
|
||||||
@@ -129,7 +129,7 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
/// a newer attempt for the same message.
|
/// a newer attempt for the same message.
|
||||||
private var activeDropOperations: [String: ActiveDropOperation] = [:]
|
private var activeDropOperations: [String: ActiveDropOperation] = [:]
|
||||||
/// Held-envelope publishes have no sender message ID, but still need an
|
/// Held-envelope publishes have no sender message ID, but still need an
|
||||||
/// in-flight identity: repeated refreshes inside the NIP-20 wait window
|
/// in-flight identity: repeated refreshes inside the relay-OK wait window
|
||||||
/// must not mint duplicate relay events for the same opaque envelope.
|
/// must not mint duplicate relay events for the same opaque envelope.
|
||||||
private var heldDropOperations: [Data: UUID] = [:]
|
private var heldDropOperations: [Data: UUID] = [:]
|
||||||
/// Deterministically invalid envelopes are suppressed for this process,
|
/// Deterministically invalid envelopes are suppressed for this process,
|
||||||
@@ -275,7 +275,7 @@ final class BridgeCourierService: ObservableObject {
|
|||||||
/// Publishes a drop, or queues it when relays are down. `messageID` is the
|
/// Publishes a drop, or queues it when relays are down. `messageID` is the
|
||||||
/// sender-side dedup key (nil for held/relayed envelopes we don't track);
|
/// sender-side dedup key (nil for held/relayed envelopes we don't track);
|
||||||
/// it rides the pending queue so an evicted or failed drop can release its
|
/// it rides the pending queue so an evicted or failed drop can release its
|
||||||
/// in-flight slot. Completion reports actual NIP-20 relay acceptance.
|
/// in-flight slot. Completion reports actual NIP-01 relay acceptance.
|
||||||
private func publishDrop(
|
private func publishDrop(
|
||||||
_ envelope: CourierEnvelope,
|
_ envelope: CourierEnvelope,
|
||||||
messageID: String? = nil,
|
messageID: String? = nil,
|
||||||
|
|||||||
@@ -172,8 +172,8 @@ final class MessageDeduplicationService {
|
|||||||
/// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format)
|
/// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format)
|
||||||
private let nostrAckCache: LRUDeduplicationCache<Bool>
|
private let nostrAckCache: LRUDeduplicationCache<Bool>
|
||||||
|
|
||||||
/// Optional cross-launch persistence for the Nostr event cache. NIP-59
|
/// Optional cross-launch persistence for the Nostr event cache. BitChat
|
||||||
/// randomizes gift-wrap timestamps, so DM subscriptions look back 24h and
|
/// randomizes private-envelope timestamps, so DM subscriptions look back 24h and
|
||||||
/// relays redeliver the same events on every launch; without this record
|
/// relays redeliver the same events on every launch; without this record
|
||||||
/// each relaunch reprocesses old PMs and acks. Nil (tests, macOS callers
|
/// each relaunch reprocesses old PMs and acks. Nil (tests, macOS callers
|
||||||
/// that don't opt in) keeps the cache purely in-memory.
|
/// that don't opt in) keeps the cache purely in-memory.
|
||||||
@@ -314,7 +314,7 @@ final class MessageDeduplicationService {
|
|||||||
// MARK: - Clear
|
// MARK: - Clear
|
||||||
|
|
||||||
/// Clears all caches. This is the wipe/panic path: the persisted
|
/// Clears all caches. This is the wipe/panic path: the persisted
|
||||||
/// gift-wrap record goes with everything else.
|
/// private-envelope record goes with everything else.
|
||||||
func clearAll() {
|
func clearAll() {
|
||||||
contentCache.clear()
|
contentCache.clear()
|
||||||
nostrEventCache.clear()
|
nostrEventCache.clear()
|
||||||
@@ -325,7 +325,7 @@ final class MessageDeduplicationService {
|
|||||||
|
|
||||||
/// Clears only the in-memory Nostr caches (events and ACKs). Runs on
|
/// Clears only the in-memory Nostr caches (events and ACKs). Runs on
|
||||||
/// every geohash channel switch, so the disk record deliberately
|
/// every geohash channel switch, so the disk record deliberately
|
||||||
/// survives — wiping it here would forfeit cross-launch gift-wrap dedup
|
/// survives — wiping it here would forfeit cross-launch private-envelope dedup
|
||||||
/// each time the user changes channels (flagged by Codex on #1398).
|
/// each time the user changes channels (flagged by Codex on #1398).
|
||||||
func clearNostrCaches() {
|
func clearNostrCaches() {
|
||||||
nostrEventCache.clear()
|
nostrEventCache.clear()
|
||||||
|
|||||||
@@ -9,8 +9,9 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Disk persistence for processed gift-wrap event IDs. NIP-59 randomizes
|
/// Disk persistence for processed private-envelope event IDs. BitChat
|
||||||
/// gift-wrap timestamps, so DM subscriptions must look back generously (24h)
|
/// randomizes envelope timestamps, so DM subscriptions must look back
|
||||||
|
/// generously (24h)
|
||||||
/// and relays redeliver the same events on every launch — without a
|
/// and relays redeliver the same events on every launch — without a
|
||||||
/// cross-launch record, each relaunch reprocesses old PMs and acks
|
/// cross-launch record, each relaunch reprocesses old PMs and acks
|
||||||
/// (re-sent DELIVERED bursts, "delivered ack for unknown mid" noise).
|
/// (re-sent DELIVERED bursts, "delivered ack for unknown mid" noise).
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ enum TransportConfig {
|
|||||||
// Fallback deadline for treating a subscription's initial fetch as complete
|
// Fallback deadline for treating a subscription's initial fetch as complete
|
||||||
// when a relay never sends EOSE (generous to cover Tor circuit setup).
|
// when a relay never sends EOSE (generous to cover Tor circuit setup).
|
||||||
static let nostrSubscriptionEOSEFallbackSeconds: TimeInterval = 10.0
|
static let nostrSubscriptionEOSEFallbackSeconds: TimeInterval = 10.0
|
||||||
// A bridge drop is durable only after NIP-20 OK. Relays that omit OK must
|
// A bridge drop is durable only after NIP-01 `OK`. Relays that omit OK must
|
||||||
// not pin the router's in-flight state indefinitely.
|
// not pin the router's in-flight state indefinitely.
|
||||||
static let nostrConfirmedSendAckTimeoutSeconds: TimeInterval = 10.0
|
static let nostrConfirmedSendAckTimeoutSeconds: TimeInterval = 10.0
|
||||||
// After this long, a relay marked permanently failed gets another chance.
|
// After this long, a relay marked permanently failed gets another chance.
|
||||||
|
|||||||
@@ -504,7 +504,8 @@ private struct ContentPrivateChatSheetView: View {
|
|||||||
if privateConversationModel.selectedPeerID?.isGroup == true {
|
if privateConversationModel.selectedPeerID?.isGroup == true {
|
||||||
return String(localized: "content.private.caption_group", comment: "Caption above the group chat composer noting messages are encrypted to group members")
|
return String(localized: "content.private.caption_group", comment: "Caption above the group chat composer noting messages are encrypted to group members")
|
||||||
}
|
}
|
||||||
// Geohash DMs are NIP-17 gift-wrapped — always end-to-end encrypted,
|
// Geohash DMs use BitChat's private-envelope encryption over Nostr —
|
||||||
|
// always end-to-end encrypted,
|
||||||
// even though they carry no Noise session status. Mesh DMs earn the
|
// even though they carry no Noise session status. Mesh DMs earn the
|
||||||
// "encrypted" claim only once the Noise handshake has secured.
|
// "encrypted" claim only once the Noise handshake has secured.
|
||||||
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"id":"8f4ac4680de5f972a586267bc7b6b5102ba548a34f618bdee47edd08929ee1e8","pubkey":"6981231b5745520fd982f66f485fa1b42f8f91ad25ab32242d4afb839d696b0a","created_at":1783727308,"kind":1059,"tags":[["p","c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"]],"content":"v2:ETvtGOrkDQ2JIiiMFMdxBlrYNLh07-QKmLds0xsjkgs-v54CP4E4uoKl1L_VvKvaREg-EviPQmksd3DOYHrBVAd5E6xuRn1o9vkoss4MYV7I71mu4RfADRt77ohZaNbc-KhrmgyRTgE-WnEsqErax8LUN6GUukjkKncVA9MAmC1WUB1MI1AR8c4BQ0IOc_ubrEqi640a8AeBZaCZOYutCb3ttqNSPBxR63XErE761KNg1uiq5pgBVo8iKvLO-N2ei7IWvQhmDTapBaEU7LexeIdHDEwMdXDoAtr5Nmd54H1VN12tBvk1wXvHnENgZCOLOR5J2E1eSwrFXXBto-ohrNpBaLKIXBTPGEoepa7x0gC0Vrh1OTf4tCI7JJ5UWnkUAQFGUgPGlTRYC8MdESgEthqmdgKT3Jc0N6sylTmv6zSVx5dXqO4fvSLHC6_7it8F_V_8-uAxUYstJz65oK4F9CwOEVglUuUdfn2mN_3cMBzASLOlKvL8jbkwwo5aMBVrShGiEwDix02hfGMNMKf7OKsLlfNiBAVSPh6MQSvAWhxbDCDnWN-yHKWF4TYxbnH70X2KGl_ZD9pXTphKVIpFuRmP7UNM-01oG53NKcv9puBSxAkLbTZd122uFL_zQebxid1ukOXT8WgSB_WYJYoAlQekeu4ITryB4I60vAAzMAa4AppCUNnf6T8jwWxuC-8G0TPf18MTxpeNkzcSpPVyVE0jtLaOwsJDXs_Pg82Id03Qc-b_fWMm9V07UzGmEnMqQ1gBMLmEXHb_4Ebg5X7TdzuXy87O36CJzau7Dm5ZfoalryF-16Z4MxzQOyXb1G61yFthRsGCT6sYi-68YkhPScMf7u_BobtMuGWiMiWoBqN_IrQ_ecMHVfaeEYvpCz5NYlrE26iAktNmzCBUDNcIr6P_nHdb6I3Q1rOOmWwEF7jsLbvnU_w_82_nXE_yfdGsoly24A2wB0L0SpdnyyEWgvKWjjfS3J3vkIVW4_iM0FT0jNelANc2X_ryb3EPTmGenlqm_qRGh86PYk_R07hKYu3ULNEgLzDTijeZ9-bP23tsMXUDdJS2VR7LP5063ygVuSC0J-GL1FmQ2c-DmJaWQSeqp0NN3sCND3pSIRzwQRwChnMNjVB65mJj","sig":"c53f339b19b9de021765588b0ee4f5f1e0c2423a34d35fe2cbcc6ce89e12e2fedbdf88a0b6a9d719c2c8580002c7574f1ace93aedeab1e255b6231d4ebb6f9b2"}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt
|
||||||
|
index a5bd956..544a29b 100644
|
||||||
|
--- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt
|
||||||
|
+++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt
|
||||||
|
@@ -10,0 +11,21 @@ class NostrProtocolTest {
|
||||||
|
+ @Test
|
||||||
|
+ fun emitCrossPlatformFixtures() {
|
||||||
|
+ val sender = NostrIdentity.fromPrivateKey(
|
||||||
|
+ "0000000000000000000000000000000000000000000000000000000000000001"
|
||||||
|
+ )
|
||||||
|
+ val recipient = NostrIdentity.fromPrivateKey(
|
||||||
|
+ "0000000000000000000000000000000000000000000000000000000000000002"
|
||||||
|
+ )
|
||||||
|
+ val giftWrap = NostrProtocol.createPrivateMessage(
|
||||||
|
+ content = "legacy fixture from Android b7f0b33d",
|
||||||
|
+ recipientPubkey = recipient.publicKeyHex,
|
||||||
|
+ senderIdentity = sender
|
||||||
|
+ ).single()
|
||||||
|
+
|
||||||
|
+ assertEquals(NostrKind.GIFT_WRAP, giftWrap.kind)
|
||||||
|
+ assertEquals(listOf(listOf("p", recipient.publicKeyHex)), giftWrap.tags)
|
||||||
|
+ println("BITCHAT_FIXTURE_EVENT=${gson.toJson(giftWrap)}")
|
||||||
|
+ println("BITCHAT_FIXTURE_RECIPIENT_PRIVATE_KEY=${recipient.privateKeyHex}")
|
||||||
|
+ println("BITCHAT_FIXTURE_SENDER_PUBLIC_KEY=${sender.publicKeyHex}")
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
@Test
|
||||||
|
fun decryptPrivateMessage_acceptsAuthenticatedSeal() {
|
||||||
|
val sender = NostrIdentity.generate()
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"android_commit": "b7f0b33d3a267c770d3d5a65ee2d8c7e755450db",
|
||||||
|
"generator": "NostrProtocol.createPrivateMessage",
|
||||||
|
"generator_patch": "AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch.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"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"content":"v2:SorfnTaoQ_Rv0XLKA8b3FZojgaFvnWgx66JR6Gj20ztnorQyL-hUYBZwAa5ohFC6ioR9hlJARJm0cgNTvWgSZuNTcfSAvoMdSG6mXK73kzsp99x351zUB_lc1_5ZXm0qqePAEz3Dl5jj5EsfAb8ZzsCd-BZENCsTwqjqC_hCFl7RitlRfIL2Tq_n4TUFEFandDCplNz3W4L7V07V89aGEoUlhzcwPU44BcxfvQjeqVKq0IRf2AetypoOduPrjSqkv674ubWaRcHtw3Asrqgo5XSYbpOlSk1PF_TttrQSPZGViNe5MuiK2P-7d-XqKXuDf_bUgAzW884KXogbct-wtIJZJbVM-utMd-dHrpC1mY81lgpS4_kPuhj0Z6Ro9hU5nCcEk2K4_vNoSM9m9QbcXP34h47qSPsw9ikmz8UoD00-1fXQVB4YJcBVUSVI06IzbZEWulo8SPXvQ4pJjV3nwPgYqRgwnrNWMNfeuKojlF8yA17UNOWvD2U7r1cs84HL8dxztzX9NdN0DGxvjMILvt3D4eWrMbcSrsIkBgyvV-uskEPd4eX3fc9GmX8MPkMgxRErcxbuq6JBUNXikOJXNH_qspOt4UIw5dCIajrHsKycKd8A_3rSgLEQteirOWMGaD2gOJEzpbe4iT72dmPkvRq7k-wDjLbO5dOSuUvpFM-ipkB07ATJz_1uUcRpl8fD_oSlcdAzdPjGKG5Y-tNv3AcUstkqCi52E5x-aO8EDUNBFi_OCbD86nkTUv1jOpAQwejowSiiOnCZ91zUEg5pRQyhBV0Ozib-j0Wf8S8VAz9M8bqQQaKedPCEowDn6csOKbFdBtqSeROf1XWKCkm1mSJGHWW9V44MiMiHebVrRUpbP0PqvxiIeJE0","created_at":1783710443,"id":"46425f8d4e8007af43abb67b3668b59604bd34c86dee1b1c3702559086927cb9","kind":1059,"pubkey":"960e391e314a7fb00bbdd85eccb0a93c17e981b6fed38487cf891f1ed6b66aeb","sig":"975c88c1c4d11f0b623603f8ecc7c181fea7385e8feacdb4a64a6b4f7536b1337ff556aee165ca337c9ec501cfab3e9703026c6df2b12bb8c702378875f00722","tags":[["p","1c108500bf53da288d30530718bac4bf80d661d1fe38854060c5b5c79eb77755"]]}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"recipient_private_key":"8355a5c110cdfef2e644f4ad5d51c39f253b2c2c80ebb6856379fb16531dc1fa"}
|
||||||
@@ -2,11 +2,10 @@
|
|||||||
// NostrProtocolTests.swift
|
// NostrProtocolTests.swift
|
||||||
// bitchatTests
|
// bitchatTests
|
||||||
//
|
//
|
||||||
// Tests for NIP-17 gift-wrapped private messages
|
// Tests for BitChat's proprietary private-envelope transport over Nostr.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
import CryptoKit
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
@@ -102,24 +101,202 @@ struct NostrProtocolTests {
|
|||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
// Try to decrypt with wrong recipient
|
// Try to decrypt with wrong recipient. The outer envelope is bound to
|
||||||
if #available(macOS 14.4, iOS 17.4, *) {
|
// the addressed recipient's `p` tag, so this now fails validation
|
||||||
#expect(throws: CryptoKitError.authenticationFailure) {
|
// before any decryption is attempted.
|
||||||
try NostrProtocol.decryptPrivateMessage(
|
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,
|
giftWrap: giftWrap,
|
||||||
recipientIdentity: wrongRecipient
|
recipientIdentity: recipient
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
#expect(throws: (any Error).self) {
|
|
||||||
try NostrProtocol.decryptPrivateMessage(
|
|
||||||
giftWrap: giftWrap,
|
|
||||||
recipientIdentity: wrongRecipient
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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 {
|
@Test func decryptRejectsInvalidSealSignature() throws {
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = 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? {
|
private static func base64URLDecode(_ s: String) -> Data? {
|
||||||
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||||
let rem = str.count % 4
|
let rem = str.count % 4
|
||||||
@@ -366,4 +585,15 @@ struct NostrProtocolTests {
|
|||||||
Issue.record("Expected NostrError.invalidEvent, got \(error)")
|
Issue.record("Expected NostrError.invalidEvent, got \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func expectInvalidCiphertext(_ operation: () throws -> Void) {
|
||||||
|
do {
|
||||||
|
try operation()
|
||||||
|
Issue.record("Expected NostrError.invalidCiphertext")
|
||||||
|
} catch NostrError.invalidCiphertext {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
Issue.record("Expected NostrError.invalidCiphertext, got \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ Public archives contain content already intended for public mesh/board distribut
|
|||||||
|
|
||||||
## Nostr and Mesh Bridge
|
## Nostr and Mesh Bridge
|
||||||
|
|
||||||
- NIP-17/NIP-44 v2 private fallback protects plaintext with secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305. Relays still see event and network metadata.
|
- BitChat's proprietary private-envelope fallback protects plaintext with secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305. It is not NIP-17, NIP-44, or NIP-59 compatible and does not provide forward secrecy against later compromise of the recipient's static Nostr private key. Relays still see the recipient public-key tag, event timing and size, and network metadata.
|
||||||
- Bridge courier drops use a throwaway publisher key, an opaque Noise-sealed envelope, and a day-rotating recipient tag. Only a party already holding the recipient's Noise static key can compute candidate tags.
|
- Bridge courier drops use a throwaway publisher key, an opaque Noise-sealed envelope, and a day-rotating recipient tag. Only a party already holding the recipient's Noise static key can compute candidate tags.
|
||||||
- Relay publication is considered successful only after an explicit NIP-20 `OK true` from at least one target relay. Rejected, disconnected, timed-out, or merely socket-written events stay retryable.
|
- Relay publication is considered successful only after an explicit NIP-01 `OK true` from at least one target relay. Rejected, disconnected, timed-out, or merely socket-written events stay retryable.
|
||||||
- When mesh bridge is enabled, public mesh messages not marked “nearby only” are signed under a per-cell Nostr identity and published to a neighborhood rendezvous geohash. Presence and public bridge traffic therefore expose a coarse area to relays and participants.
|
- When mesh bridge is enabled, public mesh messages not marked “nearby only” are signed under a per-cell Nostr identity and published to a neighborhood rendezvous geohash. Presence and public bridge traffic therefore expose a coarse area to relays and participants.
|
||||||
- A bridge gateway can carry signed bridge/location events and opaque courier drops for nearby mesh-only peers. It cannot validly publish a neighbor's radio-only message because the author must first sign the bridge event.
|
- A bridge gateway can carry signed bridge/location events and opaque courier drops for nearby mesh-only peers. It cannot validly publish a neighbor's radio-only message because the author must first sign the bridge event.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user