Protocol — NEN-PROTOCOL-V1

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.

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

Headers:

X-Nen-Session:    <sid>
X-Nen-Timestamp:  <unix_ms>
X-Nen-Signature:  base64( HMAC-SHA256(hmacKey, canonical) )

Body: { "ct": base64(AEAD.encrypt(sharedSecret, n, plaintext)), "n": base64(n) }

The canonical string that is HMAC'd is exactly:

METHOD \n PATH \n TIMESTAMP \n NONCE

PATH is the URL pathname only. A path-vs-full-URL mismatch is the most common cause of ISO-3002.

Server verification order: session lookup → payload shape → mandatory HMAC (missing → ISO-3001, bad → ISO-3002, timestamp >30s → ISO-3003) → nonce replay (ISO-5001) → AEAD decrypt (ISO-4001).

Client
Nen Server
1. AEAD encrypt(sharedSecret, nonce, plaintext)
2. Compute HMAC(hmacKey, METHOD PATH TIMESTAMP NONCE)
3. Encrypted Request
Headers: Session, Timestamp, Signature Body: { ct, n }
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.