BitChat Private Envelope Format for Nostr: Three-Layer Encryption Protocol Explained
BitChat uses a proprietary three-layer encryption protocol over Nostr relays, nesting unsigned DMs inside signed seals inside ephemeral gift-wraps to achieve sender-anonymous, end-to-end encrypted messaging.
The BitChat private envelope format is a custom cryptographic stack built on top of standard Nostr infrastructure. Unlike NIP-44 or NIP-59 implementations, BitChat reuses the historic kind numbers 13, 14, and 1059 but implements entirely proprietary payload formats, key derivation, and cryptography. This article breaks down the exact structure, walking through the source code in permissionlesstech/bitchat.
What Is the BitChat Private Envelope?
A private envelope is the complete encrypted package that travels across Nostr relays. It consists of three nested Nostr events, each serving a distinct security purpose:
| Layer | Nostr Kind | Purpose |
|---|---|---|
| Inner message (rumor) | 14 |
The actual plaintext chat payload—unsigned to prevent double-signing |
| Seal | 13 |
Encrypts the rumor for the recipient and signs with the sender's real identity key |
| Gift-wrap (outer) | 1059 |
Wraps the seal with a throwaway ephemeral key, hiding sender identity from relays |
The outer gift-wrap carries exactly one p tag pointing to the recipient's public key. Relays see only an anonymous kind 1059 event destined for a specific pubkey, with no metadata revealing who sent it.
Inner Layer: The Unsigned Rumor (Kind 14)
At the core of every private envelope sits an unsigned direct message. This design choice is intentional—signing would create cryptographic redundancy since the seal layer already provides authentication.
In bitchat/Nostr/NostrProtocol.swift, the rumor is constructed as an EventKind.dm with no signature:
// From NostrProtocol.swift — rumor creation
let rumor = NostrEvent(
kind: EventKind.dm.rawValue,
content: plaintext,
tags: [],
createdAt: UInt32(Date().timeIntervalSince1970)
)
// Note: rumor.pubKey is set but rumor.sig remains empty
The rumor's pubKey field contains the sender's identity for display purposes, but the cryptographic proof of authorship lives exclusively in the seal layer.
Middle Layer: The Signed Seal (Kind 13)
The seal encrypts the serialized rumor for the recipient and authenticates it with the sender's long-term Schnorr identity key. This is where actual cryptographic binding occurs.
Seal Creation Process
- Serialize the unsigned rumor to JSON
- Derive a shared secret via ECDH between sender and recipient keys
- Encrypt using XChaCha20-Poly1305 with the derived key
- Sign the resulting ciphertext with the sender's real identity key
The implementation lives in private helpers createSeal and openSeal within NostrProtocol.swift:
// Conceptual flow from NostrProtocol.swift
func createSeal(rumor: NostrEvent, recipientPubkey: Data, senderIdentity: NostrIdentity) throws -> NostrEvent {
let plaintext = try rumor.serialize()
let sharedSecret = try senderIdentity.sharedSecret(with: recipientPubkey)
let (ciphertext, nonce) = try XChaCha20Poly1305Compat.seal(
plaintext: plaintext,
key: sharedSecret
)
let sealContent = encodeBase64URL(ciphertext: ciphertext, nonce: nonce)
return NostrEvent(
kind: EventKind.seal.rawValue, // 13
content: sealContent,
tags: [],
createdAt: UInt32(Date().timeIntervalSince1970),
pubKey: senderIdentity.publicKey,
sig: try senderIdentity.sign(message: sealContent.data(using: .utf8)!)
)
}
XChaCha20-Poly1305 Details
BitChat's symmetric encryption is implemented in bitchat/Nostr/XChaCha20Poly1305Compat.swift. The protocol uses a 24-byte nonce split into:
- 16 bytes: HChaCha20 sub-key derivation nonce
- 8 bytes: Tail that becomes the 12-byte ChaCha20-Poly1305 nonce
This construction ensures 192-bit nonce security while maintaining compatibility with standard ChaCha20-Poly1305 hardware acceleration.
Outer Layer: The Ephemeral Gift-Wrap (Kind 1059)
The gift-wrap provides sender anonymity by encrypting the seal with a freshly generated ephemeral Schnorr key that is discarded immediately after use.
Gift-Wrap Creation
The createGiftWrap helper in NostrProtocol.swift handles this:
func createGiftWrap(seal: NostrEvent, recipientPubkey: Data) throws -> NostrEvent {
// Generate throwaway key—never stored or reused
let ephemeralIdentity = try NostrIdentity.generate()
let sealPlaintext = try seal.serialize()
let sharedSecret = try ephemeralIdentity.sharedSecret(with: recipientPubkey)
let (ciphertext, nonce) = try XChaCha20Poly1305Compat.seal(
plaintext: sealPlaintext,
key: sharedSecret
)
return NostrEvent(
kind: EventKind.giftWrap.rawValue, // 1059
content: encodeBase64URL(ciphertext: ciphertext, nonce: nonce),
tags: [["p", recipientPubkey.hexEncoded]], // Exactly one p tag
createdAt: UInt32(Date().timeIntervalSince1970),
pubKey: ephemeralIdentity.publicKey, // Anonymous—unlinkable to sender
sig: try ephemeralIdentity.sign(message: /* ... */)
)
}
Critical Validation Rules
When processing a gift-wrap, unwrapGiftWrap enforces strict checks:
- Size limit: Maximum 64 KiB ciphertext (
maximumPrivateEnvelopeCiphertextBytes) - Single recipient: Exactly one
ptag matching the recipient's pubkey - Kind verification: Must be
1059
These validations prevent abuse and ensure the anonymity set remains clean.
Complete Encryption Flow
The full sender-side pipeline, as implemented in NostrProtocol.createPrivateMessage:
| Step | Action | File/Function |
|---|---|---|
| 1 | Create unsigned rumor (kind 14) | NostrProtocol.swift |
| 2 | Seal: encrypt + sign with real key (kind 13) | createSeal |
| 3 | Gift-wrap: encrypt with ephemeral key (kind 1059) | createGiftWrap |
| 4 | Return outer envelope ready for relay publication | createPrivateMessage |
Complete Decryption Flow
Recipients use NostrProtocol.decryptPrivateMessage to reverse the process:
| Step | Action | Validation |
|---|---|---|
| 1 | Receive kind 1059 event | Check size ≤ 64 KiB, exactly one p tag |
| 2 | Unwrap gift-wrap | Decrypt using recipient's private key + ephemeral pubkey |
| 3 | Verify seal signature | Must validate against sender's real identity key |
| 4 | Open seal | Decrypt to recover inner rumor |
| 5 | Return plaintext | Plus authenticated sender pubkey and timestamp |
// Receiver implementation pattern
let (content, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateMessage(
giftWrap: incomingEvent,
recipientIdentity: myIdentity
)
If any step fails—wrong recipient, bad signature, corrupted ciphertext—the entire envelope is rejected with a descriptive error.
Key Source Files
| File | Role |
|---|---|
bitchat/Nostr/NostrProtocol.swift |
Core state machine: createPrivateMessage, decryptPrivateMessage, createSeal, unwrapGiftWrap |
bitchat/Nostr/XChaCha20Poly1305Compat.swift |
Low-level crypto: seal(), open(), nonce handling, sub-key derivation |
bitchat/Nostr/NostrIdentity.swift |
Schnorr key operations: sharedSecret(), sign(), generate() |
bitchat/Nostr/Bech32.swift |
npub/nsec encoding for key display |
bitchat/Nostr/Base64URLCoding.swift |
Payload serialization for content fields |
Summary
- BitChat private envelopes achieve sender anonymity via ephemeral gift-wrap keys while maintaining authentication through signed seals
- The three-layer design (unsigned rumor → signed seal → anonymous gift-wrap) separates concerns: plaintext transport, cryptographic binding, and relay-level privacy
- XChaCha20-Poly1305 provides authenticated encryption with 192-bit nonces, implemented in
XChaCha20Poly1305Compat.swift - Hard limits (64 KiB, single
ptag) prevent abuse and simplify validation - The protocol is not NIP-44/NIP-59 compatible—it reuses kind numbers 13, 14, 1059 with entirely custom cryptography
Frequently Asked Questions
How does BitChat's private envelope differ from standard Nostr DMs?
Standard Nostr direct messages (kind 4 or NIP-04) encrypt content directly with the recipient's key and sign with the sender's key, exposing sender identity to relays. BitChat's private envelope adds a gift-wrap layer that encrypts with a disposable ephemeral key, making the sender unlinkable to any relay observer while preserving end-to-end authentication through the inner seal signature.
Why is the inner rumor unsigned?
The inner DM (kind 14) is intentionally unsigned to prevent cryptographic redundancy and potential signature confusion. Authentication is provided exclusively by the seal layer (kind 13), which signs the encrypted rumor ciphertext. This separation ensures there is exactly one canonical signature to verify, reducing attack surface and implementation complexity.
What encryption algorithm does BitChat use?
BitChat uses XChaCha20-Poly1305 for all symmetric encryption operations. This AEAD construction provides 256-bit security with 192-bit nonces, eliminating nonce-reuse concerns without requiring complex state management. The implementation in XChaCha20Poly1305Compat.swift performs HChaCha20 sub-key derivation to bridge the 24-byte XChaCha20 nonce to the 12-byte ChaCha20-Poly1305 requirement.
What happens if a gift-wrap exceeds 64 KiB?
The envelope is rejected immediately before any decryption attempt. The constant maximumPrivateEnvelopeCiphertextBytes (65536) enforces a hard limit in unwrapGiftWrap to prevent resource exhaustion attacks and ensure the protocol remains usable on memory-constrained devices.
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 →