sdxc

[ HTTP & responses ]

@sdxc/digest-fields

RFC 9530 Content-Digest and Repr-Digest, plus the RFC 3230 Digest header

npm add @sdxc/digest-fields
pnpm add @sdxc/digest-fields
yarn add @sdxc/digest-fields
bun add @sdxc/digest-fields

RFC 9530 Content-Digest and Repr-Digest, plus the RFC 3230 Digest header.

Compute, write, read and verify the HTTP digest fields, including the Want-Content-Digest and Want-Repr-Digest preferences and the legacy Digest header that draft-cavage HTTP signatures cover.

Installation

npm add @sdxc/digest-fields

Every function answers a Result from @sdxc/result, which installs with this package. Digests run on the Web Crypto API.

Usage

Digest a body

import { digest } from "@sdxc/digest-fields";
import { unwrap } from "@sdxc/result";

let body = new TextEncoder().encode(JSON.stringify({ message: "Hello" }));
let bytes = unwrap(await digest(body, "sha-256")); // Uint8Array(32)

Write the field

import { stringify } from "@sdxc/digest-fields";

stringify({ "sha-256": bytes }, "content-digest"); // success("sha-256=:…:")
stringify({ "sha-256": bytes }, "digest"); // success("SHA-256=…")
stringify({ "sha-512": 3, "sha-256": 10 }, "want-content-digest"); // success("sha-512=3, sha-256=10")

Read a field

import { parse } from "@sdxc/digest-fields";

parse("sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:", "content-digest");
// success({ "sha-256": Uint8Array(32) })

parse("SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=", "digest");
// success({ "sha-256": Uint8Array(32) })

parse("sha-512=3, sha-256=10", "want-content-digest");
// success({ "sha-512": 3, "sha-256": 10 })

Verify a received body

import { verify } from "@sdxc/digest-fields";
import { isFailure } from "@sdxc/result";

let checked = await verify(request.headers, await request.bytes(), { field: "content-digest" });
if (isFailure(checked)) return new Response(checked.error.message, { status: 400 });

API

digest(bytes: BinaryLike, algorithm: DigestAlgorithm): Promise<Result<Uint8Array, DigestError>>

Hashes a body with sha-256 or sha-512. BinaryLike is string | Uint8Array | ArrayBuffer; text is read as UTF-8, so pass the exact bytes sent when the body is not UTF-8 text.

let bytes = await digest(body, "sha-256");
// same as
let bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", body));

parse(value: string, field: DigestField): Result<FieldValues[field], DigestError>

Reads a field into digest bytes (content-digest, repr-digest, digest) or preference weights from 0 to 10 (want-content-digest, want-repr-digest), keyed by lowercase algorithm name. The RFC 9530 fields follow the Structured Field grammar of @sdxc/structured-fields. Digest accepts algorithm names in any case, fails on a repeated algorithm, and skips an entry for another algorithm whose value is not base64, such as UNIXsum=30637.

stringify(value: FieldValues[field], field: DigestField): Result<string, DigestError>

Writes a field. Digest uppercases algorithm names (SHA-256=…), the form draft-cavage verifiers read. An empty value answers "", meaning "do not send the field".

verify(headers: Headers, body: BinaryLike, options: VerifyOptions): Promise<Result<void, DigestError>>

Checks a body against options.field: content-digest, repr-digest or digest. Every sha-256 and sha-512 entry must match, so a forged entry next to a valid one still fails; entries for other algorithms are skipped.

DIGEST_ALGORITHMS

["sha-256", "sha-512"], the algorithms digest and verify compute.

DigestError

The failure every function answers with; code says which check failed:

codeWhen
missingverify found no such field
malformedThe field text does not fit its grammar
invalidstringify got a name the field cannot carry or a bad weight
unsupported-algorithmNo sha-256/sha-512 entry to check, or digest got another
mismatchThe body differs from a digest
cryptoThe runtime refused the hash

Types

DigestAlgorithm

"sha-256" | "sha-512".

DigestField and DigestValueField

"content-digest" | "repr-digest" | "want-content-digest" | "want-repr-digest" | "digest". DigestValueField is the three that carry digests.

Digests, Preferences and FieldValues

Record<string, Uint8Array> and Record<string, number>, keyed by lowercase algorithm name. FieldValues maps each field to the one it carries.

DigestErrorCode

The union of DigestError codes in the table above.

VerifyOptions

{ field: DigestValueField }.

Pattern: Signing-Ready Outbound Request

Attach both fields so RFC 9421 and draft-cavage verifiers each find the one their signature covers.

import { digest, stringify } from "@sdxc/digest-fields";
import { unwrap } from "@sdxc/result";

let body = new TextEncoder().encode(JSON.stringify({ type: "Create" }));
let digests = { "sha-256": unwrap(await digest(body, "sha-256")) };

let request = new Request("https://inbox.example.com/", {
	method: "POST",
	body,
	headers: {
		"content-type": "application/json",
		"content-digest": unwrap(stringify(digests, "content-digest")),
		digest: unwrap(stringify(digests, "digest")),
	},
});

Pattern: Verifying Whichever Field the Sender Used

Check Content-Digest first, fall back to Digest, and map each failure to a status.

import { verify } from "@sdxc/digest-fields";
import { isFailure } from "@sdxc/result";

async function checkBody(request: Request) {
	let body = await request.bytes();
	let field = request.headers.has("content-digest") ? "content-digest" : "digest";
	let checked = await verify(request.headers, body, { field });
	if (!isFailure(checked)) return body;

	switch (checked.error.code) {
		case "mismatch":
			return new Response("Body does not match its digest", { status: 422 });
		case "crypto":
			return new Response(null, { status: 500 });
		default:
			return new Response(checked.error.message, { status: 400 });
	}
}

Pattern: Answering Want-Content-Digest

Send the algorithm the client weighs highest, falling back to sha-256.

import { DIGEST_ALGORITHMS, digest, parse, stringify } from "@sdxc/digest-fields";
import { isSuccess, unwrap } from "@sdxc/result";

function preferred(request: Request) {
	let wanted = parse(request.headers.get("want-content-digest") ?? "", "want-content-digest");
	if (!isSuccess(wanted)) return "sha-256";
	let weights = wanted.data;
	let ranked = DIGEST_ALGORITHMS.filter((name) => (weights[name] ?? 0) > 0);
	ranked.sort((a, b) => (weights[b] ?? 0) - (weights[a] ?? 0));
	return ranked[0] ?? "sha-256";
}

async function respond(request: Request, data: unknown) {
	let body = new TextEncoder().encode(JSON.stringify(data));
	let algorithm = preferred(request);
	let bytes = unwrap(await digest(body, algorithm));

	return new Response(body, {
		headers: { "content-digest": unwrap(stringify({ [algorithm]: bytes }, "content-digest")) },
	});
}

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.