Skip to content

feat(http/unstable): add RFC 9530 digest fields - #7238

Open
tomas-zijdemans wants to merge 14 commits into
denoland:mainfrom
tomas-zijdemans:digest-fields
Open

feat(http/unstable): add RFC 9530 digest fields#7238
tomas-zijdemans wants to merge 14 commits into
denoland:mainfrom
tomas-zijdemans:digest-fields

Conversation

@tomas-zijdemans

Copy link
Copy Markdown
Contributor

Adds an unstable @std/http/unstable-digest-fields module for creating and verifying RFC 9530 Content-Digest and Repr-Digest field values.

RFC 9530, an IETF standards-track RFC from Feb 2024, replaces the legacy RFC 3230 Digest field with Structured Fields based digest headers for HTTP content and representation integrity. These helpers support creating digest values from strings, bytes, or streams, and verifying Request / Response bodies 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 @std primitives: timing-safe-equal, unstable-structured-fields, and to-bytes.


Note

Recreates #7037, which was closed automatically when I accidentally deleted the head fork. The branch is identical to the original PR head.

@github-actions github-actions Bot added the http label Jul 15, 2026

@bartlomieju bartlomieju left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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`);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants