From 6a67db252bcc7d694f7c400b211488e01e782be3 Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Sun, 6 Sep 2026 16:43:38 +0200 Subject: [PATCH] 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) --- docs/stories/00-status.md | 11 +- .../porch/02-randomness-and-cookies.md | 207 ++++++++++++------ 2 files changed, 148 insertions(+), 70 deletions(-) diff --git a/docs/stories/00-status.md b/docs/stories/00-status.md index 0c6d43c..7d7407a 100644 --- a/docs/stories/00-status.md +++ b/docs/stories/00-status.md @@ -1195,15 +1195,16 @@ the language arc as v1 history. ### ▸ porch — the web framework track 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` — -none has an approved spec yet. Ordered by dependency; the first slice is -deliberately the cheapest so the store pattern and gate shape are proven before -the runtime and `Resp` are touched. +Supersedes language iteration 39, now a pointer. **Story 2 is `ready` +(brainstormed 2026-09-06, five forks locked, validated against +`.dev/reference/fiber`); 3–8 remain `refine`.** Ordered by dependency; the +first slice is deliberately the cheapest so the store pattern and gate shape are +proven before the runtime and `Resp` are touched. | # | 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) | -| 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` 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 | | 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 🔶) | diff --git a/docs/stories/porch/02-randomness-and-cookies.md b/docs/stories/porch/02-randomness-and-cookies.md index 1ce4711..45ac560 100644 --- a/docs/stories/porch/02-randomness-and-cookies.md +++ b/docs/stories/porch/02-randomness-and-cookies.md @@ -2,13 +2,17 @@ track: porch iteration: "2" status: pending -readiness: refine +readiness: ready --- # porch 2 — randomness and cookies: the foundation three iterations stand on > 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 > 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 > 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 ` 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 - **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 - is the one phase of this track that is **language-track work** (C, a new - builtin id, a corpus fixture); it is here because porch is what needs it and - splitting it across two tracks would hide the dependency. + no CSPRNG builtin exists today. This is the one phase of this track that is + **language-track work** (C, a new builtin id, a corpus fixture); it is here + because porch is what needs it and splitting it across two tracks would hide + the dependency. - **Repeated response headers, which `Resp` structurally cannot express.** `Resp.headers` is a `map`. A login response setting a session 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 - the real work of this iteration — the cookie formatting is the easy half. + one. The settled answer adds a typed `cookies` field beside the map rather + than disturbing it — the cookie formatting is the easy half. - **Cookies in both directions.** Parse a `Cookie:` request header into something typed; build a `Set-Cookie` with the attributes that matter for 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 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 ### Phase A — the runtime primitive (language-track work) -- Add a random-bytes builtin at the next free id (89 and 90 are reserved holes - for language iteration 31's `monitor` and `time.after`, so this starts at 96). -- Source it from the kernel. Decide the failure mode explicitly: if the source - is unavailable the builtin **refuses**, loudly. A CSPRNG that quietly degrades - to something weaker is worse than none, because every layer above it will - assume it worked. +- Add `random_bytes` to the compiler's bare-name builtin table + (`compiler/src/emit.ml` and the `types.ml` function list), returning `Bytes` + and taking a byte count. Take the next free id there — `84` is a hole between + `b_text_of_bytes` and `b_sha1`, with `90` and up also free; **confirm the + chosen id is not a deliberate reservation before writing it**, since the id + 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. Statistical quality is not a corpus concern — the kernel's guarantee is the guarantee. @@ -60,39 +139,46 @@ readiness: refine ### Phase B — repeated response headers -- Settle fork 1 and change `Resp`. Every response builder - (`ok_text`/`ok_json`/`ok_html`/`created_json`/`not_found`/…) and - `serialize()` in `internal/serve.wo` move with it. -- Keep the single-value path ergonomic: the overwhelmingly common case is one - value per header, and it must not get worse to write. +- Add `cookies: multi SetCookie` to `Resp` and define the `SetCookie` record: + `name` and `value`, plus `HttpOnly`, `Secure`, `SameSite`, `Max-Age`, `Path`, + `Domain`. Its defaults are the safe ones — `HttpOnly` on, `SameSite` not + `None` — because a cookie API whose defaults are insecure is a footgun that + 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; every existing header behaviour is byte-identical, proven by both serving gates passing unchanged. ### Phase C — reading request cookies -- Parse the `Cookie:` header: multiple pairs, quoted values, stray whitespace, - and duplicate names. -- Decide where parsed cookies live — a lazily-parsed field on `Req`, or a - helper the handler calls. `Req` already carries a `ctx` bag and a `params` - map, so the precedent exists either way. -- A malformed header is a 400, not a silent partial parse. The parser's - existing discipline around duplicate `Content-Length` is the model. -- Verify: values recovered exactly across the awkward cases above; malformed - input refused. +- In `parse_request`, add a structural check of the `Cookie:` header — present + and syntactically well-formed, or a 400 before any route runs. Absent is not + malformed: the nil case is the common one and must cost nothing. +- Add a `cookie(req, name)` helper that parses the header on demand and returns + the requested value, handling multiple pairs, quoted values, stray + whitespace, and duplicate names. No new `Req` field: extraction is on demand, + matching how `http/auth.wo` already pulls `bearer_token` and + `basic_credentials`. +- 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 -- A `Set-Cookie` builder covering the attribute set, with `HttpOnly` and - `SameSite` defaulted to the safe choice rather than the permissive one — a - cookie API whose defaults are insecure is a footgun that ships. -- Sign with `hmac_sha256`, verify with `ct_eq`. Decide the encoding (`base64` is - already available) and the payload framing so a value containing the delimiter - cannot forge a signature. -- Clear-cookie support, which is its own case: the attributes must match or the - browser keeps the old one. -- Verify: a one-bit change to value or signature is rejected; a valid cookie - round-trips; a cleared cookie is actually gone. +- A `Set-Cookie` builder covering the attribute set with the safe defaults from + phase B, and a clear-cookie builder — its own case, because the attributes + (`Path`, `Domain`) must match the original or the browser keeps the old + cookie; expiry is expressed as `Max-Age` zero. +- `sign(key, value)` and `verify(key, signed)` free functions using the locked + framing: base64 the value, HMAC it, base64 the mac, join with `.`; verify + splits, recomputes, and compares the base64 macs with `ct_eq`. The key is an + argument, app-supplied. +- Verify: a one-bit change to value or signature is rejected in constant time; a + 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 @@ -117,7 +203,8 @@ readiness: refine additive or it is wrong. - **Given** a `Cookie:` header with several pairs, quoted values and stray 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 signature, **when** it is verified, **then** it is rejected in constant time. - **Given** a signed value that itself contains the payload delimiter, **when** @@ -127,10 +214,16 @@ readiness: refine ## Out Of Scope -- **Encrypted cookies.** Fiber's `encryptcookie` needs a symmetric cipher, and - the runtime has digests only. Signed-and-readable is honest and sufficient for - a session id; encrypting a payload is a separate ask with a separate primitive - behind it. +- **Encrypted cookies.** Fiber's `encryptcookie` needs a symmetric cipher + (AES-GCM), and the runtime has digests only. Signed-and-readable is honest and + sufficient for a session id; encrypting a payload is a separate ask with a + 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 stops at a signed cookie carrying a value the app chose. - **CSRF** — iteration [4](04-csrf.md), which needs both this and 3. @@ -143,29 +236,13 @@ readiness: refine ## Info -Forks the spec must settle, in order of how much they move: - -1. **What replaces `Resp.headers: map`?** 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 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 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.