# How Bitchat Handles Message Encryption: A Deep Dive into the Nostr Private Envelope Protocol

> Discover how Bitchat encrypts private messages with its Nostr-based private envelope protocol. Learn about ECDH, HKDF, XChaCha20-Poly1305, and AES-GCM for secure communication.

- Repository: [permissionlesstech/bitchat](https://github.com/permissionlesstech/bitchat)
- Tags: deep-dive
- Published: 2026-08-06

---

**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/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:

```swift
// 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/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`:

```swift
// 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/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:

```swift
// 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/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:

```swift
// 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`](https://github.com/permissionlesstech/bitchat/blob/main/Nostr/NostrProtocol.swift) | High-level envelope protocol, ECDH, HKDF | `createPrivateMessage()`, `decryptPrivateMessage()`, `deriveSharedSecret()`, `derivePrivateEnvelopeKey()` |
| [`Nostr/XChaCha20Poly1305Compat.swift`](https://github.com/permissionlesstech/bitchat/blob/main/Nostr/XChaCha20Poly1305Compat.swift) | XChaCha20-Poly1305 wrapper with nonce handling | `seal()`, `open()` |
| [`Identity/SecureIdentityStateManager.swift`](https://github.com/permissionlesstech/bitchat/blob/main/Identity/SecureIdentityStateManager.swift) | Identity encryption at rest | `encryptToKeychain()`, `decryptFromKeychain()` |
| [`Nostr/Base64URLCoding.swift`](https://github.com/permissionlesstech/bitchat/blob/main/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`](https://github.com/permissionlesstech/bitchat/blob/main/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`](https://github.com/permissionlesstech/bitchat/blob/main/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`](https://github.com/permissionlesstech/bitchat/blob/main/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`](https://github.com/permissionlesstech/bitchat/blob/main/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.