toiljs 0.0.34 → 0.0.36
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/README.md +1 -0
- package/as-pect.config.js +8 -2
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +97 -0
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/auth.d.ts +42 -0
- package/build/client/auth.js +179 -0
- package/build/client/index.d.ts +5 -1
- package/build/client/index.js +3 -1
- package/build/client/routing/loader.d.ts +1 -0
- package/build/client/routing/loader.js +37 -0
- package/build/client/routing/mount.js +32 -1
- package/build/client/ssr/markers.d.ts +34 -0
- package/build/client/ssr/markers.js +49 -0
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/docs.js +88 -1
- package/build/compiler/generate.d.ts +2 -0
- package/build/compiler/generate.js +2 -2
- package/build/compiler/index.js +2 -0
- package/build/compiler/ssr-codegen.d.ts +2 -0
- package/build/compiler/ssr-codegen.js +36 -0
- package/build/compiler/template-build.d.ts +29 -0
- package/build/compiler/template-build.js +150 -0
- package/build/compiler/template.d.ts +22 -0
- package/build/compiler/template.js +169 -0
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/crypto.js +15 -0
- package/build/devserver/host.js +1 -0
- package/build/devserver/module.d.ts +1 -0
- package/build/devserver/module.js +23 -1
- package/docs/README.md +56 -0
- package/docs/auth.md +261 -0
- package/docs/caching.md +115 -0
- package/docs/cookies.md +457 -0
- package/docs/crypto.md +130 -0
- package/docs/data.md +131 -0
- package/docs/getting-started.md +128 -0
- package/docs/routing.md +259 -0
- package/docs/rpc.md +149 -0
- package/docs/ssr.md +184 -0
- package/docs/time.md +43 -0
- package/examples/basic/client/routes/auth.tsx +198 -0
- package/examples/basic/client/routes/cookies.tsx +199 -0
- package/examples/basic/client/routes/features/index.tsx +34 -10
- package/examples/basic/client/routes/hello.tsx +43 -0
- package/examples/basic/client/routes/pq.tsx +135 -0
- package/examples/basic/server/AuthTestHandler.ts +15 -0
- package/examples/basic/server/AuthVerifyHandler.ts +23 -0
- package/examples/basic/server/CacheHandler.ts +25 -0
- package/examples/basic/server/DecoCache.ts +18 -0
- package/examples/basic/server/FastTrapHandler.ts +8 -0
- package/examples/basic/server/SpinHandler.ts +18 -0
- package/examples/basic/server/SsrGreetingRender.ts +27 -0
- package/examples/basic/server/authexample-main.ts +8 -0
- package/examples/basic/server/authtest-main.ts +8 -0
- package/examples/basic/server/authverify-main.ts +8 -0
- package/examples/basic/server/cache-main.ts +8 -0
- package/examples/basic/server/core/AppHandler.ts +243 -0
- package/examples/basic/server/deco-main.ts +18 -0
- package/examples/basic/server/main.ts +2 -0
- package/examples/basic/server/routes/Auth.ts +184 -0
- package/examples/basic/server/routes/PqDemo.ts +109 -0
- package/examples/basic/server/routes/Session.ts +73 -0
- package/examples/basic/server/spin-main.ts +13 -0
- package/examples/basic/server/ssr/greeting.slots.ts +19 -0
- package/examples/basic/server/ssr-main.ts +18 -0
- package/examples/basic/server/toil-server-env.d.ts +94 -0
- package/examples/basic/server/trap-main.ts +8 -0
- package/package.json +5 -3
- package/server/globals/auth.ts +281 -0
- package/server/runtime/README.md +61 -0
- package/server/runtime/env/Server.ts +12 -0
- package/server/runtime/exports/index.ts +17 -0
- package/server/runtime/exports/render.ts +51 -0
- package/server/runtime/http/base64.ts +104 -0
- package/server/runtime/http/cookie.ts +416 -0
- package/server/runtime/http/cookies.ts +197 -0
- package/server/runtime/http/date.ts +72 -0
- package/server/runtime/http/percent.ts +76 -0
- package/server/runtime/http/securecookies.ts +224 -0
- package/server/runtime/index.ts +17 -0
- package/server/runtime/request.ts +24 -0
- package/server/runtime/response.ts +29 -0
- package/server/runtime/ssr/Ssr.ts +43 -0
- package/server/runtime/ssr/encode.ts +110 -0
- package/server/runtime/ssr/escape.ts +83 -0
- package/server/runtime/ssr/slots.ts +144 -0
- package/server/runtime/time.ts +29 -0
- package/src/cli/create.ts +105 -0
- package/src/client/auth.ts +322 -0
- package/src/client/index.ts +5 -1
- package/src/client/routing/loader.ts +56 -0
- package/src/client/routing/mount.tsx +37 -1
- package/src/client/ssr/markers.tsx +140 -0
- package/src/compiler/docs.ts +88 -1
- package/src/compiler/generate.ts +2 -2
- package/src/compiler/index.ts +5 -0
- package/src/compiler/ssr-codegen.ts +85 -0
- package/src/compiler/template-build.ts +275 -0
- package/src/compiler/template.ts +265 -0
- package/src/devserver/crypto.ts +23 -0
- package/src/devserver/host.ts +4 -0
- package/src/devserver/module.ts +39 -1
- package/test/assembly/cookie.spec.ts +302 -0
- package/test/assembly/example.spec.ts +5 -1
- package/test/assembly/ssr.spec.ts +94 -0
- package/test/devserver.test.ts +42 -0
- package/test/ssr-render.test.ts +128 -0
- package/test/ssr-template.test.tsx +348 -0
package/docs/cookies.md
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
# Cookies
|
|
2
|
+
|
|
3
|
+
A complete HTTP cookie layer for the toiljs server runtime, covering the full
|
|
4
|
+
RFC 6265bis surface (including `SameSite`, the `Partitioned`/CHIPS attribute, and
|
|
5
|
+
the `__Host-` / `__Secure-` prefixes) plus cryptographic signing and encryption.
|
|
6
|
+
|
|
7
|
+
`Cookie`, `Cookies`, `CookieMap`, `SecureCookies`, and the `SameSite` /
|
|
8
|
+
`CookieEncoding` / `CookiePrefix` enums are **ambient globals**: a handler uses
|
|
9
|
+
them with **no import**, exactly like `crypto`. They are also exported from
|
|
10
|
+
`toiljs/server/runtime` for anyone who prefers an explicit import.
|
|
11
|
+
|
|
12
|
+
- [How "global, no import" works](#how-global-no-import-works)
|
|
13
|
+
- [Quick start](#quick-start)
|
|
14
|
+
- [The `Cookie` builder](#the-cookie-builder)
|
|
15
|
+
- [The `Cookies` parser and codec](#the-cookies-parser-and-codec)
|
|
16
|
+
- [`CookieMap`](#cookiemap)
|
|
17
|
+
- [`SecureCookies` signing and encryption](#securecookies-signing-and-encryption)
|
|
18
|
+
- [`Request` and `Response` integration](#request-and-response-integration)
|
|
19
|
+
- [`base64url` helpers](#base64url-helpers)
|
|
20
|
+
- [Encoding vs encryption](#encoding-vs-encryption)
|
|
21
|
+
- [Security notes](#security-notes)
|
|
22
|
+
- [Spec compliance](#spec-compliance)
|
|
23
|
+
- [Testing](#testing)
|
|
24
|
+
- [API reference](#api-reference)
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## How "global, no import" works
|
|
29
|
+
|
|
30
|
+
The cookie types are declared with ToilScript's `@global` decorator and pulled
|
|
31
|
+
into every server build (re-exported from `toiljs/server/runtime` and
|
|
32
|
+
side-effect-imported by `toiljs/server/runtime/exports`, which every `main.ts`
|
|
33
|
+
re-exports). At compile time the symbols register in the global scope, so a
|
|
34
|
+
handler can write `Cookie.create(...)` or `req.cookie(...)` without importing
|
|
35
|
+
anything.
|
|
36
|
+
|
|
37
|
+
For the editor, `toiljs create` scaffolds `server/toil-server-env.d.ts` with
|
|
38
|
+
ambient `declare`s for these globals (the toilscript compiler ignores `.d.ts`;
|
|
39
|
+
it only feeds the language service). If you would rather import them:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { Cookie, Cookies, SecureCookies, SameSite } from 'toiljs/server/runtime';
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { ToilHandler, Request, Response } from 'toiljs/server/runtime';
|
|
51
|
+
|
|
52
|
+
export class AppHandler extends ToilHandler {
|
|
53
|
+
public handle(req: Request): Response {
|
|
54
|
+
// Read (no import needed for Cookie / Cookies / SameSite, they are global).
|
|
55
|
+
const sid = req.cookie('sid'); // string | null
|
|
56
|
+
|
|
57
|
+
// Write a hardened session cookie.
|
|
58
|
+
return Response.json('{"ok":true}').setCookie(
|
|
59
|
+
Cookie.create('sid', 'abc123')
|
|
60
|
+
.httpOnly()
|
|
61
|
+
.secure()
|
|
62
|
+
.sameSite(SameSite.Lax)
|
|
63
|
+
.maxAge(3600)
|
|
64
|
+
.asHostPrefixed(), // forces Secure + Path=/ + no Domain
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## The `Cookie` builder
|
|
73
|
+
|
|
74
|
+
A fluent builder that serializes to one `Set-Cookie` field value. Every setter
|
|
75
|
+
returns the cookie, so calls chain.
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
const c = Cookie.create('id', 'abc123')
|
|
79
|
+
.domain('example.com')
|
|
80
|
+
.path('/app')
|
|
81
|
+
.maxAge(3600)
|
|
82
|
+
.secure()
|
|
83
|
+
.httpOnly()
|
|
84
|
+
.sameSite(SameSite.Lax);
|
|
85
|
+
|
|
86
|
+
c.serialize();
|
|
87
|
+
// "id=abc123; Domain=example.com; Path=/app; Max-Age=3600; SameSite=Lax; Secure; HttpOnly"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Fields
|
|
91
|
+
|
|
92
|
+
| Field | Type | Notes |
|
|
93
|
+
| --- | --- | --- |
|
|
94
|
+
| `name` | `string` | The cookie name (a token; never encoded). |
|
|
95
|
+
| `value` | `string` | The logical value (encoded per `encoding` on serialize). |
|
|
96
|
+
| `encoding` | `CookieEncoding` | Wire encoding for the value. Default `Percent`. |
|
|
97
|
+
|
|
98
|
+
### Construction
|
|
99
|
+
|
|
100
|
+
- `new Cookie(name, value)`
|
|
101
|
+
- `Cookie.create(name, value): Cookie`, a builder-style alias.
|
|
102
|
+
|
|
103
|
+
### Attribute setters
|
|
104
|
+
|
|
105
|
+
| Method | Attribute |
|
|
106
|
+
| --- | --- |
|
|
107
|
+
| `domain(v: string)` | `Domain` |
|
|
108
|
+
| `path(v: string)` | `Path` (must begin with `/`) |
|
|
109
|
+
| `maxAge(seconds: i64)` | `Max-Age` (`0` / negative expire immediately) |
|
|
110
|
+
| `expires(epochSeconds: i64)` | `Expires`, formatted as an IMF-fixdate (`Sun, 06 Nov 1994 08:49:37 GMT`) |
|
|
111
|
+
| `expiresRaw(date: string)` | `Expires` verbatim (escape hatch) |
|
|
112
|
+
| `secure(on: bool = true)` | `Secure` |
|
|
113
|
+
| `httpOnly(on: bool = true)` | `HttpOnly` |
|
|
114
|
+
| `sameSite(s: SameSite)` | `SameSite` |
|
|
115
|
+
| `partitioned(on: bool = true)` | `Partitioned` (CHIPS) |
|
|
116
|
+
| `priority(p: string)` | `Priority` (`Low` / `Medium` / `High`) |
|
|
117
|
+
| `extension(av: string)` | An arbitrary extension attribute, appended verbatim |
|
|
118
|
+
| `withEncoding(e: CookieEncoding)` | Choose the value wire encoding |
|
|
119
|
+
|
|
120
|
+
### Prefixes
|
|
121
|
+
|
|
122
|
+
- `asSecurePrefixed(): Cookie`, prepends `__Secure-` and forces `Secure`.
|
|
123
|
+
- `asHostPrefixed(): Cookie`, prepends `__Host-` and forces `Secure`, `Path=/`, and no `Domain`.
|
|
124
|
+
- `detectedPrefix(): CookiePrefix`, the prefix detected on the current name (case-insensitive).
|
|
125
|
+
|
|
126
|
+
### Output
|
|
127
|
+
|
|
128
|
+
- `serialize(strict: bool = false): string`, returns the `Set-Cookie` value. Lenient by
|
|
129
|
+
default (always returns a best-effort cookie); pass `strict = true` to throw on
|
|
130
|
+
a hard validation failure. `Secure` is added automatically when `SameSite=None`
|
|
131
|
+
or `Partitioned` is set; `Max-Age` is clamped to the 400-day cap; control
|
|
132
|
+
characters are stripped from the name, value, and attributes.
|
|
133
|
+
- `toString(): string`, alias for `serialize()`.
|
|
134
|
+
- `encodedValue(): string`, the value transformed per `encoding`.
|
|
135
|
+
|
|
136
|
+
### Validation
|
|
137
|
+
|
|
138
|
+
`validate(): CookieValidation` checks the cookie against RFC 6265bis and returns
|
|
139
|
+
a structured result:
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
class CookieValidation {
|
|
143
|
+
valid: bool;
|
|
144
|
+
errors: Array<string>;
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
It flags: a non-token name, name+value over 4096 bytes, a `Domain`/`Path` over
|
|
149
|
+
1024 bytes, a `Path` not starting with `/`, a `Raw` value outside `cookie-octet`,
|
|
150
|
+
the `__Host-` / `__Secure-` prefix requirements, `SameSite=None` or `Partitioned`
|
|
151
|
+
without `Secure`, and a `Max-Age` beyond the 400-day cap.
|
|
152
|
+
|
|
153
|
+
### Attribute serialization order
|
|
154
|
+
|
|
155
|
+
`name=value` then, when set: `Domain`, `Path`, `Expires`, `Max-Age`, `SameSite`,
|
|
156
|
+
`Secure`, `HttpOnly`, `Partitioned`, `Priority`, then any `extension(...)` values.
|
|
157
|
+
(Attribute order is not significant to user agents; the order is stable so output
|
|
158
|
+
is predictable.)
|
|
159
|
+
|
|
160
|
+
### Enums
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
enum SameSite { Default, None, Lax, Strict } // Default omits the attribute
|
|
164
|
+
enum CookieEncoding { Percent, Raw, Base64Url } // value wire encoding
|
|
165
|
+
enum CookiePrefix { None, Secure, Host }
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## The `Cookies` parser and codec
|
|
171
|
+
|
|
172
|
+
Static helpers for the read side and a one-shot serializer.
|
|
173
|
+
|
|
174
|
+
| Method | Description |
|
|
175
|
+
| --- | --- |
|
|
176
|
+
| `Cookies.parse(cookieHeader: string): CookieMap` | Parse a request `Cookie` header (`a=1; b=2`). Values are percent-decoded; one layer of surrounding quotes is stripped; malformed pairs and empty names are skipped. On a duplicate name the first wins. |
|
|
177
|
+
| `Cookies.get(cookieHeader: string, name: string): string \| null` | Parse and return one value. |
|
|
178
|
+
| `Cookies.serialize(name: string, value: string): string` | One-shot `name=value` with no attributes (percent-encoded). For attributes, build a `Cookie`. |
|
|
179
|
+
| `Cookies.parseSetCookie(setCookie: string): Cookie` | Parse a `Set-Cookie` line back into a `Cookie` (for clients, tests, proxies). Kept verbatim (`CookieEncoding.Raw`) so re-serializing reproduces the wire form. |
|
|
180
|
+
| `Cookies.encodeValue(raw: string): string` | Percent-encode a value (the default `Cookie` encoding). |
|
|
181
|
+
| `Cookies.decodeValue(enc: string): string` | Percent-decode a value (the inverse). |
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
const jar = Cookies.parse('sid=abc123; theme=dark');
|
|
185
|
+
jar.get('sid'); // "abc123"
|
|
186
|
+
|
|
187
|
+
Cookies.serialize('sid', 'a b'); // "sid=a%20b"
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## `CookieMap`
|
|
193
|
+
|
|
194
|
+
The ordered name to value view returned by `Cookies.parse` and `Request.cookies()`.
|
|
195
|
+
Backed by parallel arrays (a request carries a handful of cookies, so a linear
|
|
196
|
+
scan beats hashing and keeps the runtime small).
|
|
197
|
+
|
|
198
|
+
| Member | Description |
|
|
199
|
+
| --- | --- |
|
|
200
|
+
| `get(name: string): string \| null` | The value, or `null`. |
|
|
201
|
+
| `has(name: string): bool` | Whether the cookie is present. |
|
|
202
|
+
| `names(): Array<string>` | A copy of the names, in encounter order. |
|
|
203
|
+
| `size: i32` | The number of cookies. |
|
|
204
|
+
| `set(name: string, value: string): void` | Insert unless present (keep-first). Used by `parse`; rarely called directly. |
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## `SecureCookies` signing and encryption
|
|
209
|
+
|
|
210
|
+
Tamper-proof and confidential cookie values, built on the `crypto` global (no new
|
|
211
|
+
host functions).
|
|
212
|
+
|
|
213
|
+
- **`SecureCookies.signed(key)`**: HMAC-SHA256. The value stays readable but is
|
|
214
|
+
bound to the cookie name, so it cannot be tampered with or moved to another
|
|
215
|
+
cookie. Sealed form: `base64url(value) "." base64url(mac)`.
|
|
216
|
+
- **`SecureCookies.encrypted(key)`**: AES-256-GCM (or AES-128-GCM) with a random
|
|
217
|
+
96-bit IV and the cookie name as additional authenticated data. The value is
|
|
218
|
+
confidential and authenticated. Sealed form: `base64url(iv ‖ ciphertext ‖ tag)`.
|
|
219
|
+
|
|
220
|
+
Keys are caller-supplied raw bytes:
|
|
221
|
+
|
|
222
|
+
- HMAC: any length (32+ bytes recommended).
|
|
223
|
+
- AES: exactly 16 or 32 bytes (enforced up front; a wrong length is rejected by
|
|
224
|
+
the factory).
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
// A real app loads a long random secret from config; never hard-code one.
|
|
228
|
+
const key = Uint8Array.wrap(String.UTF8.encode('0123456789abcdef0123456789abcdef'));
|
|
229
|
+
|
|
230
|
+
// Signed (readable, tamper-proof)
|
|
231
|
+
const signer = SecureCookies.signed(key);
|
|
232
|
+
const sealed = signer.sign('session', 'user-42');
|
|
233
|
+
const user = signer.unsign('session', sealed); // "user-42", or null if tampered
|
|
234
|
+
|
|
235
|
+
// Encrypted (confidential + authenticated)
|
|
236
|
+
const box = SecureCookies.encrypted(key);
|
|
237
|
+
resp.setCookie(box.seal(Cookie.create('secret', 'top-secret').httpOnly()));
|
|
238
|
+
const secret = box.open(req.cookies(), 'secret'); // "top-secret", or null
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
| Method | Description |
|
|
242
|
+
| --- | --- |
|
|
243
|
+
| `SecureCookies.signed(key: Uint8Array)` | HMAC-SHA256 signer/verifier. |
|
|
244
|
+
| `SecureCookies.encrypted(key: Uint8Array)` | AES-GCM (16- or 32-byte key). |
|
|
245
|
+
| `addKey(key: Uint8Array): SecureCookies` | Add a fallback key for rotation: seal with the first, open with any. |
|
|
246
|
+
| `sign(name, value): string` | Sealed signed value. |
|
|
247
|
+
| `unsign(name, sealed): string \| null` | Verify and recover, or `null`. |
|
|
248
|
+
| `encrypt(name, value): string` | Sealed encrypted value. |
|
|
249
|
+
| `decrypt(name, sealed): string \| null` | Decrypt, or `null`. |
|
|
250
|
+
| `seal(cookie: Cookie): Cookie` | Seal a cookie's value in place (sign or encrypt per the instance mode) and mark it `Raw`. Returns the same cookie. |
|
|
251
|
+
| `open(jar: CookieMap, name): string \| null` | Read and open cookie `name` from a parsed jar. |
|
|
252
|
+
|
|
253
|
+
**Key rotation:** seal with `keys[0]`; `unsign` / `decrypt` try every key in turn,
|
|
254
|
+
so you can add a new key as the first and keep an old one as a fallback while
|
|
255
|
+
existing cookies age out.
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
const signer = SecureCookies.signed(newKey).addKey(oldKey);
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## `Request` and `Response` integration
|
|
264
|
+
|
|
265
|
+
Because every handler already has a `Request` and returns a `Response`, the most
|
|
266
|
+
common operations live there directly.
|
|
267
|
+
|
|
268
|
+
**Read (`Request`):**
|
|
269
|
+
|
|
270
|
+
| Method | Description |
|
|
271
|
+
| --- | --- |
|
|
272
|
+
| `req.cookies(): CookieMap` | All cookies, parsed from the `Cookie` header (cached for the request). |
|
|
273
|
+
| `req.cookie(name: string): string \| null` | One cookie value. |
|
|
274
|
+
|
|
275
|
+
**Write (`Response`, builder-style):**
|
|
276
|
+
|
|
277
|
+
| Method | Description |
|
|
278
|
+
| --- | --- |
|
|
279
|
+
| `resp.setCookie(cookie: Cookie): Response` | Append a `Set-Cookie`. Each call adds its own header (cookies are never folded). |
|
|
280
|
+
| `resp.setCookieKV(name, value): Response` | Shorthand for `setCookie(new Cookie(name, value))`. |
|
|
281
|
+
| `resp.clearCookie(name, path = '/', domain = ''): Response` | Append a deletion cookie (empty value, `Max-Age=0`, epoch `Expires`). `path` / `domain` must match the original. |
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
## `base64url` helpers
|
|
286
|
+
|
|
287
|
+
Unpadded base64url (RFC 4648 §5), used internally by `SecureCookies` and exported
|
|
288
|
+
for convenience. Its alphabet (`A-Z a-z 0-9 - _`) is within the `cookie-octet`
|
|
289
|
+
grammar and invariant under percent-encoding, so encoded values round-trip
|
|
290
|
+
cleanly through the default cookie codec.
|
|
291
|
+
|
|
292
|
+
| Function | Description |
|
|
293
|
+
| --- | --- |
|
|
294
|
+
| `base64UrlEncode(data: Uint8Array): string` | Encode bytes as unpadded base64url. |
|
|
295
|
+
| `base64UrlDecode(s: string): Uint8Array \| null` | Decode base64url/base64 (padding and whitespace tolerated); `null` on an invalid character or length. |
|
|
296
|
+
|
|
297
|
+
---
|
|
298
|
+
|
|
299
|
+
## Encoding vs encryption
|
|
300
|
+
|
|
301
|
+
Two independent layers, easy to mix up:
|
|
302
|
+
|
|
303
|
+
- **Encoding** (`CookieEncoding`) is transport-only and reversible by anyone. It
|
|
304
|
+
keeps an arbitrary value inside the `cookie-octet` grammar.
|
|
305
|
+
- `Percent` (default): `encodeURIComponent`-style; arbitrary UTF-8 is safe.
|
|
306
|
+
- `Base64Url`: UTF-8 then base64url.
|
|
307
|
+
- `Raw`: no transformation (the value must already be valid `cookie-octet`).
|
|
308
|
+
- **Signing / encryption** (`SecureCookies`) is cryptographic. Signing keeps the
|
|
309
|
+
value readable but tamper-proof; encryption makes it unreadable and
|
|
310
|
+
authenticated. Both require a secret key.
|
|
311
|
+
|
|
312
|
+
`SecureCookies.seal` sets the value to its sealed (base64url) form and marks the
|
|
313
|
+
cookie `Raw`, so it passes through the default parse path untouched.
|
|
314
|
+
|
|
315
|
+
---
|
|
316
|
+
|
|
317
|
+
## Security notes
|
|
318
|
+
|
|
319
|
+
- **Panic-free verification.** `unsign` and `decrypt` return `null` on a tampered,
|
|
320
|
+
truncated, or wrong-key value, never a trap. (`decrypt` reads the host return
|
|
321
|
+
code directly instead of letting the underlying crypto throw, because the
|
|
322
|
+
server runs with exceptions disabled.) This makes them safe to call on
|
|
323
|
+
attacker-controlled input.
|
|
324
|
+
- **Name-binding.** Signing MACs `name + "=" + value`; encryption uses the name as
|
|
325
|
+
AAD. A sealed value made for one cookie name will not verify or decrypt under
|
|
326
|
+
another.
|
|
327
|
+
- **Control characters are stripped** from the name, value, and attribute values
|
|
328
|
+
on serialize, as a defense-in-depth guard against header injection (CR/LF).
|
|
329
|
+
Control characters are invalid in all of these per the grammar, so nothing
|
|
330
|
+
legitimate is lost. The default value encoding already neutralizes CR/LF.
|
|
331
|
+
- **Prefixes.** `asHostPrefixed()` / `asSecurePrefixed()` apply and enforce the
|
|
332
|
+
browser-recognized guarantees; `validate()` reports a name that carries a prefix
|
|
333
|
+
without satisfying its requirements.
|
|
334
|
+
- **`SameSite=None` and `Partitioned` imply `Secure`** and are emitted with it
|
|
335
|
+
automatically.
|
|
336
|
+
- **Lifetime is clamped** to the RFC 400-day cap on serialize; sizes are checked by
|
|
337
|
+
`validate()`.
|
|
338
|
+
- **Local development.** Browsers treat `http://localhost` as a secure context, so
|
|
339
|
+
`Secure` and `__Host-` cookies work under `toiljs dev` over plain HTTP.
|
|
340
|
+
|
|
341
|
+
When putting untrusted input into a cookie **name** or **attribute** (rather than
|
|
342
|
+
the value, which is encoded by default), check `validate()` or use
|
|
343
|
+
`serialize(true)`.
|
|
344
|
+
|
|
345
|
+
---
|
|
346
|
+
|
|
347
|
+
## Spec compliance
|
|
348
|
+
|
|
349
|
+
Implements RFC 6265bis (HTTP State Management) and the `Partitioned` (CHIPS)
|
|
350
|
+
companion: the `cookie-name` token and `cookie-value` `cookie-octet` grammars,
|
|
351
|
+
the `Expires` / `Max-Age` / `Domain` / `Path` / `Secure` / `HttpOnly` /
|
|
352
|
+
`SameSite` / `Partitioned` attributes plus `Priority` and arbitrary extensions,
|
|
353
|
+
the `__Host-` / `__Secure-` prefixes (matched case-insensitively), the 4096-byte
|
|
354
|
+
name+value and 1024-byte attribute limits, the 400-day lifetime cap, the
|
|
355
|
+
`SameSite=None` ⇒ `Secure` rule, and the requirement that each cookie occupy its
|
|
356
|
+
own `Set-Cookie` header (never folded).
|
|
357
|
+
|
|
358
|
+
---
|
|
359
|
+
|
|
360
|
+
## Testing
|
|
361
|
+
|
|
362
|
+
- Pure cookie logic (builder, parser, codec, validation, `Request` / `Response`
|
|
363
|
+
integration) is unit-tested with as-pect in `test/assembly/cookie.spec.ts`
|
|
364
|
+
(`npm run test:server`).
|
|
365
|
+
- `SecureCookies` is exercised end-to-end against the real toilscript-compiled
|
|
366
|
+
wasm with the Node-backed crypto host in `test/devserver.test.ts`
|
|
367
|
+
(`npm test`). It is tested there rather than under as-pect because the as-pect
|
|
368
|
+
compiler does not ship the toilscript crypto standard library.
|
|
369
|
+
|
|
370
|
+
A live demo (every attribute's serialized output, set/inspect/clear, and an
|
|
371
|
+
interactive sign/encrypt) is in the example app: run `toiljs dev` in
|
|
372
|
+
`examples/basic` and open `/cookies`. The backend lives in
|
|
373
|
+
`examples/basic/server/core/AppHandler.ts`.
|
|
374
|
+
|
|
375
|
+
---
|
|
376
|
+
|
|
377
|
+
## API reference
|
|
378
|
+
|
|
379
|
+
```ts
|
|
380
|
+
// Globals (also exported from 'toiljs/server/runtime')
|
|
381
|
+
|
|
382
|
+
enum SameSite { Default, None, Lax, Strict }
|
|
383
|
+
enum CookieEncoding { Percent, Raw, Base64Url }
|
|
384
|
+
enum CookiePrefix { None, Secure, Host }
|
|
385
|
+
|
|
386
|
+
class CookieValidation {
|
|
387
|
+
valid: bool;
|
|
388
|
+
errors: Array<string>;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
class Cookie {
|
|
392
|
+
name: string;
|
|
393
|
+
value: string;
|
|
394
|
+
encoding: CookieEncoding;
|
|
395
|
+
static create(name: string, value: string): Cookie;
|
|
396
|
+
domain(v: string): Cookie;
|
|
397
|
+
path(v: string): Cookie;
|
|
398
|
+
maxAge(seconds: i64): Cookie;
|
|
399
|
+
expires(epochSeconds: i64): Cookie;
|
|
400
|
+
expiresRaw(date: string): Cookie;
|
|
401
|
+
secure(on?: bool): Cookie;
|
|
402
|
+
httpOnly(on?: bool): Cookie;
|
|
403
|
+
sameSite(s: SameSite): Cookie;
|
|
404
|
+
partitioned(on?: bool): Cookie;
|
|
405
|
+
priority(p: string): Cookie;
|
|
406
|
+
extension(av: string): Cookie;
|
|
407
|
+
withEncoding(e: CookieEncoding): Cookie;
|
|
408
|
+
asSecurePrefixed(): Cookie;
|
|
409
|
+
asHostPrefixed(): Cookie;
|
|
410
|
+
detectedPrefix(): CookiePrefix;
|
|
411
|
+
encodedValue(): string;
|
|
412
|
+
validate(): CookieValidation;
|
|
413
|
+
serialize(strict?: bool): string;
|
|
414
|
+
toString(): string;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
class CookieMap {
|
|
418
|
+
get(name: string): string | null;
|
|
419
|
+
has(name: string): bool;
|
|
420
|
+
names(): Array<string>;
|
|
421
|
+
size: i32;
|
|
422
|
+
set(name: string, value: string): void;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
class Cookies {
|
|
426
|
+
static parse(cookieHeader: string): CookieMap;
|
|
427
|
+
static get(cookieHeader: string, name: string): string | null;
|
|
428
|
+
static serialize(name: string, value: string): string;
|
|
429
|
+
static parseSetCookie(setCookie: string): Cookie;
|
|
430
|
+
static encodeValue(raw: string): string;
|
|
431
|
+
static decodeValue(enc: string): string;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
class SecureCookies {
|
|
435
|
+
static signed(key: Uint8Array): SecureCookies;
|
|
436
|
+
static encrypted(key: Uint8Array): SecureCookies;
|
|
437
|
+
addKey(key: Uint8Array): SecureCookies;
|
|
438
|
+
sign(name: string, value: string): string;
|
|
439
|
+
unsign(name: string, sealed: string): string | null;
|
|
440
|
+
encrypt(name: string, value: string): string;
|
|
441
|
+
decrypt(name: string, sealed: string): string | null;
|
|
442
|
+
seal(cookie: Cookie): Cookie;
|
|
443
|
+
open(jar: CookieMap, name: string): string | null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function base64UrlEncode(data: Uint8Array): string;
|
|
447
|
+
function base64UrlDecode(s: string): Uint8Array | null;
|
|
448
|
+
|
|
449
|
+
// On Request
|
|
450
|
+
req.cookies(): CookieMap;
|
|
451
|
+
req.cookie(name: string): string | null;
|
|
452
|
+
|
|
453
|
+
// On Response (builder-style)
|
|
454
|
+
resp.setCookie(cookie: Cookie): Response;
|
|
455
|
+
resp.setCookieKV(name: string, value: string): Response;
|
|
456
|
+
resp.clearCookie(name: string, path?: string, domain?: string): Response;
|
|
457
|
+
```
|
package/docs/crypto.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Web Crypto
|
|
2
|
+
|
|
3
|
+
The guest gets a synchronous Web Crypto surface through the ambient `crypto`
|
|
4
|
+
global, backed by host functions. It mirrors the browser `crypto` /
|
|
5
|
+
`crypto.subtle` API but **without Promises** — ToilScript has no `async`, so
|
|
6
|
+
every call returns its result directly. Keys are opaque per-request handles in a
|
|
7
|
+
host keystore; a `CryptoKey` is valid only for the request that created it.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
const mac = crypto.hmacSha256(key, message); // Uint8Array
|
|
11
|
+
const id = crypto.randomUUID(); // string
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
This is also what [`SecureCookies`](./cookies.md) and
|
|
15
|
+
[`AuthService`](./auth.md) are built on, so most apps use crypto indirectly.
|
|
16
|
+
|
|
17
|
+
## `crypto` namespace
|
|
18
|
+
|
|
19
|
+
Convenience helpers (all synchronous):
|
|
20
|
+
|
|
21
|
+
| Function | Signature | Notes |
|
|
22
|
+
| --- | --- | --- |
|
|
23
|
+
| `getRandomValues` | `(array: Uint8Array): void` | Fill with CSPRNG bytes. |
|
|
24
|
+
| `randomUUID` | `(): string` | RFC 4122 v4 UUID. |
|
|
25
|
+
| `sha1` / `sha256` / `sha384` / `sha512` | `(data: Uint8Array): Uint8Array` | One-shot digests. |
|
|
26
|
+
| `sha1Text` … `sha512Text` | `(s: string): Uint8Array` | UTF-8 encode then digest. |
|
|
27
|
+
| `hmacSha256` | `(key: Uint8Array, msg: Uint8Array): Uint8Array` | One-shot HMAC-SHA256. |
|
|
28
|
+
| `hmacSha256Text` | `(key: Uint8Array, msg: string): Uint8Array` | HMAC-SHA256 over a UTF-8 string. |
|
|
29
|
+
| `toHex` | `(bytes: Uint8Array): string` | Lowercase hex. |
|
|
30
|
+
| `subtle` | `SubtleCrypto` | The full primitive surface (below). |
|
|
31
|
+
|
|
32
|
+
## `crypto.subtle`
|
|
33
|
+
|
|
34
|
+
| Method | Signature |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| `digest` | `digest(algorithm: string, data: Uint8Array): Uint8Array` |
|
|
37
|
+
| `importKey` | `importKey(format: string, keyData: Uint8Array, algorithm: AlgorithmParams, extractable: bool, usages: i32): CryptoKey` |
|
|
38
|
+
| `exportKey` | `exportKey(format: string, key: CryptoKey): Uint8Array` |
|
|
39
|
+
| `encrypt` | `encrypt(algorithm: AlgorithmParams, key: CryptoKey, data: Uint8Array): Uint8Array` |
|
|
40
|
+
| `decrypt` | `decrypt(algorithm: AlgorithmParams, key: CryptoKey, data: Uint8Array): Uint8Array` |
|
|
41
|
+
| `sign` | `sign(algorithm: AlgorithmParams, key: CryptoKey, data: Uint8Array): Uint8Array` |
|
|
42
|
+
| `verify` | `verify(algorithm: AlgorithmParams, key: CryptoKey, signature: Uint8Array, data: Uint8Array): bool` |
|
|
43
|
+
| `deriveBits` | `deriveBits(algorithm: AlgorithmParams, baseKey: CryptoKey, length: i32): Uint8Array` |
|
|
44
|
+
| `deriveKey` | `deriveKey(algorithm, baseKey, lengthBits, derivedKeyAlgorithm, extractable, usages): CryptoKey` |
|
|
45
|
+
|
|
46
|
+
`digest` takes a named algorithm string (`"SHA-1"`, `"SHA-256"`, `"SHA-384"`,
|
|
47
|
+
`"SHA-512"`, `"SHA3-256"`, `"SHA3-384"`, `"SHA3-512"`). `verify` returns a bool
|
|
48
|
+
(it does not throw on a mismatch). Formats are `raw`, `pkcs8`, `spki`; **`jwk`
|
|
49
|
+
is not supported**.
|
|
50
|
+
|
|
51
|
+
### Algorithm parameter classes
|
|
52
|
+
|
|
53
|
+
`crypto` and `crypto.subtle` are ambient globals (no import). The params classes
|
|
54
|
+
and the `ALG_*` / `USAGE_*` / `FMT_*` / `CURVE_*` constants and the `CryptoKey`
|
|
55
|
+
type are imported from the `'crypto'` module:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { AesGcmParams, HmacImportParams, ALG_SHA_256, USAGE_SIGN } from 'crypto';
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Each algorithm has a small params class you pass to `importKey`/`sign`/etc.:
|
|
62
|
+
|
|
63
|
+
| Class | Constructor |
|
|
64
|
+
| --- | --- |
|
|
65
|
+
| `AesGcmParams` | `(iv, additionalData?, tagLength = 128)` |
|
|
66
|
+
| `AesCbcParams` | `(iv)` |
|
|
67
|
+
| `AesCtrParams` | `(counter, length = 128)` |
|
|
68
|
+
| `HmacImportParams` | `(hash)` |
|
|
69
|
+
| `HmacParams` | `()` |
|
|
70
|
+
| `Pbkdf2Params` | `(hash, salt, iterations)` |
|
|
71
|
+
| `HkdfParams` | `(hash, salt, info?)` |
|
|
72
|
+
| `EcdsaParams` | `(hash)` |
|
|
73
|
+
| `EcKeyImportParams` | `(alg, namedCurve)` |
|
|
74
|
+
| `Ed25519Params` | `()` |
|
|
75
|
+
| `X25519ImportParams` | `()` |
|
|
76
|
+
| `EcdhParams` | `(alg, publicKeyHandle)` |
|
|
77
|
+
|
|
78
|
+
### Constants
|
|
79
|
+
|
|
80
|
+
- **Hashes / algorithms:** `ALG_SHA_1`, `ALG_SHA_256`, `ALG_SHA_384`,
|
|
81
|
+
`ALG_SHA_512`, `ALG_SHA3_256/384/512`, `ALG_AES_GCM`, `ALG_AES_CBC`,
|
|
82
|
+
`ALG_AES_CTR`, `ALG_HMAC`, `ALG_ECDSA`, `ALG_ED25519`, `ALG_ECDH`, `ALG_HKDF`,
|
|
83
|
+
`ALG_PBKDF2`.
|
|
84
|
+
- **Key formats:** `FMT_RAW`, `FMT_PKCS8`, `FMT_SPKI` (`FMT_JWK` is rejected).
|
|
85
|
+
- **Usages (bitmask):** `USAGE_ENCRYPT`, `USAGE_DECRYPT`, `USAGE_SIGN`,
|
|
86
|
+
`USAGE_VERIFY`, `USAGE_DERIVE_KEY`, `USAGE_DERIVE_BITS`, `USAGE_WRAP_KEY`,
|
|
87
|
+
`USAGE_UNWRAP_KEY` — OR them together.
|
|
88
|
+
- **Named curves:** `CURVE_P256`, `CURVE_P384` (`CURVE_P521` is not supported).
|
|
89
|
+
|
|
90
|
+
### `CryptoKey`
|
|
91
|
+
|
|
92
|
+
An opaque handle plus metadata: `handle: i32`, `type: string`
|
|
93
|
+
(`secret`/`public`/`private`), `extractable: bool`, `algorithm: i32`,
|
|
94
|
+
`usages: i32`, with `algorithmName()` and `hasUsage(u)`. A key is valid only for
|
|
95
|
+
the request that imported it.
|
|
96
|
+
|
|
97
|
+
## Examples
|
|
98
|
+
|
|
99
|
+
HMAC-SHA256 (one-shot):
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
const mac = crypto.hmacSha256(key, message);
|
|
103
|
+
const hex = crypto.toHex(mac);
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
AES-256-GCM via `subtle`:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
const key = new Uint8Array(32); crypto.getRandomValues(key);
|
|
110
|
+
const iv = new Uint8Array(12); crypto.getRandomValues(iv);
|
|
111
|
+
|
|
112
|
+
const k = crypto.subtle.importKey('raw', key, new AesGcmParams(iv, aad, 128), false, USAGE_ENCRYPT);
|
|
113
|
+
const ct = crypto.subtle.encrypt(new AesGcmParams(iv, aad, 128), k, plaintext);
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Post-quantum verify
|
|
117
|
+
|
|
118
|
+
The host also exposes ML-DSA-44 (FIPS 204) signature verification as
|
|
119
|
+
`crypto.mldsa_verify`. It is verify-only — the host never holds a secret key — and
|
|
120
|
+
underpins the [auth primitive](./auth.md). Most code reaches it through
|
|
121
|
+
`AuthService.verifyLogin(publicKey, message, signature)` rather than calling the
|
|
122
|
+
import directly. Public key is 1312 bytes, signature 2420 bytes, with a FIPS 204
|
|
123
|
+
domain-separation context.
|
|
124
|
+
|
|
125
|
+
## Limitations
|
|
126
|
+
|
|
127
|
+
- **No Promises** — every call is synchronous.
|
|
128
|
+
- **No RSA** and **no JWK** key format.
|
|
129
|
+
- **P-521** is not supported (P-256 and P-384 are).
|
|
130
|
+
- Signature *generation* for ML-DSA is client-side only; the server verifies.
|