Protocol — NEN-PROTOCOL-V2
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.
V2 (v0.3.0): the per-request nonce moved into the
X-Nen-Nonceheader, so every method is authenticated (including bodylessGET/HEAD/DELETE) and the response is always encrypted. Wire-breaking from V1.
Cryptographic parameters
| Role | Algorithm | Standard |
|---|---|---|
| Key encapsulation (KEM) | ML-KEM-768 | FIPS 203 |
| Payload encryption (AEAD) | ChaCha20-Poly1305 | RFC 8439 |
| Per-request authentication | HMAC-SHA256 | FIPS 198-1 |
| Optional identity signature | ML-DSA-65 | FIPS 204 |
Sizes (bytes): ML-KEM-768 public key 1184, ciphertext 1088, shared secret 32, HMAC key 32, nonce 12, AEAD tag 16. 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 POSTs
{ "pk": base64(pk) }(optionallysigPk+sigOfPkfor ML-DSA identity). - Server encapsulates →
(sharedSecret, ciphertext), generates a 32-byte HMAC key, stores{ sharedSecret, hmacKey }under a newsid, and returns{ "sid", "ct": base64(ciphertext), "hmac": base64(hmacKey) }. - Client decapsulates to recover the same
sharedSecret, then zeroizes its secret key.
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.