How BitChat Implements a Layered Encryption Protocol for Private Messages over Nostr
BitChat uses a two-layer encryption scheme that combines Nostr transport security with a proprietary application-level "private envelope" to protect message confidentiality from public relays.
The BitChat messaging application (permissionlesstech/bitchat) routes private conversations over the public Nostr relay network while preventing relays from reading message content or identifying recipients. This article explains the layered encryption protocol for private messages over Nostr, detailing how the Swift implementation in NostrProtocol.swift wraps sensitive payloads in a custom encrypted envelope before they ever reach decentralized infrastructure.
The Two-Layer Encryption Architecture
BitChat’s security model relies on distinct encryption layers: one protecting data in transit across the Nostr protocol, and another ensuring only the intended recipient can read the message content.
Transport-Level Encryption (Nostr)
At the network layer, BitChat publishes messages as Nostr kind 1059 events. Each event carries a secp256k1 Schnorr signature created with the sender’s private key. This provides authenticity and integrity—relays can verify the sender’s identity and detect tampering—but the content field remains encrypted to prevent snooping.
Application-Level Encryption (Private Envelope)
Before a message enters the Nostr event, BitChat encrypts the payload using a proprietary "private-envelope" format defined in WHITEPAPER.md. This envelope ensures that only the recipient possesses the keys necessary to decrypt the content, even if the relay colludes or the event leaks.
The envelope construction follows this sequence:
- Derive shared secret: Uses secp256k1 ECDH via
deriveSharedSecretbetween the sender’s private key and recipient’s public key (lines 95–104 ofNostrProtocol.swift). - Derive symmetric key: Applies HKDF-SHA256 through
derivePrivateEnvelopeKeyto generate an encryption key from the shared secret (lines 104–107). - Encrypt payload: Seals the plaintext using XChaCha20-Poly1305 with a 24-byte random nonce, producing ciphertext and a 16-byte authentication tag (lines 118–122).
- Format envelope: Prepends the literal
v2:prefix and encodes the concatenated nonce+ciphertext+tag as base64-url.
Because this encryption occurs before Nostr event creation, relays observe only the sender’s public key, payload size, and timing metadata—they cannot determine the message content or the recipient’s static Nostr key.
Cryptographic Implementation Details
The implementation in bitchat/Nostr/NostrProtocol.swift handles the envelope lifecycle through several specialized functions.
Shared Secret Derivation (ECDH)
The deriveSharedSecret function computes an ECDH shared point between the sender’s secp256k1 private scalar and the recipient’s x-only public key. This yields the initial entropy from which all message keys derive.
Key Derivation (HKDF-SHA256)
derivePrivateEnvelopeKey feeds the shared secret into HKDF-SHA256 to produce a uniform 256-bit symmetric key. This construction prevents direct use of raw ECDH output and enables domain separation for the encryption context.
Symmetric Encryption (XChaCha20-Poly1305)
BitChat utilizes XChaCha20Poly1305Compat.seal (defined in XChaCha20Poly1305Compat.swift) to handle the 24-byte nonce required by the XChaCha20-Poly1305 construction. The extended nonce space allows safer random generation without collision concerns, while Poly1305 provides authenticated encryption protecting against ciphertext manipulation.
Envelope Format and Decoding
The final envelope string follows the pattern:
v2:<base64url(24-byte nonce || ciphertext || 16-byte tag)>
During decryption, NostrProtocol.swift validates the v2: prefix, decodes the base64-url payload, and attempts decryption with both even-Y and odd-Y public-key formats to accommodate x-only pubkey representations (lines 52–74).
Code Examples
Encrypting a Private Message
This Swift function demonstrates creating an encrypted envelope using the internal encrypt method:
import BitChat
func createEncryptedEnvelope(
message: String,
recipientPubkey: String,
senderPrivateKey: P256K.Schnorr.PrivateKey
) throws -> String {
// Returns the "v2:..." string ready for Nostr event content
return try NostrProtocol.encrypt(
plaintext: message,
recipientPubkey: recipientPubkey,
senderKey: senderPrivateKey
)
}
The implementation internally performs ECDH, HKDF-SHA256 key derivation, generates a secure 24-byte nonce, and seals the message with XChaCha20-Poly1305.
Decrypting a Received Envelope
Recipients verify and open envelopes using the complementary decrypt function:
func decryptMessage(
envelope: String, // Must start with "v2:"
senderPubkey: String,
recipientPrivateKey: P256K.Schnorr.PrivateKey
) throws -> String {
return try NostrProtocol.decrypt(
ciphertext: envelope,
senderPubkey: senderPubkey,
recipientKey: recipientPrivateKey
)
}
The decryption routine handles base64-url decoding, shared secret recomputation, and automatic retry with alternating Y-parity bits when dealing with compressed public keys.
Publishing to Nostr Relays
Once encrypted, the envelope embeds into a standard Nostr event:
let encryptedContent = try createEncryptedEnvelope(
message: "Hello, private world",
recipientPubkey: recipientHex,
senderPrivateKey: myKey
)
let event = NostrEvent(
kind: 1059,
content: encryptedContent,
tags: [],
createdAt: Date()
)
try nostrClient.publish(event) // Signed with secp256k1 Schnorr before transmission
Security Properties and Limitations
The layered encryption protocol for private messages over Nostr provides confidentiality and integrity against passive relay observers and active network attackers. However, as noted in docs/privacy-assessment.md, the protocol does not provide forward secrecy. If an attacker compromises the recipient’s static Nostr private key at a future date, they can decrypt all past messages encrypted to that key because the ECDH shared secret remains deterministic.
Additionally, this envelope format is not compatible with standard Nostr encryption specifications such as NIP-44, NIP-17, or NIP-59. It represents a deliberate design choice by BitChat to prioritize recipient privacy over protocol compliance, ensuring that relays cannot identify the intended recipient from event metadata.
Summary
- Two-layer protection: Nostr Schnorr signatures provide transport authenticity while BitChat’s private envelope protects content confidentiality.
- ECDH + HKDF + XChaCha20-Poly1305: The cryptographic stack derives shared secrets via secp256k1 ECDH, keys via HKDF-SHA256, and encrypts via XChaCha20-Poly1305 with 24-byte nonces.
- v2: envelope format: Encrypted payloads use a
v2:prefix with base64-url encoding, implemented inNostrProtocol.swiftandXChaCha20Poly1305Compat.swift. - No forward secrecy: Static key compromise exposes historical messages; the protocol prioritizes metadata privacy over perfect forward secrecy.
- Kind 1059 events: All private messages travel as kind 1059 Nostr events, signed but opaque to relay infrastructure.
Frequently Asked Questions
How does BitChat’s encryption differ from NIP-44?
BitChat’s private envelope uses secp256k1 ECDH combined with XChaCha20-Poly1305 and a v2: framing format, whereas NIP-44 uses ChaCha20-Poly1305 with a different nonce construction and key derivation strategy. The BitChat implementation specifically avoids revealing recipient identity in Nostr tags, which standard NIP-44 implementations typically expose.
Why does BitChat use XChaCha20-Poly1305 instead of AES-GCM?
XChaCha20-Poly1305 utilizes a 24-byte nonce, allowing safer random nonce generation without collision risks associated with shorter 12-byte nonces. This choice simplifies the implementation in XChaCha20Poly1305Compat.swift while maintaining high security margins for long-lived messaging sessions.
Can relays determine who is receiving a private message?
No. Because the recipient’s public key does not appear in Nostr event tags and the content is encrypted via the private envelope before transmission, relays see only the sender’s public key, the payload size, and timing information. The recipient identity remains computationally hidden from relay operators.
What happens if someone steals my BitChat private key?
If your secp256k1 Schnorr private key is compromised, an attacker can derive the same shared secrets for all past messages sent to you and decrypt them, since the protocol lacks forward secrecy. You should rotate keys immediately and treat previously sent messages as potentially exposed.
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 →