Add Cookie module#35
Conversation
There was a problem hiding this comment.
Build & Tests
CI: Pending (macOS). Cannot recommend merge until CI completes.
Type-check: Both web.carp and test/web.carp pass --check cleanly with Carp 0.6.0.
Compilation: Fails locally on ARM review machine (Clang 14 crash — platform issue, not PR code). CI on x86_64 will be the real gate.
Test count: 111 assertions (94 existing + 17 new) confirmed.
Findings
1. CRITICAL: Timing attack in Cookie.verify (web.carp:2443)
(if (= sig &expected) (Maybe.Just @value) (Maybe.Nothing))
String.= is strcmp, which short-circuits on the first differing byte. An attacker can determine the correct HMAC signature one character at a time by measuring response times. This is a textbook timing oracle against HMAC verification.
Fix: Implement a constant-time comparison:
(defn constant-time-eq [a b]
(let-do [la (String.length a)
lb (String.length b)
result (bit-xor la lb)]
(for [i 0 (Int.min la lb)]
(set! result (bit-or result
(bit-xor (Char.to-int (String.char-at a i))
(Char.to-int (String.char-at b i))))))
(= result 0)))
2. HMAC-SHA1 implementation is correct (web.carp:246-283)
Verified against RFC 2104:
- Key normalization: hash if > 64 bytes, zero-pad if shorter ✓
- XOR pad values: 54 = 0x36 (ipad), 92 = 0x5c (opad) ✓
- Inner hash:
SHA1(K⊕ipad || msg)✓ - Outer hash:
SHA1(K⊕opad || inner_hash)✓ Byte.to-intusesuint8_tso no sign-extension issues ✓
However, there is no known-answer test. The tests only verify length, determinism, and differentiation — which would also pass for a completely wrong hash function. Add at least one RFC 2202 test vector, e.g.:
Key: "Jefe", Data: "what do ya want for nothing?" →
effcdf6ae5eb2fa2d27416d5f184df9c259a7c79
3. Cookie.verify dot-position logic is correct (web.carp:2431-2443)
The fixed-offset approach (dot-pos = len - 41) correctly handles values containing dots. If the value is a.b.c, the signed form is a.b.c.<40-hex>, and the last dot before the 40-char signature is always at len - 41. Minimum length check of 42 (1 char + 1 dot + 40 hex) is also correct.
4. Empty secret produces forgeable signatures (web.carp:2398)
(def secret @"") — if set-secret is never called, sign still works with an empty key HMAC. Anyone who knows the scheme can forge cookies. Either document that calling set-secret is mandatory, or guard against empty secrets in sign/verify.
5. CHANGELOG is well-written and accurate (CHANGELOG.md:4-25)
All five additions are documented with clear descriptions matching the implementation.
6. Cookie parsing edge cases (upstream http library, not this PR)
- Cookies with
=in value: handled correctly (http'sCookie.parseuses first=) - No space after
;: handled correctly (trimis applied) - URL-encoded values: not decoded — signed cookies are signed against the encoded form. Worth a note in the docs.
- Empty names/values: silently accepted by upstream parser
7. Missing test coverage
- No RFC 2202 known-answer test for HMAC-SHA1 (most important gap)
- No test for values containing dots (e.g.,
Cookie.sign "a.b.c") - No test for empty secret behavior
Verdict: revise
Two issues need addressing before merge:
- Timing attack in
Cookie.verify— replaceString.=with a constant-time comparison. This is a security fundamental for HMAC verification. - CI is pending — cannot merge without green CI.
Additionally recommended (not blocking): add an RFC 2202 known-answer test to prove HMAC correctness, and document/guard against empty secrets.
|
Addressed the two blocking findings from @carpentry-reviewer: 1. Timing attack in 2. Missing RFC 2202 known-answer test for HMAC-SHA1 — Added test vector 2 from RFC 2202 (key= Both files type-check cleanly with |
There was a problem hiding this comment.
Build & Tests
CI: pass (macOS)
Type-check: pass (web.carp + test/web.carp both clean under carp --check)
Compilation: fails on ARM Linux (pre-existing tm_zone issue, not PR-related)
Prior feedback
The previous review flagged two issues. Both have been addressed:
-
Timing attack in
Cookie.verify— Fixed.String.=replaced withconstant-time-eq(web.carp:2433-2442) that XORs all bytes and accumulates viabit-or, preventing short-circuit leakage. ✓ -
Missing RFC 2202 known-answer test — Added. Test vector 2 (key=
"Jefe", data="what do ya want for nothing?"→effcdf6ae5eb2fa2d27416d5f184df9c259a7c79) at test/web.carp:857-862 proves HMAC-SHA1 correctness. ✓
Findings
Reviewed HMAC module (web.carp:246-283), Cookie module (web.carp:2397-2458), Request helpers (2460-2499), Response helpers (2501-2570), and all 17 new tests.
No blocking issues found.
-
constant-time-eqcorrectness (web.carp:2433-2442) — Verified:resultstarts asbit-xor la lb(nonzero if lengths differ), loop XORs each byte pair and ORs intoresult. In practicesigandexpectedare always 40-char hex strings, so the length check is a safety net. The implementation matches the standard pattern for constant-time comparison. -
HMAC-SHA1 (web.carp:265-279) — Verified against RFC 2104: key normalization (hash if > 64 bytes, zero-pad if shorter), correct ipad/opad XOR values (0x36/0x5c), inner hash =
SHA1(K⊕ipad ∥ msg), outer hash =SHA1(K⊕opad ∥ inner). The RFC 2202 test vector confirms end-to-end correctness. -
verifydot-position logic (web.carp:2444-2458) — Sound. Fixed offsetlen - 41correctly identifies the last dot before the 40-char hex signature, handling values that themselves contain dots. Min-length check of 42 is correct (1 char + 1 dot + 40 hex). -
Empty secret (web.carp:2398) — Still defaults to
@"". Not blocking — it's documented thatset-secretmust be called, and this follows Carp's convention for library configuration (similar to the existing CORSorigindef). -
CHANGELOG updated accurately.
Minor observation (non-blocking)
No test for signing a value that contains dots (e.g., Cookie.sign "a.b.c"). The verify logic handles this correctly by design (fixed offset from end), but a test would document this property.
Verdict: merge
Both blocking issues from the prior review are correctly resolved. The timing attack fix follows the standard constant-time pattern, HMAC-SHA1 is proven correct by known-answer test, and the module is well-integrated.
hellerve
left a comment
There was a problem hiding this comment.
rebase and adjust your comment style.
- Request.cookies-map: all cookies as Map String String - Request.cookie: single cookie by name -> Maybe String - Cookie.set-secret / Cookie.sign / Cookie.verify: HMAC-SHA1 signing - Request.signed-cookie: combined lookup + verification - Response.set-cookie-with-max-age: Set-Cookie with Max-Age attribute - Response.clear-cookie: expire cookie (Max-Age=0) - cookie-secret: convenience function for defserver integration - HMAC module: RFC 2104 HMAC-SHA1 built on existing SHA1 module
Replace String.= (strcmp) with constant-time comparison in Cookie.verify to prevent character-by-character signature guessing. Add RFC 2202 test vector 2 (key="Jefe") as a known-answer test for HMAC-SHA1 correctness.
rebased onto current main (resolving CHANGELOG conflict). replaced inline code examples in cookie module doc strings with terse one-liners matching the rest of the codebase.
ed1301b to
f244b15
Compare
|
Rebased onto current main (resolved CHANGELOG.md conflict with the static file and chunked response entries). Stripped verbose doc strings in the Cookie, Request, and Response modules — removed inline code examples and multi-paragraph explanations, replaced with terse one-liners matching the rest of the codebase. The API surface and implementation are unchanged. |
There was a problem hiding this comment.
Build & Tests
CI pending (macOS only). Type-check passes locally. Cannot confirm full green CI yet.
Prior feedback
Two previous review rounds:
-
Timing attack in
Cookie.verify— Fixed in commitfc55a89.String.=replaced withconstant-time-eqthat XORs all bytes and accumulates viabit-or. Verified correct:resultstarts asbit-xor la lb(nonzero if lengths differ), loop ORs in per-byte XOR differences, returns= result 0. No early exit possible. ✓ -
Missing RFC 2202 known-answer test — Added. Test vector 2 (key=
"Jefe", data="what do ya want for nothing?"→effcdf6ae5eb2fa2d27416d5f184df9c259a7c79). ✓ -
Maintainer requested "rebase and adjust your comment style" — addressed in latest commits (
ddccbbd,f244b15).
Findings
Doc string style: correct
The verbose set-cookie doc (which had an inline code example) has been stripped to a terse one-liner. All new Cookie, Request, Response, and HMAC doc strings are one-liners or short two-line wraps, matching the style of Response.text, Response.not-found, SHA1.digest, etc. Multi-line docs with code fences remain only in pre-existing complex functions (chunked, WS, defserver) — the new code correctly stays out of that pattern. No stray code examples or verbose narrative remain.
HMAC-SHA1: correct
ipad=0x36 (54), opad=0x5C (92) per RFC 2104. Key normalization (hash if >64 bytes, zero-pad otherwise). Inner = SHA1(ipad‖msg), outer = SHA1(opad‖inner). RFC 2202 known-answer test confirms end-to-end correctness.
Cookie verify dot-position logic: correct
Fixed offset len - 41 correctly identifies the signature boundary even for values containing dots. Min-length check of 42 (1 char + 1 dot + 40 hex) is correct.
CHANGELOG: accurate
Five "Added" entries correctly describe the new functionality.
Minor observation (non-blocking)
No test for signing a value containing dots (e.g., Cookie.sign "a.b.c"). The verify logic handles it correctly by design (fixed offset from end), but a test would document this property explicitly. Not a blocker.
Verdict: revise
No code issues found — all prior blocking findings are correctly resolved, doc strings match house style, and the implementation is sound. However, CI is still pending. Cannot recommend merge until checks are green.
Summary
Adds comprehensive cookie support to the web framework:
Request-side helpers (reopen
Requestmodule):Request.cookies-map— returns all cookies as aMap String Stringfor easy lookupRequest.cookie— gets a single cookie value by name (Maybe String)Request.signed-cookie— gets and verifies a signed cookie in one callCookie signing (reopen
Cookiemodule):Cookie.set-secret— configures the HMAC-SHA1 signing keyCookie.sign— signs a value, producingvalue.hex-signatureCookie.verify— verifies a signed value, returnsMaybe StringResponse helpers (reopen
Responsemodule):Response.set-cookie-with-max-age— addsSet-CookiewithMax-Ageattribute (the http library'sCookie.setonly emitsExpires)Response.clear-cookie— expires a cookie by name (Max-Age=0)defserver integration:
cookie-secrettop-level convenience function, designed for use as a setup expression indefserver:HMAC-SHA1 module (
HMAC.sha1,HMAC.sha1-hex) implementing RFC 2104 on top of the existing SHA-1 module.Usage example
Test plan
Request.cookies-map: extracts cookies from parsed HTTP request, returns empty map when none presentRequest.cookie: extracts single cookie by name, returns Nothing for missing cookiesCookie.sign/Cookie.verifyround-trip, reject tampered values, reject too-short input, reject wrong secretRequest.signed-cookie: end-to-end signed cookie lookup from raw HTTP requestResponse.set-cookie-with-max-age: header containsMax-Age=NResponse.clear-cookie: header containssession=;andMax-Age=0Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.