toiljs 0.0.51 → 0.0.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/TYPESCRIPT_LAW.md +12601 -0
  3. package/build/cli/.tsbuildinfo +1 -1
  4. package/build/cli/index.js +16 -1
  5. package/build/client/.tsbuildinfo +1 -1
  6. package/build/client/auth.d.ts +9 -20
  7. package/build/client/auth.js +112 -95
  8. package/build/client/index.d.ts +2 -2
  9. package/build/client/index.js +1 -1
  10. package/build/compiler/.tsbuildinfo +1 -1
  11. package/build/compiler/generate.js +1 -1
  12. package/build/devserver/.tsbuildinfo +1 -1
  13. package/build/devserver/crypto.js +33 -0
  14. package/build/devserver/host.js +2 -0
  15. package/build/devserver/kv.d.ts +3 -0
  16. package/build/devserver/kv.js +53 -0
  17. package/build/devserver/module.js +2 -1
  18. package/docs/auth-todo.md +149 -0
  19. package/docs/auth.md +234 -173
  20. package/examples/basic/client/routes/pq.tsx +72 -103
  21. package/examples/basic/server/core/AppHandler.ts +24 -3
  22. package/examples/basic/server/main.ts +0 -1
  23. package/examples/basic/server/routes/Auth.ts +304 -99
  24. package/examples/basic/server/routes/Session.ts +5 -2
  25. package/package.json +2 -1
  26. package/server/globals/auth.ts +263 -10
  27. package/src/cli/diagnostics.ts +22 -0
  28. package/src/cli/doctor.ts +2 -0
  29. package/src/client/auth.ts +192 -174
  30. package/src/client/index.ts +2 -2
  31. package/src/compiler/generate.ts +1 -1
  32. package/src/devserver/crypto.ts +54 -0
  33. package/src/devserver/host.ts +6 -0
  34. package/src/devserver/kv.ts +93 -0
  35. package/src/devserver/module.ts +4 -1
  36. package/test/devserver-pqauth.test.ts +153 -0
  37. package/test/doctor.test.ts +22 -0
  38. package/test/pqauth-e2e.test.ts +207 -0
  39. package/examples/basic/server/routes/PqDemo.ts +0 -127
package/docs/auth.md CHANGED
@@ -1,59 +1,132 @@
1
1
  # Auth, sessions, and `@user`
2
2
 
3
- toiljs ships a complete authentication primitive: a post-quantum login
4
- (ML-DSA-44, password-derived), HMAC-signed session cookies, a `@auth` route
5
- guard, and a `@user` type that makes the signed-in user available fully typed,
6
- with no type argument on both the server (`AuthService.getUser()`) and the
7
- generated client (`getUser()`).
3
+ toiljs ships **Toil PQ-Auth**: a post-quantum password login where the password
4
+ never leaves the browser and the server stores only public verifier material.
5
+ On top of it sit HMAC-signed session cookies, a `@auth` route guard, and a
6
+ `@user` type that makes the signed-in user available - fully typed, no type
7
+ argument - on both the server (`AuthService.getUser()`) and the generated client
8
+ (`getUser()`).
9
+
10
+ > **Status.** PQ-Auth is a hybrid construction (see [What it is](#what-it-is)).
11
+ > It is opt-in and **not hardened to production yet** - the example storage is a
12
+ > dev stand-in, the secrets are dev placeholders, and the composition has not had
13
+ > an external cryptographic review. See [`docs/auth-todo.md`](./auth-todo.md) for
14
+ > the remaining work before it backs real credentials.
8
15
 
9
16
  `AuthService` is an ambient global (no import). The pieces:
10
17
 
11
- - **`@user`** declares the authenticated user's shape and registers it as
12
- *the* user type.
13
- - **`@auth`** guards a route (or a whole `@rest` class): a valid session is
14
- required or the request is rejected with `401`.
15
- - **`AuthService`** — the server runtime: verify a login, mint/read/clear a
16
- session, and `getUser()`.
17
- - **client `Auth` + generated `getUser()`** — derive the keypair from a
18
- password, register/login, and read the user for display.
18
+ - **`@user`** - declares the authenticated user's shape and registers it as *the* user type.
19
+ - **`@auth`** - guards a route (or a whole `@rest` class): a valid session is required or `401`.
20
+ - **`AuthService`** - the server runtime: the PQ-Auth crypto, plus mint/read/clear a session and `getUser()`.
21
+ - **client `Auth` + generated `getUser()`** - run the login from the browser and read the user for display.
19
22
 
20
- ## Flow at a glance
23
+ ---
24
+
25
+ ## What it is
26
+
27
+ A password is a weak, low-entropy secret. PQ-Auth turns it into a strong,
28
+ **post-quantum** credential and proves possession to the server without the
29
+ server (or anyone on the wire, or a future quantum adversary) ever seeing
30
+ anything they can replay. It is built from three independent ideas, each
31
+ defending a specific attack:
32
+
33
+ | Layer | Primitive | Defends against |
34
+ | --- | --- | --- |
35
+ | **Keyed salt** | OPRF (RFC 9497, ristretto255-SHA512) | A breached server (or a passive observer) **precomputing** a password dictionary. |
36
+ | **Credential** | Argon2id → ML-DSA-44 (FIPS 204) keypair | The password ever crossing the wire; a stolen verifier being usable without an expensive per-guess attack. |
37
+ | **Mutual auth + key** | ML-KEM-768 (FIPS 203) | A phishing/MITM server impersonating the real one; a session with no key to bind to. |
38
+
39
+ The password is stretched into a signing keypair entirely client-side; only the
40
+ **public** key is registered. Login is a challenge-response signature *plus* a
41
+ key encapsulation, so both parties authenticate each other. Authentication
42
+ (ML-DSA) and key agreement (ML-KEM) are post-quantum; the keyed-salt OPRF is
43
+ classical ristretto255 (the one non-PQ layer - a quantum break of it degrades to
44
+ a post-breach offline attack, no worse than a plain salt, while defeating
45
+ precomputation for everyone else).
46
+
47
+ ### Why a keyed salt (the OPRF)
48
+
49
+ A normal salted hash (`Argon2id(password, salt)`) lets anyone who learns the
50
+ salt - including a future attacker who simply asks the login endpoint for it -
51
+ **precompute** a dictionary offline and crack the stored verifier the instant
52
+ they breach it. PQ-Auth replaces the salt with the output of a **server-keyed**
53
+ OPRF:
54
+
55
+ ```
56
+ oprfOutput = OPRF_finalize(password, OPRF_evaluate(k_user, blind(password)))
57
+ seed = Argon2id(oprfOutput, salt)
58
+ ```
59
+
60
+ The client **blinds** the password (so the server learns nothing about it),
61
+ the server **evaluates** the blinded element under a per-user key `k_user`
62
+ derived from a server-secret master seed, and the client **unblinds** to recover
63
+ a deterministic, high-entropy `oprfOutput`. Because `k_user` is a server secret,
64
+ **no offline work is possible until that secret leaks** - precomputation is
65
+ impossible, and even a passive observer who captures a login learns nothing.
66
+ The per-user key (`k_user = DeriveKeyPair(masterSeed, username)`) means two
67
+ accounts with the same password get different outputs - no cross-account
68
+ password-equality leak.
69
+
70
+ ### Why a password-derived signing key
71
+
72
+ `seed = Argon2id(oprfOutput, salt)` deterministically expands into an
73
+ **ML-DSA-44 keypair**. The client registers only the 1312-byte **public** key;
74
+ the secret key and seed are zeroized the instant signing is done. The server
75
+ stores the public key as a verifier and can only ever *verify* - it never holds
76
+ a secret (`crypto.mldsa_verify` is verify-only on the edge). A full server breach
77
+ yields public keys, not passwords; recovering a password still requires an
78
+ offline Argon2id dictionary attack **and** the leaked OPRF master seed.
79
+
80
+ ### Why ML-KEM (mutual auth + session key)
81
+
82
+ A signature proves the *client* to the server, but nothing proves the *server*
83
+ to the client. PQ-Auth pins the server's static **ML-KEM-768 public key** in the
84
+ client. At login the client **encapsulates** a shared secret to that key; only
85
+ the genuine server (holding the matching secret key) can **decapsulate** it. Both
86
+ sides derive the same session key and the server returns a confirmation tag the
87
+ client checks - so a phishing/MITM server that lacks the secret key cannot
88
+ complete the handshake.
89
+
90
+ ---
21
91
 
22
- Register sends only a public key; login proves identity with a one-time
23
- signature the server verifies (it never holds a secret); `@auth` then checks the
24
- HMAC-signed session cookie on every guarded request.
92
+ ## Flow at a glance
25
93
 
26
94
  ```mermaid
27
95
  sequenceDiagram
28
96
  autonumber
29
97
  actor U as User
30
- participant C as Browser<br/>toiljs/client Auth
31
- participant S as Edge wasm<br/>AuthService
32
- participant DB as Your store<br/>accounts + challenges
98
+ participant C as Browser
99
+ participant S as Edge wasm
100
+ participant DB as Your store
33
101
 
34
102
  rect rgb(14, 21, 32)
35
- Note over U,DB: Register, the password never leaves the browser
103
+ Note over U,DB: Register, password never leaves the browser
36
104
  U->>C: Auth.register(username, password)
37
- C->>C: Argon2id, then ML-DSA-44 keypair,<br/>keep public key, zeroize secret key
38
- C->>S: POST /auth/register { username, publicKey 1312 B }
39
- S->>DB: store Account { username, publicKey }
40
- S-->>C: 200 ok
105
+ C->>S: POST /auth/register/start (username, blinded)
106
+ S->>S: OPRF-evaluate under k_user, issue salt and KDF params
107
+ S-->>C: salt, params, evaluated
108
+ C->>C: finalize OPRF, Argon2id, ML-DSA-44 keypair, sign PoP
109
+ C->>S: POST /auth/register/finish (username, publicKey, regProof)
110
+ S->>S: verifyRegister(publicKey, PoP)
111
+ S->>DB: store Account (username, salt, params, publicKey)
112
+ S-->>C: ok
41
113
  end
42
114
 
43
115
  rect rgb(22, 15, 31)
44
- Note over U,DB: Login, the server holds no secret
116
+ Note over U,DB: Login, mutual authentication
45
117
  U->>C: Auth.login(username, password)
46
- C->>S: GET /auth/challenge { username }
47
- S->>DB: create + store challenge (cid, nonce, iat, exp)
48
- S-->>C: { cid, aud, nonce, iat, exp }
49
- C->>C: re-derive keypair,<br/>sign buildLoginMessage(...) once
50
- C->>S: POST /auth/login { cid, signature 2420 B }
118
+ C->>S: POST /auth/login/start (username, blinded)
119
+ S->>DB: store challenge (cid, nonce, iat, exp)
120
+ S-->>C: cid, aud, salt, params, nonce, iat, exp, evaluated
121
+ C->>C: finalize OPRF, derive seed, ML-DSA keypair, ML-KEM encapsulate, sign M
122
+ C->>S: POST /auth/login/finish (cid, ct, signature)
51
123
  S->>DB: atomic consume challenge(cid)
52
- S->>S: rebuild message from stored values,<br/>verifyLogin via crypto.mldsa_verify
124
+ S->>S: rebuild M, verifyLogin, decapsulate, derive K, build confirm
53
125
  alt signature valid
54
- S-->>C: 200 + Set-Cookie __Host-toil_sess (HttpOnly, signed)<br/>and __Secure-toil_user (readable, display only)
126
+ S-->>C: ok, sessionToken, serverConfirm, Set-Cookie
127
+ C->>C: re-derive K, check serverConfirm, server authenticated
55
128
  else invalid or unknown user
56
- S-->>C: 401, constant time, anti-enumeration
129
+ S-->>C: 401 generic, anti-enumeration
57
130
  end
58
131
  end
59
132
 
@@ -61,21 +134,59 @@ sequenceDiagram
61
134
  Note over U,DB: Guarded request, the @auth guard
62
135
  U->>C: open or call an @auth route
63
136
  C->>S: request, cookies sent automatically
64
- S->>S: @auth checks AuthService.hasSession(),<br/>verify HMAC + expiry on __Host-toil_sess
137
+ S->>S: @auth verifies HMAC and expiry on __Host-toil_sess
65
138
  alt valid session
66
- S->>S: handler runs,<br/>AuthService.getUser() returns the @user
67
- S-->>C: 200 response
68
- else missing or invalid session
69
- S-->>C: 401 before the handler and body-decode
139
+ S->>S: handler runs, AuthService.getUser() returns the @user
140
+ S-->>C: 200
141
+ else missing or invalid
142
+ S-->>C: 401 before handler and body-decode
70
143
  end
71
- C->>C: getUser() reads __Secure-toil_user,<br/>untrusted, UI only
72
144
  end
73
145
  ```
74
146
 
75
- The two cookies are the trust boundary: the HttpOnly `__Host-toil_sess` is the
76
- only one the server trusts (it re-verifies its signature and expiry every
77
- request), while the readable `__Secure-toil_user` exists solely so the client
78
- `getUser()` can show a name without a round-trip and must never gate anything.
147
+ ### The signed transcript
148
+
149
+ The login message `M` the client signs (and the server rebuilds from its own
150
+ stored values) is a single fixed binary layout - no JSON, no version negotiation:
151
+
152
+ ```
153
+ u8 tag = 1
154
+ str sub (username)
155
+ str aud (service audience; server constant)
156
+ bytes cid (challenge id)
157
+ bytes nonce (32 random bytes, server-issued)
158
+ u64 iat, u64 exp (challenge validity window)
159
+ bytes ct (ML-KEM ciphertext)
160
+ u32 memKiB, iterations, parallelism (Argon2id params)
161
+ bytes serverKemKeyId (SHA-256 of the server KEM public key)
162
+ ```
163
+
164
+ Signing over all of this binds the login to: the exact challenge (so it can't be
165
+ replayed - and `cid` is consumed atomically), the **ciphertext** (so a MITM can't
166
+ swap the key encapsulation), the **KDF params** (so a downgrade can't be slipped
167
+ past the signature), and the **server key identity** (so it commits to which
168
+ server key was used). The mutual-auth tag is then:
169
+
170
+ ```
171
+ K = HMAC-SHA256(sharedSecret, "toil-session-key-v1" || SHA-256(M))
172
+ confirm = HMAC-SHA256(K, "toil-server-confirm-v1" || SHA-256(M))
173
+ ```
174
+
175
+ `K` is the authenticated session key, derived from the KEM shared secret and
176
+ bound to the transcript. Only a server that decapsulated correctly derives the
177
+ same `K`, so the client checking `confirm` proves the server's identity. (`K` is
178
+ the handle for future channel binding; binding the session *cookie* to the
179
+ transport needs the TLS exporter, which the wasm guest can't see - a follow-up.)
180
+
181
+ ### Anti-enumeration
182
+
183
+ `login/start` returns a fully-formed response for **every** username: it always
184
+ OPRF-evaluates (a real `k_user` for known users, a deterministic decoy key for
185
+ unknown ones) and returns a **deterministic per-user salt** and constant params.
186
+ Known and unknown users are byte-indistinguishable, and the eventual signature
187
+ simply fails for a non-account. Failures return one generic `401`.
188
+
189
+ ---
79
190
 
80
191
  ## `@user`
81
192
 
@@ -91,14 +202,13 @@ class Account {
91
202
  }
92
203
  ```
93
204
 
94
- There is exactly one `@user` per program; a second one is a compile error.
205
+ There is exactly one `@user` per program; a second is a compile error.
95
206
 
96
207
  ## `@auth`
97
208
 
98
209
  Put `@auth` on a route, or on the `@rest` class to guard every route in it. The
99
- generated dispatcher checks for a valid, unexpired session **before** the
100
- handler runs (and before any body-decode or cache write); without one it returns
101
- `401 unauthorized`.
210
+ generated dispatcher checks for a valid, unexpired session **before** the handler
211
+ runs (and before any body-decode or cache write); without one it returns `401`.
102
212
 
103
213
  ```ts
104
214
  @rest('session')
@@ -109,153 +219,104 @@ class Session {
109
219
  const u = AuthService.getUser(); // Account | null, auto-typed
110
220
  if (u == null) return Response.text('no session\n', 401);
111
221
  return Response.bytes(new DataWriter()
112
- .writeString(u.username)
113
- .writeBool(u.admin)
114
- .writeU64(u.score)
115
- .toBytes());
116
- }
117
-
118
- @auth
119
- @post('/logout')
120
- public logout(): Response {
121
- return Response.text('bye\n', 200)
122
- .setCookie(AuthService.clearSession())
123
- .setCookie(AuthService.clearUserCookie());
222
+ .writeString(u.username).writeBool(u.admin).writeU64(u.score).toBytes());
124
223
  }
125
224
  }
126
225
  ```
127
226
 
128
- `@auth` on the class form guards all routes:
129
-
130
- ```ts
131
- @auth
132
- @rest('admin')
133
- class Admin { /* every route requires a session */ }
134
- ```
227
+ `@auth` on the class form guards all routes in it.
135
228
 
136
229
  ## `AuthService` (server)
137
230
 
138
- `AuthService` is a global namespace. The session methods read the ambient
139
- request (`Server.currentRequest`), so `getUser()`/`hasSession()` take no
140
- argument and are only meaningful during a dispatch.
231
+ A global namespace. Session methods read the ambient request
232
+ (`Server.currentRequest`), so `getUser()`/`hasSession()` take no argument and are
233
+ only meaningful during a dispatch.
234
+
235
+ ### PQ-Auth crypto
236
+
237
+ Startup config (call once in `main.ts`; identical on every edge instance; never
238
+ in a client bundle):
239
+
240
+ | Member | Notes |
241
+ | --- | --- |
242
+ | `setSecret(secret)` | HMAC secret for session cookies. |
243
+ | `setOprfSeed(seed)` | 32-byte OPRF master seed; per-user keys derive from this + the username. |
244
+ | `setServerKemSecretKey(sk)` | Server static ML-KEM-768 secret key (2400 B) used to decapsulate. |
245
+ | `setServerKemPublicKey(pk)` | The matching public key (1184 B) for `serverKemKeyId`; it is embedded in `sk` at bytes `[1152, 2336)`, so you can pass `sk.slice(1152, 2336)`. |
246
+
247
+ Per-request building blocks:
248
+
249
+ | Member | Notes |
250
+ | --- | --- |
251
+ | `oprfEvaluate(username, blinded)` | OPRF server step: blind-evaluate under `k_user` derived from the seed + username. Returns the 32-byte evaluated element. |
252
+ | `mlkemDecapsulate(ct)` | Recover the 32-byte shared secret from the client ciphertext with the server secret key. |
253
+ | `buildLoginMessage(sub, aud, cid, nonce, iat, exp, ct, memKiB, iterations, parallelism, serverKemKeyId)` | The canonical login message `M`. Call it with the server's **own** stored values, never client-echoed fields. |
254
+ | `verifyLogin(publicKey, message, signature)` | Verify the ML-DSA login signature under `LOGIN_CONTEXT`. |
255
+ | `serverKemKeyId()` | `SHA-256(serverKemPublicKey)` - the key id bound into `M`. |
256
+ | `sha256(data)` | SHA-256, for the transcript hash. |
257
+ | `deriveSessionKey(sharedSecret, transcriptHash)` | `K = HMAC(sharedSecret, SESSION_KEY_LABEL || transcriptHash)`. |
258
+ | `serverConfirmTag(sessionKey, transcriptHash)` | The mutual-auth tag `HMAC(K, SERVER_CONFIRM_LABEL || transcriptHash)`. |
259
+ | `buildRegisterMessage(username, publicKey)` / `verifyRegister(...)` | Registration proof-of-possession (under `REGISTER_CONTEXT`). |
260
+ | `LOGIN_CONTEXT` / `REGISTER_CONTEXT` | `qauth:login:v1` / `qauth:register:v1` - FIPS 204 signing contexts. |
261
+ | `PUBLIC_KEY_LEN` `SIGNATURE_LEN` `KEM_*` `SHARED_SECRET_LEN` `OPRF_*` | Fixed sizes. |
262
+
263
+ The full register/login orchestration (the four binary endpoints, the
264
+ anti-enumeration decoy, the atomic challenge-consume) is in
265
+ `examples/basic/server/routes/Auth.ts`. **Storage is the app's** - a tenant's
266
+ wasm memory is wiped per request, so accounts and challenges live in an external
267
+ store, and challenge-consume **must** be an atomic fetch-and-delete (a
268
+ read-then-delete race makes a captured login replayable). The example uses a
269
+ **dev-only** KV for this; production wires toildb (see `docs/auth-todo.md`).
141
270
 
142
271
  ### Sessions
143
272
 
144
273
  | Member | Signature | Notes |
145
274
  | --- | --- | --- |
146
- | `getUser()` | `getUser(): AuthUser \| null` | The signed-in user, decoded from the verified session, auto-typed to your `@user` class. |
147
- | `hasSession()` | `hasSession(): bool` | Whether the request carries a valid, unexpired session. What `@auth` calls. |
148
- | `getSessionBytes()` | `getSessionBytes(): Uint8Array \| null` | The raw verified `@user` codec bytes, or `null`. |
149
- | `mintSession(userData, ttlSecs?)` | `mintSession(userData: Uint8Array, ttlSecs: u64 = 86400): Cookie` | Build the signed `__Host-toil_sess` cookie carrying `userData` (i.e. `user.encode()`). HttpOnly, Secure, SameSite=Lax. |
150
- | `clearSession()` | `clearSession(): Cookie` | A `Set-Cookie` that clears the session. |
151
- | `userCookie(userData, ttlSecs?)` | `userCookie(userData: Uint8Array, ttlSecs: u64 = 86400): Cookie` | The readable `__Secure-toil_user` companion cookie for the client's `getUser()`. Secure, **not** HttpOnly. Display-only. |
152
- | `clearUserCookie()` | `clearUserCookie(): Cookie` | Clears the companion cookie. |
153
- | `setSecret(secret)` | `setSecret(secret: Uint8Array): void` | Set the HMAC secret used to sign sessions. Call once at startup. |
154
- | `DEFAULT_SESSION_TTL_SECS` | `u64 = 86400` | 24h default lifetime. |
155
- | `SESSION_COOKIE` / `USER_COOKIE` | `string` | `__Host-toil_sess` / `__Secure-toil_user`. |
156
-
157
- To log a user in, mint both cookies on the success response:
158
-
159
- ```ts
160
- @post('/dev-login')
161
- public devLogin(ctx: RouteContext): Response {
162
- const u = new Account();
163
- u.username = new DataReader(ctx.request.body).readString();
164
- const data = u.encode();
165
- return Response.text('ok\n', 200)
166
- .setCookie(AuthService.mintSession(data, 3600)) // HttpOnly signed session
167
- .setCookie(AuthService.userCookie(data, 3600)); // readable companion
168
- }
169
- ```
170
-
171
- The session payload is `u8 version | u64 iat | u64 exp | bytes userData`, sealed
172
- with HMAC-SHA256 via `SecureCookies.signed`. `getSessionBytes()` verifies the
173
- signature, checks expiry against `Time.nowSeconds()`, and returns the `userData`
174
- bytes; `getUser()` decodes those into your `@user` class.
175
-
176
- ### The server secret
177
-
178
- Sessions are signed with a server secret that must be **identical on every edge
179
- instance** and **never shipped to the client**. There is no host-config secret
180
- mechanism yet, so set it at startup:
181
-
182
- ```ts
183
- // in main.ts, once
184
- AuthService.setSecret(/* 32 bytes, build-time constant or deployment secret */);
185
- ```
186
-
187
- If you do not call `setSecret`, a well-known **DEV placeholder** is used so local
188
- development works out of the box. That default is insecure by design — a real
189
- deployment must override it.
190
-
191
- ### Post-quantum login
192
-
193
- The login primitive proves identity without the server ever holding a secret:
194
-
195
- - The client derives an **ML-DSA-44** (FIPS 204) keypair from the password via
196
- **Argon2id**, keeps only the 1312-byte public key on the account, and signs a
197
- login challenge.
198
- - The server rebuilds the exact signed message from its own stored values and
199
- verifies the 2420-byte signature with `crypto.mldsa_verify` (verify-only; the
200
- host never holds a secret key).
201
-
202
- `AuthService` provides the message construction and verification:
203
-
204
- | Member | Signature | Notes |
205
- | --- | --- | --- |
206
- | `LOGIN_CONTEXT` | `string = 'qauth:login:v1'` | FIPS 204 signing context (domain separator). Byte-identical on client and server. |
207
- | `PUBLIC_KEY_LEN` / `SIGNATURE_LEN` | `i32` | `1312` / `2420`. |
208
- | `buildLoginMessage(sub, aud, cid, nonce, iat, exp)` | `(string, string, Uint8Array, Uint8Array, u64, u64): Uint8Array` | The canonical login message `M`, fixed binary layout. Call it with the server's **own** stored values, never client-echoed fields. |
209
- | `verifyLogin(publicKey, message, signature)` | `(Uint8Array, Uint8Array, Uint8Array): bool` | Verifies under `LOGIN_CONTEXT`; size-checks first. |
210
-
211
- The challenge → verify flow (see `examples/basic/server/routes/Auth.ts` for the
212
- full template, including the anti-enumeration and atomic challenge-consume
213
- requirements) ends with: rebuild the message, `verifyLogin(...)`, then
214
- `mintSession(account.encode())` on success. Account and challenge **storage is
215
- the app's** — a tenant's wasm memory is wiped per request, so back them with an
216
- external store and make challenge-consume an atomic fetch-and-delete.
275
+ | `getUser()` | `(): AuthUser \| null` | The signed-in user, decoded from the verified session, auto-typed to your `@user`. |
276
+ | `hasSession()` | `(): bool` | Whether the request carries a valid, unexpired session. What `@auth` calls. |
277
+ | `mintSession(userData, ttlSecs?)` | `(Uint8Array, u64=86400): Cookie` | Signed `__Host-toil_sess` cookie carrying `user.encode()`. HttpOnly, Secure, SameSite=Lax. |
278
+ | `clearSession()` / `userCookie(...)` / `clearUserCookie()` | | Logout; the readable `__Secure-toil_user` companion (display-only); clear it. |
279
+
280
+ The session payload is `u8 version || u64 iat || u64 exp || bytes userData`, sealed
281
+ with HMAC-SHA256. The HttpOnly `__Host-toil_sess` is the **only** cookie the
282
+ server trusts; the readable `__Secure-toil_user` exists solely so the client
283
+ `getUser()` can show a name without a round-trip and must never gate anything.
217
284
 
218
285
  ## The client half
219
286
 
220
- The client SDK lives in `toiljs/client` and never lets the password leave the
221
- browser:
222
-
223
287
  ```ts
224
288
  import { Auth } from 'toiljs/client';
225
289
 
226
- await Auth.register(username, password); // derive keypair, send only the public key
227
- await Auth.login(username, password); // fetch challenge, sign once, submit {cid, sig}
290
+ await Auth.register(username, password); // OPRF + Argon2id + ML-DSA keypair, send only the public key + PoP
291
+ await Auth.login(username, password); // + ML-KEM encapsulate; resolves only if the server's confirm tag verifies
228
292
  ```
229
293
 
230
- `register`/`login` stretch the password with Argon2id into a 32-byte seed,
231
- expand it to the ML-DSA-44 keypair, and zeroize the secret key and seed the
232
- instant signing is done. There is no recovery: the password *is* the key.
233
-
234
- ### `getUser()` on the client
235
-
236
- The generated `shared/server.ts` exports a typed, no-argument `getUser()` that
237
- reads the readable `__Secure-toil_user` companion cookie and decodes it with the
238
- generated `@user` codec:
239
-
240
- ```ts
241
- import { getUser } from './shared/server';
242
-
243
- const user = getUser(); // Account | null, fully typed
244
- if (user) showName(user.username);
245
- ```
294
+ `login` resolves **only after** the client verifies the server's confirmation tag
295
+ - so a resolved `login` means mutual authentication succeeded. The secret key,
296
+ seed, and shared secret are zeroized as soon as they are used. There is no
297
+ recovery: the password *is* the key (see `docs/auth-todo.md` for the recovery
298
+ work).
246
299
 
247
- This is **display-only and untrusted**: a client can forge the companion cookie,
248
- fooling only its own UI. The server re-verifies the HttpOnly signed session on
249
- every `@auth` request, so authorization never depends on the readable cookie.
300
+ The generated `shared/server.ts` also exports a typed, no-argument client
301
+ `getUser()` that reads the readable companion cookie. It is **display-only and
302
+ untrusted** - a client can forge it, fooling only its own UI. The server
303
+ re-verifies the signed session on every `@auth` request, so authorization never
304
+ depends on the readable cookie.
250
305
 
251
306
  ## Security checklist
252
307
 
253
- - Set a real `AuthService.setSecret(...)` in production; the same value on every
254
- instance; never in a client bundle.
255
- - The session cookie is HttpOnly + Secure + `__Host-` scoped; the companion
256
- cookie is readable and untrusted only ever use it for display.
257
- - Always verify server-side. `getUser()` (server) decodes a signature-verified,
308
+ - Set real secrets in `main.ts`: `setSecret`, `setOprfSeed`, and the server KEM
309
+ keypair - per-deployment, identical on every instance, never in a client
310
+ bundle. The defaults are insecure DEV placeholders.
311
+ - Pin **your** server KEM public key in the client and rotate it; the example
312
+ ships a throwaway dev keypair.
313
+ - Use a production Argon2id cost (≥ 256 MiB, ≥ 3 iterations); the demo is tuned
314
+ for browser responsiveness.
315
+ - Back accounts/challenges with a shared store and make challenge-consume atomic.
316
+ - Rate-limit `register` and `login` (online guessing is not stopped by the
317
+ offline-attack resistance).
318
+ - Always verify server-side. The server `getUser()` decodes a verified,
258
319
  expiry-checked session; the client `getUser()` does not and must not gate
259
- anything sensitive.
260
- - Login storage (accounts, challenges) is yours to provide and must consume
261
- challenges atomically to defeat signature replay.
320
+ anything.
321
+ - This is an unreviewed hybrid composition - get a cryptographic review before it
322
+ backs real credentials. Tracked in [`docs/auth-todo.md`](./auth-todo.md).