How Bitchat Handles Message Encryption: A Deep Dive into the Nostr Private Envelope Protocol
Bitchat encrypts private messages using a custom three-layer "BitChat private-envelope" scheme built on Nostr that combines ECDH key exchange, HKDF-SHA-256 key derivation, and XChaCha20-Poly1305 authenticated encryption, with identity keys protected by AES-GCM in the Keychain.
Bitchat, an open-source Nostr client developed by permissionlesstech/bitchat, implements end-to-end encryption for direct messages through a carefully constructed cryptographic protocol. Understanding how Bitchat handles message encryption reveals a defense-in-depth approach that protects both message content in transit and identity keys at rest.
The Three-Layer Encryption Architecture
Bitchat's private message encryption, as implemented in [NostrProtocol.swift](https://github.com/permissionlesstech/bitchat/blob/main/bitchat/Nostr/NostrProtocol.swift), consists of three distinct cryptographic layers designed to provide confidentiality, authenticity, and forward secrecy.
Layer 1: ECDH Shared Secret Derivation
The foundation of Bitchat message encryption begins with Elliptic Curve Diffie-Hellman (ECDH) key exchange. The sender's Schnorr private key and the recipient's secp256k1 (P-256K) public key are combined using X25519-style ECDH via the deriveSharedSecret function.
This approach leverages the existing Nostr identity keys—eliminating the need for separate encryption key pairs—while producing a shared secret that only the two communicating parties can compute.
Layer 2: HKDF-SHA-256 Key Derivation
The raw ECDH output is not used directly for encryption. Instead, Bitchat feeds it through HKDF-SHA-256 with a constant info string:
// From NostrProtocol.swift, lines 909-915
let info = "nip44-v2".data(using: .utf8)!
let symmetricKey = try NostrProtocol.derivePrivateEnvelopeKey(from: sharedSecret)
The derivePrivateEnvelopeKey function deterministically derives a 32-byte symmetric key identical on both sender and recipient ends. Using the "nip44-v2" info string ensures domain separation and compatibility with Nostr's evolving encryption standards.
Layer 3: XChaCha20-Poly1305 Authenticated Encryption
The actual message encryption uses XChaCha20-Poly1305, an authenticated encryption with associated data (AEAD) construction that provides both confidentiality and integrity. Bitchat implements this through [XChaCha20Poly1305Compat.swift](https://github.com/permissionlesstech/bitchat/blob/main/bitchat/Nostr/XChaCha20Poly1305Compat.swift), a compatibility wrapper around CryptoKit's ChaChaPoly.
The encryption process generates a fresh 24-byte nonce using SecRandomCopyBytes:
// Low-level encryption workflow
let shared = try NostrProtocol.deriveSharedSecret(
privateKey: mySchnorrKey,
publicKey: Data(hexString: recipientHex)!
)
let symKey = try NostrProtocol.derivePrivateEnvelopeKey(from: shared)
let nonce = Data((0..<24).map { _ in UInt8.random(in: 0...255) })
let sealed = try XChaCha20Poly1305Compat.seal(
plaintext: Data("hello".utf8),
key: symKey,
nonce24: nonce
)
let payload = "v2:" + Base64URLCoding.encode(nonce + sealed.ciphertext + sealed.tag)
The final payload format is: v2: prefix + Base64-URL(nonce || ciphertext || 16-byte Poly1305 tag). This design allows the 24-byte nonce to be transmitted alongside the encrypted content without requiring additional metadata fields.
Decryption and Sender Authentication
The decryption process, implemented in the attemptDecrypt and related functions of [NostrProtocol.swift](https://github.com/permissionlesstech/bitchat/blob/main/bitchat/Nostr/NostrProtocol.swift), reverses these steps with additional robustness for public key handling.
Handling X-Only Public Keys
Nostr uses x-only public keys (32 bytes, omitting the Y-coordinate). Bitchat handles this ambiguity by attempting decryption with both possible Y-parity values:
- Even-Y compressed form (
0x02prefix) - Odd-Y compressed form (
0x03prefix)
This ensures compatibility across different Nostr implementations while maintaining security.
Verification and Plaintext Recovery
Once the correct public key form is identified, the same HKDF derivation reproduces the symmetric key, and XChaCha20Poly1305Compat.open (lines 620-649) authenticates and decrypts the payload. The sealed payload is then verified against the sender's Schnorr signature, guaranteeing both confidentiality and authenticity.
Gift-Wrap: Relay-Opaque Sender Anonymity
Bitchat adds a critical privacy layer through gift-wrap encryption (Nostr kind 1059). The outer envelope uses an ephemeral Schnorr key pair generated specifically for each message. This ensures:
- Relay blindness: Relays cannot identify the true sender from the encrypted payload
- Forward secrecy: Compromise of long-term keys does not expose past message metadata
- Plausible deniability: The ephemeral key cannot be cryptographically linked to the sender's identity
The complete high-level workflow combines both layers:
// 1️⃣ Create an encrypted private message (gift-wrap + seal)
let event = try NostrProtocol.createPrivateMessage(
content: "Hey, meet at 5 pm",
recipientPubkey: recipientHex,
senderIdentity: myIdentity
)
// 2️⃣ Send `event` over a Nostr relay (kind 1059)
// 3️⃣ Receive a gift-wrap event and decrypt it
let (plain, sender, timestamp) = try NostrProtocol.decryptPrivateMessage(
giftWrap: receivedEvent,
recipientIdentity: myIdentity
)
print("From: \(sender) @ \(timestamp) → \(plain)")
Message-at-Rest Encryption for Identity Keys
Beyond transit encryption, Bitchat protects stored cryptographic material through [SecureIdentityStateManager.swift](https://github.com/permissionlesstech/bitchat/blob/main/bitchat/Identity/SecureIdentityStateManager.swift). Identity caches are encrypted using AES-GCM and stored in the Apple Keychain:
// From SecureIdentityStateManager.swift, lines 274-277
let sealed = try AES.GCM.seal(sensitiveData, using: key)
// ...
let decrypted = try AES.GCM.open(sealedBox, using: key)
This ensures that even with physical device access, extracting private keys requires bypassing both Keychain protections and AES-GCM encryption.
Security Properties Summary
| Property | Implementation Mechanism |
|---|---|
| Confidentiality | XChaCha20-Poly1305 AEAD encryption |
| Authenticity | Schnorr signatures on sealed payloads |
| Forward secrecy | Ephemeral keys per gift-wrap message |
| Key derivation | HKDF-SHA-256 with domain-separated info string |
| Metadata protection | Ephemeral sender keys in gift-wrap layer |
| Storage security | AES-GCM + Keychain for identity material |
Core Implementation Files
| File | Purpose | Key Functions |
|---|---|---|
Nostr/NostrProtocol.swift |
High-level envelope protocol, ECDH, HKDF | createPrivateMessage(), decryptPrivateMessage(), deriveSharedSecret(), derivePrivateEnvelopeKey() |
Nostr/XChaCha20Poly1305Compat.swift |
XChaCha20-Poly1305 wrapper with nonce handling | seal(), open() |
Identity/SecureIdentityStateManager.swift |
Identity encryption at rest | encryptToKeychain(), decryptFromKeychain() |
Nostr/Base64URLCoding.swift |
Payload encoding utilities | encode(), decode() |
Summary
- Bitchat implements message encryption through a custom three-layer protocol: ECDH key exchange, HKDF-SHA-256 derivation, and XChaCha20-Poly1305 AEAD encryption.
- The "v2:" payload format embeds nonce, ciphertext, and authentication tag in a single Base64-URL string compatible with Nostr's content field.
- Gift-wrap encryption adds relay-opaque anonymity using ephemeral Schnorr keys, protecting both message content and sender metadata.
- Identity keys receive additional protection through AES-GCM encryption in the Apple Keychain, implemented in
SecureIdentityStateManager.swift. - X-only public key handling includes automatic parity trial for robust cross-client compatibility.
Frequently Asked Questions
What encryption algorithm does Bitchat use for private messages?
Bitchat uses XChaCha20-Poly1305 for message content encryption, implemented through a compatibility wrapper in XChaCha20Poly1305Compat.swift. This AEAD construction provides 256-bit encryption security with built-in authentication and resistance to nonce-misuse attacks due to its 192-bit nonce.
How does Bitchat derive encryption keys from Nostr identity keys?
Bitchat performs ECDH between Schnorr private keys and secp256k1 public keys, then feeds the result through HKDF-SHA-256 with the fixed info string "nip44-v2" (source: derivePrivateEnvelopeKey, lines 909-915 of NostrProtocol.swift). This produces a 32-byte symmetric key deterministically shared between sender and recipient.
What is the purpose of the "v2:" prefix in Bitchat encrypted messages?
The "v2:" prefix identifies the encryption version and encoding format, enabling future protocol upgrades while maintaining backward compatibility. The payload that follows is Base64-URL encoded and contains: 24-byte nonce || XChaCha20 ciphertext || 16-byte Poly1305 authentication tag.
How does Bitchat protect identity keys on the device?
Device-stored identity keys are protected by AES-GCM encryption using keys held in the Apple Keychain, as implemented in SecureIdentityStateManager.swift (lines 274-277). This provides hardware-backed protection on supported devices and ensures that identity material remains encrypted even if the device filesystem is compromised.
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 →