spec: fix five signing/framing transcripts flagged by automated review

Corrects five places where the spec's normative byte transcripts diverged
from what bitchat-ios actually signs, seals, or transmits — each would
have broken interop for an independent implementation following the text:

- Packet signing (01-wire-format.md): the signing transcript is always
  padded per §6's algorithm, regardless of the message type's wire
  padding rule (toBinaryDataForSigning() defaults padding to true even
  for types transmitted unpadded).
- Noise X prologues (03-noise.md): document the non-empty, seal-specific
  prologues (bitchat-courier-v1 / bitchat-prekey-v1 || prekeyID) that
  NoiseEncryptionService mixes into the handshake hash; the spec
  previously implied Noise's default empty prologue.
- Prekey bundle signature (03-noise.md): the signed transcript is a
  domain-prefixed byte construction distinct from the bundle's TLV
  encoding, not "the preceding fields" as previously stated.
- Board post/tombstone signatures (04-payloads.md): the context string is
  preceded by a 1-byte length, per BoardWireEncoding.appendContext.
- Fragment cap (02-ble-transport.md): the 256-fragment ceiling is scoped
  to the legacy directed fileTransfer fallback for current Android
  receivers; other fragment streams are bound by the general
  10,000-fragment reassembly ceiling.

Also updates the conformance chapter's checklist items to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Yashodhan Singh 2026-08-07 01:14:51 +05:30
parent 36e2b3f955
commit 286efb0f38
5 changed files with 40 additions and 12 deletions

View File

@ -79,13 +79,15 @@ When `hasSignature` is set, a 64-byte Ed25519 signature follows the payload sect
## 5. Signing
The signature, when present, is computed over the packet's encoded bytes with two substitutions: the `signature` section itself is omitted, and `ttl` is fixed to `0` regardless of the packet's actual TTL. `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.
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 frame before checking a signature against it.
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
Only `noiseHandshake` and `noiseEncrypted` packets are padded; every other message type is encoded 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.
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.

View File

@ -42,7 +42,9 @@ A packet whose encoded size exceeds the link MTU MUST be split into `fragment` (
### 3.2 Fragment Cap and Lifetime
A sender MUST NOT split a packet into more than 256 fragments; a receiver MAY reject a fragment stream whose `total` exceeds this cap. This ceiling exists because it is a cross-platform contract, not an arbitrary internal limit — implementations that accept larger fragment counts still MUST NOT rely on peers doing the same.
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.

View File

@ -117,6 +117,15 @@ A sender who instead holds one of the recipient's published one-time `prekey`s s
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.
@ -132,7 +141,7 @@ A prekey bundle is carried as the payload of a `prekeyBundle (0x24)` packet (see
| 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 preceding fields, using the issuing peer's `signing key`. |
| 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
@ -145,4 +154,15 @@ Each entry in the `prekeys` field is 36 bytes, with no further framing between c
### 5.3 Signature
A recipient MUST verify the `signature` field against the issuing peer's known `signing key` before trusting any prekey the bundle carries, and MUST discard the bundle if verification fails.
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.

View File

@ -105,9 +105,11 @@ A decoder MUST reject a payload whose `kind` is absent or unrecognized, or that
### 6.1 Signing
A **post**'s `signature` covers, concatenated in this order: the ASCII 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`.
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 **tombstone**'s `signature` covers: the ASCII context string `bitchat-board-del-v1`, `postID`, and `deletedAt`. Only the original post's `authorSigningKey` can produce a valid tombstone for it.
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

View File

@ -16,8 +16,8 @@ Chapter 6's items are split by the REQUIRED/capability-gated boundary that chapt
- [ ] 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`, and `isRSR` excluded; a verifier MUST reconstruct this exact frame ([§5](01-wire-format.md#5-signing)).
- [ ] Only `noiseHandshake`/`noiseEncrypted` packets are padded, using PKCS#7-style padding over header-through-payload targeting the smallest of the `256/512/1024/2048`-byte buckets ([§6](01-wire-format.md#6-padding)).
- [ ] 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)).
@ -28,7 +28,7 @@ Chapter 6's items are split by the REQUIRED/capability-gated boundary that chapt
- [ ] 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 sender MUST NOT split a packet into more than 256 fragments; a receiver MAY reject a stream whose `total` exceeds this ([§3.2](02-ble-transport.md#32-fragment-cap-and-lifetime)).
- [ ] 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)).
@ -49,7 +49,9 @@ Chapter 6's items are split by the REQUIRED/capability-gated boundary that chapt
- [ ] 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
@ -61,7 +63,7 @@ Chapter 6's items are split by the REQUIRED/capability-gated boundary that chapt
- [ ] `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 use context strings `bitchat-board-v1` (post) / `bitchat-board-del-v1` (tombstone) over the defined field concatenation ([§6.1](04-payloads.md#61-signing)).
- [ ] 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)).