# BitChat Private Envelope Format for Nostr: Three-Layer Encryption Protocol Explained

> Discover the BitChat private envelope format for Nostr. Learn how its three-layer encryption protocol ensures sender-anonymous, end-to-end encrypted messaging over Nostr relays.

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

---

**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`](https://github.com/permissionlesstech/bitchat/blob/main/bitchat/Nostr/NostrProtocol.swift), the rumor is constructed as an `EventKind.dm` with no signature:

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

1. Serialize the unsigned rumor to JSON
2. Derive a shared secret via **ECDH** between sender and recipient keys
3. Encrypt using **XChaCha20-Poly1305** with the derived key
4. Sign the resulting ciphertext with the sender's real identity key

The implementation lives in private helpers `createSeal` and `openSeal` within [`NostrProtocol.swift`](https://github.com/permissionlesstech/bitchat/blob/main/NostrProtocol.swift):

```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`](https://github.com/permissionlesstech/bitchat/blob/main/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`](https://github.com/permissionlesstech/bitchat/blob/main/NostrProtocol.swift) handles this:

```swift
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 `p` tag 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`](https://github.com/permissionlesstech/bitchat/blob/main/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 |

```swift
// 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`](https://github.com/permissionlesstech/bitchat/blob/main/bitchat/Nostr/NostrProtocol.swift) | Core state machine: `createPrivateMessage`, `decryptPrivateMessage`, `createSeal`, `unwrapGiftWrap` |
| [`bitchat/Nostr/XChaCha20Poly1305Compat.swift`](https://github.com/permissionlesstech/bitchat/blob/main/bitchat/Nostr/XChaCha20Poly1305Compat.swift) | Low-level crypto: `seal()`, `open()`, nonce handling, sub-key derivation |
| [`bitchat/Nostr/NostrIdentity.swift`](https://github.com/permissionlesstech/bitchat/blob/main/bitchat/Nostr/NostrIdentity.swift) | Schnorr key operations: `sharedSecret()`, `sign()`, `generate()` |
| [`bitchat/Nostr/Bech32.swift`](https://github.com/permissionlesstech/bitchat/blob/main/bitchat/Nostr/Bech32.swift) | `npub`/`nsec` encoding for key display |
| [`bitchat/Nostr/Base64URLCoding.swift`](https://github.com/permissionlesstech/bitchat/blob/main/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`](https://github.com/permissionlesstech/bitchat/blob/main/XChaCha20Poly1305Compat.swift)
- **Hard limits** (64 KiB, single `p` tag) 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`](https://github.com/permissionlesstech/bitchat/blob/main/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.