toiljs 0.0.84 → 0.0.86
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 +10 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.d.ts +2 -0
- package/build/compiler/config.js +1 -0
- package/build/compiler/generate.js +3 -2
- package/build/compiler/index.d.ts +1 -1
- package/build/compiler/index.js +44 -5
- package/build/compiler/toil-docs.generated.js +6 -1
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/analytics/index.js +113 -33
- package/build/devserver/runtime/module.js +1 -0
- package/docs/auth/configuration.md +94 -0
- package/docs/auth/extending.md +170 -0
- package/docs/auth/how-it-works.md +138 -0
- package/docs/auth/index.md +102 -0
- package/docs/auth/usage.md +188 -0
- package/docs/auth.md +6 -0
- package/examples/basic/client/routes/analytics.tsx +164 -26
- package/examples/basic/server/models/SiteAnalytics.ts +139 -11
- package/examples/basic/server/routes/Analytics.ts +89 -13
- package/examples/basic/server/routes/UserId.ts +22 -0
- package/package.json +5 -1
- package/scripts/gen-toil-docs.mjs +15 -6
- package/server/auth/AuthController.ts +335 -0
- package/server/auth/AuthUser.ts +49 -0
- package/server/auth/index.ts +16 -0
- package/server/globals/auth.ts +31 -0
- package/server/globals/userid.ts +107 -0
- package/src/compiler/config.ts +13 -0
- package/src/compiler/generate.ts +3 -2
- package/src/compiler/index.ts +74 -5
- package/src/compiler/toil-docs.generated.ts +6 -1
- package/src/devserver/analytics/index.ts +139 -38
- package/src/devserver/runtime/module.ts +1 -0
- package/test/analytics-dev.test.ts +76 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Configuring auth for production
|
|
2
|
+
|
|
3
|
+
Built-in auth runs locally with **zero configuration** — it falls back to published, insecure DEV secrets
|
|
4
|
+
so `toiljs dev` Just Works. **A deployment MUST replace all three secrets and pin its KEM key.** This page
|
|
5
|
+
is the checklist.
|
|
6
|
+
|
|
7
|
+
## The secrets
|
|
8
|
+
|
|
9
|
+
Auth reads these from the tenant environment store (locally, `.env.secrets`; on the edge, the per-host
|
|
10
|
+
secure env). They resolve **lazily** the first time auth runs, so no startup wiring is needed.
|
|
11
|
+
|
|
12
|
+
| Key | What it is | Dev fallback |
|
|
13
|
+
| --- | --- | --- |
|
|
14
|
+
| `AUTH_SESSION_SECRET` | HMAC-SHA256 key that signs the session cookie. Must be identical on every edge instance (a cookie minted anywhere must verify everywhere). | a public constant — **anyone can forge a session** |
|
|
15
|
+
| `AUTH_OPRF_SEED` | Master seed for the per-user OPRF salt key. Rotating it invalidates every password (users must re-register). | a hashed public constant |
|
|
16
|
+
| `AUTH_KEM_SK` | The server's ML-KEM-768 **secret** key (hex). Its public half is what the client encapsulates to. | a pinned dev key pair |
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# .env.secrets (gitignored; mode 0600 on the edge, NEVER under hosts/, NEVER in the .wasm)
|
|
20
|
+
AUTH_SESSION_SECRET=…64 hex chars (32 bytes)…
|
|
21
|
+
AUTH_OPRF_SEED=…64 hex chars (32 bytes)…
|
|
22
|
+
AUTH_KEM_SK=…hex of an ML-KEM-768 secret key…
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### Generating them
|
|
26
|
+
|
|
27
|
+
`AUTH_SESSION_SECRET` and `AUTH_OPRF_SEED` are just 32 random bytes each:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`AUTH_KEM_SK` is an ML-KEM-768 key pair. Generate it and keep BOTH halves — the secret goes in the env, the
|
|
34
|
+
public half is pinned in the client (next section):
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { ml_kem768 } from '@dacely/noble-post-quantum/ml-kem';
|
|
38
|
+
const { secretKey, publicKey } = ml_kem768.keygen();
|
|
39
|
+
console.log('AUTH_KEM_SK =', Buffer.from(secretKey).toString('hex'));
|
|
40
|
+
console.log('client serverKemPublicKey =', Buffer.from(publicKey).toString('hex'));
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Pin the client's KEM public key
|
|
44
|
+
|
|
45
|
+
The browser must know the server's genuine KEM public key to run the mutual-auth handshake — this is the
|
|
46
|
+
anti-phishing anchor. The `toiljs/client` `Auth` helper ships with the **dev** key pinned, so a deployment
|
|
47
|
+
MUST pass its own:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { Auth } from 'toiljs/client';
|
|
51
|
+
|
|
52
|
+
const SERVER_KEM_PUBLIC_KEY = /* the publicKey bytes from AUTH_KEM_SK */;
|
|
53
|
+
|
|
54
|
+
await Auth.login(username, password, { serverKemPublicKey: SERVER_KEM_PUBLIC_KEY });
|
|
55
|
+
await Auth.register(username, password, { serverKemPublicKey: SERVER_KEM_PUBLIC_KEY });
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Ship the public key with your client bundle (it's public — safe to embed). If it doesn't match the server's
|
|
59
|
+
`AUTH_KEM_SK`, login's `serverConfirm` check fails and the client aborts.
|
|
60
|
+
|
|
61
|
+
## Optional: audience & domain
|
|
62
|
+
|
|
63
|
+
Both are optional and have sensible defaults; set them for stability across host aliases:
|
|
64
|
+
|
|
65
|
+
| Key | Meaning | Default |
|
|
66
|
+
| --- | --- | --- |
|
|
67
|
+
| `TOIL_AUTH_AUDIENCE` | The service audience bound into the signed login message. | `"toil"` |
|
|
68
|
+
| `TOIL_AUTH_DOMAIN` | The `domain` input of the stable `ToilUserId` (`sha256(pubkey ‖ username ‖ domain)`). | the request `Host` header, else `localhost` |
|
|
69
|
+
|
|
70
|
+
Set `TOIL_AUTH_DOMAIN` explicitly if your site answers on multiple hostnames — otherwise the same user could
|
|
71
|
+
get different `ToilUserId`s from different aliases. Once users exist, changing it changes everyone's id, so
|
|
72
|
+
pick it before launch.
|
|
73
|
+
|
|
74
|
+
## Argon2id strength (known limitation)
|
|
75
|
+
|
|
76
|
+
The built-in controller currently uses **demo-light** Argon2id params (32 MiB, 2 iterations, 1 lane) so it
|
|
77
|
+
stays responsive in a browser tab. These are baked into the shipped controller today; **config-driven
|
|
78
|
+
tuning is a planned follow-up.** For a high-value production deployment you should either wait for the
|
|
79
|
+
config knob or hand-write your own controller with `≥ 256 MiB / ≥ 3 iterations`. The OPRF still provides the
|
|
80
|
+
primary offline-attack resistance regardless, but raise these before protecting anything sensitive.
|
|
81
|
+
|
|
82
|
+
The client always derives against whatever params the server returns in `/login/start`, so when the config
|
|
83
|
+
knob lands you can raise them server-side with **no client change**.
|
|
84
|
+
|
|
85
|
+
## Deploy checklist
|
|
86
|
+
|
|
87
|
+
- [ ] `AUTH_SESSION_SECRET` set (32 random bytes), identical on every edge instance.
|
|
88
|
+
- [ ] `AUTH_OPRF_SEED` set (32 random bytes).
|
|
89
|
+
- [ ] `AUTH_KEM_SK` set (an ML-KEM-768 secret key), and its **public** half pinned in the client via
|
|
90
|
+
`serverKemPublicKey`.
|
|
91
|
+
- [ ] `TOIL_AUTH_DOMAIN` set if you serve multiple hostnames (stable `ToilUserId`).
|
|
92
|
+
- [ ] (Recommended) Argon2id params reviewed for your threat model.
|
|
93
|
+
|
|
94
|
+
The CLI doctor warns when `server.auth` is on and the secrets are missing — run it before you ship.
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# Extending & integrating auth
|
|
2
|
+
|
|
3
|
+
Built-in auth is deliberately opinionated so the common case is one line. This page covers the identity
|
|
4
|
+
you build ON — `ToilUserId` — and how to go beyond the defaults: keying your own data on a user, a custom
|
|
5
|
+
user shape, and hand-writing auth from the same primitives.
|
|
6
|
+
|
|
7
|
+
## `ToilUserId`
|
|
8
|
+
|
|
9
|
+
The stable, tenant-scoped user identity: `sha256(mldsaPublicKey ‖ identifier ‖ domain)`, a 256-bit value.
|
|
10
|
+
It's a **global** (no import), like `crypto`.
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
// Read the current user's id in any handler (gate on hasSession() — see the null note below).
|
|
14
|
+
const id: ToilUserId = AuthService.userId()!;
|
|
15
|
+
|
|
16
|
+
// Or derive one yourself.
|
|
17
|
+
const id2 = ToilUserId.derive(mldsaPublicKey, 'alice@example.com', 'acme.dacely.com');
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
| Member | Description |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| `ToilUserId.derive(pk, identifier, domain)` | Derive from an ML-DSA public key + email/username + tenant domain. Deterministic. |
|
|
23
|
+
| `ToilUserId.fromBytes(b)` | Rebuild from a 32-byte digest (from `toBytes()` or storage). |
|
|
24
|
+
| `toBytes(): Uint8Array` | The 32 identity bytes. |
|
|
25
|
+
| `toHex(): string` | Lowercase 64-char hex — a convenient string key. |
|
|
26
|
+
| `isZero(): bool` | True for the unset / anonymous id. |
|
|
27
|
+
| `equals(other): bool` | Value equality. |
|
|
28
|
+
| `a == b` / `a != b` | Overloaded value comparison — **O(1)** (four `u64` word compares, no byte loop, no allocation). |
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
const a = ToilUserId.derive(pk, 'alice', 'acme.com');
|
|
32
|
+
const b = ToilUserId.derive(pk, 'alice', 'acme.com');
|
|
33
|
+
const c = ToilUserId.derive(pk, 'bob', 'acme.com');
|
|
34
|
+
a == b; // true — same inputs, same id
|
|
35
|
+
a != c; // true — different user
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
> **Null-check gotcha:** because `ToilUserId` overloads `==`, `AuthService.userId() == null` does NOT
|
|
39
|
+
> type-check (`==` expects a `ToilUserId`). Gate with `AuthService.hasSession()` and then `userId()!`, or
|
|
40
|
+
> compare with `getUser()` (a plain nullable). `===` is reference identity in AssemblyScript and is not
|
|
41
|
+
> overloadable — use `==` for value equality.
|
|
42
|
+
|
|
43
|
+
## Keying your own data on the user
|
|
44
|
+
|
|
45
|
+
`toilUserId` is the right key for per-user data — it's stable across sessions/devices and opaque. Use the
|
|
46
|
+
hex as a string key, or the bytes in a `@data` key class:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
@data
|
|
50
|
+
class UserKey {
|
|
51
|
+
id: Uint8Array = new Uint8Array(0); // toilUserId bytes
|
|
52
|
+
constructor(id: Uint8Array = new Uint8Array(0)) { this.id = id; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@data class Profile { displayName: string = ''; bio: string = ''; }
|
|
56
|
+
|
|
57
|
+
@database
|
|
58
|
+
class AppDb {
|
|
59
|
+
@collection static profiles: Documents<UserKey, Profile>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@rest('profile')
|
|
63
|
+
class ProfileApi {
|
|
64
|
+
@auth
|
|
65
|
+
@post('/')
|
|
66
|
+
public save(ctx: RouteContext): Response {
|
|
67
|
+
const key = new UserKey(AuthService.userId()!.toBytes());
|
|
68
|
+
const p = Profile.decode(ctx.request.body);
|
|
69
|
+
AppDb.profiles.enqueue(key, p); // upsert this user's profile
|
|
70
|
+
return Response.text('saved\n');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## A custom user shape — opt out and hand-write
|
|
76
|
+
|
|
77
|
+
Built-in auth ships the single `@user` (`{ toilUserId, username }`), and there is exactly **one `@user` per
|
|
78
|
+
program**. If you need a richer authenticated user (roles, a display name, a tenant), do **not** enable
|
|
79
|
+
`server.auth`; hand-write a controller and your own `@user` using the same primitives. The
|
|
80
|
+
`examples/basic` app does exactly this — copy `server/routes/Auth.ts` + `server/routes/Session.ts` as your
|
|
81
|
+
starting point. The shape:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
@user
|
|
85
|
+
class Account {
|
|
86
|
+
username: string = '';
|
|
87
|
+
admin: bool = false;
|
|
88
|
+
score: u64 = 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// After verifyLogin succeeds, mint your own session payload:
|
|
92
|
+
resp.setCookie(AuthService.mintSession(account.encode(), 3600));
|
|
93
|
+
resp.setCookie(AuthService.userCookie(account.encode(), 3600));
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
You still get `@auth`, `AuthService.getUser()` (typed to YOUR `@user`), and every crypto primitive — you're
|
|
97
|
+
just choosing your own routes and user fields. You can still derive a `ToilUserId` yourself and put it in
|
|
98
|
+
your `@user` if you want the stable id.
|
|
99
|
+
|
|
100
|
+
## Adding email verification / 2FA
|
|
101
|
+
|
|
102
|
+
Layer a second factor on top of the session with `TwoFactor` (stateless email codes — no DB; see
|
|
103
|
+
[email](../email.md)). Typical flow: after login, require a verified email before granting access to
|
|
104
|
+
sensitive routes.
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
@rest('2fa')
|
|
108
|
+
class TwoFactorApi {
|
|
109
|
+
// Step 1: email a code to the logged-in user, hand back the signed token.
|
|
110
|
+
@auth @post('/send')
|
|
111
|
+
public send(): Response {
|
|
112
|
+
const email = /* the user's email — e.g. their username, or a stored profile field */;
|
|
113
|
+
const ch = TwoFactor.send(email, 'login'); // emails the code, returns { token, status }
|
|
114
|
+
return Response.bytes(new DataWriter().writeString(ch.token).toBytes());
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Step 2: verify the code the user typed against the token.
|
|
118
|
+
@auth @post('/verify')
|
|
119
|
+
public verify(ctx: RouteContext): Response {
|
|
120
|
+
const r = new DataReader(ctx.request.body);
|
|
121
|
+
const token = r.readString(); const email = r.readString(); const code = r.readString();
|
|
122
|
+
if (!TwoFactor.verify(token, email, code)) return Response.text('bad code\n', 401);
|
|
123
|
+
// Mark this session 2FA-verified: re-mint the session with a flag in your own @user, or store a
|
|
124
|
+
// per-user "verified" record keyed on AuthService.userId().
|
|
125
|
+
return Response.text('verified\n');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`TwoFactor` gives integrity + expiry but not single-use (a code re-verifies within its TTL); keep the TTL
|
|
131
|
+
short. For a branded email, use `TwoFactor.issue(...)` (returns the code without sending) + your own
|
|
132
|
+
`Emails.*` template. Call `TwoFactor.setSecret(...)` once at startup in production.
|
|
133
|
+
|
|
134
|
+
## The `AuthService` primitive reference
|
|
135
|
+
|
|
136
|
+
Everything the built-in controller is built from, available for hand-written auth. All are ambient globals
|
|
137
|
+
(no import).
|
|
138
|
+
|
|
139
|
+
**Sessions & cookies**
|
|
140
|
+
- `mintSession(userData: Uint8Array, ttlSecs?: u64): Cookie` — the signed `__Host-toil_sess` cookie.
|
|
141
|
+
- `userCookie(userData, ttlSecs?): Cookie` — the readable `__Secure-toil_user` companion.
|
|
142
|
+
- `clearSession(): Cookie` / `clearUserCookie(): Cookie`.
|
|
143
|
+
- `hasSession(): bool` — the `@auth` predicate.
|
|
144
|
+
- `getSessionBytes(): Uint8Array | null` — the verified `@user` payload bytes.
|
|
145
|
+
- `getUser(): <your @user> | null` — decoded, typed to your `@user`.
|
|
146
|
+
- `userId(): ToilUserId | null` — the stable id (built-in `@user` layout).
|
|
147
|
+
- `setSecret(secret: Uint8Array)` — override the session HMAC key programmatically.
|
|
148
|
+
|
|
149
|
+
**Post-quantum login crypto**
|
|
150
|
+
- `oprfEvaluate(username, blinded): Uint8Array` — server-keyed OPRF eval.
|
|
151
|
+
- `buildRegisterMessage(username, pk)` / `verifyRegister(pk, msg, sig): bool` — proof-of-possession.
|
|
152
|
+
- `buildLoginMessage(sub, aud, cid, nonce, iat, exp, ct, memKiB, iterations, parallelism, serverKemKeyId)`
|
|
153
|
+
/ `verifyLogin(pk, msg, sig): bool`.
|
|
154
|
+
- `mlkemDecapsulate(ct): Uint8Array` · `serverKemKeyId(): Uint8Array`.
|
|
155
|
+
- `deriveSessionKey(sharedSecret, transcriptHash)` · `serverConfirmTag(sessionKey, transcriptHash)` — the
|
|
156
|
+
mutual-auth confirmation.
|
|
157
|
+
- `sha256(data): Uint8Array`.
|
|
158
|
+
- `setOprfSeed(seed)` · `setServerKemSecretKey(sk)` · `setServerKemPublicKey(pk)` — override the seeds/keys.
|
|
159
|
+
- Sizes: `PUBLIC_KEY_LEN`, `SIGNATURE_LEN`, `OPRF_ELEMENT_LEN`, `KEM_CIPHERTEXT_LEN`, `SHARED_SECRET_LEN`, …
|
|
160
|
+
|
|
161
|
+
**Related globals** — `TwoFactor` (email codes; see [email](../email.md)), `RateLimitService` (used by
|
|
162
|
+
`@ratelimit`), `Environment` (the secret store).
|
|
163
|
+
|
|
164
|
+
## Two ways in, one behavior
|
|
165
|
+
|
|
166
|
+
The config flag and the import are identical at build time — both make the build append the shipped
|
|
167
|
+
`@user` + `@rest('auth')` controller to the toilscript **entry** set, where their decorators weave and the
|
|
168
|
+
`@rest` class self-mounts. (A framework decorator source only weaves as an entry; that's why a plain
|
|
169
|
+
`import` of the controller isn't enough on its own — the marker import is detected by the build, which does
|
|
170
|
+
the entry injection.) This is why built-in auth needs no runtime registration call.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# How Toil auth works
|
|
2
|
+
|
|
3
|
+
Toil auth is a **post-quantum, password-based, mutually-authenticated** login. The design goal: the user
|
|
4
|
+
types a password, but the server never sees it, never stores it, and can't be phished into leaking a
|
|
5
|
+
verifier — and every primitive is quantum-resistant.
|
|
6
|
+
|
|
7
|
+
This page explains the protocol. You do not need to understand it to use auth (see [Usage](./usage.md)),
|
|
8
|
+
but you should understand the guarantees before you ship.
|
|
9
|
+
|
|
10
|
+
## The building blocks
|
|
11
|
+
|
|
12
|
+
| Primitive | Role |
|
|
13
|
+
| --- | --- |
|
|
14
|
+
| **OPRF** (RFC 9497, ristretto255-SHA512) | A *server-keyed* salt. The client blinds its password, the server evaluates it under a per-user key, the client unblinds. The result can't be computed without the server, so a stolen database can't be brute-forced offline. |
|
|
15
|
+
| **Argon2id** | Stretches the OPRF output into key material (memory-hard, GPU/ASIC-resistant). Runs in the browser. |
|
|
16
|
+
| **ML-DSA-44** (FIPS 204) | The user's identity key pair is *derived* from the Argon2id output. The server stores only the **public** key. Login is proved by an ML-DSA signature. |
|
|
17
|
+
| **ML-KEM-768** (FIPS 203) | Key encapsulation on login: the server proves it holds its KEM secret by returning a confirmation tag only derivable from the decapsulated shared secret → **mutual** auth (anti-phishing). |
|
|
18
|
+
| **HMAC-SHA256** | Signs the session cookie (`AUTH_SESSION_SECRET`). |
|
|
19
|
+
|
|
20
|
+
> The whole thing is sometimes called an *aPAKE* (asymmetric password-authenticated key exchange). Do not
|
|
21
|
+
> call it OPAQUE — this is Toil's own OPRF + KEM + signature construction.
|
|
22
|
+
|
|
23
|
+
## What the server stores
|
|
24
|
+
|
|
25
|
+
Per account, in ToilDB (`@database AuthDb`):
|
|
26
|
+
|
|
27
|
+
- `username`, a deterministic `salt`, the **ML-DSA public key**, and the Argon2id params.
|
|
28
|
+
|
|
29
|
+
That's it. **No password, no password hash, no verifier that can be brute-forced without the OPRF key.**
|
|
30
|
+
Login challenges are a second collection, consumed exactly once (atomic `getDelete`).
|
|
31
|
+
|
|
32
|
+
## Registration
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
Browser (client) Toil edge (server)
|
|
36
|
+
───────────────── ──────────────────
|
|
37
|
+
blind(password) OPRF key = f(seed, username)
|
|
38
|
+
│ username, blinded │
|
|
39
|
+
│ ───────── POST /auth/register/start ───────────► │
|
|
40
|
+
│ │ evaluated = OPRF(blinded)
|
|
41
|
+
│ ◄──── {mem,iters,par, salt, evaluated} ───────── │ (salt is deterministic per user)
|
|
42
|
+
│ │
|
|
43
|
+
unblind → oprfOut │
|
|
44
|
+
seed = Argon2id(oprfOut, salt, params) │
|
|
45
|
+
(pk, sk) = ML-DSA-44.keygen(seed) │
|
|
46
|
+
proof = ML-DSA.sign(sk, "register|username|pk") │
|
|
47
|
+
│ username, pk, proof │
|
|
48
|
+
│ ───────── POST /auth/register/finish ──────────► │
|
|
49
|
+
│ │ verifyRegister(pk, msg, proof) ✓ proof-of-possession
|
|
50
|
+
│ ◄──────────── {status: 0 ok | 1 taken} ──────── │ store AuthAccount{username, salt, pk, params}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The server never learns the password or the secret key `sk` — only the public key `pk` and a proof the
|
|
54
|
+
client holds the matching `sk`. A duplicate username returns a **distinguishable** `status = 1` (so the UI
|
|
55
|
+
can say "taken, log in instead"); everything else fails generically.
|
|
56
|
+
|
|
57
|
+
## Login (with mutual auth)
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
Browser (client) Toil edge (server)
|
|
61
|
+
───────────────── ──────────────────
|
|
62
|
+
blind(password) │
|
|
63
|
+
│ username, blinded │
|
|
64
|
+
│ ───────── POST /auth/login/start ──────────────► │ evaluated = OPRF(blinded) (ALWAYS, even unknown user)
|
|
65
|
+
│ │ if known: store Challenge{cid, nonce, iat, exp}
|
|
66
|
+
│ ◄─ {cid, aud, params, salt, nonce, iat, exp, ── │ (identical response whether the user exists or not)
|
|
67
|
+
│ evaluated} │
|
|
68
|
+
unblind → Argon2id → (pk, sk) = ML-DSA.keygen │
|
|
69
|
+
(ct, ssС) = ML-KEM-768.encapsulate(serverKemPublicKey) │
|
|
70
|
+
msg = "login|username|aud|cid|nonce|iat|exp|ct|params|kid" │
|
|
71
|
+
sig = ML-DSA.sign(sk, msg) │
|
|
72
|
+
│ cid, ct, sig │
|
|
73
|
+
│ ───────── POST /auth/login/finish ─────────────► │ ch = challenges.getDelete(cid) (consume once; check exp)
|
|
74
|
+
│ │ rebuild msg from OUR stored values + ct
|
|
75
|
+
│ │ verifyLogin(acct.pk, msg, sig) ✓ it's really this user
|
|
76
|
+
│ │ ssS = ML-KEM.decapsulate(ct) ✓ we hold the KEM key
|
|
77
|
+
│ │ K = deriveSessionKey(ssS, H(msg))
|
|
78
|
+
│ ◄──── {0, sessionToken, serverConfirm} ──────── │ serverConfirm = tag(K, H(msg))
|
|
79
|
+
│ + Set-Cookie: __Host-toil_sess=… │ mint session cookie
|
|
80
|
+
verify serverConfirm using ssC ✓ the server is genuine │
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Two verifications, both required:
|
|
84
|
+
1. **The server verifies the client** — the ML-DSA signature over a message bound to the challenge, the
|
|
85
|
+
Argon2id params, and the server's KEM key id. Replays fail (the challenge is consumed).
|
|
86
|
+
2. **The client verifies the server** — the `serverConfirm` tag is derivable only from the ML-KEM shared
|
|
87
|
+
secret, which only the holder of the KEM secret key can decapsulate. A phishing site can't forge it.
|
|
88
|
+
|
|
89
|
+
## Anti-enumeration
|
|
90
|
+
|
|
91
|
+
`/login/start` behaves identically for a known and an unknown user: it always runs the OPRF (a decoy key
|
|
92
|
+
for unknown users), returns a **deterministic** per-user salt and constant params, and a fresh challenge.
|
|
93
|
+
The challenge is persisted only for a real account, and `/login/finish` fails generically at consume for an
|
|
94
|
+
unknown user. So an attacker can't probe which usernames exist. Every failure path returns the same
|
|
95
|
+
`401 auth: request failed`.
|
|
96
|
+
|
|
97
|
+
## Sessions & cookies
|
|
98
|
+
|
|
99
|
+
On successful login the server mints **two** cookies:
|
|
100
|
+
|
|
101
|
+
- **`__Host-toil_sess`** — the authoritative session. HMAC-SHA256 signed with `AUTH_SESSION_SECRET`,
|
|
102
|
+
`HttpOnly`, `Secure`, `SameSite=Lax`. It carries the `@user` codec payload. `@auth` and
|
|
103
|
+
`AuthService.getUser()` open + verify this cookie server-side; a forged or tampered cookie fails.
|
|
104
|
+
- **`__Secure-toil_user`** — a **readable** companion carrying the same payload, so the browser can show
|
|
105
|
+
"logged in as …" via the client's `getUser()`. The server **never trusts it** — it is display-only.
|
|
106
|
+
|
|
107
|
+
Each request runs in a fresh wasm instance, but the signed cookie is self-contained, so no server-side
|
|
108
|
+
session store is needed. `AUTH_SESSION_SECRET` must be identical across every edge instance (it is, via the
|
|
109
|
+
env store) so a cookie minted anywhere verifies everywhere.
|
|
110
|
+
|
|
111
|
+
## The stable user identity — `ToilUserId`
|
|
112
|
+
|
|
113
|
+
At login the server derives a stable, tenant-scoped id and stores it in the session (the first field of the
|
|
114
|
+
built-in `@user`):
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
toilUserId = SHA-256( mldsaPublicKey ‖ username ‖ domain ) // 256 bits
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
- **Stable:** same login key + username on the same tenant `domain` → same id, forever, across sessions
|
|
121
|
+
and devices. Key your own data on it.
|
|
122
|
+
- **Opaque + one-way:** it's a hash — safe to store, log, or expose without leaking the key or the address.
|
|
123
|
+
- Read it anywhere with `AuthService.userId()`. See [Extending](./extending.md#toiluserid) for the
|
|
124
|
+
`ToilUserId` API (O(1) `==` / `!=`, `toHex()`, …).
|
|
125
|
+
|
|
126
|
+
## Threat-model summary
|
|
127
|
+
|
|
128
|
+
- **Server database stolen** → attacker gets public keys + salts, not passwords. Brute-forcing needs the
|
|
129
|
+
OPRF key (server-side) *and* Argon2id work per guess.
|
|
130
|
+
- **Server compromised / malicious** → still can't recover passwords (only public keys) and can't forge a
|
|
131
|
+
past session without `AUTH_SESSION_SECRET`.
|
|
132
|
+
- **Phishing site** → can't produce the `serverConfirm` tag (no KEM secret), so a correct client aborts.
|
|
133
|
+
- **Quantum adversary** → ML-DSA + ML-KEM are post-quantum; the OPRF/Argon2id/HMAC pieces are classical but
|
|
134
|
+
not the long-term identity or key-exchange.
|
|
135
|
+
- **Replay** → challenges are single-use (`getDelete`) with a short TTL.
|
|
136
|
+
|
|
137
|
+
Residual responsibilities are yours: set the [secrets](./configuration.md), pin your deployment's KEM
|
|
138
|
+
public key in the client, and raise the Argon2id params for production.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Authentication
|
|
2
|
+
|
|
3
|
+
Toil ships a complete, **post-quantum password login** you turn on with one line. No passwords on your
|
|
4
|
+
server, no third-party identity provider, no hand-written crypto: enable it and you get a `/auth/*` API,
|
|
5
|
+
signed sessions, `@auth`-guarded routes, and a stable per-user identity.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
// toil.config.ts
|
|
9
|
+
import { defineConfig } from 'toiljs/compiler';
|
|
10
|
+
|
|
11
|
+
export default defineConfig({
|
|
12
|
+
server: { auth: true }, // ← mounts the full /auth/* API + sessions
|
|
13
|
+
});
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
That is the whole setup. The build appends the framework's auth controller to your server, so `/auth/*`
|
|
17
|
+
is live and `@auth` works everywhere.
|
|
18
|
+
|
|
19
|
+
## What you get
|
|
20
|
+
|
|
21
|
+
| Endpoint | Purpose |
|
|
22
|
+
| --- | --- |
|
|
23
|
+
| `POST /auth/register/start` · `/register/finish` | Create an account (password never leaves the browser) |
|
|
24
|
+
| `POST /auth/login/start` · `/login/finish` | Log in; sets a signed session cookie |
|
|
25
|
+
| `GET /auth/me` *(`@auth`)* | The current user (`toilUserId` + `username`) |
|
|
26
|
+
| `POST /auth/logout` *(`@auth`)* | Clear the session |
|
|
27
|
+
|
|
28
|
+
Plus, in your own code:
|
|
29
|
+
|
|
30
|
+
- **`@auth`** on any route (or a whole `@rest` class) → 401 unless there's a valid session.
|
|
31
|
+
- **`AuthService.getUser()`** → the typed logged-in user.
|
|
32
|
+
- **`AuthService.userId()`** → the stable [`ToilUserId`](./extending.md#toiluserid) (a 256-bit id you can
|
|
33
|
+
key your data on).
|
|
34
|
+
- **The client** — `import { Auth } from 'toiljs/client'`, then `Auth.register(username, password)` /
|
|
35
|
+
`Auth.login(username, password)`. It does all the browser-side crypto and talks to `/auth/*` for you.
|
|
36
|
+
|
|
37
|
+
## A login page in full
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
// client/routes/login.tsx
|
|
41
|
+
import { useState } from 'react';
|
|
42
|
+
import { Auth } from 'toiljs/client';
|
|
43
|
+
|
|
44
|
+
export default function Login() {
|
|
45
|
+
const [u, setU] = useState('');
|
|
46
|
+
const [p, setP] = useState('');
|
|
47
|
+
const [msg, setMsg] = useState('');
|
|
48
|
+
|
|
49
|
+
const register = async () => {
|
|
50
|
+
try { await Auth.register(u, p); setMsg('registered — now log in'); }
|
|
51
|
+
catch (e) { setMsg(parseError(e)); }
|
|
52
|
+
};
|
|
53
|
+
const login = async () => {
|
|
54
|
+
try { await Auth.login(u, p); setMsg('logged in'); } // sets the session cookie
|
|
55
|
+
catch (e) { setMsg(parseError(e)); }
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<main>
|
|
60
|
+
<input value={u} onChange={(e) => setU(e.currentTarget.value)} placeholder="username" />
|
|
61
|
+
<input value={p} type="password" onChange={(e) => setP(e.currentTarget.value)} placeholder="password" />
|
|
62
|
+
<button onClick={register}>Register</button>
|
|
63
|
+
<button onClick={login}>Log in</button>
|
|
64
|
+
<p>{msg}</p>
|
|
65
|
+
</main>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
// server/routes/Secret.ts — a route only a logged-in user can reach
|
|
72
|
+
import { Response } from 'toiljs/server/runtime';
|
|
73
|
+
|
|
74
|
+
@rest('secret')
|
|
75
|
+
class Secret {
|
|
76
|
+
@auth // 401 without a valid session
|
|
77
|
+
@get('/')
|
|
78
|
+
public secret(): Response {
|
|
79
|
+
const user = AuthService.getUser()!; // typed: { toilUserId, username }
|
|
80
|
+
return Response.text('hello ' + user.username + '\n');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
That's a real, production-grade auth system — the password is stretched with Argon2id in the browser into
|
|
86
|
+
an ML-DSA-44 key pair, your server only ever stores a public key, and login is a mutually-authenticated
|
|
87
|
+
ML-KEM-768 challenge. You didn't write any of it.
|
|
88
|
+
|
|
89
|
+
## Where to go next
|
|
90
|
+
|
|
91
|
+
- **[How it works](./how-it-works.md)** — the protocol (OPRF + Argon2id + ML-DSA + ML-KEM), sessions,
|
|
92
|
+
cookies, and the `ToilUserId`, with sequence diagrams.
|
|
93
|
+
- **[Usage](./usage.md)** — enabling it, the client API, guarding routes, reading the user, the full wire
|
|
94
|
+
contract of each endpoint.
|
|
95
|
+
- **[Configuration](./configuration.md)** — the secrets a deployment MUST set, the audience/domain, tuning
|
|
96
|
+
Argon2id, and the deploy checklist.
|
|
97
|
+
- **[Extending & integrating](./extending.md)** — `ToilUserId`, keying your own data on a user, a custom
|
|
98
|
+
user shape / opting out, and the `AuthService` primitive reference.
|
|
99
|
+
|
|
100
|
+
> **One rule before you ship:** built-in auth runs with **insecure DEV fallback secrets** so it Just Works
|
|
101
|
+
> locally. A deployment MUST set `AUTH_SESSION_SECRET`, `AUTH_OPRF_SEED`, and `AUTH_KEM_SK`. See
|
|
102
|
+
> [Configuration](./configuration.md).
|