feat(http/unstable): add RFC 9530 digest fields - #7238
Conversation
bartlomieju
left a comment
There was a problem hiding this comment.
This is very good work — approving.
I checked the conformance points that matter and they hold up. Content-Digest and Repr-Digest are properly distinguished, and the JSDoc explanation of why a fetched response with server-applied Content-Encoding fails verifyContentDigest is exactly the kind of thing users hit and can never diagnose on their own. Encoding via the existing structured-fields primitives produces correct RFC 8941 byte sequences, and multiple digest headers fall out correctly from Headers.get() comma-joining.
The security posture is the part I'm happiest with: only sha-256 and sha-512 are accepted, so a message carrying only the deprecated md5/sha/unixsum registrations fails closed rather than verifying weakly. Constant-time compare, digest-length validation before reading the body, and the maxBodySize cap for the §6.7 exhaustion vector are all right.
Nits below — none blocking, and the error-type ones are the only ones I'd really like fixed before this stabilizes. The missing Appendix D vectors are worth adding while the spec is fresh in your head.
| for (const [alg, statedDigest] of stated) { | ||
| const computed = await computeDigest(bodyBytes, alg); | ||
| if (!timingSafeEqual(statedDigest, computed)) { | ||
| throw new Error( |
There was a problem hiding this comment.
This is the one inconsistency I'd like fixed: a digest mismatch is an integrity failure, but it throws a bare Error while every other failure in the module throws TypeError. That leaves callers string-matching the message to tell "the body was tampered with" from "I passed a bad argument" — and those two want very different handling (reject the request vs. fix the code).
A distinct exported error type would be ideal. Failing that, anything other than bare Error that a caller can instanceof against.
| maxBodySize !== undefined && | ||
| (!Number.isInteger(maxBodySize) || maxBodySize < 0) | ||
| ) { | ||
| throw new TypeError(`"maxBodySize" must be a non-negative integer`); |
There was a problem hiding this comment.
unstable_message_signatures.ts:933 throws RangeError for the analogous out-of-range numeric option (maxAge). Worth matching that here so the two sibling modules agree on which error type an out-of-range option produces.
| } catch (cause) { | ||
| throw new TypeError(`"${headerName}" header is malformed`, { cause }); | ||
| } | ||
| for (const [key, member] of dict) { |
There was a problem hiding this comment.
Worth a comment explaining the asymmetry here, because it isn't obvious: an unsupported algorithm is silently skipped, but a malformed value under a supported algorithm rejects the entire header — even if another supported algorithm in the same dictionary would have verified fine.
Failing closed is the right call for a security primitive and I'm not asking you to change it, but RFC 9530 §2 does permit ignoring individual digests, so a future reader will wonder whether this was deliberate.
| const DIGEST_ALGORITHMS = ["sha-256", "sha-512"] as const; | ||
|
|
||
| /** Supported digest algorithms per RFC 9530 §5. */ | ||
| export type DigestAlgorithm = "sha-256" | "sha-512"; |
There was a problem hiding this comment.
| export type DigestAlgorithm = "sha-256" | "sha-512"; | |
| export type DigestAlgorithm = typeof DIGEST_ALGORITHMS[number]; |
The hand-written union duplicates DIGEST_ALGORITHMS on the line above, so adding a third algorithm means remembering to touch both. Deriving it keeps them from drifting.
| while (true) { | ||
| const { done, value } = await reader.read(); | ||
| if (done) break; | ||
| size += value.byteLength; |
There was a problem hiding this comment.
Minor precision issue in the maxBodySize doc above, which says verification rejects "before the whole payload is buffered". True, but the chunk that trips the limit has already been read into memory by then — so the real bound is maxBodySize plus one chunk, not maxBodySize. Worth stating, since the whole point of the option is bounding memory against a hostile peer.
| * {@link https://www.rfc-editor.org/rfc/rfc9530 | RFC 9530} Digest Fields | ||
| * (Content-Digest and Repr-Digest). | ||
| * | ||
| * Digest fields provide end-to-end integrity of HTTP message bodies. Values are |
There was a problem hiding this comment.
Could you note here that Want-Content-Digest / Want-Repr-Digest (RFC 9530 §4) are intentionally out of scope for now, ideally with a follow-up issue? Scoping them out is fine, but a reader who comes to this module for RFC 9530 support has no way to tell "deliberately deferred" from "overlooked".
| @@ -0,0 +1,543 @@ | |||
| // Copyright 2018-2026 the Deno authors. MIT license. | |||
There was a problem hiding this comment.
The tests here are thorough — I particularly liked the assertions that the body isn't read when the header is bad, and the maxBodySize edge cases.
One gap: RFC 9530 Appendix D gives concrete vectors, e.g. {"hello": "world"} → sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=: and the sha-512 counterpart. The current tests check prefixes and hand-rolled byte arrays, so nothing pins the full colon-delimited base64 output end to end. Adding the spec's own vectors would catch a serialization regression that hand-rolled expectations can't.
Adds an unstable
@std/http/unstable-digest-fieldsmodule for creating and verifying RFC 9530Content-DigestandRepr-Digestfield values.RFC 9530, an IETF standards-track RFC from Feb 2024, replaces the legacy RFC 3230
Digestfield with Structured Fields based digest headers for HTTP content and representation integrity. These helpers support creating digest values from strings, bytes, or streams, and verifyingRequest/Responsebodies without consuming the original body.This is also useful with HTTP Message Signatures (RFC 9421), where a signed digest field can bind the signature to the message body. The implementation is small and browser-compatible, reusing existing
@stdprimitives:timing-safe-equal,unstable-structured-fields, andto-bytes.Note
Recreates #7037, which was closed automatically when I accidentally deleted the head fork. The branch is identical to the original PR head.