How BitChat Encrypts Private Messages Over the Nostr Protocol: A Technical Deep Dive
BitChat employs a custom "private-envelope" encryption scheme that combines X25519-style ECDH key agreement, HKDF-SHA-256 key derivation, and XChaCha20-Poly1305 authenticated encryption to secure direct messages transported over the Nostr network.
The permissionlesstech/bitchat repository implements a proprietary end-to-end encryption pipeline for iOS devices that protects message content from Nostr relays and intermediary nodes. This encryption process for private messages over the Nostr path in BitChat diverges from standard NIP-44 implementations while maintaining partial compatibility through specific HKDF parameters.
The Private Envelope Encryption Flow
BitChat uses a six-step private-envelope scheme implemented in NostrProtocol.swift to transform plaintext into secure ciphertext ready for Nostr event propagation.
Key Agreement via X25519-Style ECDH
The process begins with Elliptic Curve Diffie-Hellman (ECDH) key exchange. The sender invokes deriveSharedSecret using their Schnorr private key and the recipient's compressed P-256K public key.
This operation performs an X25519-style key agreement that correctly handles the even/odd-Y bit for x-only public keys, ensuring consistent shared secret derivation regardless of public key compression format.
HKDF-SHA-256 Key Derivation
The raw ECDH shared secret feeds into HKDF-SHA-256 via the derivePrivateEnvelopeKey function. The derivation uses:
- Salt: Empty (zero-length)
- Info string:
"nip44-v2"(retained for backward compatibility only) - Output: 32-byte symmetric encryption key
This step isolates the asymmetric key material from the symmetric cipher, providing domain separation through the HKDF extract-and-expand process.
XChaCha20-Poly1305 Authenticated Encryption
BitChat encrypts the UTF-8 plaintext using XChaCha20-Poly1305 via XChaCha20Poly1305Compat.seal. The implementation generates a cryptographically random 24-byte nonce using SecRandomCopyBytes for each message.
The XChaCha20Poly1305Compat.swift helper constructs the XChaCha20 cipher by:
- Deriving a sub-key via HChaCha20 from the 32-byte HKDF output and the first 16 bytes of the nonce.
- Reducing the 24-byte nonce to a 12-byte ChaCha20 nonce (four zero bytes concatenated with the last 8 bytes of the original nonce).
- Applying ChaCha20-Poly1305 AEAD to produce ciphertext and a 16-byte authentication tag.
Wire Format and Envelope Structure
The encrypted payload serializes into a compact string format:
v2:<base64url(nonce24 || ciphertext || tag)>
The v2: prefix identifies the envelope version. The payload concatenates the 24-byte nonce, variable-length ciphertext, and 16-byte Poly1305 tag, then encodes the entire byte sequence using base64url encoding (URL-safe Base64 without padding).
Implementation in BitChat Source Code
The encryption pipeline spans four core files in the bitchat/Nostr/ directory:
NostrProtocol.swift: Contains the primaryencrypt()anddecrypt()methods,deriveSharedSecret,derivePrivateEnvelopeKey, and envelope framing logic.XChaCha20Poly1305Compat.swift: Implements the XChaCha20 construction including HChaCha20 sub-key derivation and nonce conversion utilities.Base64URLCoding.swift: Handles base64url encode/decode operations for the final envelope string.NostrIdentity.swift: Defines the Schnorr key types (P256K.Schnorr.PrivateKey) used for ECDH operations.
Practical Code Examples
Encrypting a Message
// Sender-side encryption
let encryptedEnvelope = try NostrProtocol.encrypt(
plaintext: "Hello, world!", // UTF-8 message content
recipientPubkey: recipientHexPubkey, // Recipient's compressed P-256K pubkey (hex)
senderKey: senderSchnorrPrivateKey // Sender's Schnorr private key
)
// Result: "v2:..." string ready for Nostr event content field
Decrypting a Message
// Receiver-side decryption
let plaintext = try NostrProtocol.decrypt(
ciphertext: encryptedEnvelope, // The "v2:..." string from Nostr event
senderPubkey: senderHexPubkey, // Sender's compressed pubkey (hex)
recipientKey: receiverSchnorrPrivateKey // Receiver's Schnorr private key
)
// Returns original "Hello, world!" UTF-8 string
The decryption process reverses the encryption steps: stripping the v2: prefix, base64url decoding, extracting the nonce and tag, re-deriving the shared secret and symmetric key (including even/odd-Y handling), and finally opening the XChaCha20-Poly1305 box via XChaCha20Poly1305Compat.open.
Differences from NIP-44 Standard
While BitChat uses the "nip44-v2" HKDF info string for interoperability signaling, the actual cryptographic implementation differs from NIP-44 in three critical ways:
- Envelope Framing: BitChat uses the
v2:prefix with anonce24||ciphertext||taglayout, whereas NIP-44 specifies different framing and length prefixes. - Key Schedule: BitChat derives a single envelope key via HKDF-SHA-256 that encrypts the entire payload. NIP-44 derives per-message subkeys using a different construction.
- Cipher Selection: BitChat explicitly implements XChaCha20-Poly1305 with custom HChaCha20 sub-key derivation, diverging from NIP-44's prescribed cipher suites.
Summary
- BitChat's private-envelope scheme uses ECDH (X25519-style) between Schnorr keys to establish shared secrets.
- HKDF-SHA-256 with info string
"nip44-v2"derives the 32-byte symmetric key used for encryption. - XChaCha20-Poly1305 provides authenticated encryption with 24-byte random nonces generated via
SecRandomCopyBytes. - Wire format concatenates nonce, ciphertext, and tag into a base64url string prefixed with
v2:. - Core implementation resides in
NostrProtocol.swiftandXChaCha20Poly1305Compat.swift, handling the complete encrypt/decrypt lifecycle.
Frequently Asked Questions
What encryption algorithm does BitChat use for Nostr private messages?
BitChat uses XChaCha20-Poly1305 for authenticated symmetric encryption, implemented through a custom compatibility layer in XChaCha20Poly1305Compat.swift. The algorithm extends standard ChaCha20-Poly1305 to support 192-bit (24-byte) nonces via HChaCha20 sub-key derivation, providing resistance to nonce-collision attacks without requiring a random number generator for the cipher stream itself.
How does BitChat's encryption differ from NIP-44?
BitChat diverges from NIP-44 in envelope structure and key derivation. While NIP-44 uses specific per-message key derivation and framing, BitChat employs a custom private-envelope format with the v2: prefix and derives a single envelope key via HKDF-SHA-256 that protects the entire message payload. The "nip44-v2" info string exists only for compatibility signaling and does not indicate strict NIP-44 compliance.
Where is the encryption logic implemented in the BitChat codebase?
The primary encryption logic resides in bitchat/Nostr/NostrProtocol.swift, which orchestrates the ECDH key agreement, HKDF key derivation, and envelope construction. The low-level XChaCha20-Poly1305 implementation lives in bitchat/Nostr/XChaCha20Poly1305Compat.swift, while Base64URLCoding.swift and NostrIdentity.swift provide supporting encoding and key type definitions.
Why does BitChat use Schnorr keys for ECDH instead of standard Nostr encryption keys?
BitChat performs X25519-style ECDH using Schnorr private keys and compressed P-256K public keys to leverage existing key infrastructure while maintaining compatibility with Nostr's public key formats. The deriveSharedSecret function handles the conversion between Schnorr signing keys and the ECDH key agreement protocol, including proper handling of the even/odd-Y coordinate for x-only public keys as defined in BIP-340.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →