docs(porch-cookies): brainstorm story 2 (randomness+cookies) to ready

- five forks locked: cookies: multi SetCookie beside unchanged headers
  map; bare-name random_bytes(n)->Bytes; structural-400 in parse_request
  + on-demand cookie() helper; base64(value).base64(mac) signing;
  app-supplied key, no middleware (that is iteration 3)
- validated against .dev/reference/fiber (v3, 3ca9a9d): exactly ONE
  language enhancement needed (the CSPRNG); repeated Set-Cookie, cookie
  attributes, parsing and signing all map to existing primitives
- corrects phase A registry: random_bytes joins the crypto-family
  bare-name table (emit.ml b_* + types.ml), NOT wob.h's module enum;
  next free id 84/90, not 110
- board: story 2 marked ready, porch-2 row rewritten off the stale
  wob.h/110 claim

(cherry picked from commit 4d31d5359436496aed40cb25611abc7ccd4d7875)
This commit is contained in:
shoney.arickathil 2026-09-06 16:43:38 +02:00
parent 1d78e0fa70
commit 6a67db252b
2 changed files with 148 additions and 70 deletions

View file

@ -1195,15 +1195,16 @@ the language arc as v1 history.
### ▸ porch — the web framework track ### ▸ porch — the web framework track
New 2026-08-26, from [the Fiber v3.5.0 parity study](../plan/exploration/fiber/00-fiber-parity.md). New 2026-08-26, from [the Fiber v3.5.0 parity study](../plan/exploration/fiber/00-fiber-parity.md).
Supersedes language iteration 39, now a pointer. All eight are ⬜ `refine` — Supersedes language iteration 39, now a pointer. **Story 2 is `ready`
none has an approved spec yet. Ordered by dependency; the first slice is (brainstormed 2026-09-06, five forks locked, validated against
deliberately the cheapest so the store pattern and gate shape are proven before `.dev/reference/fiber`); 3–8 remain `refine`.** Ordered by dependency; the
the runtime and `Resp` are touched. first slice is deliberately the cheapest so the store pattern and gate shape are
proven before the runtime and `Resp` are touched.
| # | Iteration | State | | # | Iteration | State |
| --- | --- | --- | | --- | --- | --- |
| 1 | [Store-backed middleware](porch/01-store-backed-middleware.md) | ✅ **DONE 2026-08-30** — rate limiter + idempotency serialized through a per-key actor pool, both durable in a `@table`. Gate-proven end to end: threshold + restart + exact concurrent counts (limiter), byte-identical replay + digest refusal + concurrent duplicates + no-5xx-replay (idempotency), and pool saturation failing closed (503, never a bypass) | | 1 | [Store-backed middleware](porch/01-store-backed-middleware.md) | ✅ **DONE 2026-08-30** — rate limiter + idempotency serialized through a per-key actor pool, both durable in a `@table`. Gate-proven end to end: threshold + restart + exact concurrent counts (limiter), byte-identical replay + digest refusal + concurrent duplicates + no-5xx-replay (idempotency), and pool saturation failing closed (503, never a bypass) |
| 2 | [Randomness and cookies](porch/02-randomness-and-cookies.md) | ⬜ the foundation. Phase A is **language-track work**: a CSPRNG builtin (id 96+; 89/90 are iteration 31's reserved holes). Then repeated response headers — `Resp.headers` is a `map<Text,Text>` and structurally cannot emit two `Set-Cookie` lines — then `Cookie:` parsing and signed cookies | | 2 | [Randomness and cookies](porch/02-randomness-and-cookies.md) | ✅ **`ready` 2026-09-06** — the foundation; the reference read settled that **exactly one language enhancement is needed**. Phase A is that language work: a bare-name `random_bytes(n) -> Bytes` builtin in the compiler's crypto-family table (`emit.ml` `b_*` + `types.ml` function list — **not** `wob.h`'s module enum; next free id `84`/`90`, confirm before use), `getrandom(2)`-sourced, refuses loudly. Then `Resp` gains `cookies: multi SetCookie` beside the unchanged `headers` map (the map can't emit two `Set-Cookie` lines; `multi` already exists), `Cookie:` parsing (structural 400 in `parse_request`, on-demand `cookie()` helper), and signed cookies (`base64(value).base64(mac)`, app-supplied key) |
| 3 | [Sessions](porch/03-sessions.md) | ⬜ after 2. Server-side rows keyed by a random id, idle **and** absolute timeout, id rotation on login, revoke-all-for-principal, durable across restart | | 3 | [Sessions](porch/03-sessions.md) | ⬜ after 2. Server-side rows keyed by a random id, idle **and** absolute timeout, id rotation on login, revoke-all-for-principal, durable across restart |
| 4 | [CSRF](porch/04-csrf.md) | ⬜ after 2 + 3. Session-bound tokens, trusted origins as the second layer, opt-in single use, and refusal classes that are distinguishable in logs | | 4 | [CSRF](porch/04-csrf.md) | ⬜ after 2 + 3. Session-bound tokens, trusted origins as the second layer, opt-in single use, and refusal classes that are distinguishable in logs |
| 5 | [Routing + response ergonomics](porch/05-routing-response-ergonomics.md) | ⬜ **independent, any time** — `patch`/`options`/`head`/`all`, named routes + URL building, per-route body limit (today `BODY_MAX` is one compile-time number), request ids, `Location`/`Vary`/`Attachment`, and q-value ranking (retires a standing 🔶) | | 5 | [Routing + response ergonomics](porch/05-routing-response-ergonomics.md) | ⬜ **independent, any time** — `patch`/`options`/`head`/`all`, named routes + URL building, per-route body limit (today `BODY_MAX` is one compile-time number), request ids, `Location`/`Vary`/`Attachment`, and q-value ranking (retires a standing 🔶) |

View file

@ -2,13 +2,17 @@
track: porch track: porch
iteration: "2" iteration: "2"
status: pending status: pending
readiness: refine readiness: ready
--- ---
# porch 2 — randomness and cookies: the foundation three iterations stand on # porch 2 — randomness and cookies: the foundation three iterations stand on
> Part of [Story — `porch`, the writeonce web framework](00-story.md). > Part of [Story — `porch`, the writeonce web framework](00-story.md).
> Source: [the Fiber parity study](../../plan/exploration/fiber/00-fiber-parity.md) §0–§1. > Source: [the Fiber parity study](../../plan/exploration/fiber/00-fiber-parity.md) §0–§1,
> re-checked 2026-09-06 against `.dev/reference/fiber` (v3, commit `3ca9a9d`):
> its `crypto/rand` consumers (`utils.SecureToken`, `utils.UUIDv4`,
> `encryptcookie.GenerateKey`), the `fiber.Cookie` struct in `res.go`, and the
> `Cookie`/`Cookies`/`ClearCookie` methods.
> >
> **The study's sharpest finding lives here.** porch's own README claimed signed > **The study's sharpest finding lives here.** porch's own README claimed signed
> cookies, CSRF and session integrity were "UNBLOCKED — the primitives exist > cookies, CSRF and session integrity were "UNBLOCKED — the primitives exist
@ -17,18 +21,45 @@ readiness: refine
> no source of randomness anywhere. An HMAC over a guessable session id is a > no source of randomness anywhere. An HMAC over a guessable session id is a
> signed guess. Phase A closes that before anything depends on it. > signed guess. Phase A closes that before anything depends on it.
## What the reference confirmed
The fiber read settled the scope precisely: **exactly one language enhancement
is needed — the CSPRNG — and nothing else.** Every other thing fiber does with
randomness and cookies maps onto primitives writeonce already has.
- **Randomness is the one gap.** fiber's `SecureToken` is
`base64.RawURLEncoding` over 32 `crypto/rand` bytes; `UUIDv4` is the same
entropy behind a format; `encryptcookie.GenerateKey` is a raw `rand.Read`.
All three **panic** if the source fails. writeonce has no RNG at all. This is
phase A, and it is the whole of the language work.
- **Repeated `Set-Cookie` is not language work.** fiber gets multiple lines from
fasthttp appending them; porch expresses the same with a `multi SetCookie`
field, and `multi <Class>` is an existing language feature.
- **Cookie attributes are not language work.** fiber's `Cookie` struct is plain
scalars (name/value/path/domain/same-site as text, max-age as int,
secure/http-only/partitioned as bool, plus an `Expires` date). A `SetCookie`
record covers all of it, and choosing `Max-Age` over `Expires` avoids the only
attribute that would need a date formatter.
- **Parsing and signing are not language work.** Reading the `Cookie:` header
uses the existing `split`/`trim`/`substr`; signing uses the existing
`hmac_sha256`/`sha256`/`base64_encode`/`base64_decode`/`ct_eq`/`bytes_of_text`.
- **One deliberate divergence.** fiber has no HMAC-signed-readable cookie — it
ships plaintext, or AES-GCM via `encryptcookie`, or server-side storage. porch
chooses signed-readable because writeonce has digests but no cipher. That is
the honest choice for this runtime, not a gap; encryption stays out of scope.
## Goals ## Goals
- **A CSPRNG builtin in the runtime.** No `getrandom`, no `/dev/urandom` read, - **A CSPRNG builtin in the runtime.** No `getrandom`, no `/dev/urandom` read,
no CSPRNG builtin exists today — grep the runtime and nothing comes back. This no CSPRNG builtin exists today. This is the one phase of this track that is
is the one phase of this track that is **language-track work** (C, a new **language-track work** (C, a new builtin id, a corpus fixture); it is here
builtin id, a corpus fixture); it is here because porch is what needs it and because porch is what needs it and splitting it across two tracks would hide
splitting it across two tracks would hide the dependency. the dependency.
- **Repeated response headers, which `Resp` structurally cannot express.** - **Repeated response headers, which `Resp` structurally cannot express.**
`Resp.headers` is a `map<Text, Text>`. A login response setting a session `Resp.headers` is a `map<Text, Text>`. A login response setting a session
cookie *and* a flash cookie needs two `Set-Cookie` lines and the map can hold cookie *and* a flash cookie needs two `Set-Cookie` lines and the map can hold
one. This is a change to porch's most public type, and deciding its shape is one. The settled answer adds a typed `cookies` field beside the map rather
the real work of this iteration — the cookie formatting is the easy half. than disturbing it — the cookie formatting is the easy half.
- **Cookies in both directions.** Parse a `Cookie:` request header into - **Cookies in both directions.** Parse a `Cookie:` request header into
something typed; build a `Set-Cookie` with the attributes that matter for something typed; build a `Set-Cookie` with the attributes that matter for
security — `HttpOnly`, `Secure`, `SameSite`, `Max-Age`, `Path`, `Domain`. security — `HttpOnly`, `Secure`, `SameSite`, `Max-Age`, `Path`, `Domain`.
@ -39,16 +70,64 @@ readiness: refine
- **Correct the ledger row that started this.** The README's crypto line, and - **Correct the ledger row that started this.** The README's crypto line, and
anything else claiming the sessions/CSRF path was already open. anything else claiming the sessions/CSRF path was already open.
## Decisions locked (brainstorm 2026-09-06)
1. **`Resp` gains `cookies: multi SetCookie`; the `headers` map is untouched.**
Of the three candidates — a raw `multi Text` of extra lines, a typed cookie
field, or replacing the map with a repeated-header list — the typed field is
the smallest diff and the most typed at once. `serialize()`'s existing
`for k, v in resp.headers` loop does not move, so the byte-identical gate is
trivial; a second loop renders the cookies. `SecurityHeaders` and `Cors`
write into the map exactly as before. The one thing this shape cannot express
— a repeated *non-cookie* header — has no consumer, so it is YAGNI.
2. **The RNG is a bare-name `random_bytes(n) -> Bytes`, one primitive, no
wrappers.** It joins the crypto family it composes with — `sha256`,
`hmac_sha256`, `base64_encode` — which live in the compiler's bare-name
builtin-function table (`compiler/src/emit.ml`'s `b_*` ids plus the
`types.ml` function list), **not** in `wob.h`'s module-member enum. This
corrects the earlier note that pointed phase A at `wob.h` id 110: that is the
wrong registry. A session id is then `base64_encode(random_bytes(32))`,
mirroring fiber's `SecureToken`; no hex or UUID helper rides along because
base64 already turns bytes into a text id.
3. **A malformed `Cookie:` header is a 400, checked structurally in
`parse_request`; values are extracted on demand.** The developer chose to
keep the 400 policy after it was questioned (RFC 6265 would skip bad pairs;
the framing-critical analogy to duplicate `Content-Length` is weaker for a
non-framing header). The resolution is a hybrid: `parse_request` does a
cheap structural syntax check — nil-check first, so an absent `Cookie` header
costs nothing — and refuses before routing; a `cookie(req, name)` helper
parses and extracts the requested value on demand, so requests that read no
cookie pay no parse into a map.
4. **Signed-cookie framing is `base64(value)` + `.` + `base64(mac)`.** Encoding
the value first means the delimiter cannot appear inside either half — the
base64 alphabet has no `.` — so a value containing the delimiter cannot shift
the boundary. Verify splits on `.` into exactly two parts and compares the
two base64 mac strings with `ct_eq`; they are always the same length (a
32-byte HMAC is always 44 base64 chars), so only the value is decoded. Every
character used — base64's `+ / =`, and `.` — is RFC 6265 cookie-safe, so the
signed string needs no further escaping.
5. **The signing key is app-supplied policy, passed to free functions.** No
`SignedCookies` middleware in this iteration — that is the shape iteration 3
introduces for sessions. `sign` and `verify` take the key as an argument, the
way `ct_eq` and `bearer_token` are helpers the app calls with its own secret.
The framework neither invents nor persists the key.
## Phases ## Phases
### Phase A — the runtime primitive (language-track work) ### Phase A — the runtime primitive (language-track work)
- Add a random-bytes builtin at the next free id (89 and 90 are reserved holes - Add `random_bytes` to the compiler's bare-name builtin table
for language iteration 31's `monitor` and `time.after`, so this starts at 96). (`compiler/src/emit.ml` and the `types.ml` function list), returning `Bytes`
- Source it from the kernel. Decide the failure mode explicitly: if the source and taking a byte count. Take the next free id there — `84` is a hole between
is unavailable the builtin **refuses**, loudly. A CSPRNG that quietly degrades `b_text_of_bytes` and `b_sha1`, with `90` and up also free; **confirm the
to something weaker is worse than none, because every layer above it will chosen id is not a deliberate reservation before writing it**, since the id
assume it worked. space is shared and drifts.
- Source it from the kernel (`getrandom(2)`, falling back to a `/dev/urandom`
read only as the same kernel source). Decide the failure mode explicitly: if
the source is unavailable the builtin **refuses**, loudly — it traps, it does
not return short or weak bytes. A CSPRNG that quietly degrades is worse than
none, because every layer above it assumes it worked. This matches fiber,
which panics rather than continue.
- Corpus fixtures: the builtin's arity and type contract, and the refusal path. - Corpus fixtures: the builtin's arity and type contract, and the refusal path.
Statistical quality is not a corpus concern — the kernel's guarantee is the Statistical quality is not a corpus concern — the kernel's guarantee is the
guarantee. guarantee.
@ -60,39 +139,46 @@ readiness: refine
### Phase B — repeated response headers ### Phase B — repeated response headers
- Settle fork 1 and change `Resp`. Every response builder - Add `cookies: multi SetCookie` to `Resp` and define the `SetCookie` record:
(`ok_text`/`ok_json`/`ok_html`/`created_json`/`not_found`/…) and `name` and `value`, plus `HttpOnly`, `Secure`, `SameSite`, `Max-Age`, `Path`,
`serialize()` in `internal/serve.wo` move with it. `Domain`. Its defaults are the safe ones — `HttpOnly` on, `SameSite` not
- Keep the single-value path ergonomic: the overwhelmingly common case is one `None` — because a cookie API whose defaults are insecure is a footgun that
value per header, and it must not get worse to write. ships.
- Extend `serialize()` in `internal/serve.wo` with a second loop that renders
each `SetCookie` to a `Set-Cookie:` line after the existing header loop, which
does not change. Every response builder
(`ok_text`/`ok_json`/`ok_html`/`created_json`/`not_found`/…) gains the new
field as an empty container and is otherwise untouched.
- Verify: `serialize()` emits two distinct `Set-Cookie` lines for one response; - Verify: `serialize()` emits two distinct `Set-Cookie` lines for one response;
every existing header behaviour is byte-identical, proven by both serving every existing header behaviour is byte-identical, proven by both serving
gates passing unchanged. gates passing unchanged.
### Phase C — reading request cookies ### Phase C — reading request cookies
- Parse the `Cookie:` header: multiple pairs, quoted values, stray whitespace, - In `parse_request`, add a structural check of the `Cookie:` header — present
and duplicate names. and syntactically well-formed, or a 400 before any route runs. Absent is not
- Decide where parsed cookies live — a lazily-parsed field on `Req`, or a malformed: the nil case is the common one and must cost nothing.
helper the handler calls. `Req` already carries a `ctx` bag and a `params` - Add a `cookie(req, name)` helper that parses the header on demand and returns
map, so the precedent exists either way. the requested value, handling multiple pairs, quoted values, stray
- A malformed header is a 400, not a silent partial parse. The parser's whitespace, and duplicate names. No new `Req` field: extraction is on demand,
existing discipline around duplicate `Content-Length` is the model. matching how `http/auth.wo` already pulls `bearer_token` and
- Verify: values recovered exactly across the awkward cases above; malformed `basic_credentials`.
input refused. - Verify: values recovered exactly across the awkward cases above; a malformed
header is refused with 400, never a silent partial parse.
### Phase D — writing and signing cookies ### Phase D — writing and signing cookies
- A `Set-Cookie` builder covering the attribute set, with `HttpOnly` and - A `Set-Cookie` builder covering the attribute set with the safe defaults from
`SameSite` defaulted to the safe choice rather than the permissive one — a phase B, and a clear-cookie builder — its own case, because the attributes
cookie API whose defaults are insecure is a footgun that ships. (`Path`, `Domain`) must match the original or the browser keeps the old
- Sign with `hmac_sha256`, verify with `ct_eq`. Decide the encoding (`base64` is cookie; expiry is expressed as `Max-Age` zero.
already available) and the payload framing so a value containing the delimiter - `sign(key, value)` and `verify(key, signed)` free functions using the locked
cannot forge a signature. framing: base64 the value, HMAC it, base64 the mac, join with `.`; verify
- Clear-cookie support, which is its own case: the attributes must match or the splits, recomputes, and compares the base64 macs with `ct_eq`. The key is an
browser keeps the old one. argument, app-supplied.
- Verify: a one-bit change to value or signature is rejected; a valid cookie - Verify: a one-bit change to value or signature is rejected in constant time; a
round-trips; a cleared cookie is actually gone. valid cookie round-trips; a value that itself contains the delimiter cannot be
re-framed to forge a signature; a cleared cookie is actually gone.
### Phase E — prove it and correct the record ### Phase E — prove it and correct the record
@ -117,7 +203,8 @@ readiness: refine
additive or it is wrong. additive or it is wrong.
- **Given** a `Cookie:` header with several pairs, quoted values and stray - **Given** a `Cookie:` header with several pairs, quoted values and stray
whitespace, **when** it is parsed, **then** each value is recovered exactly; whitespace, **when** it is parsed, **then** each value is recovered exactly;
**and** a malformed header yields 400, never a partial parse. **and** a malformed header yields 400, never a partial parse; **and** an
absent header costs no parse.
- **Given** a signed cookie altered by one bit in either the value or the - **Given** a signed cookie altered by one bit in either the value or the
signature, **when** it is verified, **then** it is rejected in constant time. signature, **when** it is verified, **then** it is rejected in constant time.
- **Given** a signed value that itself contains the payload delimiter, **when** - **Given** a signed value that itself contains the payload delimiter, **when**
@ -127,10 +214,16 @@ readiness: refine
## Out Of Scope ## Out Of Scope
- **Encrypted cookies.** Fiber's `encryptcookie` needs a symmetric cipher, and - **Encrypted cookies.** Fiber's `encryptcookie` needs a symmetric cipher
the runtime has digests only. Signed-and-readable is honest and sufficient for (AES-GCM), and the runtime has digests only. Signed-and-readable is honest and
a session id; encrypting a payload is a separate ask with a separate primitive sufficient for a session id; encrypting a payload is a separate ask with a
behind it. separate primitive behind it.
- **UUID-formatted ids.** fiber's `UUIDv4` is a format over the same entropy
`random_bytes` provides; a base64'd 32-byte token is stronger and needs no new
builtin. A UUID *format* helper, if ever wanted, is pure `.wo`.
- **Cookie `Expires` as an absolute date.** `Max-Age` in seconds covers expiry
and clear-cookie without a date formatter; an `Expires` attribute would build
on `time.local` in pure `.wo` and is not needed here.
- **Server-side session state** — iteration [3](03-sessions.md). This iteration - **Server-side session state** — iteration [3](03-sessions.md). This iteration
stops at a signed cookie carrying a value the app chose. stops at a signed cookie carrying a value the app chose.
- **CSRF** — iteration [4](04-csrf.md), which needs both this and 3. - **CSRF** — iteration [4](04-csrf.md), which needs both this and 3.
@ -143,29 +236,13 @@ readiness: refine
## Info ## Info
Forks the spec must settle, in order of how much they move:
1. **What replaces `Resp.headers: map<Text, Text>`?** Three candidate shapes: a
`multi Text` of raw extra header lines beside the existing map; a dedicated
typed `cookies` field on `Resp` that `serialize()` renders; or a general
repeated-header list replacing the map. The first is the smallest change, the
second the most typed, the third the most honest about HTTP — and the third
touches every builder and both consumers. This fork decides the size of the
whole iteration, so settle it first.
2. **What shape is the builtin?** A bytes-returning primitive composes with
everything iterations 19 and 34 added (`Bytes`, `base64_encode`,
`hmac_sha256`), which argues for exactly one function and no convenience
wrappers. Decide whether a hex/id helper rides along or whether
`base64_encode` is enough.
3. **Where do parsed cookies live?** A field on `Req` parsed eagerly costs every
request that has no cookies; a helper parsed on demand costs nothing but is
easy to call twice. `Req` is a `typedef` record, so adding a field is cheap —
the cost is the eager parse, not the shape.
4. **Signed-cookie framing.** Value-then-signature with a delimiter is the
obvious encoding and the obvious place to get it wrong. Decide the framing so
that a value containing the delimiter cannot shift the boundary.
Phase A is the only part of this track that is language-track work. It is Phase A is the only part of this track that is language-track work. It is
sequenced here rather than filed as a language iteration because nothing else sequenced here rather than filed as a language iteration because nothing else
wants it yet, and a primitive with no consumer is how the `Component` interface wants it yet, and a primitive with no consumer is how the `Component` interface
became decoration. became decoration.
The `Resp` change is deliberately additive: because `cookies` is a new field
beside the `headers` map rather than a replacement for it, the whole of the
existing response machinery — every builder, `serialize()`'s header loop,
`set_header`, and the `SecurityHeaders`/`Cors` middleware — is unchanged, which
is what makes the byte-identical gate cheap to meet.