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
| Role | Algorithm | Standard |
|---|---|---|
| Key encapsulation (KEM) | ML-KEM-768 | FIPS 203 |
| Classical key agreement | X25519 (hybrid) | RFC 7748 |
| Key derivation (schedule) | HKDF-SHA256 | RFC 5869 |
| Payload encryption (AEAD) | ChaCha20-Poly1305 | RFC 8439 |
| Per-request authentication | HMAC-SHA256 | FIPS 198-1 |
| Transcript / identity signature | ML-DSA-65 | FIPS 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
| Method | Path | Purpose |
|---|---|---|
| POST | /api/nen/handshake | Establish a session |
| POST | /api/nen/rotate | Destroy old session, establish a new one |
| POST | /api/nen/terminate | Destroy a session (logout / forward secrecy) |
| GET | /api/nen/status | Liveness check for a session id |
Handshake (once per session)
- 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" }. - Server encapsulates the ML-KEM key →
(ss_kem, ciphertext), runs X25519 DH →ss_x, and combines them into one shared secretss. It then derivesk_enc = HKDF(ss, "nen/v3 enc")andk_mac = HKDF(ss, "nen/v3 mac"), stores{ encKey, macKey }under a newsid, and returns{ "sid", "ct": base64(ciphertext), "pk_x_server": base64(pk) }— no key material. (With identity enabled it also returnsserver_sig,server_nonce,server_id_pkbinding the transcript; see Threat model.) - Client recovers the same
ss(decapsulate + X25519 DH + combine), derives the samek_enc/k_maclocally, then zeroizes its 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.
| Method | Authenticated | Request body | Response body |
|---|---|---|---|
GET | ✅ | — | encrypted |
HEAD | ✅ | — | none (metadata headers only) |
DELETE | ✅ | optional | encrypted |
POST / PUT / PATCH | ✅ | encrypted | encrypted |
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
POSTbody, not a query string.
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.
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.