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-Nonce header, so every method is authenticated (including bodyless GET/HEAD/DELETE) and the response is always encrypted. Wire-breaking from V1.

Cryptographic parameters

RoleAlgorithmStandard
Key encapsulation (KEM)ML-KEM-768FIPS 203
Payload encryption (AEAD)ChaCha20-Poly1305RFC 8439
Per-request authenticationHMAC-SHA256FIPS 198-1
Optional identity signatureML-DSA-65FIPS 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

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 POSTs { "pk": base64(pk) } (optionally sigPk + sigOfPk for ML-DSA identity).
  2. Server encapsulates → (sharedSecret, ciphertext), generates a 32-byte HMAC key, stores { sharedSecret, hmacKey } under a new sid, and returns { "sid", "ct": base64(ciphertext), "hmac": base64(hmacKey) }.
  3. Client decapsulates to recover the same sharedSecret, then zeroizes its secret key.
Client
Nen Server
1. Generate ML-KEM-768 keypair
2. POST /api/nen/handshake
{ "pk": base64(pk) }
3. Encapsulate pk → (sharedSecret, ciphertext)
Generate 32-byte HMAC key Store { sharedSecret, hmacKey } under sid
4. Return Session Keys
{ "sid": "...", "ct": base64(ciphertext), "hmac": base64(hmacKey) }
5. Decapsulate ct to recover sharedSecret
Zeroize 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.

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.