Skip to content

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

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

feat(http/unstable): add RFC 9530 digest fields#7238
tomas-zijdemans wants to merge 17 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.

Comment thread http/unstable_digest_fields.ts Outdated
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 20b9978: digest mismatches now throw an exported DigestMismatchError.

Comment thread http/unstable_digest_fields.ts Outdated
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 20b9978: now a RangeError, matching maxAge in message signatures.

} 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added a comment in 20b9978 noting the asymmetry is deliberate fail-closed behavior.

Comment thread http/unstable_digest_fields.ts Outdated
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 20b9978. Also exported DIGEST_ALGORITHMS, since deno doc --lint rejects a public type referencing a private const (same pattern as DIGEST_ALGORITHM_NAMES in @std/crypto).

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 20b9978: the doc now states peak memory is maxBodySize plus one chunk.

* {@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".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 20b9978: filed #7266 and noted the scope in the module doc.

@@ -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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in 20b9978: Appendix D vectors for sha-256, sha-512, and the combined dictionary, on both the create and verify paths.

- throw exported DigestMismatchError on digest mismatch instead of bare Error
- throw RangeError for an out-of-range maxBodySize, matching message signatures
- export DIGEST_ALGORITHMS and derive DigestAlgorithm from it
- document the unsupported-vs-malformed algorithm asymmetry
- document that peak memory is maxBodySize plus one chunk
- note Want-Content-Digest / Want-Repr-Digest are out of scope (denoland#7266)
- add RFC 9530 Appendix D test vectors
@tomas-zijdemans

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review, especially for checking the conformance points against the RFC.

All feedback addressed in 20b9978, conflict with main resolved. The two error-type fixes you flagged as the important ones: digest mismatches now throw an exported DigestMismatchError, and an out-of-range maxBodySize throws RangeError to match message signatures. The Appendix D vectors are in the tests too. Filed #7266 for Want-*.

@codecov

codecov Bot commented Aug 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.05%. Comparing base (cdb83c6) to head (20d070a).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7238      +/-   ##
==========================================
+ Coverage   95.03%   95.05%   +0.01%     
==========================================
  Files         618      619       +1     
  Lines       51596    52038     +442     
  Branches     9340     9433      +93     
==========================================
+ Hits        49035    49463     +428     
- Misses       2021     2030       +9     
- Partials      540      545       +5     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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