digest-fields-parse
Zero-dependency parser and serializer for the HTTP
Content-Digest,Repr-Digest,Want-Content-Digest, andWant-Repr-Digestheader fields defined in RFC 9530 — for Node.js (≥18, ESM + CJS) and Python (≥3.11).
Why
HTTP integrity digests (RFC 9530) let endpoints communicate the integrity of HTTP message content via Content-Digest, Repr-Digest, Want-Content-Digest, and Want-Repr-Digest headers. The headers look like:
Content-Digest: sha-256=:pZGm1Av0IEBKARczz7exkNYsZb8LzaMrV7J32a2fFG4=:
Want-Content-Digest: sha-256;q=1, sha-512;q=0.5
Most developers today hand-roll this with header.split(','), which silently mishandles multi-algorithm headers, base64 padding inside values, case sensitivity, q-value sorting, and malformed entries. digest-fields-parse handles all four header types correctly in <300 LOC per language, zero runtime dependencies in either package.
Install
Node.js
npm install digest-fields-parse
ESM:
import {
parseContentDigest,
parseReprDigest,
parseWantContentDigest,
parseWantReprDigest,
serializeContentDigest,
serializeReprDigest,
serializeWantContentDigest,
serializeWantReprDigest,
} from 'digest-fields-parse';
CJS:
const {
parseContentDigest,
parseReprDigest,
parseWantContentDigest,
parseWantReprDigest,
serializeContentDigest,
serializeReprDigest,
serializeWantContentDigest,
serializeWantReprDigest,
} = require('digest-fields-parse');
TypeScript definitions are bundled (index.d.ts).
Python
pip install digest-fields-parse
from digest_fields_parse import (
parse_content_digest,
parse_repr_digest,
parse_want_content_digest,
parse_want_repr_digest,
serialize_content_digest,
serialize_repr_digest,
serialize_want_content_digest,
serialize_want_repr_digest,
)
Module ships with py.typed (PEP 561) for full mypy --strict compatibility.
Usage
Parse Content-Digest / Repr-Digest
// Node
parseContentDigest('sha-256=:pZGm1Av0IEBKARczz7exkNYsZb8LzaMrV7J32a2fFG4=:');
// → Map { 'sha-256' => 'pZGm1Av0IEBKARczz7exkNYsZb8LzaMrV7J32a2fFG4' }
parseContentDigest('sha-256=:abc=, sha-512=:xyz=');
// → Map { 'sha-256' => 'abc', 'sha-512' => 'xyz' }
# Python
parse_content_digest('sha-256=:pZGm1Av0IEBKARczz7exkNYsZb8LzaMrV7J32a2fFG4=:')
# → OrderedDict([('sha-256', 'pZGm1Av0IEBKARczz7exkNYsZb8LzaMrV7J32a2fFG4')])
parseReprDigest is semantically identical — the field names are separate only because servers distinguish content vs. representation integrity.
Parse Want-Content-Digest / Want-Repr-Digest
parseWantContentDigest('sha-256, sha-512;q=0.5, sha-1;q=1');
// → [
// { algorithm: 'sha-1', q: 1.0, raw: 'sha-1;q=1' },
// { algorithm: 'sha-256', q: 1.0, raw: 'sha-256' },
// { algorithm: 'sha-512', q: 0.5, raw: 'sha-512;q=0.5' },
// ]
// (sorted by descending q; ties keep input order)
parse_want_content_digest('sha-256, sha-512;q=0.5')
# → [
# DigestPreference(algorithm='sha-256', q=1.0, raw='sha-256'),
# DigestPreference(algorithm='sha-512', q=0.5, raw='sha-512;q=0.5'),
# ]
Serialise
serializeContentDigest(new Map([['sha-256', 'pZGm1Av0IEBKARczz7exkNYsZb8LzaMrV7J32a2fFG4']]));
// → 'sha-256=:pZGm1Av0IEBKARczz7exkNYsZb8LzaMrV7J32a2fFG4:'
serializeWantContentDigest([
{ algorithm: 'sha-256', q: 1.0 },
{ algorithm: 'sha-512', q: 0.5 },
]);
// → 'sha-256;q=1, sha-512;q=0.5'
Round-trip
const m = parseContentDigest(wire);
const sameWire = serializeContentDigest(m);
parseContentDigest(sameWire); // === m
Semantics
| Header | Wire form | Returned type (Node) | Returned type (Python) |
|---|---|---|---|
Content-Digest / Repr-Digest |
alg=:base64:,( alg=:base64:)* |
Map<string, string> |
OrderedDict[str, str] |
Want-Content-Digest / Want-Repr-Digest |
alg[;q=N.NNN][, …] |
Array<{algorithm, q, raw}> |
list[DigestPreference] |
- Base64 padding. Trailing
=padding inside the value is stripped (per RFC 9530 §2.2). Internal=characters (which are part of the base64 alphabet) are preserved. - Algorithm case. Algorithm names are case-sensitive.
SHA-256andsha-256are distinct keys. - Multi-algorithm. A single header may contain multiple
alg=:base64:pairs separated by commas. The parser uses insertion-order; the last duplicate wins (matches the spec example). - Empty header.
Content-Digest:(empty value) returns an empty Map / OrderedDict. - Malformed entries. Garbage that lacks the
=:delimiters is silently dropped, not raised — matches the typical HTTP-header parser philosophy (lenient on input, strict on output). - Want q sorting. Preferences are sorted by descending
q. Ties keep input order (stable sort). Defaultqis1.0when;q=is omitted. - Want q format. Strict
[0, 1]decimal matching^(0(\.\d{1,3})?|1(\.0{1,3})?)$. Malformed q-values fall back to1.0(no silentparseFloatconsumption). - Want serializer canonical form.
serializeWant*always emits explicit;q=N(matches spec AC-16 wire form) so parse ∘ serialize is a stable round-trip.
Error handling
| Call | Behaviour |
|---|---|
parse*(null) |
throws TypeError |
parse*(undefined) |
throws TypeError |
parse*(<non-string>) |
throws TypeError |
parse*(<empty string>) |
returns empty Map / [] |
parse*(<malformed entry>) |
silently skips bad entries, returns partial result |
serialize*(null) |
throws TypeError |
serialize*({}) |
returns empty string (valid) |
Tests
Both languages ship a deep-coverage test suite that exercises all 33 spec acceptance criteria plus regression cases for every parser/serializer edge case.
155 tests (73 Node.js + 82 Python)
|| Suite | Command | Test count |
|| --- | --- | --- |
|| Node | npm test | 73 tests |
|| Python | pytest | 82 tests |
Type-checking is part of the test contract:
# Node — bundled types
npx --yes tsc --noEmit index.d.ts
# Python — strict static typing
mypy --strict digest_fields_parse.py
Non-goals
Explicitly out of scope:
- No crypto. This library parses and serialises the wire format only. It does NOT compute or verify digest hashes. Pair it with
crypto.createHash('sha256')in Node orhashlib.sha256()in Python if you need to compute digests. - No RFC 3230. The older
Digest/Want-Digestheaders (RFC 3230) are obsolete and use a different syntax. This package implements only the four RFC 9530 header types. - No RFC 9421. HTTP Message Signatures are a separate standard with a different scope.
- No
Content-MD5. The MD5-based header (RFC 1864) is deprecated and not part of RFC 9530. - No streaming. Buffers must be in memory; no incremental parsing API.
Known limitations / Cross-runtime parity
These are documented divergences and accepted trade-offs for v0.1.0. None of them affect correctly-encoded ASCII inputs (which is everything RFC 9530 §5 examples and §2.2 normative ABNF produce). They are confined to pathological inputs that RFC 9530 does not constrain.
L-1 — Lenient on control characters inside opaque-tag / digest-value / algorithm-name
RFC 9530 §2.2 specifies opaque-tag = ALPHA *( ALPHA / DIGIT / "-" ) and algorithm-name = token (RFC 9110), both of which exclude CTL bytes (\x00–\x1F, \x7F). v0.1.0's parser is lenient and preserves CTL bytes verbatim on round-trip, so an attacker who controls header input could pass a CRLF-containing value that, if a downstream consumer concatenates library output into an HTTP header context, could enable header injection.
Mitigation in your code: before serializing library output into a header, validate that the algorithm-name, opaque-tag, and digest-value do not contain CR (\r), LF (\n), or NUL (\x00). The library deliberately does NOT enforce this because it is not itself a header-writing sink — it is the consumer's responsibility to validate before serialization.
L-2 — Cross-runtime: U+FEFF (\uFEFF, ZERO WIDTH NO-BREAK SPACE) handling differs
V8's String.prototype.trim() strips U+FEFF; CPython's str.strip() does not. This causes up to 138 documented parity divergences on fuzz inputs that mix U+FEFF with ASCII whitespace at token boundaries (opaque-tag positions, algorithm-name positions, and around ;q= values).
Impact: zero on RFC 9530 §5 documented inputs. Documented in VULN_AUDIT.md (cycle_39/05 addendum) as findings F-04 and F-05 (both Low). The library is lenient on both runtimes; if you need strict parity, normalize U+FEFF in your input before parsing:
// Node
parseContentDigest(input.replace(/\uFEFF/g, ''));
# Python
parse_content_digest(input.replace('\uFEFF', ''))
L-3 — RFC 9530 §2.2 conformance is "lenient-on-input, strict-on-output" only for ASCII
The parser silently skips malformed entries (entries lacking the =: delimiters, or with invalid q-values). This matches typical HTTP-header-parser philosophy and is intentional, but it means the parser does NOT raise on RFC 9530 §2.2 violations for CTL bytes (see L-1). If you need strict ABNF validation, run the output of parse* through your own validator before serializing.
Competitive landscape
| Package | Why it doesn't fit |
|---|---|
@misskey-dev/node-http-message-signatures |
Scoped package; non-zero deps (WebCrypto); focus on signing (RFC 9421), not digest-field parsing |
@shujaapay/http-message-signatures |
Scoped; focus on RFC 9421 + GNAP signing; no dedicated Content-Digest parsing API |
http-digest (npm) |
Pre-RFC-9530; implements the obsolete RFC 3230 Digest header |
Hand-rolled header.split(',') |
Silently fails on multi-algorithm, base64 padding, q-values, mixed-case |
| (No PyPI equivalent) | No prior Python package exists for RFC 9530 digest fields |
License
MIT. See LICENSE.
Metadata
Release files for digest-fields-parse 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| digest_fields_parse-0.1.1.tar.gz | 21.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| digest_fields_parse-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 30.5 kB
Release files / digest_fields_parse-0.1.1.tar.gz
| Download URL | digest_fields_parse-0.1.1.tar.gz |
|---|---|
| Size | 21.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f22e698b51dc01e683baaca5cf9832456fe5124089c6a391efe94f8765eba3ca
|
|
BLAKE2b-256 checksum How to use checksums |
6b61a2816141abbfad262f09eb4e1975e6e88b38dc2fbe49fc052f0b7282c7af
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.11.15
|
Release files / digest_fields_parse-0.1.1-py3-none-any.whl
| Download URL | digest_fields_parse-0.1.1-py3-none-any.whl |
|---|---|
| Size | 9.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
33ee6e77435a2deb4b637a94ebdbcaef81b59c1348a5cbbb453303e49ee6ab53
|
|
BLAKE2b-256 checksum How to use checksums |
4dd244e69635e4d0ed36170ec8a1f3c78101f66586750f98628655085917bbc8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.11.15
|