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.
- package/CHANGELOG.md +19 -0
- package/TYPESCRIPT_LAW.md +12601 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +16 -1
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/auth.d.ts +9 -20
- package/build/client/auth.js +112 -95
- package/build/client/index.d.ts +2 -2
- package/build/client/index.js +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/generate.js +1 -1
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/crypto.js +33 -0
- package/build/devserver/host.js +2 -0
- package/build/devserver/kv.d.ts +3 -0
- package/build/devserver/kv.js +53 -0
- package/build/devserver/module.js +2 -1
- package/docs/auth-todo.md +149 -0
- package/docs/auth.md +234 -173
- package/examples/basic/client/routes/pq.tsx +72 -103
- package/examples/basic/server/core/AppHandler.ts +24 -3
- package/examples/basic/server/main.ts +0 -1
- package/examples/basic/server/routes/Auth.ts +304 -99
- package/examples/basic/server/routes/Session.ts +5 -2
- package/package.json +2 -1
- package/server/globals/auth.ts +263 -10
- package/src/cli/diagnostics.ts +22 -0
- package/src/cli/doctor.ts +2 -0
- package/src/client/auth.ts +192 -174
- package/src/client/index.ts +2 -2
- package/src/compiler/generate.ts +1 -1
- package/src/devserver/crypto.ts +54 -0
- package/src/devserver/host.ts +6 -0
- package/src/devserver/kv.ts +93 -0
- package/src/devserver/module.ts +4 -1
- package/test/devserver-pqauth.test.ts +153 -0
- package/test/doctor.test.ts +22 -0
- package/test/pqauth-e2e.test.ts +207 -0
- 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
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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`**
|
|
12
|
-
|
|
13
|
-
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
31
|
-
participant S as Edge wasm
|
|
32
|
-
participant DB as Your store
|
|
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,
|
|
103
|
+
Note over U,DB: Register, password never leaves the browser
|
|
36
104
|
U->>C: Auth.register(username, password)
|
|
37
|
-
C->>
|
|
38
|
-
|
|
39
|
-
S
|
|
40
|
-
|
|
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,
|
|
116
|
+
Note over U,DB: Login, mutual authentication
|
|
45
117
|
U->>C: Auth.login(username, password)
|
|
46
|
-
C->>S:
|
|
47
|
-
S->>DB:
|
|
48
|
-
S-->>C:
|
|
49
|
-
C->>C:
|
|
50
|
-
C->>S: POST /auth/login
|
|
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
|
|
124
|
+
S->>S: rebuild M, verifyLogin, decapsulate, derive K, build confirm
|
|
53
125
|
alt signature valid
|
|
54
|
-
S-->>C:
|
|
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
|
|
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
|
|
137
|
+
S->>S: @auth verifies HMAC and expiry on __Host-toil_sess
|
|
65
138
|
alt valid session
|
|
66
|
-
S->>S: handler runs
|
|
67
|
-
S-->>C: 200
|
|
68
|
-
else missing or invalid
|
|
69
|
-
S-->>C: 401 before
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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()` | `
|
|
147
|
-
| `hasSession()` | `
|
|
148
|
-
| `
|
|
149
|
-
| `
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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); //
|
|
227
|
-
await Auth.login(username, password); //
|
|
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
|
-
`
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
|
254
|
-
instance
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
260
|
-
-
|
|
261
|
-
|
|
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).
|