Merge 286efb0f38720b4c42d13518a8b7a35b5f7da395 into 1f59e814f90c3f489f48d68262cb1bf640bf6181

This commit is contained in:
Yashodhan Singh Rathore 2026-08-07 01:16:44 +05:30 committed by GitHub
commit 0418fd1020
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1280 additions and 0 deletions

143
spec/01-wire-format.md Normal file
View File

@ -0,0 +1,143 @@
# Wire Format
This chapter defines the byte-level encoding of a bitchat packet: the fixed header, the variable sections that follow it, padding, and the two general-purpose TLV (type-length-value) framings used elsewhere in this specification. Application-layer payload catalogs (which TLV types exist and what their values mean) are defined in the chapters that own them, not here; this chapter defines only the grammar those catalogs are written in.
## 1. Packet Versions
A packet carries one of two version numbers, `1` or `2`, as the first byte of its header. The two versions share the same field order and differ only in:
- the width of the `payloadLength` field (2 bytes for v1, 4 bytes for v2), and
- the availability of the optional source-route section, which v1 packets MUST NOT carry.
A decoder MUST reject a packet whose version byte is neither `1` nor `2`.
## 2. Header Layout
All multi-byte integer fields, in the header and everywhere else in this chapter, are big-endian (network byte order) unless stated otherwise.
```
+--------+------+-----+------------------------+-------+------------------+
|Version | Type | TTL | Timestamp | Flags | PayloadLength |
|1 byte |1 byte|1byte| 8 bytes |1 byte | 2 or 4 bytes |
+--------+------+-----+------------------------+-------+------------------+
offset 0 1 2 3 11 12
```
| Offset | Length (bytes) | Field | Description |
|---|---|---|---|
| 0 | 1 | `version` | `1` or `2`. Selects the header size and `payloadLength` width. |
| 1 | 1 | `type` | The message type. See [Message Types](#7-message-types). |
| 2 | 1 | `ttl` | Hop-count budget. Decremented by each relay; a packet MUST NOT be relayed once its `ttl` reaches `0`. |
| 3 | 8 | `timestamp` | Milliseconds since the Unix epoch. |
| 11 | 1 | `flags` | Bitfield. See [Flags](#3-flags). |
| 12 | 2 (v1) / 4 (v2) | `payloadLength` | Length, in bytes, of the `payload` section only (see [Payload and Compression](#43-payload-and-compression)). Excludes the source-route section. |
The header is therefore **14 bytes for v1** and **16 bytes for v2** — the only difference is the width of `payloadLength`, not an added field. A v1 packet's `payloadLength` is a 16-bit field, so its payload section is bounded to 65,535 bytes; a v2 packet's 32-bit `payloadLength` raises that ceiling, subject to whatever transport-level limits the carrying link imposes (see the BLE Transport chapter).
## 3. Flags
The `flags` byte is a bitfield, bit 0 the least significant:
| Bit | Value | Name | Meaning |
|---|---|---|---|
| 0 | 0x01 | `hasRecipient` | The 8-byte `recipientID` section is present. |
| 1 | 0x02 | `hasSignature` | The 64-byte `signature` section is present. |
| 2 | 0x04 | `isCompressed` | The `payload` section is compressed; see [Payload and Compression](#43-payload-and-compression). |
| 3 | 0x08 | `hasRoute` | The source-route section is present. MUST NOT be set on a v1 packet. |
| 4 | 0x10 | `isRSR` | Marks the packet as a solicited response to a prior sync request. This bit is excluded from the signed frame (see [Signing](#5-signing)) because it is set after signing and MAY change during relay. Its consumption is defined in the Store and Forward chapter. |
| 57 | 0x200x80 | reserved | MUST be `0` on encode. A decoder MUST ignore reserved bits rather than reject the packet, to allow future extension. |
## 4. Variable Sections
Following the header, sections appear in this fixed order. Each is present only under the condition given; absent sections contribute no bytes.
| Section | Size | Present when |
|---|---|---|
| `senderID` | 8 bytes, fixed | always |
| `recipientID` | 8 bytes, fixed | `hasRecipient` |
| route | 1-byte hop count + 8 bytes/hop | `hasRoute` (v2 only) |
| `payload` | `payloadLength` bytes (optionally prefixed by a 2/4-byte original-size field; see below) | always |
| `signature` | 64 bytes, fixed | `hasSignature` |
### 4.1 Sender ID and Recipient ID
`senderID` and `recipientID` are each 8-byte `peer ID` values (see the glossary in [`README.md`](README.md)). `recipientID` is present only when `hasRecipient` is set; its absence marks the packet as a broadcast rather than a directed send.
### 4.2 Source Route
A v2 packet MAY carry an explicit `source route`: a 1-byte hop count `N`, followed by `N` 8-byte peer IDs, in traversal order. This section is present only when `hasRoute` is set, and its bytes are **not** counted in `payloadLength`. `N` MUST NOT exceed 255 (it is bounded by the 1-byte count prefix).
### 4.3 Payload and Compression
The `payload` section is `payloadLength` bytes. When `isCompressed` is set, the first bytes of the payload section are an original-size preamble — 2 bytes for a v1 packet, 4 bytes for a v2 packet, big-endian — giving the decompressed size, followed by the compressed bytes; both the preamble and the compressed bytes are counted in `payloadLength`. When `isCompressed` is not set, the payload section is the payload bytes verbatim.
The interpretation of the (decompressed) payload bytes depends on `type`; see the Payloads, Noise, and Store and Forward chapters for the payload encodings each message type carries.
### 4.4 Signature
When `hasSignature` is set, a 64-byte Ed25519 signature follows the payload section. See [Signing](#5-signing) for what is signed.
## 5. Signing
The signature, when present, is computed over the packet's encoded bytes with three substitutions: the `signature` section itself is omitted, `ttl` is fixed to `0` regardless of the packet's actual TTL, and the frame is always padded per [Padding](#6-padding) — even for a message type that §6 transmits unpadded. `ttl` is excluded because a relay decrementing it in place would otherwise invalidate every signed packet it forwards. `isRSR` is likewise excluded, being set after the packet is signed.
A verifier MUST reconstruct the same fixed-TTL, signature-omitted, RSR-omitted, **padded** frame before checking a signature against it, regardless of whether the packet as received on the wire carried padding.
## 6. Padding
This section defines the padding algorithm and states which message types carry padding **on the wire**. The signing transcript is a separate case: it is always padded by this same algorithm regardless of message type (see [Signing](#5-signing)), because the signature is computed before the type-dependent choice of whether to pad the transmitted frame is applied.
Only `noiseHandshake` and `noiseEncrypted` packets are padded on the wire; every other message type is transmitted at its natural length. Padding is applied to the full encoded frame (header through payload, before the signature section) and is PKCS#7-style: the pad length is appended as that many bytes, each byte equal to the pad length itself.
Padding targets the smallest of the block sizes `256`, `512`, `1024`, `2048` bytes that the frame (plus a 16-byte allowance for encryption overhead) fits into. Because the pad length must fit in a single byte, a frame that would need more than 255 bytes of padding to reach its target block is emitted **unpadded** instead of padded to a smaller-than-optimal bucket. A decoder MUST attempt to decode a frame as unpadded first, and only on failure retry after stripping trailing PKCS#7 padding.
## 7. Message Types
The `type` byte selects both the message's purpose and, indirectly, the shape of its payload:
| Value | Name |
|---|---|
| 0x01 | `announce` |
| 0x02 | `message` |
| 0x03 | `leave` |
| 0x04 | `courierEnvelope` |
| 0x10 | `noiseHandshake` |
| 0x11 | `noiseEncrypted` |
| 0x20 | `fragment` |
| 0x21 | `requestSync` |
| 0x22 | `fileTransfer` |
| 0x23 | `boardPost` |
| 0x24 | `prekeyBundle` |
| 0x25 | `groupMessage` |
| 0x26 | `ping` |
| 0x27 | `pong` |
| 0x28 | `nostrCarrier` |
| 0x29 | `voiceFrame` |
Each type's payload encoding is defined in the chapter that owns it (Payloads, Noise, Store and Forward, BLE Transport, or Nostr Bridge). A decoder MUST skip — not reject the enclosing packet for — a `type` value it does not recognize, to allow forward-compatible extension.
## 8. TLV Encodings
Two distinct TLV (type-length-value) byte framings are used across this specification. They are structurally different — most notably in the width of the length field — so this chapter names them distinctly rather than describing one universal "TLV format." Both use unknown-type-skip decoding: a decoder MUST skip a TLV entry whose type it does not recognize (using the entry's length to find the next one) rather than rejecting the payload that contains it.
### 8.1 TLV-8
```
+------+--------+-------------------+
| Type | Length | Value |
|1 byte|1 byte | Length bytes |
+------+--------+-------------------+
```
`Length` is the number of bytes in `Value`, as an unsigned 8-bit integer — a single TLV-8 entry's value is therefore at most 255 bytes. This framing is used by the `announce` payload and by gossip neighbor lists; see the Payloads chapter for the type catalog.
### 8.2 TLV-16
```
+------+-----------------+-------------------+
| Type | Length | Value |
|1 byte| 2 bytes (BE) | Length bytes |
+------+-----------------+-------------------+
```
`Length` is the number of bytes in `Value`, as a big-endian unsigned 16-bit integer. This framing is used by `prekeyBundle` payloads (see the Noise chapter) and `courierEnvelope` payloads (see the Store and Forward chapter).

71
spec/02-ble-transport.md Normal file
View File

@ -0,0 +1,71 @@
# BLE Transport
This chapter defines bitchat's Bluetooth Low Energy transport: the GATT service and characteristic peers connect over, the MTU-driven fragmentation scheme that lets a `bitchat packet` exceed a single BLE write, and the advertising/scanning behavior peers use to find each other. It does not define the packet header or payload encodings themselves; see the Wire Format chapter for those.
## 1. GATT Service and Characteristic
A bitchat node runs both the GATT peripheral and central roles simultaneously, so it can be discovered by, and discover, other nodes without a fixed client/server split.
| Item | Value |
|---|---|
| Service UUID | `F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C` |
| Characteristic UUID | `A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D` |
| Characteristic properties | notify, write, write-without-response, read |
There is a single characteristic on the service; it is the sole data channel, carrying every `bitchat packet` in both directions. There is no separate read/write characteristic pair.
## 2. MTU and Fragment Sizing
BLE links in this specification are sized to a 512-byte MTU ceiling. A sender's default per-fragment chunk size is 469 bytes, leaving headroom for the fragment header (see [Fragment Header](#31-fragment-header)), the enclosing packet's own header and ID sections, and encryption overhead when the fragment rides inside a `noiseEncrypted` packet.
## 3. Fragmentation
A packet whose encoded size exceeds the link MTU MUST be split into `fragment` (`0x20`) packets (see the Wire Format chapter's [Message Types](01-wire-format.md#7-message-types)) and reassembled at the receiving node. Each fragment carries one slice of the original packet's encoded bytes as its payload; the original packet's own header and sections are not re-transmitted per fragment beyond what is captured by the fragment header below.
### 3.1 Fragment Header
```
+----------------+-------------+-------------+------------+----------------+
| Fragment ID | Index | Total | Orig. Type | Fragment Data |
| 8 bytes | 2 bytes | 2 bytes | 1 byte | variable |
+----------------+-------------+-------------+------------+----------------+
offset 0 8 10 12 13
```
| Offset | Length (bytes) | Field | Description |
|---|---|---|---|
| 0 | 8 | `fragmentID` | A random identifier generated per fragmented stream, shared by every fragment in that stream. |
| 8 | 2 | `index` | This fragment's zero-based position within the stream, big-endian. |
| 10 | 2 | `total` | The total number of fragments in the stream, big-endian. |
| 12 | 1 | `originalType` | The `type` byte of the packet being fragmented (see [Message Types](01-wire-format.md#7-message-types)), so the receiver knows how to interpret the reassembled bytes. |
| 13 | variable | `fragmentData` | One contiguous slice of the original packet's encoded bytes. |
### 3.2 Fragment Cap and Lifetime
A receiver MUST reject a fragment stream whose `total` exceeds 10,000; this is the general reassembly ceiling and applies regardless of the fragmented packet's type.
A stricter cap applies to one case: a directed `fileTransfer (0x22)` packet (the legacy migration fallback for private media, used when the recipient has not advertised the `privateMedia` capability — see the Payloads chapter's [§5](04-payloads.md#5-peer-state-and-capabilities)) MUST NOT be split into more than 256 fragments, and a receiver MAY reject such a stream if `total` exceeds that lower cap. This exists as a cross-platform contract with clients whose reassembler enforces a 256-fragment ceiling on that path specifically; implementations that accept larger fragment counts on it still MUST NOT rely on peers doing the same. Public files and capability-gated encrypted private media (carried as `noiseEncrypted` fragments to a peer advertising `privateMedia`) are bound only by the general 10,000-fragment ceiling above.
A receiving node reassembles fragments for a stream by collecting them keyed by `(sender, fragmentID)` until the number received equals `total`, then concatenates them in `index` order to recover the original packet's encoded bytes, which are then decoded per the Wire Format chapter using `originalType`. A stream with no new fragment arriving for 30 seconds is considered stalled; a node SHOULD request the missing fragments (see the Store and Forward chapter's sync mechanism) rather than discarding the stream outright.
Per-node limits on concurrent in-flight reassemblies and their buffered byte size are resource guards, not part of the wire protocol — they are implementation-defined and MAY differ between nodes without affecting interop.
### 3.3 Route-Aware Fragmentation (v2)
When the packet being fragmented carries a v2 [source route](README.md#glossary), the fragment-carrying packets MAY themselves carry that same source route so each fragment follows the same explicit path. Doing so adds the route section's bytes to every fragment's overhead, so a sender MAY shrink its per-fragment chunk size below the 469-byte default to keep the outer packet within the MTU, floored at a 64-byte minimum chunk size.
## 4. Advertising and Scanning
### 4.1 Advertisement Contents
A node's BLE advertisement (and any scan-response packet) MUST carry only the service UUID from [§1](#1-gatt-service-and-characteristic). It MUST NOT carry the device's local name, TX power level, peer ID, or any other peer-identifying bytes — advertisement contents are the only signal visible to a passive scanner before a connection and handshake establish identity, and leaking a stable identifier there undermines that.
A peer's `peer ID` is learned only after connecting and exchanging an `announce` packet over the characteristic, never from the advertisement itself.
### 4.2 Scanning
A central-role node scans filtered to the service UUID from [§1](#1-gatt-service-and-characteristic) and, on connecting, (re)discovers that same service and its characteristic before use.
### 4.3 Advertising and Scan Interval
The physical advertising interval and scan duty cycle are not part of the wire protocol — they are implementation-defined, since the platform BLE stack, not the application, ultimately governs them. Implementations MAY duty-cycle scanning or vary advertising parameters for power management without affecting interop, provided the contents in [§4.1](#41-advertisement-contents) and the service/characteristic in [§1](#1-gatt-service-and-characteristic) are unchanged.

168
spec/03-noise.md Normal file
View File

@ -0,0 +1,168 @@
# Noise
This chapter defines bitchat's use of the Noise Protocol Framework: the `XX` handshake pattern and post-handshake transport framing for live, bidirectional sessions between connected peers, and the one-way `X` pattern used to seal store-and-forward mail (`courier envelope`s) when no live session exists. It also defines the `prekeyBundle` wire packet, which provisions the one-time keys the `X` path seals against.
It does not define the field catalog of application payloads carried inside a live session (see the Payloads chapter) or the `courierEnvelope` TLV that wraps an `X`-sealed message for physical or relayed carriage (see the Store and Forward chapter) — only the cryptographic framing those chapters build on.
## 1. Cipher Suite
Both handshake patterns in this chapter use the same primitives: Curve25519 for Diffie-Hellman, ChaCha20-Poly1305 for AEAD, and SHA-256 for hashing and key derivation — `Noise_XX_25519_ChaChaPoly_SHA256` for §2 and `Noise_X_25519_ChaChaPoly_SHA256` for §4.
## 2. Live Sessions: the XX Pattern
Two peers with an active BLE connection establish a `Noise session` with the interactive, mutually-authenticating `XX` pattern before exchanging any private payload.
### 2.1 Handshake Message Sequence
```
Initiator Responder
--------- ---------
-> e 32 bytes
<- e, ee, s, es 96 bytes
-> s, se 48 bytes
```
| Message | Direction | Tokens | Size (bytes) | Contents |
|---|---|---|---|---|
| 1 | initiator → responder | `e` | 32 | initiator's ephemeral public key, cleartext |
| 2 | responder → initiator | `e, ee, s, es` | 96 | responder's ephemeral public key (32 bytes, cleartext), then responder's static public key encrypted under the running key (32-byte ciphertext + 16-byte Poly1305 tag) |
| 3 | initiator → responder | `s, se` | 48 | initiator's static public key encrypted under the running key (32-byte ciphertext + 16-byte Poly1305 tag) |
A handshake message MUST NOT exceed 2048 bytes.
On completion of message 3, both sides derive a pair of directional transport cipher keys via the standard Noise `Split()` function: the initiator's send key is the responder's receive key, and vice versa.
### 2.2 Wire Carriage of Handshake Messages
Each handshake message's bytes, exactly as produced in [§2.1](#21-handshake-message-sequence), ride **unwrapped** as the entire `payload` section of a `noiseHandshake (0x10)` packet (see the Wire Format chapter's [Message Types](01-wire-format.md#7-message-types)). No additional TLV or length framing wraps a handshake message; the packet header's `payloadLength` field already bounds it.
## 3. Post-Handshake Transport
Once a session's transport ciphers are derived ([§2.1](#21-handshake-message-sequence)), every subsequent private message between the two peers is carried as a `noiseEncrypted (0x11)` packet.
### 3.1 Transport Ciphertext Framing
A `noiseEncrypted` packet's `payload` section is:
```
+------------------+-------------------------------+----------------+
| Nonce | Ciphertext | Tag |
| 4 bytes | variable | 16 bytes |
+------------------+-------------------------------+----------------+
```
| Offset | Length (bytes) | Field | Description |
|---|---|---|---|
| 0 | 4 | `nonce` | Big-endian message counter for this direction, prefixed explicitly rather than left implicit. |
| 4 | variable | `ciphertext` | The AEAD ciphertext of the application payload described in [§3.3](#33-application-payload-framing). |
| — | 16 | `tag` | ChaCha20-Poly1305 authentication tag, immediately following the ciphertext. |
The decrypted application plaintext MUST NOT exceed 65,535 bytes.
### 3.2 Replay Protection
A receiver validates an inbound `nonce` against a sliding window of the 1024 most recently accepted nonces for that direction. A receiver MUST reject a message whose `nonce` falls before the window or has already been accepted within it.
### 3.3 Application Payload Framing
The AEAD plaintext decrypted from a `noiseEncrypted` packet is itself framed as a 1-byte type tag followed by type-specific data, with no explicit length prefix — the boundary is the decrypted plaintext's own length:
```
+------+-------------------+
| Type | Data |
|1 byte| variable |
+------+-------------------+
```
| Value | Name |
|---|---|
| 0x01 | `privateMessage` |
| 0x02 | `readReceipt` |
| 0x03 | `delivered` |
| 0x06 | `groupInvite` |
| 0x07 | `groupKeyUpdate` |
| 0x08 | `voiceFrame` |
| 0x10 | `verifyChallenge` |
| 0x11 | `verifyResponse` |
| 0x12 | `vouch` |
| 0x20 | `privateFile` |
| 0x21 | `authenticatedPeerState` |
A decoder MUST also accept `0x09` as a legacy-decode alias for `privateFile (0x20)`, but MUST NOT emit `0x09` when encoding.
The field catalog for each of these types — including `authenticatedPeerState`, which binds a peer's signing key and capabilities to the session — is defined in the Payloads chapter.
## 4. Offline Seals: the X Pattern
When no live session exists to a recipient, a sender may instead seal a message directly to a key the recipient has published, using the one-way `X` pattern, and hand the result to a `courier envelope` for physical or relayed carriage (see the Store and Forward chapter).
### 4.1 Handshake Message
`X` is a single-message pattern: `-> e, es, s, ss`.
| Tokens | Size (bytes) | Contents |
|---|---|---|
| `e` | 32 | sender's ephemeral public key, cleartext |
| `es, s, ss` | 48 | sender's static public key encrypted under the running key (32-byte ciphertext + 16-byte Poly1305 tag) |
As with the `XX` handshake, this message MUST NOT exceed 2048 bytes. Because `X` is a one-way pattern, the sealed application payload is appended to the same message as AEAD ciphertext, encrypted under the key established immediately after the `s` token — the standard Noise mechanism for attaching a payload to a one-way handshake, and how a single `X` message doubles as a seal operation rather than a bare handshake. The resulting bytes (handshake fields plus sealed payload) are opaque to any party other than the recipient, and are carried as-is in the enclosing `courierEnvelope`'s `ciphertext` field (see the Store and Forward chapter).
### 4.2 Courier Envelopes
The default case seals to the recipient's long-term `static key` (see [glossary](README.md#glossary)), known to the sender from the recipient's `announcement` or prior verification. This path has **no forward secrecy**: a party who later compromises the recipient's static key can decrypt any previously captured seal made against it.
### 4.3 Prekey Envelopes
A sender who instead holds one of the recipient's published one-time `prekey`s seals to that prekey's public key rather than the recipient's static key. Because a prekey is consumed and discarded after a single use, this path provides forward secrecy that [§4.2](#42-courier-envelopes) lacks: compromising the recipient's long-term static key does not expose envelopes sealed under an already-discarded prekey. The enclosing `courierEnvelope`'s optional `prekeyID` field (see the Store and Forward chapter) identifies which prekey the sender consumed, so the recipient knows which private prekey to use in opening the seal.
A prekey MUST NOT be reused across more than one seal; once consumed, it MUST be discarded from future `prekeyBundle`s ([§5](#5-prekey-bundles)).
### 4.4 Domain Separation (Prologue)
Both seal types in this section use Noise's `prologue` mechanism — arbitrary bytes mixed into the handshake hash via `MixHash` before the first handshake message, per the Noise specification — to keep an `X`-pattern transcript from ever being confused with the `XX` handshake ([§2](#2-live-sessions-the-xx-pattern), which uses the default empty prologue) or with the other seal type. Because the prologue is mixed into the handshake hash, it participates in every subsequent key derivation; sealing and opening a given envelope MUST use bit-identical prologue bytes or the handshake fails to authenticate.
| Seal type | Prologue bytes |
|---|---|
| Courier envelope ([§4.2](#42-courier-envelopes), sealed to the recipient's static key) | ASCII `bitchat-courier-v1` (18 bytes) |
| Prekey envelope ([§4.3](#43-prekey-envelopes), sealed to a one-time prekey) | ASCII `bitchat-prekey-v1` (17 bytes) followed by the 4-byte big-endian `prekeyID` ([§5.2.1](#521-prekey-entry)) of the prekey being sealed against |
## 5. Prekey Bundles
A `prekey bundle` is how a device publishes a batch of one-time prekeys for other peers to seal [§4.3](#43-prekey-envelopes) envelopes against.
### 5.1 Wire Packet
A prekey bundle is carried as the payload of a `prekeyBundle (0x24)` packet (see the Wire Format chapter's [Message Types](01-wire-format.md#7-message-types)), using the TLV-16 framing (see the Wire Format chapter's [§8.2](01-wire-format.md#82-tlv-16)).
### 5.2 Fields
| Type | Field | Length (bytes) | Description |
|---|---|---|---|
| 0x01 | `noiseStaticPublicKey` | 32 | The issuing peer's long-term Curve25519 static public key, included so a recipient can validate the bundle's signature ([§5.3](#53-signature)) without a separate lookup. |
| 0x02 | `prekeys` | variable | Repeated fixed-size entries (see [§5.2.1](#521-prekey-entry)). A sender MUST NOT include more than 8 entries in a single bundle. |
| 0x03 | `generatedAt` | 8 | Milliseconds since the Unix epoch at which the bundle was generated. |
| 0x04 | `signature` | 64 | Ed25519 signature over the canonical transcript defined in [§5.3](#53-signature), using the issuing peer's `signing key`. |
#### 5.2.1 Prekey Entry
Each entry in the `prekeys` field is 36 bytes, with no further framing between consecutive entries:
| Offset (within entry) | Length (bytes) | Field | Description |
|---|---|---|---|
| 0 | 4 | `prekeyID` | Big-endian identifier for this prekey, referenced by a `courierEnvelope`'s `prekeyID` field (see the Store and Forward chapter) once consumed. |
| 4 | 32 | `publicKey` | The one-time Curve25519 public key ([§4.3](#43-prekey-envelopes)). |
### 5.3 Signature
The `signature` field is computed over a canonical byte transcript distinct from the bundle's TLV encoding — not the raw concatenation of the preceding fields' TLV bytes. An encoder or verifier MUST construct this transcript as follows, in order:
| Bytes | Contents |
|---|---|
| 1 | Length of the domain string below, as an unsigned 8-bit integer: `24`. |
| 24 | ASCII domain string `bitchat-prekey-bundle-v1`. |
| 32 | `noiseStaticPublicKey` ([§5.2](#52-fields)), raw. |
| 1 | Number of `prekeys` entries, as an unsigned 8-bit integer. |
| 36 × count | Each `prekeys` entry ([§5.2.1](#521-prekey-entry)) in order: 4-byte big-endian `prekeyID` followed by the 32-byte raw `publicKey` — the same layout as the wire encoding, not separately TLV-framed. |
| 8 | `generatedAt` ([§5.2](#52-fields)), big-endian. |
A recipient MUST verify the `signature` field against this transcript, using the issuing peer's known `signing key`, before trusting any prekey the bundle carries, and MUST discard the bundle if verification fails.

293
spec/04-payloads.md Normal file
View File

@ -0,0 +1,293 @@
# Payloads
This chapter catalogs bitchat's application-layer payload encodings: the field-by-field contents carried by the outer, plaintext `MessageType` packets that are not owned by another chapter, and by the `NoisePayloadType` values carried inside a `noiseEncrypted (0x11)` packet's application-payload framing (see the Noise chapter's [§3.3](03-noise.md#33-application-payload-framing)). The Wire Format chapter's [Message Types](01-wire-format.md#7-message-types) table lists every `type` value; this chapter defines the payload shape for each one it owns.
Not owned by this chapter: `noiseHandshake`/`noiseEncrypted` framing itself and `prekeyBundle` (Noise chapter), `courierEnvelope` and `requestSync` (Store and Forward chapter), `fragment` (BLE Transport chapter), and `nostrCarrier` (Nostr Bridge chapter).
## 1. Outer and Inner Payloads
An outer payload is the `payload` section of a plaintext `BitchatPacket` — its type is visible to every relay. An inner payload is one of the eleven `NoisePayloadType` values, visible only to the two ends of a `Noise session`. A handful of payload shapes — files and voice bursts — are carried both ways: the identical wire body rides as an outer `fileTransfer`/`voiceFrame` packet when sent publicly, and as an inner `privateFile`/`voiceFrame` payload when sent to one peer over a session. Each section below states which case it covers.
## 2. Presence: Announce
`announce (0x01)` is a signed, plaintext broadcast (`AnnouncementPacket`) by which a device identifies itself. Its payload is a [TLV-8](01-wire-format.md#81-tlv-8) sequence:
| Type | Field | Length (bytes) | Description |
|---|---|---|---|
| 0x01 | `nickname` | ≤255, UTF-8 | Display name. |
| 0x02 | `noisePublicKey` | 32 | The device's Noise `static key`. |
| 0x03 | `signingPublicKey` | 32 | The device's Ed25519 `signing key`. |
| 0x04 | `directNeighbors` | multiple of 8, optional | Up to 10 direct-neighbor `peer ID`s, concatenated. |
| 0x05 | `capabilities` | 18, optional | `PeerCapabilities` bitfield ([§5.1](#51-peercapabilities-bitfield)). |
| 0x06 | `bridgeGeohash` | ≤12, optional, UTF-8 | Coarse geohash cell this peer bridges to Nostr, present only when advertising the `bridge` capability. Full encoding is defined in the Nostr Bridge chapter. |
`nickname`, `noisePublicKey`, and `signingPublicKey` are REQUIRED; a decoder MUST reject an `announce` payload missing any of them. `directNeighbors`, `capabilities`, and `bridgeGeohash` are each OPTIONAL and MAY be absent, including from clients that predate them.
## 3. Public and Private Messages
### 3.1 Public Message
`message (0x02)` is a signed, plaintext broadcast. Its payload is the message's UTF-8 content bytes verbatim — no TLV framing and no length prefix beyond the enclosing packet's `payloadLength`.
### 3.2 Private Message
`privateMessage (0x01)` is an inner payload carrying a one-to-one message inside a `Noise session`. Its data is a [TLV-8](01-wire-format.md#81-tlv-8) sequence:
| Type | Field | Length (bytes) | Description |
|---|---|---|---|
| 0x00 | `messageID` | ≤255, UTF-8 | Sender-assigned identifier, echoed by [§4](#4-delivery-and-read-acknowledgement) acknowledgements. |
| 0x01 | `content` | ≤255, UTF-8 | Message text. |
### 3.3 Leave
`leave (0x03)` is a signed, plaintext, empty-payload broadcast a device sends when it is about to disconnect.
## 4. Delivery and Read Acknowledgement
`delivered (0x03)` and `readReceipt (0x02)` are inner payloads acknowledging a `privateMessage`. Each carries no TLV framing: the payload is the acknowledged message's `messageID` as raw UTF-8 bytes, verbatim.
## 5. Peer State and Capabilities
`authenticatedPeerState (0x21)` is an inner payload binding a peer's signing key and feature capabilities to the session, so a receiver need not trust the plaintext `announce` for this information. Its data is a 1-byte version tag followed by a [TLV-8](01-wire-format.md#81-tlv-8) sequence:
```
+---------+------------------------------+
| Version | TLV |
| 1 byte | variable |
+---------+------------------------------+
```
| Type | Field | Length (bytes) | Description |
|---|---|---|---|
| 0x01 | `capabilities` | 18 | `PeerCapabilities` bitfield ([§5.1](#51-peercapabilities-bitfield)), minimal little-endian encoding. |
| 0x02 | `signingPublicKey` | 32 | The peer's Ed25519 `signing key`. |
`version` MUST be `0x01`; a decoder MUST reject any other value. A decoder MUST reject a payload with a duplicate TLV entry, and MUST reject a `capabilities` TLV that is not the minimal (trailing-zero-byte-stripped) encoding described in [§5.1](#51-peercapabilities-bitfield).
### 5.1 `PeerCapabilities` Bitfield
`PeerCapabilities` is a little-endian bitfield, encoded as the fewest whole bytes needed to represent its highest set bit (at least 1 byte when non-empty; an encoder omits trailing all-zero bytes). A decoder accepts any length and keeps only the low 64 bits, so an unrecognized high bit set by a newer client round-trips without corrupting bits the decoder does understand. This encoding is used both standalone (`announce`'s `capabilities` TLV, [§2](#2-presence-announce)) and inside `authenticatedPeerState` ([§5](#5-peer-state-and-capabilities)).
| Bit | Name | Meaning |
|---|---|---|
| 0 | `prekeys` | Peer publishes `prekey bundle`s. |
| 1 | `wifiBulk` | Peer supports bulk transfer over a local Wi-Fi side channel. |
| 2 | `gateway` | Peer relays between the mesh and Nostr (see the Nostr Bridge chapter). |
| 3 | `groups` | Peer supports private groups ([§7](#7-private-groups)). |
| 4 | `board` | Peer supports board posts ([§6](#6-board-posts)). |
| 5 | `vouch` | Peer supports web-of-trust vouching ([§12](#12-web-of-trust-vouch)). |
| 6 | `meshDiagnostics` | Peer responds to `ping`/`pong` ([§10](#10-mesh-diagnostics)). |
| 7 | `bridge` | Peer advertises a Nostr bridge rendezvous geohash ([§2](#2-presence-announce)). |
| 8 | `privateMedia` | Peer accepts private files/voice over a session ([§8](#8-files), [§9](#9-voice)). |
| 9 | `privateMediaReceipts` | Peer sends delivery/read acknowledgements for private media. |
| 10 | `nonDestructiveNoiseReplacement` | Reserved; never advertised by a conforming encoder. |
| 1163 | reserved | MUST be `0` on encode. A decoder MUST preserve, not reject on, an unrecognized set bit. |
## 6. Board Posts
`boardPost (0x23)` is a signed, plaintext broadcast (`BoardWire`) carrying a bulletin-board post or its deletion tombstone, scoped either to the local mesh (empty `geohash`) or to a Nostr Bridge geohash region. Its payload is a [TLV-16](01-wire-format.md#82-tlv-16) sequence:
| Type | Field | Length (bytes) | Description |
|---|---|---|---|
| 0x01 | `kind` | 1 | `0x01` post, `0x02` tombstone. Selects which of the remaining fields apply. |
| 0x02 | `postID` | 16 | Random identifier, shared between a post and its tombstone. |
| 0x03 | `geohash` | ≤12, UTF-8 | Empty for the mesh-local board; post only. |
| 0x04 | `content` | 1512, UTF-8 | Post body; post only. |
| 0x05 | `authorSigningKey` | 32 | Author's Ed25519 `signing key`; both kinds. |
| 0x06 | `authorNickname` | ≤64, UTF-8 | Post only. |
| 0x07 | `createdAt` | 8 | Milliseconds since the Unix epoch; post only. |
| 0x08 | `expiresAt` | 8 | Milliseconds since the Unix epoch; MUST NOT exceed `createdAt` plus 7 days; post only. |
| 0x09 | `flags` | 1 | Bit 0 = urgent; post only. |
| 0x0A | `signature` | 64 | Ed25519 signature ([§6.1](#61-signing)); both kinds. |
| 0x0B | `deletedAt` | 8 | Milliseconds since the Unix epoch; tombstone only. |
A decoder MUST reject a payload whose `kind` is absent or unrecognized, or that is missing a field its `kind` requires.
### 6.1 Signing
Both kinds' `signature` open with their ASCII context string preceded by its own 1-byte length (unlike the length-prefixed fields later in the transcript, this length is a single byte, not 2-byte big-endian) — `0x10` (16) + `bitchat-board-v1` for a post, `0x14` (20) + `bitchat-board-del-v1` for a tombstone.
A **post**'s `signature` covers, concatenated in this order: the length-prefixed context string `bitchat-board-v1`; `postID`; `geohash` and `content` each preceded by their own 2-byte big-endian length; `authorSigningKey`; `authorNickname` preceded by its 2-byte big-endian length; `createdAt`; `expiresAt`; and `flags`.
A **tombstone**'s `signature` covers: the length-prefixed context string `bitchat-board-del-v1`, `postID`, and `deletedAt`. Only the original post's `authorSigningKey` can produce a valid tombstone for it.
## 7. Private Groups
A private group's roster and symmetric key are distributed over a `Noise session` as `groupInvite (0x06)` or `groupKeyUpdate (0x07)` — the same wire shape (`GroupStatePayload`) for both; the two `NoisePayloadType` values distinguish an initial invite from a later key rotation or roster update, but nothing inside the payload itself does. A receiver MUST require that the `Noise session` peer delivering this payload be the group's creator, per [§7.1](#71-signing).
Its data is a [TLV-16](01-wire-format.md#82-tlv-16) sequence:
| Type | Field | Length (bytes) | Description |
|---|---|---|---|
| 0x01 | `groupID` | 16 | Random identifier. |
| 0x02 | `name` | variable, UTF-8 | Display name. |
| 0x03 | `key` | 32 | Symmetric ChaCha20-Poly1305 key for the epoch named below. |
| 0x04 | `epoch` | 4 | Big-endian; bumped on every key rotation. |
| 0x05 | `roster` | variable | Member list ([§7.2](#72-roster-encoding)). |
| 0x06 | `creatorFingerprint` | 32 | SHA-256 fingerprint of the creator's Noise `static key`. |
| 0x07 | `signature` | 64 | Ed25519 signature ([§7.1](#71-signing)) by the creator. |
A group has at most 16 members. A decoder MUST reject a payload with more than 16 roster entries, or whose `creatorFingerprint` does not match a fingerprint present in `roster`.
### 7.1 Signing
`signature` covers, concatenated: the ASCII context string `bitchat-group-v1`; `groupID`; `epoch` (4-byte big-endian); the SHA-256 hash of `key`; the SHA-256 hash of the encoded `roster`; and the SHA-256 hash of `name`'s UTF-8 bytes. A receiver MUST verify this signature against the signing key of the roster member whose fingerprint equals `creatorFingerprint`, and MUST reject the payload if that member is absent from the roster or the signature does not verify.
### 7.2 Roster Encoding
`roster` is a count byte followed by that many fixed-plus-length-prefixed entries, with no framing between entries:
```
+-------+-------------------------------------------------------+
| Count | Member × Count |
|1 byte | |
+-------+-------------------------------------------------------+
```
Each member entry is:
| Offset (within entry) | Length (bytes) | Field | Description |
|---|---|---|---|
| 0 | 32 | `fingerprint` | SHA-256 fingerprint of the member's Noise `static key`. |
| 32 | 32 | `signingKey` | The member's Ed25519 `signing key`. |
| 64 | 1 | `nicknameLength` | Length of the field below, in bytes. |
| 65 | `nicknameLength` | `nickname` | UTF-8, truncated to at most 64 bytes on a whole-character boundary. |
### 7.3 Group Message
`groupMessage (0x25)` is an outer, plaintext-framed broadcast whose payload (`GroupMessageEnvelope`) is a [TLV-16](01-wire-format.md#82-tlv-16) sequence:
| Type | Field | Length (bytes) | Description |
|---|---|---|---|
| 0x01 | `groupID` | 16 | Identifies the group; visible to relays. |
| 0x02 | `epoch` | 4 | Big-endian; visible to relays, so a stale-epoch message can be dropped without decrypting. |
| 0x03 | `nonce` | 12 | ChaCha20-Poly1305 nonce. |
| 0x04 | `ciphertext` | variable | AEAD ciphertext (message content, see [§7.4](#74-group-message-plaintext)) plus its 16-byte trailing tag. |
The AEAD is ChaCha20-Poly1305, keyed by the group's `key` for `epoch`, with associated data `groupID || epoch` (4-byte big-endian) — binding the envelope's visible routing fields to the ciphertext without encrypting them.
### 7.4 Group Message Plaintext
The AEAD plaintext, once decrypted, is itself a [TLV-16](01-wire-format.md#82-tlv-16) sequence:
| Type | Field | Length (bytes) | Description |
|---|---|---|---|
| 0x01 | `messageID` | variable, UTF-8 | Sender-assigned identifier. |
| 0x02 | `senderSigningKey` | 32 | The sending member's Ed25519 `signing key`, proving authorship within the group. |
| 0x03 | `senderNickname` | variable, UTF-8 | Sender's display name at send time. |
| 0x04 | `timestamp` | 8 | Milliseconds since the Unix epoch, big-endian. |
| 0x05 | `content` | variable, UTF-8 | Message text. |
| 0x06 | `signature` | 64 | Ed25519 signature by `senderSigningKey`. |
`signature` covers, concatenated: the ASCII context string `bitchat-group-msg-v1`; the enclosing envelope's `groupID` and `epoch` (4-byte big-endian); `messageID`; `timestamp` (8-byte big-endian); and `content`. A receiver MUST reject a group message whose `senderSigningKey` does not belong to a current member of the group, or whose signature does not verify.
## 8. Files
`privateFile (0x20)` (inner, carried in a `Noise session`) and `fileTransfer (0x22)` (outer, plaintext broadcast) share one wire body (`BitchatFilePacket`) for finalized file, image, and voice-note attachments. Its framing departs from both of this specification's general TLV grammars: it is 1-byte type plus a **2-byte big-endian length**, except the `content` field, whose length prefix is **4 bytes big-endian**:
| Type | Field | Length-field width | Description |
|---|---|---|---|
| 0x01 | `fileName` | 2 bytes | UTF-8, optional. |
| 0x02 | `fileSize` | 2 bytes | 4-byte big-endian `UInt32` byte count of `content`. |
| 0x03 | `mimeType` | 2 bytes | UTF-8, optional. |
| 0x04 | `content` | 4 bytes | Opaque file bytes. |
A decoder MUST additionally accept a legacy `fileSize` TLV whose length field reads `8` (an old 8-byte size encoding) and, if the 4-byte `content` length read does not fit the remaining bytes, MUST retry with a legacy 2-byte `content` length — both purely for backward decode compatibility; an encoder MUST NOT emit either legacy form.
An encoder MUST NOT emit a `content` field larger than 1 MiB (1,048,576 bytes) for a general file, or larger than 512 KiB (524,288 bytes) for a voice note or an image.
## 9. Voice
`voiceFrame (0x08)` (inner, private) and `voiceFrame (0x29)` (outer, public broadcast) share one wire body (`VoiceBurstPacket`) for a live push-to-talk audio burst. Unlike this chapter's other payloads, it is a fixed-field layout, not TLV:
```
+---------+-----+-------+------------------+
| BurstID | Seq | Flags | Payload |
| 8 bytes |2byte|1 byte | variable |
+---------+-----+-------+------------------+
```
| Offset | Length (bytes) | Field | Description |
|---|---|---|---|
| 0 | 8 | `burstID` | Identifies all frames of one burst. |
| 8 | 2 | `seq` | Big-endian sequence number within the burst. |
| 10 | 1 | `flags` | `0x01` START, `0x02` END, `0x04` CANCELED, `0x00` data. |
| 11 | variable | `payload` | Depends on `flags` (below). |
`payload`'s shape depends on `flags`:
| `flags` | `payload` |
|---|---|
| START (`0x01`) | 1 byte: `codec`. `0x01` = `aacLC16kMono` (AAC-LC, 16 kHz, mono, ~16 kbps) — the only codec this specification defines. |
| data (`0x00`) | Repeated `[length: 2 bytes big-endian][AAC frame: length bytes]` entries. |
| END (`0x02`) | `[totalDataPackets: 2 bytes big-endian][durationMs: 4 bytes big-endian]`. |
| CANCELED (`0x04`) | Empty. |
## 10. Mesh Diagnostics
`ping (0x26)` and `pong (0x27)` are outer, unencrypted, unsigned, directed packets used to probe reachability and hop distance. Both share the fixed 9-byte layout `MeshPingPayload`:
```
+---------+-----------+
| Nonce | OriginTTL |
| 8 bytes | 1 byte |
+---------+-----------+
```
| Offset | Length (bytes) | Field | Description |
|---|---|---|---|
| 0 | 8 | `nonce` | Random for a `ping`; a `pong` echoes the `nonce` of the `ping` it answers. |
| 8 | 1 | `originTTL` | The packet's `ttl` ([Wire Format §2](01-wire-format.md#2-header-layout)) at the moment it was sent. |
A receiver computes hop count as `originTTL receivedTTL + 1` (the `+ 1` counts the final delivery link); a `receivedTTL` greater than `originTTL` indicates an inconsistent or forged pair and MUST be treated as unmeasurable rather than a negative hop count. A decoder MUST accept a payload longer than 9 bytes, ignoring the excess, so a future revision can extend the format without breaking older clients.
## 11. Identity Verification
`verifyChallenge (0x10)` and `verifyResponse (0x11)` are inner payloads implementing an out-of-band identity check: a party who has learned a peer's expected Noise static key through a side channel (e.g. an in-person QR scan) issues a challenge over the live session, and the other side must prove possession of the matching private key. This specification defines only the two payloads' wire shape; the side channel that conveys the expected key is implementation-defined.
`verifyChallenge`'s data is a [TLV-8](01-wire-format.md#81-tlv-8) sequence:
| Type | Field | Length (bytes) | Description |
|---|---|---|---|
| 0x01 | `noiseKeyHex` | ≤255, ASCII | Hex encoding of the Noise `static key` the challenger expects the session peer to hold. |
| 0x02 | `nonceA` | ≤255 | Challenge nonce, chosen by the challenger. |
`verifyResponse`'s data is a [TLV-8](01-wire-format.md#81-tlv-8) sequence:
| Type | Field | Length (bytes) | Description |
|---|---|---|---|
| 0x01 | `noiseKeyHex` | ≤255, ASCII | Echoed from the challenge. |
| 0x02 | `nonceA` | ≤255 | Echoed from the challenge. |
| 0x03 | `signature` | ≤255 | Ed25519 signature ([below](#111-response-signing)) by the responder's `signing key`. |
### 11.1 Response Signing
`signature` covers, concatenated: the ASCII context string `bitchat-verify-resp-v1`; `noiseKeyHex`'s ASCII bytes preceded by their own 1-byte length; and `nonceA`. The challenger MUST verify this signature against the responder's known `signing key` before treating the identity as verified.
## 12. Web of Trust: Vouch
`vouch (0x12)` is an inner payload carrying a batch of transitive-verification attestations: a signed statement, made by the session peer sending this payload, that they have separately verified some third party's identity. The voucher's own identity is implicit — it is whoever holds the `Noise session` this payload arrives on — so a receiver authenticates an attestation against the session peer's announce-bound `signing key`, not against anything named inside the attestation itself.
The batch body is a count byte followed by that many length-prefixed attestations:
```
+-------+---------------------------------------------------+
| Count | Attestation × Count |
|1 byte | |
+-------+---------------------------------------------------+
```
Each attestation entry is a 2-byte big-endian length followed by that many bytes of a [TLV-8](01-wire-format.md#81-tlv-8)-encoded attestation:
| Type | Field | Length (bytes) | Description |
|---|---|---|---|
| 0x01 | `voucheeFingerprint` | 32 | SHA-256 fingerprint of the vouched-for party's Noise `static key`. |
| 0x02 | `voucheeSigningKey` | 32 | The vouched-for party's Ed25519 `signing key`. |
| 0x03 | `timestamp` | 8 | Milliseconds since the Unix epoch, big-endian. |
| 0x04 | `signature` | 64 | Ed25519 signature ([below](#121-attestation-signing)) by the voucher's `signing key`. |
A batch MUST NOT carry more than 16 attestations. A receiver MUST reject an attestation whose `timestamp` is more than 30 days in the past or more than 1 hour in the future.
### 12.1 Attestation Signing
`signature` covers, concatenated: the ASCII context string `bitchat-vouch-v1`, `voucheeFingerprint`, `voucheeSigningKey`, and `timestamp` (8-byte big-endian). A receiver MUST verify this signature against the sending `Noise session` peer's announce-bound `signing key`.

View File

@ -0,0 +1,216 @@
# Store and Forward
This chapter covers how bitchat delivers a message to a peer who is not reachable right now: the BLE mesh's controlled-flood relay policy, the sender's own retry queue for private messages, the `courier envelope` mailbag that lets a third device carry sealed mail, and `gossip sync`, the periodic reconciliation that lets a peer catch up on public broadcast history it missed.
Not owned by this chapter: the source-route byte layout (Wire Format chapter [§4.2](01-wire-format.md#42-source-route)), the Noise `X` pattern used to seal a courier envelope's `ciphertext`, the sealing-to-a-prekey variant, and the `prekeyBundle (0x24)` packet itself ([§4](03-noise.md#4-offline-seals-the-x-pattern), [§4.3](03-noise.md#43-prekey-envelopes), and [§5](03-noise.md#5-prekey-bundles) of the Noise chapter, respectively), the inner payload field catalogs for messages the mechanisms here carry unopened (Payloads chapter), the `announce (0x01)` payload including its `directNeighbors` TLV ([§2](04-payloads.md#2-presence-announce) of the Payloads chapter), and the board post signing/tombstone/expiry model ([§6](04-payloads.md#6-board-posts) of the Payloads chapter). This chapter's own wire types are `courierEnvelope (0x04)` and `requestSync (0x21)` (see the Wire Format chapter's [Message Types](01-wire-format.md#7-message-types)). The Nostr-relay publish path a courier envelope may additionally take, and the NIP-level event encoding it uses, are specified in the Nostr Bridge chapter; this chapter defines only the store-and-forward-relevant parameters of that path (§3.6).
## 1. Mesh Relay and Flood Control
A `bitchat packet` reaches peers beyond direct radio range by controlled flooding: each relay that receives a packet not addressed to itself decides whether, and to whom, to re-send it. This section defines that decision.
### 1.1 Suppression
A relay MUST NOT re-send a packet it authored itself, a packet whose `recipientID` matches its own `peer ID`, or a packet whose `ttl` is `0` or `1` after decrement. A `requestSync (0x21)` packet MUST NEVER be relayed, regardless of its `ttl`: it is defined ([§4](#4-gossip-sync)) as link-local between the two ends of a single connection, and relaying it would let a crafted request replay a full sync round onto a next hop that never asked for one.
A relay SHOULD deduplicate packets it has already relayed, keyed by sender, timestamp, type, and payload, over a bounded recent-history window (RECOMMENDED: 1000 entries, 5-minute expiry) so redundant copies arriving from different neighbors after the first are dropped rather than re-flooded.
### 1.2 TTL
A packet's `ttl` field ([§2](01-wire-format.md#2-header-layout)) is set by its originator and decremented by each relay. The default originating `ttl` is `7` hops. A relay MAY clamp the `ttl` it forwards with below the incoming value, tuned to local connection degree (the number of currently connected links):
| Degree | Clamp |
|---|---|
| ≤ 2 (thin chain) | No clamp — relay at the full incoming depth; every hop matters and flood cost is minimal. |
| 35 | Clamp to 6 hops (7 for `announce` and an urgent `boardPost`). |
| ≥ 6 (dense) | Clamp to 5 hops. |
These thresholds are RECOMMENDED defaults for congestion control, not a cross-platform contract — an implementation MAY tune them without affecting interop, since every relay applies its own clamp independently and a receiver's decoding does not depend on which clamp the last hop used.
### 1.3 Fanout Subsetting
A broadcast packet (no `recipientID`) that is not a `fragment`, `announce`, or `requestSync` is, by default, **not** relayed to every connected link. A relay SHOULD instead select a deterministic pseudo-random subset of its links:
1. Start from every connected link, minus the link the packet arrived on (split horizon) and any explicitly excluded link. Where more than one link is bound to the same peer, collapse them to one.
2. If the resulting link count `n` is ≤ 2, the subset is all of them.
3. Otherwise the subset size is `k = bitlength(n 1) + 1` (equivalently `⌈log₂ n⌉ + 1`, clamped to `[1, n]`).
4. For each candidate link `id`, compute `SHA-256("{messageID}::{id}")`. Sort candidates by this digest (ties broken by `id`) and keep the `k` lowest.
`fragment`, `announce`, and `requestSync` packets bypass subsetting and go to every allowed link: fragments need every link a large transfer might be split across, announces bind links to peers and are already rate-limited, and `requestSync` is never relayed at all ([§1.1](#11-suppression)).
### 1.4 Directed Delivery
A packet with a single-peer `recipientID` is delivered, in order of preference:
1. **Direct link.** If a link is already bound to the recipient's `peer ID`, the packet goes only to that link.
2. **Source route.** Otherwise, the sender MAY attach a v2 [source route](01-wire-format.md#42-source-route) instead of falling back to flooding. A relay that receives a packet carrying a source route follows it hop-by-hop: it looks up its own `peer ID` in the route, forwards to the next entry (or to the packet's `recipientID` if it is the last entry), and decrements `ttl`. If the computed next hop is not currently connected, the relay falls back to flood relay rather than dropping the packet.
3. **Flood.** Otherwise the packet is relayed per [§1.21.3](#12-ttl).
### 1.5 Source-Route Origination Policy
An implementation MAY originate a v2 source route on a packet it authors. This chapter does not mandate that an implementation do so, but a conformant implementation that does MUST gate origination on all of the following, so that route attachment never produces an undeliverable or mis-signed packet:
- The packet is authored locally (a relay MUST NOT attach or alter a route on a packet it did not originate — that would invalidate the original signature).
- The packet has a single-peer `recipientID`.
- `ttl > 1`.
- The recipient is not already directly connected (a direct link already delivers in one hop).
- A complete path to the recipient is known, where every intermediate hop and the recipient have been observed speaking the v2 packet version — a v1-only peer cannot decode a v2 frame, so routing through one would silently drop the packet.
A relay SHOULD track per-recipient route health: if a routed send sees no inbound packet from that recipient within 10 seconds, subsequent directed sends toward it SHOULD fall back to flooding for 60 seconds before a route is attempted again.
## 2. Sender Outbox
The `sender outbox` is the persistent, per-peer retry queue for a private message the sender could not deliver promptly. An implementation MUST NOT discard such a message outright; it MUST be retained and retried as the recipient becomes reachable, until one of the following ends its wait: a `delivered (0x03)` or `readReceipt (0x02)` acknowledgement arrives ([§4](04-payloads.md#4-delivery-and-read-acknowledgement) of the Payloads chapter), or the message is dropped per the limits below.
RECOMMENDED limits: 100 queued messages per peer (oldest evicted first), a 24-hour retention TTL, and a cap of 8 retried send attempts before the message is dropped with a visible failure to the user. While queued, a message SHOULD also be offered to eligible couriers ([§3](#3-courier-envelopes)); a RECOMMENDED cap of 3 distinct couriers per message bounds how widely a single queued message spreads.
The outbox holds plaintext message content pending delivery, not wire-encoded packets. An implementation SHOULD persist it across restarts under encryption at rest, since it otherwise holds undelivered private content only the sender has seen.
## 3. Courier Envelopes
A `courier envelope` lets a message reach a recipient who is not reachable by any live transport, by handing a sealed, opaque copy to another device that may physically encounter the recipient later. The envelope's ciphertext is produced by the Noise `X` seal defined in the Noise chapter's [§4](03-noise.md#4-offline-seals-the-x-pattern); a courier that carries an envelope cannot decrypt it and learns neither the sender, the recipient, nor the content.
### 3.1 Wire Format
A `courierEnvelope (0x04)` packet's payload is a [TLV-16](01-wire-format.md#82-tlv-16) sequence:
| Type | Field | Length (bytes) | Description |
|---|---|---|---|
| 0x01 | `recipientTag` | 16 | Rotating recipient tag ([§3.2](#32-rotating-recipient-tag)). REQUIRED. |
| 0x02 | `expiry` | 8 | Milliseconds since epoch, big-endian, after which the envelope MUST be discarded. REQUIRED. |
| 0x03 | `ciphertext` | 116384 | The Noise `X`-sealed message. REQUIRED. |
| 0x04 | `copies` | 1 | Remaining spray-and-wait copy budget ([§3.4](#34-spray-and-wait)), 18. OPTIONAL — a decoder that does not find this TLV MUST treat the envelope as `copies = 1` (carry-only). |
| 0x05 | `prekeyID` | 4 | Big-endian identifier of the one-time `prekey` this envelope was sealed to (see the Noise chapter's [§4.3](03-noise.md#43-prekey-envelopes)). OPTIONAL — present only for a forward-secret seal; absent for a static-key seal. |
A decoder MUST reject an envelope missing `recipientTag`, `expiry`, or `ciphertext`, or whose `ciphertext` exceeds 16384 bytes. `copies` and `prekeyID` are each encoded only when their value needs stating (`copies` is omitted when it equals `1`; `prekeyID` is omitted for a static-key seal), so a static-sealed, unsprayed envelope stays byte-identical whether or not the sender or courier supports spraying or forward secrecy.
### 3.2 Rotating Recipient Tag
`recipientTag` is the only routing information a courier envelope carries. It is computed as:
```
epochDay = floor(unixSeconds / 86400)
recipientTag = HMAC-SHA256(recipientNoiseStaticKey, "bitchat-courier-tag-v1" || epochDay)[0..16]
```
where `epochDay` is encoded as a big-endian 32-bit integer appended to the ASCII context string before hashing. The tag is therefore computable only by a party that already knows the recipient's Noise `static key` — the same key an `announce` publishes in cleartext — and it rotates once per UTC day, so envelopes addressed to the same recipient on different days do not correlate for a courier or observer who does not hold that key.
Because envelopes may be sealed and carried across a day boundary, a party checking whether an envelope is addressed to a given recipient MUST test the candidate tags for `epochDay 1`, `epochDay`, and `epochDay + 1` (evaluated at check time), not only the current day's tag.
### 3.3 Deposit Policy and Trust Tiers
A device accepting a courier envelope deposit from another peer classifies the depositor into a `trust tier`: **favorite** (a mutual `favorite`) or **verified** (any peer with a signature-verified `announce`, but not a mutual favorite). The tier bounds how much mail that depositor may place:
| Limit | Value |
|---|---|
| Total envelopes carried | 40 |
| Verified-tier share of the total | ≤ 20 |
| Per-favorite-depositor quota | 5 |
| Per-verified-depositor quota | 2 |
| Envelope lifetime cap (`expiry` beyond deposit time) | 24 hours + 1 hour clock-skew slack |
A deposit that would exceed the depositor's per-tier quota MUST be rejected. When the total cap is reached, an implementation MUST evict oldest-first, evicting verified-tier envelopes before any favorite-tier envelope; a verified-tier deposit MUST be rejected outright, never displacing favorite-tier mail, once only favorite-tier envelopes remain. This ordering means a crowd of unfavorited, merely-verified peers can still carry mail for each other, but can never crowd out a mutual favorite's queued messages.
A depositor SHOULD re-offer a queued message to a newly-encountered eligible courier until it has been accepted by up to 3 distinct couriers or the message expires ([§2](#2-sender-outbox)).
### 3.4 Spray-and-Wait
An envelope carries a `copies` budget (RECOMMENDED initial value: 4, hard cap: 8) governing how far it may diffuse between couriers before it must simply wait to meet the recipient. When one courier encounters another eligible courier (not the recipient), it MAY offer each envelope it still has spray budget for, splitting the offered copy's remaining budget in half (`copies / 2`, minimum 1) between the two couriers. An envelope with `copies = 1` is carry-only and MUST NOT be sprayed further. A courier MUST NOT spray the same envelope to a peer it has already sprayed it to, so a given envelope sprays to each courier along its path at most once and the total copies in flight for it never exceeds its original depositor's budget.
### 3.5 Handover
When a courier verifies a peer's `announce` as the envelope's recipient:
- On a **direct** announce (the recipient is the peer that just connected), the courier hands over every matching envelope on the live link and removes it from local storage. This handover is non-destructive at the offer stage: an envelope is removed only after the transport confirms it was actually delivered onto the link, so a failed send leaves it intact for the next encounter.
- On a **relayed** announce (heard via a multi-hop relay, not a direct connection), the courier MAY speculatively flood a copy toward the recipient as a directed packet, while the carried original stays in storage — a routed multi-hop send is not a delivery guarantee. This SHOULD be rate-limited per envelope (RECOMMENDED: at most once per 10 minutes) so repeated relayed announces don't re-flood the same mail.
A receiver deduplicates delivered messages by `messageID` ([§3.2](04-payloads.md#32-private-message) of the Payloads chapter), so redundant copies arriving via multiple couriers, or alongside the sender's own retained outbox original, are harmless.
### 3.6 Nostr Relay Drop
A courier envelope MAY additionally be parked on Nostr relays so its delivery does not require a physical courier encounter with the recipient. This chapter defines only the store-and-forward-relevant shape of that path; the event kind, tag structure, and relay-selection criteria are defined in the Nostr Bridge chapter.
A device offering this path SHOULD bound it: a RECOMMENDED cap of 20 pending drops (oldest evicted first), a per-envelope republish cooldown of 30 minutes, and an encoded-drop size cap of 20 KiB (the 16 KiB `ciphertext` limit plus TLV and envelope overhead). Each publish SHOULD use a fresh, throwaway signing key rather than a stable per-device key, so a passive relay observer cannot fingerprint courier traffic to a single publisher across drops. A publish is not considered complete — and MUST NOT be treated as freeing the local pending-drop slot — until the relay acknowledges it (a NIP-01 `OK`), not merely once it has been written to a socket.
## 4. Gossip Sync
`gossip sync` reconciles the cache of recently-seen **broadcast** (unencrypted, flooded) packets between two directly connected peers, so a peer that missed messages — because it just joined, walked between two partitions of the mesh, or was offline — can catch up from a peer that has them. It is a distinct mechanism from courier envelopes: gossip sync never carries a `courierEnvelope`, and a courier envelope is never a candidate for gossip sync ([§4.5](#45-cache-scope-and-retention)).
### 4.1 REQUEST_SYNC Payload
A `requestSync (0x21)` packet's payload is a [TLV-16](01-wire-format.md#82-tlv-16) sequence:
| Type | Field | Length (bytes) | Description |
|---|---|---|---|
| 0x01 | `p` | 1 | Golomb-Rice parameter of the filter in `data`. REQUIRED, 132. |
| 0x02 | `m` | 4 | Hash-bucket modulus, big-endian. REQUIRED, > 0. |
| 0x03 | `data` | variable | Golomb-coded set (GCS) filter bitstream ([§4.2](#42-golomb-coded-set-filter)). REQUIRED. |
| 0x04 | `types` | 18 | Bitfield of which message types this round covers ([§4.5](#45-cache-scope-and-retention)). OPTIONAL — a decoder that finds this TLV absent MUST treat the request as covering `announce` and `message` only. |
| 0x05 | `sinceTimestamp` | 8 | Milliseconds since epoch, big-endian. OPTIONAL cursor: the filter in `data` only covers candidates at or after this timestamp; older matching packets are outside the filter but not missing, and MUST NOT be treated as such. |
| 0x06 | `fragmentIdFilter` | variable, UTF-8 | Comma-separated, lowercase-hex-encoded 8-byte fragment stream IDs (at most 60), narrowing a `fragment`-type round to exactly the named stalled reassembly streams. OPTIONAL. |
A `requestSync` packet MUST be sent with `ttl = 0`: it is never relayed ([§1.1](#11-suppression)), so a `ttl` budget beyond the immediate link is meaningless. `p`, `m`, and `data` are always present, even for an empty cache (`p` derived per [§4.2](#42-golomb-coded-set-filter), `m = 1`, `data` empty), so the recipient can distinguish "nothing to report" from a malformed request. A decoder MUST reject `p > 32` or `m = 0`.
### 4.2 Golomb-Coded Set Filter
The `data` field is a Golomb-coded set: a compact, probabilistic membership filter over the requester's known packet IDs, letting the responder compute what the requester is missing without transmitting every ID it already holds.
A packet's ID, for gossip-sync purposes, is the first 16 bytes of `SHA-256(type || senderID || timestamp || payload)` (`type` as its single byte, `timestamp` big-endian). To place a 16-byte ID into the filter:
1. Hash it: `h = first 8 bytes of SHA-256(id)`, interpreted as a big-endian 63-bit unsigned integer (the top bit is cleared).
2. Map it into `[1, m)`: `bucket = h mod m`; if the result is `0`, remap it to `1` — every bucket value in the filter is therefore in `[1, m 1]`, keeping every encoded delta strictly positive.
To build the filter, the sender sorts its buckets ascending, deduplicates equal values, and Golomb-Rice encodes the sequence of successive deltas `x ≥ 1` (the first delta is the value itself, taken from an implicit zero): each delta is split into a quotient `q = (x 1) >> p` and a `p`-bit remainder `r = (x 1) & ((1 << p) 1)`; `q` is written as that many `1` bits followed by a `0` bit (unary), then `r` follows as `p` bits. The bitstream is packed MSB-first within each byte, with the final byte's unused low bits set to `0`.
To test whether a 16-byte ID is a member, a decoder computes its `bucket` per step 2 above (using the filter's own `m`) and binary-searches the filter's decoded, sorted bucket list — decoded by reading each delta back off the bitstream (unary quotient, then `p`-bit remainder, reconstructing `x`, and accumulating `x` onto a running sum) until the running sum reaches or exceeds `m`.
`p` SHOULD be derived from a target false-positive rate `f` as `p = ⌈log₂(1/f)⌉`, clamped to `[1, 32]` (a target of 1% yields `p = 7`). `m` is fixed to the candidate count at encode time (scaled by `2^p`) and stays fixed even if the encoder must trim candidates to fit a byte budget, so `m` alone does not reveal how many candidates the filter actually reached — a responder relies on `sinceTimestamp` ([§4.1](#41-request_sync-payload)) for that.
### 4.3 Sync Rounds
A peer SHOULD request a sync round from each newly connected peer shortly after connecting (RECOMMENDED delay: 15 seconds), covering every type it tracks ([§4.5](#45-cache-scope-and-retention)).
Beyond the initial round, a peer SHOULD run a periodic sync round per message-type group against every currently connected peer (unicast to each; broadcast only if none are connected yet), at a RECOMMENDED cadence tuned to how time-sensitive that type is:
| Type group | RECOMMENDED interval |
|---|---|
| `announce` + `message` (+ `groupMessage`, where supported) | 15 s |
| `fragment` | 30 s |
| `fileTransfer` | 60 s |
| `boardPost` | 60 s |
| `prekeyBundle` (see the Noise chapter's [§5](03-noise.md#5-prekey-bundles)) | 60 s |
A peer whose `fragment` reassembly has stalled (no new fragment for its stream in 30 seconds, per the BLE Transport chapter's [§3.2](02-ble-transport.md#32-fragment-cap-and-lifetime)) SHOULD send a targeted round immediately, using the `fragmentIdFilter` TLV to name only the stalled stream IDs, rather than waiting for the next periodic `fragment` round.
### 4.4 Responses and the RSR Flag
A responder answering a `requestSync` walks its cache of the requested type(s), tests each candidate packet's ID against the requester's filter ([§4.2](#42-golomb-coded-set-filter)), and re-sends every packet that tests as **not present** in the filter — i.e., every packet the filter indicates the requester is missing. Each such response:
- Is sent as the packet's own original type (`message`, `announce`, `fragment`, `fileTransfer`, `groupMessage`, `prekeyBundle`, or `boardPost`) — there is no separate response packet type.
- MUST have `ttl = 0`: a sync response is a direct answer to the requester, not something for the requester to further relay.
- MUST have the `isRSR` flag ([§3](01-wire-format.md#3-flags) of the Wire Format chapter) set, marking it a solicited **Request-Sync Response** rather than an unprompted broadcast.
`announce` and `prekeyBundle` responses are exempt from the `sinceTimestamp` cursor: there is at most one live packet of each per owner, so resending it whenever the filter doesn't already cover it is cheap and lets a peer joining long after a bundle was published still learn it.
A peer that sent a `requestSync` treats an inbound `isRSR`-flagged packet from a given sender as attributable to its own request only within a bounded window after sending it (RECOMMENDED: 30 seconds); an `isRSR` packet arriving outside that window, or from a peer it never asked, SHOULD be treated as unsolicited. A responder SHOULD also rate-limit how many sync rounds it answers for a single requester in a short window (RECOMMENDED: 8 responses per 30 seconds) so a rapid burst of requests cannot be used to repeatedly replay a peer's whole cache.
### 4.5 Cache Scope and Retention
Gossip sync covers exactly the broadcast types a `types` bitfield can name: `announce`, `message`, `fragment`, `fileTransfer`, `boardPost`, `prekeyBundle`, and `groupMessage`. It MUST NOT cover `courierEnvelope` (a directed deposit between trusted peers, never gossiped), `ping`/`pong` (ephemeral directed probes), `nostrCarrier` (ephemeral gateway traffic), `voiceFrame` (live audio, useless once stale), `noiseHandshake`/`noiseEncrypted` (session-bound, not broadcast), or `requestSync` itself.
A peer tracks each covered type in a separate bounded, time-windowed cache:
| Type | RECOMMENDED capacity | RECOMMENDED age window |
|---|---|---|
| `message` | 1000 | A long window (RECOMMENDED: 6 hours) so a device that reconnects after a partition still serves recent public chat history. |
| `groupMessage` | 200 | Shares `message`'s long window — a member catching up after time off-mesh should backfill group history the same way. |
| `announce` | One retained per known peer (no shared pool cap) | 15 minutes |
| `fragment` | 600 | 15 minutes |
| `fileTransfer` | 200 | 15 minutes |
| `boardPost` | 200 | No independent window — a board post ages out only via its own signed expiry/tombstone ([§6](04-payloads.md#6-board-posts) of the Payloads chapter). |
| `prekeyBundle` | 200 | 24 hours |
A device SHOULD persist its `message` cache across restarts (so a relaunching device still has recent public history to serve) but MAY keep the shorter-lived caches (`fragment`, `fileTransfer`) in memory only.
## 5. Delivery Metrics
An implementation MAY keep bare local counters of store-and-forward activity — for example, deposits, handovers, sprays, and outbox flushes or drops — to let delivery behavior be measured on-device. Such counters MUST NOT record message IDs, peer identities, or timestamps, and MUST NOT be transmitted off the device. They are cleared by a `panic wipe` along with the rest of this chapter's persisted state (the `sender outbox`, carried `courier envelope`s, and the gossip-sync cache).

186
spec/06-nostr-bridge.md Normal file
View File

@ -0,0 +1,186 @@
# Nostr Bridge
This chapter specifies bitchat's use of the Nostr protocol: the event kinds and tags it defines, the private-envelope construction carried over Nostr for one-to-one messages, relay-selection criteria, geohash-scoped public channels, and the `gateway`/`bridge` services that carry mesh traffic across the internet.
Two of this chapter's mechanisms are foundational and REQUIRED of every conformant implementation: the private-message envelope (§2) and geohash public channels (§34) are bitchat's only long-distance transport and have no `PeerCapabilities` gate; `courier drop`s (§5) are likewise REQUIRED, closing the forward reference the Store and Forward chapter's [§3.6](05-store-and-forward.md#36-nostr-relay-drop) already opened. `gateway` (§6) and `bridge` (§7) are each capability-gated and OPTIONAL to implement — a mesh-only implementation that advertises neither bit is fully conformant — but an implementation that advertises the `gateway` or `bridge` bit ([Payloads §5.1](04-payloads.md#51-peercapabilities-bitfield)) MUST implement that section's wire format and semantics exactly.
Not owned by this chapter: `nostrCarrier (0x28)`'s message-type value and the TLV-16 framing it uses ([§7](01-wire-format.md#7-message-types), [§8.2](01-wire-format.md#82-tlv-16) of the Wire Format chapter); the `privateMessage`, `delivered`, and `readReceipt` inner-payload shapes carried inside the private-envelope's embedded packet ([§3.2](04-payloads.md#32-private-message), [§4](04-payloads.md#4-delivery-and-read-acknowledgement) of the Payloads chapter); the `gateway`/`bridge` `PeerCapabilities` bits and the `bridgeGeohash` TLV slot on `announce` ([§5.1](04-payloads.md#51-peercapabilities-bitfield), [§2](04-payloads.md#2-presence-announce) of the Payloads chapter — this chapter defines `bridgeGeohash`'s value); the `courierEnvelope (0x04)` wire format, rotating recipient tag algorithm, deposit quotas, and spray-and-wait budget ([§3](05-store-and-forward.md#3-courier-envelopes) of the Store and Forward chapter — this chapter defines only how a courier envelope becomes a relay-hosted event).
## 1. Event Construction
Every event in this chapter is a standard NIP-01 Nostr event: `id` is the lowercase-hex SHA-256 of the canonical JSON array `[0, pubkey, created_at, kind, tags, content]` (`pubkey` lowercase-hex, `created_at` Unix seconds, `tags` an array of string arrays); `sig` is a 64-byte Schnorr signature (BIP-340) over `id`, verified against the 32-byte x-only `pubkey`. An implementation MUST reject an event whose `id` does not match its recomputed value or whose `sig` does not verify against `pubkey`.
The kinds this chapter defines:
| Kind | Name | Requirement | Content |
|---|---|---|---|
| 1 | `textNote` | REQUIRED (§3.2) | Plaintext geohash-scoped location note. |
| 5 | `deletion` | REQUIRED (§3.2) | Empty; deletes a self-authored `textNote` (NIP-09). |
| 13 | `seal` | REQUIRED (§2) | Encrypted `rumor`. |
| 14 | `dm` | REQUIRED (§2) | Plaintext (once decrypted): the rumor. |
| 1059 | `giftWrap` | REQUIRED (§2) | Encrypted `seal`. |
| 20000 | `ephemeralEvent` | REQUIRED (§3), OPTIONAL reuse (§7) | Plaintext geohash chat message, or (tagged `r` instead of `g`) a `bridge` rendezvous message. |
| 20001 | `geohashPresence` | REQUIRED (§3), OPTIONAL reuse (§7) | Empty; a geohash or (tagged `r`) `bridge`-cell presence heartbeat. |
| 1401 | `courierDrop` | REQUIRED (§5) | Base64 `courierEnvelope` wire bytes. |
| 0 | `metadata` | Reserved | Not used to construct any event in this specification. |
Kind 5, 13, 14, and 1059 reuse NIP-09's and NIP-17/NIP-59's kind numbers, and kind 13/14/1059's `content` construction reuses NIP-44's `nip44-v2` HKDF info label, but §2's envelope is **not** NIP-17-, NIP-44-, or NIP-59-compatible: it interoperates only with other bitchat clients, not with generic Nostr DM clients.
An implementation SHOULD bound inbound events defensively before processing: RECOMMENDED limits are 64 tags per event, 16 values per tag, and 1024 UTF-8 bytes per tag value.
## 2. Private Messages
A private message reaches a peer who is not reachable over the mesh, but whose Nostr public key is known (a mutual `favorite`, or a peer sharing the sender's current geohash channel), by riding a three-layer envelope over Nostr relays. Content is BitChat-specific and opaque to relays and to any non-bitchat Nostr client.
### 2.1 Envelope Layers
From innermost to outermost:
1. **Rumor** (kind `dm`, 14) — the unsigned inner event. `content` is the embedded bitchat packet (§2.3). `tags` MUST be empty; a decoder MUST also accept exactly one `["p", recipientPubkey]` tag (a historical encoder shape) and MUST reject any other tag shape. `sig` MUST be absent.
2. **Seal** (kind `seal`, 13) — the rumor, JSON-serialized and encrypted (§2.2) to the recipient, signed with the sender's own long-term Nostr identity key. `tags` MUST be empty. This is the layer that authenticates the sender: a decoder MUST verify the seal's signature and MUST treat its signer, not any claim inside the rumor, as the message's authenticated sender.
3. **Gift wrap** (kind `giftWrap`, 1059) — the seal, JSON-serialized and encrypted (§2.2) to the recipient using a fresh one-time key generated for this message alone, signed with that same one-time key. `tags` MUST be exactly `[["p", recipientPubkey]]`. The one-time signing key hides the sender's stable identity from relays and from any observer who is not the recipient.
The seal's and gift wrap's `created_at` are each independently randomized by up to ±15 minutes from the real send time (uniform, resampled per layer) so relay-visible timestamps do not correlate; the rumor's `created_at` carries the true send time and is recoverable only after both decryption steps.
A decoder MUST reject a gift wrap whose `content` exceeds 64 KiB before attempting decryption, and MUST apply the same bound to the decrypted seal `content` before parsing it as JSON, and again to the decrypted rumor.
### 2.2 Encryption
Both encrypted layers (seal→rumor, gift wrap→seal) use the same construction:
1. Compute an ECDH shared secret over secp256k1 between the layer's signing private key and the recipient's public key (the recipient's x-only Nostr pubkey, tried with an even-Y prefix and, on failure, an odd-Y prefix).
2. Derive a 32-byte key: `HKDF-SHA256(ikm = sharedSecret, salt = "", info = "nip44-v2", L = 32)`.
3. Generate a random 24-byte nonce. Seal the layer's canonical JSON with XChaCha20-Poly1305 under the derived key and nonce, producing ciphertext and a 16-byte authentication tag.
4. `content = "v2:" || base64url(nonce || ciphertext || tag)` (unpadded).
Decryption reverses this: strip and require the `v2:` prefix, base64url-decode, split into `nonce (24) || ciphertext || tag (16)`, and open. A decoder MUST reject a `content` value under 41 bytes (24 + 16 + 1) after the prefix is stripped, or lacking the `v2:` prefix at all.
### 2.3 Embedded BitChat Packet
The rumor's `content`, once decrypted, is not raw text: it is `"bitchat1:" || base64url(packetBytes)` (unpadded), where `packetBytes` is a complete `BitchatPacket` ([§2](01-wire-format.md#2-header-layout) of the Wire Format chapter) with:
- `type` = `noiseEncrypted (0x11)`.
- `signature` absent — the packet is unsigned, since the surrounding seal already authenticates the sender.
- `ttl` = `7`.
- `recipientID` = the recipient's 8-byte `peer ID` for a favorites DM, or absent for a message sent within a geohash channel's anonymous DM context.
- `payload` = a one-byte `NoisePayloadType` tag followed by that inner payload's bytes: `privateMessage (0x01)` ([§3.2](04-payloads.md#32-private-message) of the Payloads chapter) for message content, or `delivered (0x03)`/`readReceipt (0x02)` ([§4](04-payloads.md#4-delivery-and-read-acknowledgement) of the Payloads chapter) for an acknowledgement.
### 2.4 Sending and Reachability
An implementation SHOULD prefer a live mesh link and fall back to this chapter's private-message path only when the recipient is not reachable over the mesh but a Nostr public key for them is known and a relay connection to the default relay set (§4.1) is live. A courier drop (§5) is the further fallback when neither is available.
## 3. Geohash Public Channels
A geohash channel is a public, unencrypted chat room scoped to a geohash cell, letting peers beyond radio range converse regionally over Nostr relays.
### 3.1 Ephemeral Chat and Presence
A channel message is a kind `ephemeralEvent` (20000) event: `content` is the plaintext message; `tags` MUST include exactly one `["g", geohash]`, MAY include `["n", nickname]`, and MAY include `["t", "teleport"]` to mark a post made from outside the poster's physical geohash. A presence heartbeat is a kind `geohashPresence` (20001) event with empty `content` and `tags` = exactly `[["g", geohash]]` — no nickname or teleport tag.
### 3.2 Location Notes and Deletion
A persistent (non-ephemeral) location note is a kind `textNote` (1) event: `content` is the note text; `tags` MUST include exactly one `["g", geohash]`, MAY include `["n", nickname]`, MAY include `["expiration", unixSeconds]` (NIP-40, §8), and MAY include `["t", "urgent"]`. An author MAY retract a self-authored `textNote` with a kind `deletion` (5) event whose `tags` is exactly `[["e", noteEventID]]` and empty `content` (NIP-09); a relay honoring NIP-09 drops the referenced event, and a receiving client SHOULD do the same on receipt of a validly-signed deletion from the note's original author.
## 4. Relay Selection
### 4.1 Default Relay Set
Private messages (§2) and courier drops (§5) target a fixed default relay set. An implementation SHOULD use:
```
wss://relay.damus.io
wss://nos.lol
wss://relay.primal.net
wss://offchain.pub
```
merged with any user-added custom relays (RECOMMENDED cap: 8). An implementation SHOULD connect this set only when it has a reason to need it — a mutual favorite, a granted location permission, or an active geohash channel — rather than unconditionally on startup.
### 4.2 Geo-Proximity Relay Set
Geohash channel traffic (§3) and `bridge` rendezvous traffic (§7) instead target the relays nearest the relevant geohash cell: decode the geohash to a lat/lon center and select the `count` (RECOMMENDED: 5) relays with the smallest haversine distance to it, ties broken by hostname so publishers and subscribers agree on the same set. Relay coordinates come from a maintained directory (host, latitude, longitude); an implementation SHOULD source this directory from a reviewed, validated copy rather than trusting an unauthenticated third-party feed directly at fetch time, and SHOULD validate a refreshed copy (bounded size, valid coordinate ranges, a minimum-overlap check against the previous copy) before replacing what it already has.
### 4.3 Private-Message Subscription
A client subscribes for gift wraps (kind 1059) addressed to each Nostr identity it holds (its stable favorites identity, and any per-geohash identity for an open channel), filtered by the `p` tag equal to that identity's pubkey. An implementation SHOULD subscribe with a lookback window (RECOMMENDED: 24 hours) from the current time on every (re)connect, so a client that was offline still retrieves mail waiting on relays.
## 5. Courier Drops
A courier drop parks a sealed `courier envelope` ([§3](05-store-and-forward.md#3-courier-envelopes) of the Store and Forward chapter) on relays so its delivery does not require a physical courier encounter, closing the Nostr Relay Drop path that chapter's [§3.6](05-store-and-forward.md#36-nostr-relay-drop) opens. Every conformant implementation MUST support both depositing and retrieving courier drops.
A drop is a kind `courierDrop` (1401) event: `content` is the base64 encoding (standard alphabet, padded) of the courier envelope's own wire-encoded bytes ([§3.1](05-store-and-forward.md#31-wire-format) of the Store and Forward chapter — the full TLV-16 packet payload, not just its `ciphertext` field); `tags` MUST include exactly one `["x", recipientTagHex]` (the envelope's `recipientTag`, lowercase hex) and exactly one `["expiration", unixSeconds]` (NIP-40, §8) matching the envelope's `expiry`. A depositor MUST sign each drop with a fresh, single-use Nostr identity rather than a stable per-device key, so relay observers cannot correlate drops from the same publisher across messages.
A drop targets the default relay set (§4.1). A recipient (or a `gateway`/`bridge` peer retrieving on a mesh-only peer's behalf) subscribes for kind `courierDrop` events tagged with any of its own candidate recipient tags for `epochDay 1`, `epochDay`, and `epochDay + 1` ([§3.2](05-store-and-forward.md#32-rotating-recipient-tag) of the Store and Forward chapter), and, on a match, decodes `content` back into a `courierEnvelope` and verifies its own computed `recipientTag` matches the event's `x` tag before treating it as addressed to it — an event's tag is untrusted routing metadata, not proof of addressing. An expired envelope (by the enclosed `courierEnvelope`'s own `expiry`, not just the event's NIP-40 `expiration`) MUST be discarded rather than opened or forwarded.
## 6. Gateway
`gateway` is an OPTIONAL, capability-gated service (`PeerCapabilities` bit 2, [§5.1](04-payloads.md#51-peercapabilities-bitfield) of the Payloads chapter): a device with both mesh and internet connectivity that lets mesh-only peers reach a geohash channel (§3) by relaying between the two. An implementation MUST NOT advertise the `gateway` bit unless it implements this section's wire format and semantics exactly.
### 6.1 `NostrCarrierPacket` Wire Format
`nostrCarrier (0x28)`'s payload is a [TLV-16](01-wire-format.md#82-tlv-16) sequence:
| Type | Field | Length (bytes) | Description |
|---|---|---|---|
| 0x01 | `direction` | 1 | One of the values below. REQUIRED. |
| 0x02 | `geohash` | 112, UTF-8 | The channel or cell this event belongs to. REQUIRED. |
| 0x03 | `eventJSON` | 116384 | The complete signed Nostr event, JSON-encoded. REQUIRED. |
`direction` values:
| Value | Name | Used By |
|---|---|---|
| 0x01 | `toGateway` | §6.2 |
| 0x02 | `fromGateway` | §6.3 |
| 0x03 | `toBridge` | §7.2 |
| 0x04 | `fromBridge` | §7.3 |
A decoder MUST reject a `direction` byte outside `0x01``0x04`. This is deliberate: a decoder that predates `bridge` support (§7) fails to decode a `0x03`/`0x04` carrier and drops it, so `bridge` traffic degrades to invisible on an old client rather than being misrouted. A decoder MUST independently re-verify `eventJSON`'s signature after decoding — the carrier itself carries no trust, only transport.
### 6.2 Uplink (mesh → relay)
A mesh-only peer composing a geohash event it cannot publish directly (no live relay connection) MAY send a `toGateway` carrier as a directed packet to a known gateway peer. On receipt, a gateway:
1. Structurally validates the carried event (parses, checks size, confirms `event.kind == ephemeralEvent (20000)`, confirms a `["g", geohash]` tag matches the carrier's `geohash` field, checks freshness — RECOMMENDED: reject anything older than 15 minutes) before any signature check.
2. Rejects a duplicate of an event it has already published, queued, or learned from the mesh (§6.4).
3. Applies a per-depositor rate limit (RECOMMENDED: 10 accepted deposits per depositor per minute) before paying for signature verification.
4. Verifies the event's signature (§1); rejects on failure.
5. Publishes immediately if a relay connection is live, or holds the event in a bounded per-depositor queue (RECOMMENDED: 20 total, 5 per depositor, oldest evicted first) until one is.
### 6.3 Downlink (relay → mesh)
Every event a gateway's own geohash subscription delivers, it MAY rebroadcast onto the mesh as a broadcast `fromGateway` carrier, gated the same way as uplink (freshness, the event's own `g` tag matching, loop prevention per §6.4, signature verification) and rate-limited (RECOMMENDED: 30 rebroadcasts per minute, with a bounded drop-oldest queue beyond that budget).
### 6.4 Loop Prevention
A gateway MUST enforce all of the following, so that mesh-carried traffic is never re-injected onto the relay network or echoed back onto the mesh it came from:
1. An event learned from a `fromGateway` mesh broadcast MUST NOT be re-published to relays, re-uplinked, or rebroadcast.
2. An event this gateway has already published (via uplink) MUST NOT subsequently be rebroadcast (via downlink) even if the gateway's own relay subscription redelivers it; a given event MUST NOT be published more than once, nor rebroadcast more than once.
3. Uplink MUST be attempted only for a locally-composed event — an event received over a carrier or from a relay subscription MUST NOT itself trigger a further uplink.
## 7. Bridge
`bridge` is an OPTIONAL, capability-gated service (`PeerCapabilities` bit 7, [§5.1](04-payloads.md#51-peercapabilities-bitfield) of the Payloads chapter) distinct from `gateway`: it stitches together disjoint BLE mesh islands that share a physical place but have no direct radio path between them, by routing public mesh traffic through Nostr as a rendezvous. An implementation MUST NOT advertise the `bridge` bit unless it implements this section's wire format and semantics exactly.
### 7.1 Rendezvous Cell and Events
A peer's rendezvous cell is its current geohash truncated to precision 6 (~1.2 km); this is the value an `announce`'s `bridgeGeohash` TLV ([§2](04-payloads.md#2-presence-announce) of the Payloads chapter) carries when advertising `bridge`. A rendezvous message reuses kind `ephemeralEvent` (20000) with `tags` = `[["r", cell]]` (optionally `["n", nickname]`) instead of a `g` tag — keeping bridge traffic out of geohash-channel (§3) subscriptions, which filter on `#g`. A rendezvous message MAY additionally carry `["m", [stableID, meshSenderIDHex, meshTimestampMs]]`, an unauthenticated hint correlating the event to a specific mesh-originated packet; a receiver MUST NOT treat a `m`-tag match alone as authenticating a message (§7.3). A rendezvous presence heartbeat reuses kind `geohashPresence` (20001) with `tags` = `[["r", cell]]` and empty `content`.
Signing identity for rendezvous events is per-cell and SHOULD be distinct from a peer's geohash-channel and favorites identities, so relay observers cannot link a bridge participant to their geohash-channel activity from key reuse alone.
### 7.2 Publishing (mesh → relay)
A device with `bridge` enabled additionally signs every public mesh message it sends as a rendezvous event (§7.1) and either publishes it directly to the geo-proximity relay set for its cell (§4.2), or, if mesh-only, deposits it as a directed `toBridge` carrier (§6.1's structure, `direction = toBridge`) to a peer serving as a bridge gateway. A per-message flag MAY suppress composing the rendezvous copy for a message the sender knows is only relevant locally.
### 7.3 Receiving and the Radio Race
A `bridge`-enabled device subscribed to its cell (and, RECOMMENDED, its immediate geohash neighbors) that receives a rendezvous event: verifies the event's own signature and that its `r` tag is within its subscribed cell set; classifies it (by kind) as a presence heartbeat or a message; and injects it into the mesh timeline marked as bridged. A device also serving that mesh island (`bridge` and internet both available) additionally rebroadcasts genuine remote rendezvous events onto the local mesh as `fromBridge` carriers (§6.1's structure, `direction = fromBridge`), subject to the same loop-prevention rules as §6.4 applied to `toBridge`/`fromBridge` in place of `toGateway`/`fromGateway`. A `fromBridge` carrier MUST be accepted for injection regardless of whether the local device has `bridge` enabled — reception is passive, unlike publishing and serving.
An authenticated radio-received copy of a message (heard directly over the mesh) MUST take precedence over a bridge-relayed row that only matched on the untrusted `m`-tag hint (§7.1): if a radio copy of a message arrives after a bridge-sourced row for the same content, the receiver MUST replace the bridge row with the authenticated one rather than displaying both. The `m` tag MAY be used to merge a duplicate but MUST NOT be used to suppress delivery of the genuine signed event.
## 8. Expiration and Deletion
`["expiration", unixSeconds]` (NIP-40) marks an event for relay-side garbage collection once relays observe the given Unix timestamp has passed; this chapter uses it on courier drops (§5, REQUIRED) and MAY use it on location notes (§3.2). A relay's honoring of NIP-40 is a hygiene measure, not a delivery guarantee — a receiving client MUST independently discard an expired courier envelope regardless of whether the hosting relay has (§5).
`["e", eventID]` on a kind `deletion` (5) event (NIP-09) requests removal of one self-authored event; a client MUST sign a deletion with the same key that signed the original event, and a receiving client honoring a deletion MUST verify that signature match before acting on it.

131
spec/07-conformance.md Normal file
View File

@ -0,0 +1,131 @@
# Conformance
This chapter is a checklist, not a re-explanation of the protocol: each item below is a single checkable point, restating a MUST/MUST NOT/SHOULD/REQUIRED rule already defined normatively in one of the six preceding chapters and linking back to it. An implementation is conformant with a given mechanism when every applicable item under it holds. Items describing a RECOMMENDED default (tuning constants, cache sizes, timing) are non-normative — an implementation MAY use a different value without losing conformance, unless the item says otherwise.
Chapter 6's items are split by the REQUIRED/capability-gated boundary that chapter defines: an implementation MUST satisfy every item under a REQUIRED mechanism, but only needs to satisfy a capability-gated mechanism's items if it advertises that capability at all.
## 1. Wire Format
- [ ] A decoder MUST reject a packet whose version byte is neither `1` nor `2` ([§1](01-wire-format.md#1-packet-versions)).
- [ ] The header is 14 bytes for v1, 16 bytes for v2, both big-endian, with fields in the fixed order `version, type, ttl, timestamp, flags, payloadLength` ([§2](01-wire-format.md#2-header-layout)).
- [ ] A relay MUST NOT forward a packet once its `ttl` reaches `0` ([§2](01-wire-format.md#2-header-layout)).
- [ ] The `flags` bitfield matches the defined table (`hasRecipient` 0x01, `hasSignature` 0x02, `isCompressed` 0x04, `hasRoute` 0x08 v2-only, `isRSR` 0x10, bits 5-7 reserved) ([§3](01-wire-format.md#3-flags)).
- [ ] `hasRoute` MUST NOT be set on a v1 packet ([§3](01-wire-format.md#3-flags)).
- [ ] Reserved flag bits MUST be `0` on encode; a decoder MUST ignore, not reject on, an unrecognized reserved bit ([§3](01-wire-format.md#3-flags)).
- [ ] The variable-section order is fixed: `senderID`, `recipientID` (if `hasRecipient`), source route (if `hasRoute`), `payload`, `signature` (if `hasSignature`) ([§4](01-wire-format.md#4-variable-sections)).
- [ ] A source route is a 1-byte hop count `N` followed by `N` 8-byte peer IDs; `N` MUST NOT exceed 255, and route bytes are excluded from `payloadLength` ([§4.2](01-wire-format.md#42-source-route)).
- [ ] When `isCompressed` is set, the payload begins with a 2-byte (v1) or 4-byte (v2) big-endian original-size preamble, itself counted in `payloadLength` ([§4.3](01-wire-format.md#43-payload-and-compression)).
- [ ] A signature, when `hasSignature` is set, is a 64-byte Ed25519 signature immediately following the payload ([§4.4](01-wire-format.md#44-signature)).
- [ ] A signature is computed over the packet with its `signature` section omitted, `ttl` fixed to `0`, `isRSR` excluded, and the frame always padded per §6 regardless of the message type's wire padding rule; a verifier MUST reconstruct this exact frame ([§5](01-wire-format.md#5-signing)).
- [ ] Only `noiseHandshake`/`noiseEncrypted` packets are padded **on the wire**; every other type is transmitted at its natural length. This does not apply to the signing transcript, which is always padded (see the item above) ([§6](01-wire-format.md#6-padding)).
- [ ] A frame needing more than 255 bytes of padding to reach its target bucket MUST be emitted unpadded instead ([§6](01-wire-format.md#6-padding)).
- [ ] A decoder MUST attempt unpadded decode first and retry with PKCS#7 stripping only on failure ([§6](01-wire-format.md#6-padding)).
- [ ] The full `type` byte table MUST be supported for dispatch, and a decoder MUST skip (not reject the enclosing packet for) an unrecognized `type` ([§7](01-wire-format.md#7-message-types)).
- [ ] TLV-8 (`type`:1, `length`:1, `value`:≤255 bytes) and TLV-16 (`type`:1, `length`:2 BE, `value`:≤65535 bytes) are structurally distinct, and both framings require a decoder to skip an unrecognized TLV type using its length field rather than reject the enclosing payload ([§8](01-wire-format.md#8-tlv-encodings)).
## 2. BLE Transport
- [ ] The service UUID, characteristic UUID, and characteristic properties (notify, write, write-without-response, read) match the defined values, with a single characteristic carrying traffic in both directions ([§1](02-ble-transport.md#1-gatt-service-and-characteristic)).
- [ ] A packet exceeding the link MTU MUST be split into `fragment (0x20)` packets ([§3](02-ble-transport.md#3-fragmentation)).
- [ ] The fragment header layout — `fragmentID`(8B), `index`(2B BE), `total`(2B BE), `originalType`(1B), then `fragmentData` — is a 13-byte fixed prefix ([§3.1](02-ble-transport.md#31-fragment-header)).
- [ ] A receiver MUST reject a fragment stream whose `total` exceeds 10,000, regardless of type; a directed `fileTransfer (0x22)` packet (the legacy Android-compatible private-media fallback) is further capped at 256 fragments, MAY be rejected above that by a receiver, and MUST NOT be split above it by a sender ([§3.2](02-ble-transport.md#32-fragment-cap-and-lifetime)).
- [ ] Reassembly is keyed by `(sender, fragmentID)` and concatenates fragments in `index` order ([§3.2](02-ble-transport.md#32-fragment-cap-and-lifetime)).
- [ ] A v2 source-routed packet's fragments MAY carry the same route, with per-fragment chunk size MAY shrinking, floored at 64 bytes ([§3.3](02-ble-transport.md#33-route-aware-fragmentation-v2)).
- [ ] An advertisement (and scan response) MUST carry only the service UUID — MUST NOT carry local name, TX power, peer ID, or any other peer-identifying bytes ([§4.1](02-ble-transport.md#41-advertisement-contents)).
- [ ] Central scanning is filtered to the service UUID ([§4.2](02-ble-transport.md#42-scanning)).
## 3. Noise
- [ ] Both the `XX` and `X` patterns use Curve25519 DH, ChaCha20-Poly1305 AEAD, and SHA-256 hash/KDF (`Noise_XX_25519_ChaChaPoly_SHA256` / `Noise_X_25519_ChaChaPoly_SHA256`) ([§1](03-noise.md#1-cipher-suite)).
- [ ] The `XX` message sequence and exact byte sizes match: msg1 `e` (32B), msg2 `e,ee,s,es` (96B), msg3 `s,se` (48B) ([§2.1](03-noise.md#21-handshake-message-sequence)).
- [ ] A handshake message MUST NOT exceed 2048 bytes, for either pattern ([§2.1](03-noise.md#21-handshake-message-sequence), [§4.1](03-noise.md#41-handshake-message)).
- [ ] Handshake message bytes ride unwrapped as the entire payload of a `noiseHandshake (0x10)` packet — no extra TLV or length framing ([§2.2](03-noise.md#22-wire-carriage-of-handshake-messages)).
- [ ] `noiseEncrypted (0x11)` payload framing is `nonce`(4B BE) `||` `ciphertext` `||` `tag`(16B) ([§3.1](03-noise.md#31-transport-ciphertext-framing)).
- [ ] Decrypted application plaintext MUST NOT exceed 65,535 bytes ([§3.1](03-noise.md#31-transport-ciphertext-framing)).
- [ ] A receiver maintains a 1024-most-recent-accepted-nonce replay window per direction, and MUST reject a nonce before the window or already accepted ([§3.2](03-noise.md#32-replay-protection)).
- [ ] Application-payload framing inside decrypted plaintext is 1-byte type + data, with no length prefix (the boundary is the plaintext length) ([§3.3](03-noise.md#33-application-payload-framing)).
- [ ] The full `NoisePayloadType` table MUST be supported for dispatch ([§3.3](03-noise.md#33-application-payload-framing)).
- [ ] A decoder MUST accept `0x09` as a legacy alias for `privateFile (0x20)` on decode, and MUST NOT emit `0x09` on encode ([§3.3](03-noise.md#33-application-payload-framing)).
- [ ] The `X` pattern's single message is `-> e, es, s, ss`, with `e` cleartext (32B) and `es,s,ss` ciphertext+tag (48B) ([§4.1](03-noise.md#41-handshake-message)).
- [ ] A courier envelope seal uses the recipient's long-term static key, with no forward secrecy ([§4.2](03-noise.md#42-courier-envelopes)).
- [ ] A prekey MUST NOT be reused across more than one seal, and MUST be discarded from future bundles once consumed ([§4.3](03-noise.md#43-prekey-envelopes)).
- [ ] The `X` pattern's prologue is non-empty and depends on the seal: ASCII `bitchat-courier-v1` for a courier envelope, ASCII `bitchat-prekey-v1` plus the 4-byte big-endian `prekeyID` for a prekey envelope; sealing and opening a given envelope MUST use bit-identical prologue bytes ([§4.4](03-noise.md#44-domain-separation-prologue)).
- [ ] The `prekeyBundle (0x24)` packet uses TLV-16 framing, with fields `noiseStaticPublicKey`(32B), `prekeys`(repeated 36B entries, MUST NOT exceed 8), `generatedAt`(8B), `signature`(64B Ed25519) ([§5.1](03-noise.md#51-wire-packet), [§5.2](03-noise.md#52-fields)).
- [ ] A prekey bundle's `signature` covers the canonical transcript — 1-byte length + 24-byte domain string `bitchat-prekey-bundle-v1`, `noiseStaticPublicKey`, a 1-byte prekey count, each raw `(prekeyID, publicKey)` entry, then `generatedAt` — not the bundle's TLV encoding ([§5.3](03-noise.md#53-signature)).
- [ ] A recipient MUST verify a prekey bundle's `signature` against the issuer's known signing key before trusting any prekey, and MUST discard the bundle on verification failure ([§5.3](03-noise.md#53-signature)).
## 4. Payloads
- [ ] `announce (0x01)` is TLV-8 with `nickname`, `noisePublicKey`(32B), `signingPublicKey`(32B) REQUIRED; a decoder MUST reject an `announce` missing any of the three ([§2](04-payloads.md#2-presence-announce)).
- [ ] `message (0x02)` (public) and the `leave (0x03)` and `delivered`/`readReceipt` inner payloads carry raw content directly, with no TLV framing ([§3.1](04-payloads.md#31-public-message), [§3.3](04-payloads.md#33-leave), [§4](04-payloads.md#4-delivery-and-read-acknowledgement)).
- [ ] `privateMessage (0x01, inner)` is TLV-8 with `messageID` and `content` ([§3.2](04-payloads.md#32-private-message)).
- [ ] `authenticatedPeerState (0x21)` is a 1-byte version + TLV-8; a decoder MUST reject any `version` other than `0x01`, MUST reject duplicate TLV entries, and MUST reject a non-minimal `capabilities` encoding ([§5](04-payloads.md#5-peer-state-and-capabilities)).
- [ ] `PeerCapabilities` is little-endian and minimal-byte-length (trailing zero bytes stripped); a decoder keeps only the low 64 bits so unknown high bits round-trip ([§5.1](04-payloads.md#51-peercapabilities-bitfield)).
- [ ] The full `PeerCapabilities` bit table MUST be supported for dispatch, bit 10 (`nonDestructiveNoiseReplacement`) MUST never be advertised by a conforming encoder, and bits 11-63 MUST be `0` on encode while a decoder MUST preserve (not reject on) an unrecognized set bit ([§5.1](04-payloads.md#51-peercapabilities-bitfield)).
- [ ] `boardPost (0x23)` is TLV-16; a decoder MUST reject an absent/unrecognized `kind` or a payload missing a field its `kind` requires, and `expiresAt` MUST NOT exceed `createdAt` + 7 days ([§6](04-payloads.md#6-board-posts)).
- [ ] Board post signatures open with a 1-byte length + context string (`bitchat-board-v1` post / `bitchat-board-del-v1` tombstone), not the bare context string, followed by the defined field concatenation ([§6.1](04-payloads.md#61-signing)).
- [ ] `groupInvite (0x06)`/`groupKeyUpdate (0x07)` share the `GroupStatePayload` TLV-16 shape; a receiver MUST require the delivering session peer be the group's creator ([§7](04-payloads.md#7-private-groups)).
- [ ] A private group MUST NOT exceed 16 members; a decoder MUST reject more than 16 roster entries or a `creatorFingerprint` absent from the roster ([§7.2](04-payloads.md#72-roster-encoding)).
- [ ] Group state signatures use context `bitchat-group-v1`, verified against the roster member matching `creatorFingerprint`; a receiver MUST reject an absent or invalid signature ([§7.1](04-payloads.md#71-signing)).
- [ ] `groupMessage (0x25)`'s outer TLV-16 AEAD associated data is `groupID || epoch` ([§7.3](04-payloads.md#73-group-message)).
- [ ] Group message plaintext signatures use context `bitchat-group-msg-v1`; a receiver MUST reject a message whose signer isn't a current member or whose signature fails ([§7.4](04-payloads.md#74-group-message-plaintext)).
- [ ] File payloads (`privateFile 0x20` inner / `fileTransfer 0x22` outer) use the bespoke 1-byte-type + 2-byte-length framing (4-byte BE for `content`'s length), not either general TLV framing ([§8](04-payloads.md#8-files)).
- [ ] A decoder MUST accept the legacy 8-byte `fileSize` length and, on a 4-byte `content`-length mismatch, retry with the legacy 2-byte `content` length; an encoder MUST NOT emit either legacy form ([§8](04-payloads.md#8-files)).
- [ ] An encoder MUST NOT emit `content` over 1 MiB for a general file, or over 512 KiB for a voice note or image ([§8](04-payloads.md#8-files)).
- [ ] Voice (`voiceFrame 0x08` inner / `0x29` outer) is fixed-layout — `burstID`(8B), `seq`(2B BE), `flags`(1B), `payload` — not TLV, and the only defined codec is `0x01 = aacLC16kMono` ([§9](04-payloads.md#9-voice)).
- [ ] `ping (0x26)`/`pong (0x27)` are a fixed 9-byte `MeshPingPayload`; a decoder MUST accept a payload longer than 9 bytes, ignoring the excess ([§10](04-payloads.md#10-mesh-diagnostics)).
- [ ] `vouch (0x12)` batches MUST NOT carry more than 16 attestations, and a receiver MUST reject an attestation timestamped more than 30 days in the past or 1 hour in the future ([§12](04-payloads.md#12-web-of-trust-vouch)).
- [ ] Vouch signatures use context `bitchat-vouch-v1`, verified against the sending session peer's announce-bound signing key ([§12.1](04-payloads.md#121-attestation-signing)).
## 5. Store and Forward
- [ ] A relay MUST NOT re-send a packet it authored, a packet addressed to its own peer ID, or a packet at `ttl` 0 or 1-after-decrement; `requestSync (0x21)` MUST NEVER be relayed regardless of `ttl` ([§1.1](05-store-and-forward.md#11-suppression)).
- [ ] The fanout-subsetting algorithm for broadcasts other than `fragment`/`announce`/`requestSync` — split horizon, link collapse, subset size `k = ⌈log₂ n⌉ + 1` clamped `[1,n]` for `n > 2`, ranked by ascending `SHA-256("{messageID}::{id}")`, ties by `id` — matches the defined procedure exactly ([§1.3](05-store-and-forward.md#13-fanout-subsetting)).
- [ ] `fragment`, `announce`, and `requestSync` bypass fanout subsetting entirely ([§1.3](05-store-and-forward.md#13-fanout-subsetting)).
- [ ] Directed delivery follows the preference order direct link → v2 source route (falling back to flood if the next hop is not connected) → flood ([§1.4](05-store-and-forward.md#14-directed-delivery)).
- [ ] Source-route origination requires all of: locally authored only (a relay MUST NOT attach or alter a route on a packet it didn't originate), a single-peer `recipientID`, `ttl > 1`, the recipient not already directly connected, and a full v2-capable path known ([§1.5](05-store-and-forward.md#15-source-route-origination-policy)).
- [ ] The sender outbox MUST NOT discard an undelivered private message outright; it MUST retain and retry until a `delivered`/`readReceipt` acknowledgement or a limit-based drop ([§2](05-store-and-forward.md#2-sender-outbox)).
- [ ] `courierEnvelope (0x04)` is TLV-16 with `recipientTag`(16B), `expiry`(8B), `ciphertext`(1-16384B) REQUIRED; a decoder MUST reject an envelope missing any of the three, or whose `ciphertext` exceeds 16384 bytes ([§3.1](05-store-and-forward.md#31-wire-format)).
- [ ] The rotating recipient tag is `HMAC-SHA256(recipientStaticKey, "bitchat-courier-tag-v1" || epochDay[4B BE])[0..16]` with `epochDay = floor(unixSeconds/86400)`, and a tag-checking party MUST test `epochDay1`, `epochDay`, and `epochDay+1` ([§3.2](05-store-and-forward.md#32-rotating-recipient-tag)).
- [ ] An over-quota courier deposit MUST be rejected; at the total cap, eviction is oldest-first with verified-tier evicted before any favorite-tier, and a verified deposit MUST be rejected outright once only favorite-tier mail remains ([§3.3](05-store-and-forward.md#33-deposit-policy-and-trust-tiers)).
- [ ] Spray-and-wait: an envelope with `copies=1` MUST NOT be sprayed further, and the same envelope MUST NOT be sprayed to a peer it has already been sprayed to ([§3.4](05-store-and-forward.md#34-spray-and-wait)).
- [ ] `requestSync (0x21)` is TLV-16 with `p`(1B, 1-32), `m`(4B BE, >0), `data` (GCS bitstream) REQUIRED and always present even for an empty cache; a decoder MUST reject `p > 32` or `m = 0` ([§4.1](05-store-and-forward.md#41-request_sync-payload)).
- [ ] `requestSync` MUST be sent with `ttl = 0` ([§4.1](05-store-and-forward.md#41-request_sync-payload)).
- [ ] The GCS filter construction — packet ID as the first 16 bytes of `SHA-256(type || senderID || timestamp || payload)`, top-bit-cleared 63-bit hash mod `m` bucket mapping (remapping 0→1), Golomb-Rice delta encoding with parameter `p`, MSB-first bit packing, zero-padded final byte — is reproducible bit-exactly from the defined encoding ([§4.2](05-store-and-forward.md#42-golomb-coded-set-filter)).
- [ ] A sync response is sent as the packet's own original type, MUST have `ttl = 0`, and MUST have `isRSR` set; `announce`/`prekeyBundle` responses are exempt from the `sinceTimestamp` cursor ([§4.4](05-store-and-forward.md#44-responses-and-the-rsr-flag)).
- [ ] Gossip-sync scope is exactly `announce, message, fragment, fileTransfer, boardPost, prekeyBundle, groupMessage`, and MUST NOT cover `courierEnvelope`, `ping`/`pong`, `nostrCarrier`, `voiceFrame`, `noiseHandshake`/`noiseEncrypted`, or `requestSync` itself ([§4.5](05-store-and-forward.md#45-cache-scope-and-retention)).
- [ ] Delivery-metric counters, if kept, MUST NOT record message IDs, peer identities, or timestamps, and MUST NOT be transmitted off-device ([§5](05-store-and-forward.md#5-delivery-metrics)).
## 6. Nostr Bridge
Private messages, geohash public channels, relay selection, and courier drops (this section's first four items) are REQUIRED — bitchat's only long-distance transport. `gateway` and `bridge` (the remaining items) are each capability-gated and OPTIONAL to implement overall, but an implementation advertising either `PeerCapabilities` bit MUST implement that mechanism's items exactly.
- [ ] A Nostr event's `id` is the lowercase-hex SHA-256 of its canonical `[0,pubkey,created_at,kind,tags,content]` serialization, and `sig` is a 64-byte BIP-340 Schnorr signature over `id`; an implementation MUST reject a mismatched `id` or a failed `sig` ([§1](06-nostr-bridge.md#1-event-construction)).
- [ ] The private-message envelope's three layers match: rumor (kind 14, empty or single legacy `p`-tag, unsigned), seal (kind 13, empty tags, signature authenticates the sender), gift wrap (kind 1059, `tags` exactly `[["p", recipientPubkey]]`, signed by a fresh one-time key) ([§2.1](06-nostr-bridge.md#21-envelope-layers)).
- [ ] Encryption is ECDH secp256k1 → `HKDF-SHA256(ikm=shared, salt="", info="nip44-v2", L=32)` → XChaCha20-Poly1305 with a random 24-byte nonce → `content = "v2:" || base64url(nonce||ciphertext||tag)`; a decoder MUST reject `content` under 41 bytes after the `v2:` prefix strip or lacking the prefix entirely ([§2.2](06-nostr-bridge.md#22-encryption)).
- [ ] A rumor's content decrypts to `"bitchat1:" || base64url(packetBytes)`, where the embedded packet has `type=noiseEncrypted(0x11)`, no signature, and `ttl=7` ([§2.3](06-nostr-bridge.md#23-embedded-bitchat-packet)).
- [ ] Ephemeral chat (kind 20000) `tags` MUST include exactly one `["g", geohash]`; presence (kind 20001) has empty content and `tags` = exactly `[["g", geohash]]` ([§3.1](06-nostr-bridge.md#31-ephemeral-chat-and-presence)).
- [ ] A location note (kind 1) MUST include exactly one `["g", geohash]` tag; a deletion (kind 5) has `tags` = exactly `[["e", noteEventID]]` and empty content, signed with the original event's key ([§3.2](06-nostr-bridge.md#32-location-notes-and-deletion)).
- [ ] A courier drop (kind 1401) has `content` = standard-padded base64 of the courier-envelope TLV-16 wire bytes, `tags` including exactly one `["x", recipientTagHex]` and exactly one `["expiration", unixSeconds]` matching the envelope's `expiry`, signed with a fresh single-use Nostr identity ([§5](06-nostr-bridge.md#5-courier-drops)).
- [ ] Courier drop retrieval subscribes on candidate recipient tags for `epochDay1/epochDay/epochDay+1`; on a match, the retriever MUST verify its own computed `recipientTag` against the event's `x` tag before treating it as addressed, and MUST discard (not open or forward) an envelope already expired by its own `expiry` ([§5](06-nostr-bridge.md#5-courier-drops)).
- [ ] `NostrCarrierPacket (0x28)` is TLV-16 with `direction`(1B), `geohash`(1-12B), `eventJSON`(1-16384B) all REQUIRED; a decoder MUST reject a `direction` byte outside `0x01`-`0x04` ([§6.1](06-nostr-bridge.md#61-nostrcarrierpacket-wire-format)).
- [ ] A decoder MUST independently re-verify a carried `eventJSON`'s signature after decoding ([§6.1](06-nostr-bridge.md#61-nostrcarrierpacket-wire-format)).
- [ ] Gateway loop prevention holds: an event learned from a `fromGateway` mesh broadcast MUST NOT be re-published, re-uplinked, or rebroadcast; an already-uplinked event MUST NOT later be downlinked, and no event is published or rebroadcast more than once; uplink is attempted only for a locally-composed event ([§6.4](06-nostr-bridge.md#64-loop-prevention)).
- [ ] A `bridge`-advertising implementation MUST NOT deviate from its rendezvous-cell (geohash precision 6), kind-reuse (20000/20001 with `r` instead of `g` tags), and loop-prevention rules ([§7](06-nostr-bridge.md#7-bridge)).
- [ ] A `fromBridge` carrier MUST be accepted for injection regardless of local `bridge` enablement, since reception is passive ([§7.3](06-nostr-bridge.md#73-receiving-and-the-radio-race)).
- [ ] A radio-received copy of a message MUST take precedence over a bridge-relayed row that only matched on the untrusted `m`-tag hint; the receiver MUST replace the bridge row, not display both ([§7.3](06-nostr-bridge.md#73-receiving-and-the-radio-race)).
- [ ] A client honoring a NIP-09 deletion MUST verify the deleting event's signature matches the original event's key before acting on it, and MUST independently discard an expired courier envelope regardless of relay-side garbage collection ([§8](06-nostr-bridge.md#8-expiration-and-deletion)).
## 7. Test Vectors
[`bitchatTests/Noise/NoiseTestVectors.json`](../bitchatTests/Noise/NoiseTestVectors.json) is the normative test-vector source for the `XX` pattern ([§2](03-noise.md#2-live-sessions-the-xx-pattern)): two independently-sourced vectors for `Noise_XX_25519_ChaChaPoly_SHA256`, each giving the initiator/responder static and ephemeral private keys, the handshake prologue, and the full sequence of handshake and transport message payload/ciphertext pairs. An implementation's `XX` handshake and transport encryption MUST reproduce every `ciphertext` in both vectors from the given keys and payloads.
No equivalent vector file exists yet for the `X` pattern, courier envelopes, or the wire-format/BLE framing layers — conformance to those mechanisms is checked against this chapter's checklist items only, not a hex fixture, for this `0.1.0` release.
## 8. Known Gaps (Non-Normative)
The following capability bits and concepts are used normatively elsewhere in this specification but have no defined wire mechanism of their own. They are not checklist items — there is nothing to check yet — and are noted here so an implementer does not mistake the absence of a checklist entry for the absence of the concept.
- **`wifiBulk`** (`PeerCapabilities` bit 1, [§5.1](04-payloads.md#51-peercapabilities-bitfield) of Payloads) is defined only as "peer supports bulk transfer over a local Wi-Fi side channel." No chapter specifies a wire format, discovery mechanism, or session-establishment procedure for this side channel.
- **`favorite` / `mutual favorite`** gates courier deposit trust tiers ([§3.3](05-store-and-forward.md#33-deposit-policy-and-trust-tiers) of Store and Forward), Nostr private-message reachability, and relay-identity separation ([§2.4](06-nostr-bridge.md#24-sending-and-reachability), [§7.1](06-nostr-bridge.md#71-rendezvous-cell-and-events) of Nostr Bridge), but no chapter specifies how a peer proposes, signals, exchanges, or verifies favorite status on the wire. Only the [glossary](README.md#glossary) defines the term.
- **`privateMediaReceipts`** (`PeerCapabilities` bit 9, [§5.1](04-payloads.md#51-peercapabilities-bitfield) of Payloads) implies delivery/read acknowledgement for private media, but neither `privateFile`/`fileTransfer` ([§8](04-payloads.md#8-files) of Payloads) nor `voiceFrame` ([§9](04-payloads.md#9-voice) of Payloads) defines a `messageID`-equivalent field for a `delivered`/`readReceipt` payload to reference.

71
spec/README.md Normal file
View File

@ -0,0 +1,71 @@
# bitchat Protocol Specification
**Version:** 0.1.0 (see [`VERSION`](VERSION))
## Status of This Document
This specification is the normative definition of the bitchat protocol. Reference client implementations (Swift, Kotlin, or otherwise) are expected to conform to it. Where an implementation's behavior diverges from this document, the implementation — not the spec — is considered in error, until this document is formally revised.
This is a `0.1.0` release: a first, from-scratch, unreviewed draft. It has not yet been checked against every reference implementation in full, and its numbering reflects that — it does not claim the stability of a `1.0.0` release.
## Chapters
1. [Wire Format](01-wire-format.md) — packet header, byte offsets, TLV/field encodings
2. [BLE Transport](02-ble-transport.md) — GATT UUIDs, advertising format, MTU/fragmentation
3. [Noise](03-noise.md) — the Noise `XX` (live session) and `X` (offline seal) handshake message sequences and payloads
4. [Payloads](04-payloads.md) — application-layer payload encodings: announcements, private/public messages, board posts, group messages, files, voice frames
5. [Store and Forward](05-store-and-forward.md) — sender outbox, courier envelope format and rotating recipient tag, spray-and-wait budget, gossip-sync reconciliation
6. [Nostr Bridge](06-nostr-bridge.md) — NIP usage, relay interaction contract, relay-selection criteria
7. [Conformance](07-conformance.md) — implementation conformance checklist
## Requirements Language
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174) (which clarifies the original [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119)). They appear in this document only in their uppercase form, with that normative meaning; any lowercase occurrence of one of these words carries its ordinary English sense only.
## Byte-Layout Notation
Every fixed-layout structure in this specification (packet headers, fixed-size fields) is presented two ways, together:
- **An ASCII byte-diagram**, showing field boundaries visually across the structure's bytes.
- **A table**, giving the precise field-by-field breakdown, in this column order:
| Offset | Length (bytes) | Field | Description |
|---|---|---|---|
| *(byte offset from the start of the structure)* | *(field width, or "variable")* | *(field name)* | *(field's meaning and constraints)* |
Multi-byte integers are big-endian unless a chapter states otherwise. Variable-length fields (TLV-encoded payloads, and similar) are described in prose immediately following the table, using the same two-column style for their own sub-fields where useful.
## Glossary
Terms below are defined once here and used consistently across every chapter; a chapter marks a term in backticks on first use rather than redefining it locally. Vocabulary carries over from `WHITEPAPER.md` rather than being renamed.
- **peer ID** — the 8-byte identifier a device presents on the BLE mesh, derived from the first 8 bytes of the SHA-256 fingerprint of its Noise static key. Stable across sessions; changes only when the underlying identity is replaced.
- **static key** — a device's long-term Curve25519 key pair, used for Noise key agreement. Its SHA-256 fingerprint is the peer's stable identity.
- **signing key** — a device's long-term Ed25519 key pair, used to sign packets and announcements.
- **announcement** — a signed packet a device broadcasts to identify itself, carrying its nickname, static key, and signing key in cleartext, plus a short list of direct-neighbor peer IDs.
- **board post** — a signed, persistent bulletin-board notice broadcast to the mesh or to a Nostr Bridge geohash region; deleted by a matching signed tombstone from the same author.
- **source route** — an explicit, ordered list of peer IDs a version-2 packet may carry, directing it along a known path instead of relying on flooding.
- **fragment** — one piece of a packet that exceeded the transport's MTU and was split for independent relay and reassembly at the receiving node.
- **Noise session** — a live, bidirectional encrypted channel between two connected peers, established with the Noise `XX` handshake pattern.
- **courier envelope** — an opaque, one-way-sealed message (Noise `X` pattern) handed to an intermediate peer for physical carriage to a recipient who is not currently reachable.
- **prekey** — a one-time Curve25519 key pair a device generates and publishes in a `prekey bundle`; consumed by exactly one courier envelope seal, then discarded, so that seal retains forward secrecy where sealing to a static key does not.
- **prekey bundle** — a device's signed, gossiped batch of current prekeys, letting another peer seal a courier envelope to a one-time key without a live session.
- **rotating recipient tag** — the opaque, day-rotating addressing tag on a courier envelope, computable only by parties who already know the recipient's static key.
- **trust tier** — the deposit quota a courier extends to a sender, based on whether the sender is a mutual favorite or merely signature-verified.
- **spray-and-wait** — the copy-budget scheme by which a courier envelope diffuses across couriers who encounter each other, rather than riding a single carrier.
- **sender outbox** — the persistent, per-peer retry queue a sender holds for private messages that have not yet been delivered or acknowledged.
- **favorite** / **mutual favorite** — a pinned trust relationship between two devices' static keys; when mutual, it unlocks Nostr-path delivery and a larger courier deposit quota.
- **private group** — a creator-managed set of up to 16 members sharing a rotating symmetric key, distributed and rotated over Noise sessions with the creator's signature.
- **vouch** — a signed, transitive statement that the sender of the enclosing Noise session has independently verified a third party's identity.
- **gossip sync** — the periodic reconciliation of cached public broadcast history between peers, so a peer that missed messages can catch up from another peer's cache.
- **panic wipe** — the operation that erases all local identity, keys, and persisted protocol state.
- **relay** (verb) — a BLE mesh node forwarding a packet it did not originate, toward other links.
- **relay** (noun, Nostr context) — a Nostr server that stores and forwards signed events; distinct from a BLE mesh relay.
- **TTL** — the hop-count budget on a BLE mesh packet, decremented by each relay; a packet is not forwarded once it reaches zero.
- **gift wrap** — the outer layer of a Nostr private-message envelope: a `seal`, encrypted again under a fresh one-time key, hiding the sender's stable identity from relays.
- **seal** — the middle layer of a Nostr private-message envelope: a `rumor`, encrypted and signed with the sender's long-term Nostr identity key, authenticating the sender.
- **rumor** — the innermost, unsigned layer of a Nostr private-message envelope, carrying the actual message content.
- **courier drop** — a `courier envelope` parked on Nostr relays under its rotating recipient tag, so delivery does not require a physical courier encounter.
- **gateway** — a peer that bridges a geohash channel between the BLE mesh and Nostr relays for mesh-only peers who cannot reach relays directly.
- **bridge** — a peer that stitches disjoint BLE mesh islands together by routing public mesh traffic through Nostr as a rendezvous.
- **rendezvous cell** — the geohash-precision-6 cell a `bridge` peer signs and subscribes to when relaying mesh traffic across islands.

1
spec/VERSION Normal file
View File

@ -0,0 +1 @@
0.1.0