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 (0x02 prefix)
  • Odd-Y compressed form (0x03 prefix)

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →