# Aura Protocol The Aura protocol provides a mutually-authenticated, post-quantum-secure tunnel between a client and a server. It is implemented in the `aura-proto` crate on top of `aura-crypto` (hybrid KEM, HKDF, AEAD) and `aura-pki` (mutual X.509 verification). This document is for an engineer auditing or reimplementing the protocol. Everything below reflects the **actual implementation**, not an idealized spec. Where the original spec was ambiguous (notably the handshake message order), the implementation pins an exact choice and that pinned choice is what is documented here. ## Layering ``` +-------------------------------------------------------------+ | Application IP packets (TUN) | +-------------------------------------------------------------+ | Aura inner session: Frame -> AEAD-sealed Data record | <- real security boundary | Aura inner handshake: hybrid KEM + mutual X.509 | +-------------------------------------------------------------+ | Outer QUIC/TLS (quinn + rustls) — MIMICRY ONLY | <- NOT a security boundary | ALPN h3 / h3-29, Chrome-like transport params, | | client accepts ANY server cert | +-------------------------------------------------------------+ | UDP | +-------------------------------------------------------------+ ``` The two layers have very different jobs: - **Outer QUIC/TLS** is camouflage. It is configured to look like ordinary browser HTTP/3 traffic. It performs **no** meaningful authentication — see [Mimicry layer](#mimicry-layer). - **Inner Aura handshake/session** is the real security boundary: hybrid post-quantum key agreement plus mutual certificate verification against the Aura CA, then an AEAD-protected record stream with replay protection. The inner protocol is transport-agnostic: `client_handshake` / `server_handshake` are generic over a separate `tokio::io::AsyncRead` reader and `AsyncWrite` writer, so the same code drives an in-memory duplex pipe (tests) and quinn's split `RecvStream` / `SendStream` (the QUIC transport) identically. --- ## Wire format Every Aura protocol message is a **5-byte header** followed by a payload (`crates/aura-proto/src/frame.rs`): ``` byte 0 : msg_type (u8) bytes 1..4 : length (u24, big-endian) = payload length in bytes byte 4 : version = 0x01 bytes 5.. : payload (length bytes) ``` - `length` is a 24-bit big-endian integer, so the maximum payload is `0x00FF_FFFF` (16 MiB − 1). An oversize payload is rejected with `FrameTooLarge`. - `version` is `0x01`. A header whose byte 4 is not `0x01` is rejected with `BadVersion`. ### Message types | Byte | `MsgType` | Direction | Encrypted | Role | |--------|---------------|-----------|-----------|--------------------------------------------| | `0x01` | `ClientHello` | C→S | no | Handshake 1: hybrid public key + nonce | | `0x02` | `ServerHello` | S→C | no | Handshake 2: hybrid ciphertext + nonce | | `0x03` | `ClientAuth` | C→S | yes | Handshake 4: client cert + signature | | `0x04` | `ServerAuth` | S→C | yes | Handshake 3: server cert + signature | | `0x05` | `Finished` | both | yes | Handshake 5/6: HMAC over the transcript | | `0x06` | `Data` | both | yes | Application record (AEAD-sealed `Frame`) | | `0xFF` | `Alert` | both | no | Fatal alert; payload byte 0 is the code | > Note: the numeric byte values do **not** follow the send order. `ServerAuth` (`0x04`) is > sent *before* `ClientAuth` (`0x03`). The send order is fixed by the state machine > (below), not by the type byte. ### Application frames Once the session is established, the application payload carried inside each encrypted `Data` record is a `Frame` (`crates/aura-proto/src/frame.rs`). All multi-byte integers are big-endian: | Frame | Tag | Encoding | |---------|--------|-----------------------------------------------------| | `Data` | `0x01` | `0x01 \|\| stream_id(u32) \|\| payload` | | `Ping` | `0x02` | `0x02 \|\| seq(u32)` | | `Pong` | `0x03` | `0x03 \|\| seq(u32)` | | `Close` | `0x04` | `0x04 \|\| code(u8) \|\| reason_len(u32) \|\| reason_utf8` | --- ## Handshake ### Pinned message order The original spec diagram was ambiguous about the order of the encrypted auth/Finished messages. The implementation pins this exact order, and both peers follow it lock-step (`crates/aura-proto/src/handshake.rs`): ``` 1. C -> S ClientHello (plaintext): x25519_pub[32] || mlkem_ek[1184] || client_nonce[32] 2. S -> C ServerHello (plaintext): x25519_ephemeral[32] || mlkem_ct[1088] || server_nonce[32] -- both sides derive the hybrid shared secret and the two directional SessionKeys -- 3. S -> C ServerAuth (encrypted under s2c): u16(cert_der_len) || server_leaf_cert_der || sig(transcript) 4. C -> S ClientAuth (encrypted under c2s): u16(cert_der_len) || client_leaf_cert_der || sig(transcript) 5. C -> S Finished (encrypted under c2s): HMAC-SHA256(key_c2s, transcript) 6. S -> C Finished (encrypted under s2c): HMAC-SHA256(key_s2c, transcript) -- encrypted Data channel is now open in both directions -- ``` ```mermaid sequenceDiagram participant C as Client participant S as Server Note over C,S: plaintext C->>S: 1. ClientHello (x25519_pub, mlkem_ek, client_nonce) S->>C: 2. ServerHello (x25519_eph, mlkem_ct, server_nonce) Note over C,S: both derive shared secret + SessionKeys
transcript = SHA-256(CH_frame || SH_frame) Note over C,S: encrypted (AEAD under directional keys) S->>C: 3. ServerAuth (server cert + sig over transcript) C->>S: 4. ClientAuth (client cert + sig over transcript) C->>S: 5. Finished (HMAC_c2s over transcript) S->>C: 6. Finished (HMAC_s2c over transcript) Note over C,S: session established; Data records flow both ways ``` ### Hello payloads (exact sizes) | Field | ClientHello | ServerHello | Bytes | |-------------------|:-----------:|:-----------:|------:| | X25519 pub / eph | ✔ | ✔ | 32 | | ML-KEM-768 ek | ✔ | | 1184 | | ML-KEM-768 ct | | ✔ | 1088 | | nonce | ✔ | ✔ | 32 | | **Total payload** | **1248** | **1152** | | Hellos are sent in plaintext and validated for exact length on receipt; a wrong length is rejected with `MalformedHandshake`. ### Transcript hash ``` transcript = SHA-256( ClientHello_frame_bytes || ServerHello_frame_bytes ) ``` The hash covers the **full serialized frames** (5-byte header + payload) of ClientHello and ServerHello, exactly as transmitted on the wire. This binds the negotiated key material and the protocol version into both the signatures and the Finished MACs. ### Authentication (ServerAuth / ClientAuth) Each Auth payload is: ``` u16_be(cert_der_len) || leaf_cert_der || signature ``` - `leaf_cert_der` is the sender's **leaf certificate** in DER (sent inline; no chain — the CA is the trust anchor on the receiving side). - `signature` is an **ECDSA P-256 / SHA-256** signature, ASN.1 DER encoded (`ECDSA_P256_SHA256_ASN1`), computed over the 32-byte `transcript` (via `ring`). Verification (`crates/aura-proto/src/handshake.rs`): 1. The receiver builds an `AuraCertVerifier` from its configured CA PEM and verifies the peer's leaf against the CA (chain + key-usage + validity; see `pki.md`). - The **client** additionally requires the server leaf to be valid for the expected `server_name` (DNS SAN match). - The **server** captures the verified **client id** (leaf Common Name) and stores it as the session's `peer_id`. 2. The receiver extracts the leaf's EC public-key point and verifies `signature` over `transcript`. A failure is `Signature(...)`. Possession of the certificate's private key is therefore proven by the signature over the transcript; the certificate identity is proven by the CA chain check. ### Finished Each side sends, then verifies, a Finished MAC bound to the transcript and the direction key: ``` Finished_c2s = HMAC-SHA256(key_c2s, transcript) // client sends (msg 5), server verifies Finished_s2c = HMAC-SHA256(key_s2c, transcript) // server sends (msg 6), client verifies ``` Verification is constant-time (`Hmac::verify_slice`); a mismatch is `FinishedMismatch`. The Finished exchange confirms both sides derived identical keys and agree on the full transcript. ### Encrypted handshake messages and counter continuity Messages 3–6 are AEAD-sealed under the **same** two directional `AeadSession`s that protect application Data; their nonce counters are continuous across the handshake/data boundary. - The AAD for each encrypted handshake message is its 5-byte frame header (binding type + length), matching the Data-record convention. - Each direction seals **exactly two** encrypted handshake messages before Data begins: - c2s seals `ClientAuth` (counter 0) and `Finished` (counter 1) - s2c seals `ServerAuth` (counter 0) and `Finished` (counter 1) - Therefore both directions reach AEAD counter **2** at the end of the handshake, and the first application Data record stamps `seq == 2` (`POST_HANDSHAKE_COUNTER`). This seeds the replay window (below). --- ## Hybrid KEM The key exchange is a hybrid of classical X25519 ECDH and post-quantum ML-KEM-768 (`crates/aura-crypto/src/kem/`). An attacker must break **both** primitives to recover the session key. > **ML-KEM-768 (FIPS 203)**, via the RustCrypto `ml-kem` crate (v0.3) — this is the > standardized FIPS 203 scheme, **not** round-3 Kyber. ### Roles - The **client** owns the long-term `HybridPrivateKey` and publishes its `HybridPublicKey` in ClientHello. - The **server** calls `encapsulate()` against that public key: it generates an **ephemeral** X25519 keypair and an ML-KEM encapsulation, returns the `HybridCiphertext` in ServerHello, and derives the shared secret. - The **client** recovers the same secret via `decapsulate()`. So X25519 is **ephemeral–static** (server ephemeral against client static public), while ML-KEM is a standard KEM against the client's encapsulation key. ### Sizes | Quantity | Bytes | Constant | |-----------------------------------|------:|---------------------| | X25519 public / ephemeral / secret| 32 | `X25519_LEN` | | ML-KEM-768 encapsulation key (ek) | 1184 | `EK_LEN` | | ML-KEM-768 ciphertext (ct) | 1088 | `CT_LEN` | | ML-KEM-768 shared secret | 32 | `SS_LEN` | | ML-KEM-768 decapsulation key (dk) | 2400 | `DK_LEN` | > **Implementation detail — dk encoding.** The decapsulation (secret) key is stored in the > FIPS 203 **expanded 2400-byte** form (`ExpandedKeyEncoding`), not the 64-byte seed that > `ml-kem` 0.3 prefers. This is the encoding the project's ACVP / FIPS-203 known-answer test > vectors operate on, so it is used for interop/KAT compatibility. The dk never travels on the > wire — only `ek` (1184 B) and `ct` (1088 B) do. ### Combined shared secret ``` shared = x25519_ss (32 B) || mlkem_ss (32 B) // 64 bytes total ``` ML-KEM decapsulation is infallible on a correctly sized ciphertext: a tampered ciphertext yields a pseudo-random secret (implicit rejection) rather than an error, which surfaces later as an AEAD/Finished failure. --- ## Key derivation (HKDF) Directional session keys are derived with **HKDF-SHA256** (RFC 5869) (`crates/aura-crypto/src/kdf.rs`): ``` salt = client_nonce || server_nonce (64 bytes) IKM = x25519_ss || mlkem_ss (64 bytes) info = "aura-v1-session" OKM = HKDF-Expand(HKDF-Extract(salt, IKM), info, 64) (64 bytes) key_client_to_server = OKM[0..32] key_server_to_client = OKM[32..64] ``` The derivation is fully deterministic in its inputs. The `info` string provides domain separation. Intermediate secret material (`salt`, `IKM`, `OKM`) is zeroized after use, and `SessionKeys` zeroizes its keys on drop. --- ## AEAD The record cipher is **ChaCha20-Poly1305** (`crates/aura-crypto/src/aead.rs`). An `AeadSession` holds a 256-bit key and a 64-bit message counter; each direction has its own session. ### Nonce scheme The 96-bit (12-byte) nonce is derived from the counter: ``` nonce[0..8] = counter as little-endian u64 nonce[8..12] = 0x00 00 00 00 ``` The counter advances by one on every `seal` **and** every `open` (even on a failed `open`), so a paired seal/open stay aligned without transmitting the nonce. The nonce is never reused within a session (the 2^64 counter wrap is unreachable; an overflow panics rather than reusing a nonce). The key is zeroized on drop. --- ## Data records and replay protection After the handshake, application `Frame`s are exchanged as `Data` records (`crates/aura-proto/src/session.rs`). Each `Data` record's **payload** is: ``` seq (u64, big-endian) || ChaCha20Poly1305_seal( frame_bytes, aad = header || seq ) ``` - `seq` is the 8-byte big-endian record counter. On the happy path it equals the sealing AEAD's counter (and the receiver's expected AEAD counter). - The AEAD **AAD** is the 5-byte frame `header` concatenated with the 8-byte `seq`, so the record is cryptographically bound to both its declared length/type and its claimed position. - The ciphertext includes the 16-byte Poly1305 tag. So the full record on the wire is: ``` [ header(5) ][ seq(8) ][ ciphertext + tag ] \_____________________________________________/ header.length = 8 + len(ciphertext+tag) ``` ### Sliding replay window The receiver runs a **64-wide sliding-window** replay check (`REPLAY_WINDOW = 64`) *before* touching the AEAD, so a duplicate or too-old record is rejected with `Replay(seq)` without disturbing the AEAD counter (the session stays usable). The window: - tracks the highest accepted `seq` plus a 64-bit bitmap of accepted positions below it; - accepts a `seq` iff it is strictly newer than everything seen, or falls within the window and has not been seen before; - rejects a `seq` that equals the current highest, is already marked in the bitmap, or is more than `REPLAY_WINDOW` below the highest. The window is seeded at the post-handshake counter (`start = 2`): everything strictly below `start` is treated as already-consumed, so the first legitimate Data record (`seq == 2`) is accepted as "newer". ### Full-duplex split A `Session` can be `split()` into independent `SessionSender` (writer + outbound AEAD + send counter) and `SessionReceiver` (reader + inbound AEAD + replay window) halves, which can be driven from separate tasks for a concurrent read/write data path (e.g. the VPN tunnel). `recv_frame` is **not** cancellation-safe and must be driven from a single owning task. --- ## Mimicry layer The outer QUIC/TLS layer (`crates/aura-transport/`) exists purely to disguise the connection as browser HTTP/3 traffic. It is explicitly **not** the authentication boundary. - **ALPN** advertises `h3` and `h3-29` (`ALPN_H3`) — exactly what Chrome offers for HTTP/3 — so the ALPN extension is indistinguishable from a real browser's. - **Transport params** mirror a Chromium HTTP/3 connection: ~30 s idle timeout, ~15 s keep-alive, 100 concurrent bidi/uni streams, ~10 MB flow-control receive windows (`chrome_quic_transport_config`). - **SNI** defaults to a generic CDN-looking hostname (`cdn.example.com`) when the caller does not supply one; deployments pass their own camouflage hostname. - The QUIC **client accepts any server certificate** (`AcceptAnyServerCert` — all verifier methods return success). This is safe *only* because the outer TLS is not authentication: the real mutual auth is the inner Aura handshake. The server's outer TLS likewise disables client auth (`with_no_client_auth`). > Do not reuse `AcceptAnyServerCert` anywhere the TLS layer *is* the authentication boundary. --- ## Error model The protocol layer surfaces `ProtoError` (`crates/aura-proto/src/lib.rs`), including: `Io`, `Crypto`, `Pki`, `UnknownMsgType`, `BadVersion`, `FrameTooLarge`, `UnexpectedMsg`, `MalformedHandshake`, `MalformedFrame`, `Signature`, `FinishedMismatch`, `Replay`, and `Alert`. A peer may send a fatal `Alert` frame (type `0xFF`); the first payload byte is the alert code, surfaced to the local side as `ProtoError::Alert(code)`.