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:
parent
1d78e0fa70
commit
6a67db252b
2 changed files with 148 additions and 70 deletions
|
|
@ -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<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 |
|
||||
| 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 🔶) |
|
||||
|
|
|
|||
|
|
@ -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 <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
|
||||
|
||||
- **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<Text, Text>`. 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<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
|
||||
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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue