From 7d2d8478cc0ed94226cb1218ddba159e0fa9ce6e Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 15:10:03 +0000 Subject: [PATCH] dream: signed session cookies (tamper-evident sid) + 11 tests Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/dream/session.sx | 68 ++++++++++++++++++++++++++++++++++++++ lib/dream/tests/session.sx | 61 ++++++++++++++++++++++++++++------ plans/dream-on-sx.md | 9 ++++- 3 files changed, 127 insertions(+), 11 deletions(-) diff --git a/lib/dream/session.sx b/lib/dream/session.sx index 5ca4c818..cb6c647a 100644 --- a/lib/dream/session.sx +++ b/lib/dream/session.sx @@ -72,6 +72,48 @@ dream-drop-cookie (fn (resp name) (dream-set-cookie resp name "" {:max-age 0}))) +;; ── signed cookie values (tamper-evident) ────────────────────────── +;; NOTE: pure-SX keyed hash — not cryptographic; production should inject a host +;; HMAC. Value carries no "." so the first "." splits value from signature. +(define + dr/sess-hash + (fn (s) (dr/sess-hash-loop s 0 (string-length s) 7))) +(define + dr/sess-hash-loop + (fn + (s i n h) + (if + (>= i n) + h + (dr/sess-hash-loop + s + (+ i 1) + n + (mod (+ (* h 131) (char-code (char-at s i))) 2147483647))))) +(define + dr/sess-sig + (fn (secret val) (str (dr/sess-hash (str secret "|" val))))) + +(define + dream-cookie-sign + (fn (secret val) (str val "." (dr/sess-sig secret val)))) +(define + dream-cookie-unsign + (fn + (secret signed) + (if + (or (nil? signed) (= signed "")) + nil + (let + ((dot (index-of signed "."))) + (if + (< dot 0) + nil + (let + ((val (substr signed 0 dot)) + (sig (substr signed (+ dot 1)))) + (if (= sig (dr/sess-sig secret val)) val nil))))))) + ;; ── in-memory session store (tests + demos) ──────────────────────── ;; A backend is (fn (op) result) where op is a dict {:op ... :sid ... :key ...}. (define @@ -143,6 +185,32 @@ sid {:path "/" :http-only true :same-site "Lax"})))))))))) +;; signed variant: the cookie value is signed so a guessed/forged sid is rejected +(define + dream-sessions-signed + (fn + (backend secret) + (fn + (next) + (fn + (req) + (let + ((sid0 (dream-cookie-unsign secret (dream-cookie req dream-session-cookie-name)))) + (let + ((have (and sid0 (backend {:op "session/exists" :sid sid0})))) + (let + ((sid (if have sid0 (backend {:op "session/create"})))) + (let + ((resp (next (assoc req :dream-session {:io backend :sid sid})))) + (if + have + resp + (dream-set-cookie + resp + dream-session-cookie-name + (dream-cookie-sign secret sid) + {:path "/" :http-only true :same-site "Lax"})))))))))) + ;; ── handler-facing session API ───────────────────────────────────── (define dr/session-of (fn (req) (get req :dream-session))) (define dream-session-id (fn (req) (get (dr/session-of req) :sid))) diff --git a/lib/dream/tests/session.sx b/lib/dream/tests/session.sx index 7706af27..d1c91a2d 100644 --- a/lib/dream/tests/session.sx +++ b/lib/dream/tests/session.sx @@ -1,4 +1,4 @@ -;; lib/dream/tests/session.sx — cookies, store, session round-trip. +;; lib/dream/tests/session.sx — cookies, store, session round-trip, signed cookies. (define dream-ss-pass 0) (define dream-ss-fail 0) @@ -65,6 +65,19 @@ "Max-Age=0") true) +;; ── signed cookie values ─────────────────────────────────────────── +(dream-ss-test + "sign/unsign roundtrip" + (dream-cookie-unsign "k" (dream-cookie-sign "k" "s5")) + "s5") +(dream-ss-test + "unsign wrong secret" + (dream-cookie-unsign "k2" (dream-cookie-sign "k" "s5")) + nil) +(dream-ss-test "unsign tampered" (dream-cookie-unsign "k" "s5.999") nil) +(dream-ss-test "unsign no dot" (dream-cookie-unsign "k" "s5") nil) +(dream-ss-test "unsign nil" (dream-cookie-unsign "k" nil) nil) + ;; ── in-memory store ──────────────────────────────────────────────── (define dream-ss-store (dream-memory-sessions)) (define dream-ss-sid (dream-ss-store {:op "session/create"})) @@ -93,7 +106,6 @@ (dream-text (str "count=" (+ n 1))))))) (define dream-ss-app ((dream-sessions dream-ss-backend) dream-ss-counter-h)) -;; first request: no cookie -> creates session, sets cookie (define dream-ss-r1 (dream-ss-app (dream-request "GET" "/" {} ""))) (dream-ss-test "first body count=1" (dream-resp-body dream-ss-r1) "count=1") (dream-ss-test @@ -109,7 +121,6 @@ (contains? (first (dream-resp-cookies dream-ss-r1)) "HttpOnly") true) -;; second request: carries the cookie -> reuses, sees prior count, no new cookie (define dream-ss-r2 (dream-ss-app (dream-request "GET" "/" {:Cookie "dream.session=s1"} ""))) (dream-ss-test "second body count=2" (dream-resp-body dream-ss-r2) "count=2") (dream-ss-test @@ -117,11 +128,9 @@ (len (dream-resp-cookies dream-ss-r2)) 0) -;; third request continues (define dream-ss-r3 (dream-ss-app (dream-request "GET" "/" {:Cookie "dream.session=s1"} ""))) (dream-ss-test "third body count=3" (dream-resp-body dream-ss-r3) "count=3") -;; unknown cookie id -> fresh session created (define dream-ss-r4 (dream-ss-app (dream-request "GET" "/" {:Cookie "dream.session=bogus"} ""))) (dream-ss-test "bogus id starts fresh" @@ -133,11 +142,6 @@ 1) ;; ── session-all + invalidate via middleware ──────────────────────── -(define - dream-ss-inspect-h - (fn (req) (dream-text (str (dream-session-all req))))) -(define dream-ss-app2 ((dream-sessions dream-ss-backend) dream-ss-inspect-h)) -(define dream-ss-r5 (dream-ss-app2 (dream-request "GET" "/" {:Cookie "dream.session=s1"} ""))) (dream-ss-test "session-all shows count" (dream-session-all @@ -153,4 +157,41 @@ (dream-ss-app3 (dream-request "GET" "/" {:Cookie "dream.session=s1"} "")) (dream-ss-test "invalidate clears store" (dream-ss-backend {:op "session/exists" :sid "s1"}) false) +;; ── signed session middleware ────────────────────────────────────── +(define dream-ss-sbackend (dream-memory-sessions)) +(define + dream-ss-sapp + ((dream-sessions-signed dream-ss-sbackend "topsecret") + (fn (req) (dream-text (dream-session-id req))))) + +(define dream-ss-sr1 (dream-ss-sapp (dream-request "GET" "/" {} ""))) +(dream-ss-test "signed first sid" (dream-resp-body dream-ss-sr1) "s1") +(dream-ss-test + "signed cookie is signed" + (contains? (first (dream-resp-cookies dream-ss-sr1)) "dream.session=s1.") + true) + +;; forged plaintext sid (no signature) is rejected -> a fresh session is made +(dream-ss-test + "forged plaintext rejected -> new session" + (dream-resp-body (dream-ss-sapp (dream-request "GET" "/" {:Cookie "dream.session=s1"} ""))) + "s2") + +;; a validly-signed cookie reuses the session +(define dream-ss-signed-val (dream-cookie-sign "topsecret" "s1")) +(define dream-ss-sr3 (dream-ss-sapp (dream-request "GET" "/" {:Cookie (str "dream.session=" dream-ss-signed-val)} ""))) +(dream-ss-test "valid signed reuses s1" (dream-resp-body dream-ss-sr3) "s1") +(dream-ss-test + "valid signed sets no new cookie" + (len (dream-resp-cookies dream-ss-sr3)) + 0) + +;; a cookie signed with the wrong secret is rejected +(dream-ss-test + "wrong-secret signed rejected" + (= + (dream-resp-body (dream-ss-sapp (dream-request "GET" "/" {:Cookie (str "dream.session=" (dream-cookie-sign "other" "s1"))} ""))) + "s1") + false) + (define dream-ss-tests-run! (fn () {:total (+ dream-ss-pass dream-ss-fail) :passed dream-ss-pass :failed dream-ss-fail :fails dream-ss-fails})) diff --git a/plans/dream-on-sx.md b/plans/dream-on-sx.md index 2184f11a..5a7bbdf0 100644 --- a/plans/dream-on-sx.md +++ b/plans/dream-on-sx.md @@ -112,6 +112,13 @@ with extensions + hardening below. `dream-json-body` (parse request body). GOTCHA: `number?` is unreliable in this env — used `(= (type-of v) "number")`; `parse-float` handles decimals. Multi-key dict encode order follows `keys` (non-deterministic) so tests assert via parse round-trip. +- **2026-06-07 — Ext: signed session cookies** (`lib/dream/session.sx`, session suite + 30→41, 340 total). The default store uses guessable sids (`s1`, `s2`), so + `dream-sessions-signed backend secret` signs the cookie value (`sid.signature`) and + rejects any cookie whose signature doesn't verify — a forged plaintext `s1` or a + wrong-secret cookie yields a fresh session instead of a hijack. `dream-cookie-sign` / + `dream-cookie-unsign` (keyed hash; same not-cryptographic caveat — inject a host HMAC + in production). Plain `dream-sessions` unchanged for the no-secret case. ## Extensions (post-roadmap) @@ -122,7 +129,7 @@ The five-types core is complete; these harden it toward a production HTTP front - [x] **Status reason phrases** + `dream-status-text` (`lib/dream/error.sx`). - [x] **CORS middleware** (`dream-cors`). - [x] **Error-handling middleware** (`dream-catch` / custom 500 templates; `guard`-based). -- [ ] **Signed session cookies** (the noted hardening — sign the sid). +- [x] **Signed session cookies** (`dream-sessions-signed` — tamper-evident sid). - [x] **JSON helpers** (encode + recursive-descent parse, pure SX). - [ ] **Query/header convenience** (`dream-queries`, defaults). - [ ] **`api.sx` facade + README** — single load point listing the public surface.