fuck-eu-chat-control docs
Protocol v1 specification
Protocol v1 Specification
The frozen wire and cryptographic protocol specification. This is the source of truth for the canonical codec, the crypto module, framing, and the orchestrator. Changes here require a new ADR.
The handshake supports two authentication modes, both first-class:
SafetyNumberOnly (0x01, the default) and Pake (0x02). See
ADR 001 and the
threat model. The mode is recorded in the transcript and
both peers must agree on it.
1. Versioning
| Constant | Value | Where |
|---|---|---|
| Protocol version byte | 0x01 |
First byte of the transcript and of every frame header / AAD. |
| Transcript version | 0x01 |
First byte of the canonical transcript encoding. |
| Export-bundle version | 1 |
Integer in the JSON envelope's v field. |
An unknown version byte on the wire is a terminal protocol error. Receivers do not attempt forward-compatible parsing of an unknown version.
All multi-byte integers are big-endian, unsigned, and fixed-width. The
canonical encodings use no length-prefixed varints; lengths are implicit in
the type where fixed, and explicit as a fixed-width uint32 where variable
(ciphertext length only).
2. Primitive encodings
2.1 P-256 public key — uncompressed SEC1, 65 bytes
A public key is the raw uncompressed SEC1 encoding:
0x04 || X(32 bytes, big-endian) || Y(32 bytes, big-endian) = 65 bytes total
- The leading byte must be
0x04. - Compressed encodings (
0x02/0x03) are not accepted. - The point must lie on the P-256 curve (
secp256r1). The codec validates this via@noble/curvesbefore a key is accepted into any typed value.
Reference: SEC 1, §2.3.3 (uncompressed point encoding), §3.2.1 (point validation). Equivalent to NIST SP 800-186 §3.2.1 and X9.62 §4.3.6.
2.2 ECDSA signature — IEEE P1363 (r || s), 64 bytes
A signature is the fixed-width concatenation of the two integer halves, each exactly 32 bytes big-endian, zero-padded on the left:
r(32 bytes, big-endian) || s(32 bytes, big-endian) = 64 bytes total
DER-encoded signatures are not accepted. (@noble/curves emits this as the
"compact" format.)
Reference: IEEE P1363, §5.6.2. Equivalent to the WebCrypto ecdsa raw
signature format for P-256.
2.3 Conversation ID — 16 bytes (128 bits)
Opaque bearer rendezvous handle. CSPRNG-generated by the initiator, exactly 16 bytes, validated strictly on the broker and by the codec. Carried in the URL fragment; never in an HTTP request path or query.
2.4 Session ID — 32 bytes (256 bits)
Random per-peer, per-connection value. Each peer contributes one session ID
at handshake time. Both session IDs are bound into the transcript. The
sender's session ID is carried in every frame's AAD and drives deterministic
per-direction nonce derivation (§5). The receiver keeps a replay window
keyed by (sender_role, sender_session_id).
3. Role derivation
Roles are derived deterministically from the two peers' identity public keys, never from broker messages.
- Encode both peers' identity public keys as 65-byte uncompressed SEC1.
- Compare the two byte strings lexicographically (unsigned byte-wise from offset 0; equal-length prefixes compare element-wise).
- The peer whose identity public key bytes are lexicographically smaller is the Initiator. The other peer is the Responder.
- The transcript records keys and session IDs in canonical
(initiator, responder)order, so the role is implicit in a well-formed transcript.
Tie-breaker (defensive, normally impossible): if the two identity public key
byte strings are equal, the peers share an identity key, which means a device
is attempting to talk to itself. This is a fatal protocol error
(RoleIndeterminable); no session is established.
Perfect-negotiation collision policy (simultaneous rejoin)
When both peers rejoin the broker room for the same chat at the same time, both may emit a WebRTC offer ("glare"). Roles are already fixed cryptographically, so the glare is resolved deterministically by the perfect-negotiation pattern:
- The Initiator is the impolite peer: on glare, it keeps its offer and proceeds.
- The Responder is the polite peer: on glare, it rolls back its local description and answers the initiator's offer.
This guarantees forward progress without any broker-mediated role message. Broker-originated role assertions are never trusted for cryptographic identity or authentication. The same initiator/resolver split fixes the SPAKE2 A/B roles in PAKE mode.
4. HKDF labels and key schedule
The traffic key schedule derives from the ECDH shared secret (the X-coordinate of the P-256 shared point, 32 bytes) and the SHA-256 hash of the canonical transcript (§6):
prk = HKDF-Extract(salt = transcript_hash, IKM = ecdh_secret)
init_key = HKDF-Expand(prk, L = 32, info = Label("fck-chat-v1/init->resp/traffic"))
resp_key = HKDF-Expand(prk, L = 32, info = Label("fck-chat-v1/resp->init/traffic"))
Label(s) is the ASCII bytes of s with no trailing NUL.
init_keyencrypts frames sent by the initiator and is decrypted by the responder.resp_keyencrypts frames sent by the responder and is decrypted by the initiator. Directional labels prevent reflection and key/nonce reuse across directions.In PAKE mode (
authMode = 0x02), the 32-byte SPAKE2 shared secret is appended to eachinfovalue before the expand step:info_pake = Label("<direction>/traffic") || pakeSecretso a wrong code yields non-matching keys. The secret is the raw output of
spake.finish.
HKDF is RFC 5869 with SHA-256.
5. Per-direction nonce derivation
The AEAD nonce is 96 bits (12 bytes), constructed from the sender's session ID and the frame sequence number:
nonce[0..8] = sender_session_id[0..8]
nonce[8..12] = sender_session_id[8..12] XOR seq_be4
where seq_be4 is the sender sequence number encoded as a 4-byte big-endian
unsigned integer. (The uint32 sequence value occupies the low 4 bytes of
the nonce; the high 8 bytes come straight from the session ID prefix.)
- Because each peer contributes its own session ID, the two directions have disjoint nonce spaces. Directional keys make this belt-and-suspenders.
- Because the sequence number is monotonically increasing within a session
and the sender stops before the
uint32space is exhausted, a nonce never repeats under one(sender_session_id, key)pair. - The construction is deterministic and stateless on the sender side given
(session_id, sequence), so the receiver can reject a repeated nonce/sequence via the replay window without decrypting.
Nonces are not HKDF-derived and are not caller-supplied (§10). The codec
derives them from (sender_session_id, sequence) exclusively.
6. Canonical transcript
The transcript is a single canonical byte string over which both peers compute
the SHA-256 hash that binds the handshake and feeds the key schedule. Fields
are concatenated in the exact order below with no length prefixes (all widths
are fixed). Roles are already canonical, so all fields appear in
(initiator, responder) order.
| Offset | Width (B) | Field | Encoding |
|---|---|---|---|
| 0 | 1 | transcript version | 0x01 |
| 1 | 1 | protocol version | 0x01 |
| 2 | 16 | conversation ID | raw |
| 18 | 1 | authentication mode | 0x01 = SafetyNumberOnly / 0x02 = Pake |
| 19 | 65 | initiator identity public key | SEC1 uncompressed |
| 84 | 65 | responder identity public key | SEC1 uncompressed |
| 149 | 65 | initiator ephemeral ECDH key | SEC1 uncompressed |
| 214 | 65 | responder ephemeral ECDH key | SEC1 uncompressed |
| 279 | 32 | initiator session ID | raw |
| 311 | 32 | responder session ID | raw |
Total: 343 bytes. The transcript hash is SHA-256(transcript).
Identity signatures are ECDSA-P-256 over the SHA-256 hash of the transcript,
encoded per §2.2 (r || s, 64 bytes). Each peer signs with its own identity
private key and verifies the peer's signature against the peer's identity
public key (TOFU on first contact; match against the stored key on
resumption). Because authMode is in the transcript, the signature binds the
Pake-vs-SafetyNumberOnly choice.
7. Handshake messages
The handshake runs in-band over the data channel (DTLS-encrypted transport, not yet application-authenticated). Two rounds are always present; PAKE mode adds two more.
7.1 Hello (both modes)
HelloMessage = protocolVersion(1) | identityPublicKey(65) | ephemeralPublicKey(65) | sessionId(32)
= 163 bytes
7.2 Signature (both modes)
After both peers know each other's identity keys, ephemeral keys, and session IDs, each builds the canonical transcript (§6), signs it, and sends:
SignatureMessage = protocolVersion(1) | signature(64) = 65 bytes
7.3 PAKE exchange (PAKE mode only)
PAKE mode runs SPAKE2 (RFC 9383) over Ed25519, asymmetric A/B, using the raw
6-digit code as the password. The Initiator plays side A (0x41); the
Responder plays side B (0x42). Each side sends a share, then a confirmation
tag:
PakeShareMessage = protocolVersion(1) | sideByte(1) | share(33) = 35 bytes
PakeConfirmMessage = protocolVersion(1) | sideByte(1) | confirmTag(32) = 34 bytes
sideByteis0x41('A') for the initiator,0x42('B') for the responder — the SPAKE2 role the sender plays.shareis the 33-byte SPAKE2 share (1 side byte + a 32-byte Ed25519 Edwards point, as emitted by the crate).confirmTagis computed as:confirmKey = HKDF-SHA256(IKM = pakeSecret, salt = empty, info = "fck-chat-v1/pake-confirm", L = 32) confirmTag = HMAC-SHA256(key = confirmKey, message = sideByte(1) || transcriptHash(32))The role prefix makes the two directions' tags non-interchangeable. The transcript hash binds the conversation, both identity keys, the ephemeral keys, both session IDs, and the mode.
The exchange sits after signature verification and before traffic-key derivation. A wrong code produces divergent secrets, the confirm tags do not match, and the handshake aborts with a durable auth-failure flag (see the threat model). Field widths are fixed; decoding rejects the wrong length or version before any allocation.
7.4 Handshake order
Per peer: Hello → Signature → (Verifying) → [PAKE: PakeShare → PakeConfirm] → Connected. The PAKE share and confirm messages are full bidirectional exchanges, gated by bounded waits; their relative ordering across peers is not enforced, only the complementarity of the side bytes.
8. Frame format and AAD
A v1 frame on the wire is:
FrameHeader || nonce(12) || ciphertext(ciphertextLength)
Per-frame authentication is provided by the AES-256-GCM tag embedded in
ciphertext; peer-identity authentication is bound once at handshake via the
transcript signature (§6).
8.1 Frame header (and authenticated additional data)
The FrameHeader carries the per-frame metadata. The authenticated additional
data (AAD) for AES-GCM is the FrameHeader without the trailing
ciphertextLength field. Lengths are fixed-width big-endian.
FrameHeader fields, in canonical order:
| Width (B) | Field | Encoding / notes |
|---|---|---|
| 1 | protocol version | 0x01 |
| 32 | sender session ID | raw, identifies the sender's nonce/replay space |
| 4 | sender sequence | uint32 BE, monotonically increasing within the session |
| 1 | frame type | enum (§9) |
| 4 | transfer ID | uint32 BE, 0 when the frame is not part of a transfer |
| 4 | chunk ID | uint32 BE, 0-based index of the chunk within the transfer; 0 when the frame is not a chunk |
| 4 | ciphertext length | uint32 BE, length of the ciphertext field that follows |
FrameHeader size: 50 bytes. AAD size: 46 bytes (everything but
ciphertextLength).
The codec validates, in order: protocol version, frame type enum, sequence
range, transfer/chunk ID relationships per frame type, and
ciphertextLength against the limit for the frame type — all before the
ciphertext buffer is allocated.
8.2 Frame type enum
| Value | Name | Transfer/Chunk IDs | Ciphertext payload cap |
|---|---|---|---|
0x01 |
text |
both 0 |
MAX_TEXT_FRAME_BYTES (16384) |
0x02 |
file_manifest |
transfer ID set, chunk 0 |
MAX_TEXT_FRAME_BYTES (manifest envelope cap) |
0x03 |
file_chunk |
transfer ID set, chunk ID = 0-based index | MAX_CHUNK_BYTES (16384) |
0x04 |
control |
both 0 |
MAX_TEXT_FRAME_BYTES (control envelope cap) |
0x05 |
media_manifest |
transfer ID set, chunk 0 |
reserved, not implemented |
0x06 |
media_chunk |
transfer ID set, chunk ID = 0-based index | reserved, not implemented |
For file_chunk/media_chunk, chunk ID is a 0-based index into the
transfer's chunks: the first chunk of every transfer legitimately carries
chunk ID = 0. 0 is the absent-marker only for frame types that are not
chunks — text, control, and the manifest types, where it means "no chunk".
See ADR 002.
Any other frame type value is a terminal protocol error. The media types are typed so future work does not need to break the enum; they are rejected by the transport.
8.3 Control subtypes
For frame_type = 0x04 control, the first byte of the decrypted plaintext is
the control subtype:
| Value | Name |
|---|---|
0x01 |
session_key_exchange |
0x02 |
identity_announcement |
0x03 |
leave |
0x04 |
safety_number_announcement |
0x05 |
pake_share |
0x06 |
transfer_cancel |
transfer_cancel carries a 4-byte big-endian transferId payload. Any other
control subtype value is a terminal protocol error.
9. Hard limits
These are the frozen limits, exposed as readonly constants in
protocol/limits.ts. Every decoder validates against the relevant limit
before allocating a payload buffer.
| Constant | Value | Meaning |
|---|---|---|
PROTOCOL_VERSION |
0x01 |
Wire protocol version byte. |
TRANSCRIPT_VERSION |
0x01 |
Canonical transcript version byte. |
EXPORT_BUNDLE_VERSION |
1 |
Export/import JSON envelope v field. |
CONVERSATION_ID_BYTES |
16 |
Conversation / room ID length (128 bits). |
SESSION_ID_BYTES |
32 |
Per-peer session ID length (256 bits). |
PUBLIC_KEY_BYTES |
65 |
Uncompressed SEC1 P-256 public key length. |
SIGNATURE_BYTES |
64 |
IEEE-P1363 P-256 signature length. |
MAX_SEQUENCE |
4294967295 |
Maximum sender sequence (2^32 - 1); sender stops at it. |
MAX_TEXT_FRAME_BYTES |
16384 |
Codec cap on a text/manifest/control frame's wire payload (16 KiB). |
MAX_CHUNK_BYTES |
16384 |
Codec cap on a file_chunk/media_chunk wire payload (16 KiB). |
MAX_MANIFEST_NAME_BYTES |
255 |
Maximum file name byte length in a manifest. |
MAX_MANIFEST_MIME_BYTES |
127 |
Maximum MIME type byte length in a manifest. |
MAX_MANIFEST_SIZE_BYTES |
4294967295 |
Maximum declared file size (2^32 - 1, uint32). |
MAX_CONCURRENT_TRANSFERS |
4 |
Maximum simultaneous active transfers per conversation. |
MAX_INCOMPLETE_TRANSFER_BYTES |
67108864 |
Maximum sum of incomplete-transfer buffered bytes (64 MiB). |
MAX_BUFFERED_DATA_BYTES |
1048576 |
Maximum total buffered data backpressure cap (1 MiB). |
REPLAY_WINDOW_SEQUENCES |
1024 |
Bounded replay window size per (sender, session). |
HANDSHAKE_TIMEOUT_MS |
30000 |
Maximum handshake duration (30 s). |
FRAME_PARSE_TIMEOUT_MS |
5000 |
Maximum time to parse/validate one frame (5 s). |
MAX_SEQUENCE is the largest representable uint32; the sender must stop and
tear down before sending a frame whose sequence would exceed it, so the nonce
space never wraps.
10. Safety number
The safety number is derived only from the conversation ID and both identity public keys; it does not depend on the authentication mode or on any PAKE material. It is computed identically for safety-number and PAKE chats.
material = conversationId(16) || min(init_idpub, resp_idpub) || max(init_idpub, resp_idpub)
(the two keys are ordered by lexicographic byte comparison)
digest = SHA-256(material)
truncated = uint40BigEndian(digest[0..5]) // first 40 bits, big-endian
value = truncated mod 10^12
safetyNo = format("%012d", value) // zero-pad to 12 digits
display = pairs(safetyNo, 2) // grouped into six 2-digit pairs, space-separated
Example display: 12 34 56 78 90 12. The mod 10^12 forces exactly twelve
digits from a value that can be up to thirteen before reduction.
11. Export / import bundle
The export/import bundle is a versioned JSON envelope. All binary fields are base64 (standard, padded) of their raw byte form.
{
"v": 1,
"kdf": {
"algorithm": "argon2id",
"version": 19,
"m": 67108864,
"t": 3,
"p": 1,
"salt": "<base64, 16 bytes>"
},
"aead": {
"algorithm": "aes-256-gcm",
"nonce": "<base64, 12 bytes>"
},
"ciphertext": "<base64>"
}
kdf.algorithmis exactly"argon2id";kdf.versionis the Argon2 algorithm version19(0x13).m(memory in bytes),t(iterations),p(parallelism), and the 16-bytesaltare frozen at bundle creation and recorded so an importer can re-derive the key without negotiation.aead.nonceis the 12-byte AES-256-GCM nonce used for the bundle.ciphertextis the AEAD output (ciphertext || 16-byte tag) over the inner payload (identity private key plus serialized history).- Unknown envelope fields are ignored by the importer; a missing or
unsupported
v,kdf.algorithm, oraead.algorithmis a terminal import error.
Argon2id parameters (default): m = 67108864 (64 MiB), t = 3, p = 1.
These run off the main thread (Web Worker) to avoid blocking the UI;
revisiting them requires a new ADR.
12. Wire-level parsing invariants
These are the non-negotiable properties of the canonical codec:
- Validate before allocate. Every decoder checks lengths, versions, and enum values against fixed constants and limits before allocating any application-payload buffer. A malformed frame never causes unbounded allocation.
- No implicit encoding. Every byte is explicit in the canonical order above; there are no length-implied fields, no varints, no optional fields inferred by absence.
- No mutable transcripts. Transcript objects are readonly and canonical; there is no API to mutate a field without re-encoding.
- No caller nonces. Nonces are derived from
(sender_session_id, sequence)(§5); the codec never accepts an external nonce. - Typed errors. Every malformed input is rejected by throwing a
ProtocolErrorcarrying a stablecodefrom theProtocolErrorCodeenum. Callers branch on the code, not on message text.