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/curves before 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.

  1. Encode both peers' identity public keys as 65-byte uncompressed SEC1.
  2. Compare the two byte strings lexicographically (unsigned byte-wise from offset 0; equal-length prefixes compare element-wise).
  3. The peer whose identity public key bytes are lexicographically smaller is the Initiator. The other peer is the Responder.
  4. 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_key encrypts frames sent by the initiator and is decrypted by the responder. resp_key encrypts 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 each info value before the expand step:

    info_pake = Label("<direction>/traffic") || pakeSecret
    

    so 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 uint32 space 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
  • sideByte is 0x41 ('A') for the initiator, 0x42 ('B') for the responder — the SPAKE2 role the sender plays.

  • share is the 33-byte SPAKE2 share (1 side byte + a 32-byte Ed25519 Edwards point, as emitted by the crate).

  • confirmTag is 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.algorithm is exactly "argon2id"; kdf.version is the Argon2 algorithm version 19 (0x13).
  • m (memory in bytes), t (iterations), p (parallelism), and the 16-byte salt are frozen at bundle creation and recorded so an importer can re-derive the key without negotiation.
  • aead.nonce is the 12-byte AES-256-GCM nonce used for the bundle.
  • ciphertext is 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, or aead.algorithm is 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:

  1. 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.
  2. 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.
  3. No mutable transcripts. Transcript objects are readonly and canonical; there is no API to mutate a field without re-encoding.
  4. No caller nonces. Nonces are derived from (sender_session_id, sequence) (§5); the codec never accepts an external nonce.
  5. Typed errors. Every malformed input is rejected by throwing a ProtocolError carrying a stable code from the ProtocolErrorCode enum. Callers branch on the code, not on message text.