Protocol — NEN-PROTOCOL-V3

The wire protocol spoken between @withnen/client and @withnen/server, as implemented. It runs at the application layer, on top of TLS — Nen assumes TLS is present and does not replace it.

V3 (v0.4.0): the handshake now runs an HKDF-SHA256 key schedule — both the ChaCha20 key (k_enc = HKDF(ss, "nen/v3 enc")) and the HMAC key (k_mac = HKDF(ss, "nen/v3 mac")) are derived locally on each side from the shared secret, so no key material ever crosses the wire (V2 returned the HMAC key in the handshake response — V3 removes it). The exchange is hybrid by default: X25519 + ML-KEM-768 combined, so the session is safe unless both are broken. Adds transcript-bound server identity (ML-DSA), session resumption, and a per-request rekey ratchet. Wire-breaking from V2.

Cryptographic parameters

RoleAlgorithmStandard
Key encapsulation (KEM)ML-KEM-768FIPS 203
Classical key agreementX25519 (hybrid)RFC 7748
Key derivation (schedule)HKDF-SHA256RFC 5869
Payload encryption (AEAD)ChaCha20-Poly1305RFC 8439
Per-request authenticationHMAC-SHA256FIPS 198-1
Transcript / identity signatureML-DSA-65FIPS 204

Sizes (bytes): ML-KEM-768 public key 1184, ciphertext 1088, shared secret 32, X25519 public key 32, derived k_enc/k_mac 32 each, nonce 12, AEAD tag 16. The derived keys are never transmitted — each side computes them with HKDF. All binary travels as base64 (encoded inside the Wasm boundary), never as JSON number arrays.

Endpoints

MethodPathPurpose
POST/api/nen/handshakeEstablish a session
POST/api/nen/rotateDestroy old session, establish a new one
POST/api/nen/terminateDestroy a session (logout / forward secrecy)
GET/api/nen/statusLiveness check for a session id

Handshake (once per session)

  1. Client generates an ML-KEM-768 keypair and an X25519 keypair (hybrid is the default) and POSTs { "pk_kem": base64(pk), "pk_x": base64(pk), "securityMode": "hybrid" }.
  2. Server encapsulates the ML-KEM key → (ss_kem, ciphertext), runs X25519 DH → ss_x, and combines them into one shared secret ss. It then derives k_enc = HKDF(ss, "nen/v3 enc") and k_mac = HKDF(ss, "nen/v3 mac"), stores { encKey, macKey } under a new sid, and returns { "sid", "ct": base64(ciphertext), "pk_x_server": base64(pk) }no key material. (With identity enabled it also returns server_sig, server_nonce, server_id_pk binding the transcript; see Threat model.)
  3. Client recovers the same ss (decapsulate + X25519 DH + combine), derives the same k_enc / k_mac locally, then zeroizes its secret keys.
Client
Nen Server
1. Generate ML-KEM-768 + X25519 keypairs
2. POST /api/nen/handshake
{ "pk_kem": base64(pk), "pk_x": base64(pk), "securityMode": "hybrid" }
3. Encapsulate + X25519 DH → combine → ss
k_enc = HKDF(ss, "nen/v3 enc") k_mac = HKDF(ss, "nen/v3 mac") Store { encKey, macKey } under sid
4. Return ciphertext + server X25519 key (no key material)
{ "sid": "...", "ct": base64(ciphertext), "pk_x_server": base64(pk) }
5. Recover ss, derive same k_enc / k_mac locally
Zeroize secret keys

Encrypted request / response

Encryption is symmetric and method-agnostic: every method is authenticated, a request body is encrypted when present, and the response is always encrypted — bodyless GET / HEAD / DELETE included.

MethodAuthenticatedRequest bodyResponse body
GETencrypted
HEADnone (metadata headers only)
DELETEoptionalencrypted
POST / PUT / PATCHencryptedencrypted

Headers (on every request):

X-Nen-Session:    <sid>
X-Nen-Timestamp:  <unix_ms>
X-Nen-Nonce:      base64(n)          // per-request nonce — always present
X-Nen-Signature:  base64( HMAC-SHA256(hmacKey, canonical) )

Body (only when the method carries one): { "ct": base64(AEAD.encrypt(sharedSecret, n, plaintext)) }

The per-request nonce travels in the X-Nen-Nonce header (not the body), so it exists for bodyless methods too. When a body is present it is sealed under that same nonce, so the body is just { ct }.

The canonical string that is HMAC'd is exactly:

METHOD \n PATH \n TIMESTAMP \n NONCE

PATH is the URL pathname only and NONCE is the X-Nen-Nonce value. A path-vs-full-URL mismatch is the most common cause of ISO-3002.

Server verification order (all methods): session lookup → nonce present (missing → ISO-3005) → mandatory HMAC (missing → ISO-3001, bad → ISO-3002, timestamp >30s → ISO-3003) → nonce replay (ISO-5001) → AEAD decrypt of the body if present (ISO-4001). The response is then encrypted back as { ct, n } with a fresh server nonce.

GET query strings are request-line metadata (logged by proxies, CDNs, access logs) and cannot be confidential while the request stays a real GET. The pathname is integrity-protected and the response is encrypted, but put secret selectors in a POST body, not a query string.

Client
Nen Server
1. AEAD encrypt(sharedSecret, nonce, plaintext)
2. Compute HMAC(hmacKey, METHOD PATH TIMESTAMP NONCE)
3. Encrypted Request
Headers: Session, Timestamp, Nonce, Signature Body: { ct } (omitted on GET/HEAD)
4. Verify HMAC & Timestamp
5. Check nonce replay 6. AEAD Decrypt → Plaintext

Encrypted streaming (SSE)

The response sets X-Nen-Stream-Nonce: base64(baseNonce) and emits SSE frames data: base64(ciphertext)\n\n. Each chunk's nonce is baseNonce with its last 4 bytes XOR-ed by the chunk index; the stream ends with an encrypted __FIN__ sentinel.

Nen Server
Client
1. Generate baseNonce
Set X-Nen-Stream-Nonce header
2. SSE Response Headers
3. For each chunk (i)
nonce = baseNonce ^ i ct = AEAD.encrypt(chunk) Format: data: base64(ct)
4. SSE Chunk Frame
5. __FIN__
Encrypted Sentinel

Identity model

  • v1 (default): server identity rides the existing TLS certificate; the handshake runs inside the authenticated TLS channel. We trust the web PKI for identity even though we do not rely on it for long-term confidentiality — different properties.
  • v2 (opt-in): with identityMode: 'pqc', the client signs the ephemeral ML-KEM key with a long-lived ML-DSA key, giving a TLS-independent trust root. One-time, at handshake — never per request. Failure → ISO-3004.

The full spec lives in PROTOCOL.md in the repository.