- tls-server-accept.sh: each probe pins openssl s_client's -ciphersuites — ec/ChaCha20-Poly1305, rsa/AES-128-GCM, plus openssl's default list whose first suite (AES-256-GCM) the server must skip — 5/0 - tls-accept.sh: the Python/OpenSSL stub prints the negotiated suite; the happy-path ok line carries it — 5/0 (ChaCha under the peer's server-preference default) - rv2 8 story: E landed (real-protocol interop replaces the infeasible `openssl enc` AEAD check); D (encrypted-cookie wrapper) re-homed to porch as the consumer's phase after porch 2 — fork auto-approved, review_pending; status: done - porch 2: the encrypted-cookie out-of-scope bullet now points at the landed primitives and names the wrapper as its follow-on - board row rv2 8: in-progress -> done Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit ac3bf74da4f45f624d7d3b440c8bcf17f4aec3a9)
15 KiB
| track | iteration | status | readiness |
|---|---|---|---|
| porch | 2 | pending | ready |
porch 2 — randomness and cookies: the foundation three iterations stand on
Part of Story —
porch, the writeonce web framework. Source: the Fiber parity study §0–§1, re-checked 2026-09-06 against.dev/reference/fiber(v3, commit3ca9a9d): itscrypto/randconsumers (utils.SecureToken,utils.UUIDv4,encryptcookie.GenerateKey), thefiber.Cookiestruct inres.go, and theCookie/Cookies/ClearCookiemethods.The study's sharpest finding lives here. porch's own README claimed signed cookies, CSRF and session integrity were "UNBLOCKED — the primitives exist since iteration 34". For CSRF and sessions that is wrong: SHA-256 and HMAC let a program authenticate a token, they cannot mint one, and writeonce has 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
SecureTokenisbase64.RawURLEncodingover 32crypto/randbytes;UUIDv4is the same entropy behind a format;encryptcookie.GenerateKeyis a rawrand.Read. All three panic if the source fails. writeonce exposes no RNG to.woyet — the runtime does have agetrandom(2)source internally (runtime-v2 9's TLS uses it for ephemeral keys), so phase A is surfacing that as arandom_bytesbuiltin, not inventing entropy. It is the whole of the language work. - Repeated
Set-Cookieis not language work. fiber gets multiple lines from fasthttp appending them; porch expresses the same with amulti SetCookiefield, andmulti <Class>is an existing language feature. - Cookie attributes are not language work. fiber's
Cookiestruct is plain scalars (name/value/path/domain/same-site as text, max-age as int, secure/http-only/partitioned as bool, plus anExpiresdate). ASetCookierecord covers all of it, and choosingMax-AgeoverExpiresavoids the only attribute that would need a date formatter. - Parsing and signing are not language work. Reading the
Cookie:header uses the existingsplit/trim/substr; signing uses the existinghmac_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/urandomread, 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
Respstructurally cannot express.Resp.headersis amap<Text, Text>. A login response setting a session cookie and a flash cookie needs twoSet-Cookielines and the map can hold one. The settled answer adds a typedcookiesfield 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 aSet-Cookiewith the attributes that matter for security —HttpOnly,Secure,SameSite,Max-Age,Path,Domain. - Signed cookies. HMAC-SHA256 over the value with constant-time comparison
on the way back (
hmac_sha256andct_eqboth already exist). This is the half that genuinely was unblocked by iteration 34, and it is what makes a cookie tamper-evident without a server-side lookup. - 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)
Respgainscookies: multi SetCookie; theheadersmap is untouched. Of the three candidates — a rawmulti Textof 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 existingfor k, v in resp.headersloop does not move, so the byte-identical gate is trivial; a second loop renders the cookies.SecurityHeadersandCorswrite 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.- 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'sb_*ids plus thetypes.mlfunction list), not inwob.h's module-member enum. This corrects the earlier note that pointed phase A atwob.hid 110: that is the wrong registry. A session id is thenbase64_encode(random_bytes(32)), mirroring fiber'sSecureToken; no hex or UUID helper rides along because base64 already turns bytes into a text id. - A malformed
Cookie:header is a 400, checked structurally inparse_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 duplicateContent-Lengthis weaker for a non-framing header). The resolution is a hybrid:parse_requestdoes a cheap structural syntax check — nil-check first, so an absentCookieheader costs nothing — and refuses before routing; acookie(req, name)helper parses and extracts the requested value on demand, so requests that read no cookie pay no parse into a map. - 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 withct_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. - The signing key is app-supplied policy, passed to free functions. No
SignedCookiesmiddleware in this iteration — that is the shape iteration 3 introduces for sessions.signandverifytake the key as an argument, the wayct_eqandbearer_tokenare 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
random_bytesto the compiler's bare-name builtin table (compiler/src/emit.mland thetypes.mlfunction list), returningBytesand taking a byte count. Take the next free id there —84is a hole betweenb_text_of_bytesandb_sha1, with90and 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/urandomread 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.
- Document it in the builtin-surface contract and the error catalog if it adds a diagnostic, in the same change. Both of those went stale once before by not doing this.
- Verify:
oop-e2egreen; the value differs across separate processes and is not derivable from the clock.
Phase B — repeated response headers
- Add
cookies: multi SetCookietoRespand define theSetCookierecord:nameandvalue, plusHttpOnly,Secure,SameSite,Max-Age,Path,Domain. Its defaults are the safe ones —HttpOnlyon,SameSitenotNone— because a cookie API whose defaults are insecure is a footgun that ships. - Extend
serialize()ininternal/serve.wowith a second loop that renders eachSetCookieto aSet-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 distinctSet-Cookielines for one response; every existing header behaviour is byte-identical, proven by both serving gates passing unchanged.
Phase C — reading request cookies
- In
parse_request, add a structural check of theCookie: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 newReqfield: extraction is on demand, matching howhttp/auth.woalready pullsbearer_tokenandbasic_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-Cookiebuilder 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 asMax-Agezero. sign(key, value)andverify(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 withct_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
- A login/logout flow in a sample exercising two cookies on one response, using the signed-cookie path only — no sessions yet, that is iteration 3.
- Correct porch's README crypto row and re-point the ledger rows this iteration touched.
- Verify:
just web-app,just site,oop-accept,just linkcheckall green.
Acceptance Criteria
- Given the random builtin, when many values are drawn across separate processes, then none repeats and none is derivable from the clock; and when the kernel source is unavailable, the builtin refuses loudly rather than returning weak bytes.
- Given a handler setting a session cookie and a flash cookie on one
response, when it is serialized, then two distinct
Set-Cookieheaders reach the wire. This is the criterion today'smap<Text, Text>provably cannot satisfy. - Given every pre-existing response shape, when both serving gates run,
then output is byte-identical to before phase B — the
Respchange is 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 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 it round-trips, then it cannot be re-framed to forge a valid signature.
- Given cookie defaults, when a cookie is created without explicit
attributes, then
HttpOnlyis on andSameSiteis notNone.
Out Of Scope
- Encrypted cookies. Fiber's
encryptcookieneeds a symmetric cipher. Signed-and-readable is honest and sufficient for a session id, so this iteration ships without it — but the primitive now exists (runtime-v2 8:chacha20poly1305_seal/open,aes_gcm_seal/open, done 2026-09-09). The wrapper — random nonce fromrandom_bytesprepended to the ciphertext, default ChaCha — is rv2 8's phase D, re-homed here as porch's follow-on to this iteration: pure.woon theSetCookiemachinery andrandom_bytesthis iteration builds. - UUID-formatted ids. fiber's
UUIDv4is a format over the same entropyrandom_bytesprovides; a base64'd 32-byte token is stronger and needs no new builtin. A UUID format helper, if ever wanted, is pure.wo. - Cookie
Expiresas an absolute date.Max-Agein seconds covers expiry and clear-cookie without a date formatter; anExpiresattribute would build ontime.localin pure.woand is not needed here. - Server-side session state — iteration 3. This iteration stops at a signed cookie carrying a value the app chose.
- CSRF — iteration 4, which needs both this and 3.
- JWT. Verification is already possible with
hmac_sha256; issuing needs phase A. Either way it is a library slice with a hard stop at HS256 — no RS256, no JOSE — and not this iteration. - Cookie-based cache keys. Fiber's cache middleware has
KeyCookies; the cache belongs to language iteration 18.
Info
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.