@sdxc/digest-fields 0.0.0-stage → 2026.10.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sergio Xalambrí
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,148 @@
1
- # Temporary Holding Version
1
+ # @sdxc/digest-fields
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ RFC 9530 `Content-Digest`, `Repr-Digest` and their `Want-*` preferences, plus the RFC 3230 `Digest` header that draft-cavage HTTP signatures cover.
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ npm add @sdxc/digest-fields
9
+ ```
10
+
11
+ Every function answers a `Result` from [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result), which installs with this package. Digests run on the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest).
12
+
13
+ ## Usage
14
+
15
+ ```typescript
16
+ import { digest, stringify, verify } from "@sdxc/digest-fields";
17
+ import { isFailure, unwrap } from "@sdxc/result";
18
+
19
+ let body = new TextEncoder().encode(JSON.stringify(activity));
20
+ let bytes = unwrap(await digest(body, "sha-256"));
21
+
22
+ let headers = new Headers({
23
+ "content-digest": unwrap(stringify({ "sha-256": bytes }, "content-digest")), // "sha-256=:…:"
24
+ digest: unwrap(stringify({ "sha-256": bytes }, "digest")), // "SHA-256=…"
25
+ });
26
+
27
+ let checked = await verify(request.headers, await request.bytes(), { field: "content-digest" });
28
+ if (isFailure(checked)) return new Response(checked.error.message, { status: 400 });
29
+ ```
30
+
31
+ Reading a field into its value model:
32
+
33
+ ```typescript
34
+ import { parse } from "@sdxc/digest-fields";
35
+
36
+ parse("sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:", "content-digest");
37
+ // success({ "sha-256": Uint8Array(32) })
38
+
39
+ parse("sha-512=3, sha-256=10", "want-content-digest");
40
+ // success({ "sha-512": 3, "sha-256": 10 })
41
+ ```
42
+
43
+ ## API
44
+
45
+ ### `digest(bytes: BinaryLike, algorithm: DigestAlgorithm): Promise<Result<Uint8Array, DigestError>>`
46
+
47
+ Hashes a body with `sha-256` or `sha-512`. Text is read as UTF-8, so pass the exact bytes sent when the body is not UTF-8 text.
48
+
49
+ ```typescript
50
+ let bytes = await digest(body, "sha-256");
51
+ // same as
52
+ let bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", body));
53
+ ```
54
+
55
+ ### `parse(value: string, field: DigestField): Result<FieldValues[field], DigestError>`
56
+
57
+ 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 go through the Structured Field grammar of [`@sdxc/structured-fields`](https://www.npmjs.com/package/@sdxc/structured-fields). `Digest` accepts algorithm names in any case, and drops an entry for another algorithm whose value is not base64, such as `UNIXsum=30637`.
58
+
59
+ ### `stringify(value: FieldValues[field], field: DigestField): Result<string, DigestError>`
60
+
61
+ Writes a field. `Digest` uppercases algorithm names (`SHA-256=…`), which is how every draft-cavage verifier reads them. An empty value answers `""`, meaning "do not send the field".
62
+
63
+ ### `verify(headers: Headers, body: BinaryLike, options: { field: DigestValueField }): Promise<Result<void, DigestError>>`
64
+
65
+ Checks a body against `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, and entries for other algorithms are skipped.
66
+
67
+ ### `DIGEST_ALGORITHMS`
68
+
69
+ `["sha-256", "sha-512"]`, the algorithms `digest` and `verify` compute.
70
+
71
+ ### `DigestError`
72
+
73
+ The failure every function answers with. `code` says which check failed:
74
+
75
+ | `code` | When |
76
+ | ----------------------- | -------------------------------------------------------------- |
77
+ | `missing` | `verify` found no such field |
78
+ | `malformed` | The field text does not fit its grammar |
79
+ | `invalid` | `stringify` got a name the field cannot carry or a bad weight |
80
+ | `unsupported-algorithm` | No `sha-256`/`sha-512` entry to check, or `digest` got another |
81
+ | `mismatch` | The body differs from a digest |
82
+ | `crypto` | The runtime refused the hash |
83
+
84
+ ### Types
85
+
86
+ #### `DigestAlgorithm`
87
+
88
+ `"sha-256" | "sha-512"`.
89
+
90
+ #### `DigestField`
91
+
92
+ `"content-digest" | "repr-digest" | "want-content-digest" | "want-repr-digest" | "digest"`. `DigestValueField` is the three that carry digests.
93
+
94
+ #### `Digests` and `Preferences`
95
+
96
+ `Record<string, Uint8Array>` and `Record<string, number>`, keyed by lowercase algorithm name. `FieldValues` maps each field to the one it carries.
97
+
98
+ ## Pattern: Answering `Want-Content-Digest`
99
+
100
+ Send the algorithm the client weighs highest, falling back to `sha-256`.
101
+
102
+ ```typescript
103
+ import { DIGEST_ALGORITHMS, digest, parse, stringify } from "@sdxc/digest-fields";
104
+ import { isSuccess, unwrap } from "@sdxc/result";
105
+
106
+ function preferred(request: Request) {
107
+ let wanted = parse(request.headers.get("want-content-digest") ?? "", "want-content-digest");
108
+ if (!isSuccess(wanted)) return "sha-256";
109
+ let weights = wanted.data;
110
+ let ranked = DIGEST_ALGORITHMS.filter((name) => (weights[name] ?? 0) > 0);
111
+ ranked.sort((a, b) => (weights[b] ?? 0) - (weights[a] ?? 0));
112
+ return ranked[0] ?? "sha-256";
113
+ }
114
+
115
+ let body = new TextEncoder().encode(JSON.stringify(data));
116
+ let algorithm = preferred(request);
117
+ let bytes = unwrap(await digest(body, algorithm));
118
+
119
+ return new Response(body, {
120
+ headers: { "content-digest": unwrap(stringify({ [algorithm]: bytes }, "content-digest")) },
121
+ });
122
+ ```
123
+
124
+ ## Versioning
125
+
126
+ Releases are dated rather than semantic. A version is the UTC date it was published, written `YYYY.M.D`, so `2026.9.4` is the release from 4 September 2026. At most one release goes out per day.
127
+
128
+ Those numbers say when, not what: a later date means a later release and carries no compatibility promise. Any release may change or remove an export.
129
+
130
+ Depend on one exact date, and move it when you are ready to take the change:
131
+
132
+ ```json
133
+ {
134
+ "dependencies": {
135
+ "@sdxc/digest-fields": "2026.9.4"
136
+ }
137
+ }
138
+ ```
139
+
140
+ A caret or tilde range reads the date as major, minor and patch, so it accepts every later release in the same year. An exact version keeps the upgrade yours to schedule.
141
+
142
+ ## License
143
+
144
+ MIT
145
+
146
+ ## Author
147
+
148
+ [Sergio Xalambrí](https://sergiodxa.com)
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Computes the digest of a body with one of the algorithms the digest fields name, the
3
+ * value a sender writes into `Content-Digest` or `Digest` and a receiver recomputes.
4
+ *
5
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
6
+ * @copyright Sergio Xalambrí 2026
7
+ */
8
+ import type { BinaryLike, Bytes } from "@sdxc/crypto";
9
+ import type { Result } from "@sdxc/result";
10
+ import type { DigestAlgorithm } from "./types.js";
11
+ import { DigestError } from "./errors.js";
12
+ /** The algorithms this package computes, for narrowing a name read from a field. */
13
+ export declare const DIGEST_ALGORITHMS: readonly DigestAlgorithm[];
14
+ /**
15
+ * Whether a lowercase algorithm name is one `digest` computes.
16
+ *
17
+ * @param name - An algorithm name as a field spells it, already lowercased.
18
+ */
19
+ export declare function isDigestAlgorithm(name: string): name is DigestAlgorithm;
20
+ /**
21
+ * Digests a body. Text is read as UTF-8, so pass the exact bytes sent on the wire when a
22
+ * body is not UTF-8 text.
23
+ *
24
+ * @param bytes - The content (for `Content-Digest`) or representation (for `Repr-Digest`).
25
+ * @param algorithm - `sha-256` or `sha-512`.
26
+ * @returns The digest bytes, `unsupported-algorithm` for any other name, or `crypto` when
27
+ * the runtime refuses the hash.
28
+ * @example
29
+ * let bytes = await digest(body, "sha-256"); // success(32 bytes)
30
+ */
31
+ export declare function digest(bytes: BinaryLike, algorithm: DigestAlgorithm): Promise<Result<Bytes, DigestError>>;
package/dist/digest.js ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Computes the digest of a body with one of the algorithms the digest fields name, the
3
+ * value a sender writes into `Content-Digest` or `Digest` and a receiver recomputes.
4
+ *
5
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
6
+ * @copyright Sergio Xalambrí 2026
7
+ */
8
+ import { sha256, sha512 } from "@sdxc/crypto";
9
+ import { failure, isFailure, success } from "@sdxc/result";
10
+ import { DigestError } from "./errors.js";
11
+ /** The algorithms this package computes, for narrowing a name read from a field. */
12
+ export const DIGEST_ALGORITHMS = ["sha-256", "sha-512"];
13
+ /**
14
+ * Whether a lowercase algorithm name is one `digest` computes.
15
+ *
16
+ * @param name - An algorithm name as a field spells it, already lowercased.
17
+ */
18
+ export function isDigestAlgorithm(name) {
19
+ return DIGEST_ALGORITHMS.includes(name);
20
+ }
21
+ /**
22
+ * Digests a body. Text is read as UTF-8, so pass the exact bytes sent on the wire when a
23
+ * body is not UTF-8 text.
24
+ *
25
+ * @param bytes - The content (for `Content-Digest`) or representation (for `Repr-Digest`).
26
+ * @param algorithm - `sha-256` or `sha-512`.
27
+ * @returns The digest bytes, `unsupported-algorithm` for any other name, or `crypto` when
28
+ * the runtime refuses the hash.
29
+ * @example
30
+ * let bytes = await digest(body, "sha-256"); // success(32 bytes)
31
+ */
32
+ export async function digest(bytes, algorithm) {
33
+ if (!isDigestAlgorithm(algorithm)) {
34
+ return failure(new DigestError("unsupported-algorithm", "Unsupported digest algorithm"));
35
+ }
36
+ let hashed = algorithm === "sha-256" ? await sha256(bytes) : await sha512(bytes);
37
+ if (isFailure(hashed)) {
38
+ return failure(new DigestError("crypto", hashed.error.message, { cause: hashed.error }));
39
+ }
40
+ return success(hashed.data);
41
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The one failure every function in this package answers with, carrying a code a caller
3
+ * branches on, so a missing field, a malformed one and a body that does not match stay
4
+ * distinguishable without parsing messages.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ /**
10
+ * Why a digest field could not be read, written or verified. `mismatch` is the only code
11
+ * that says the content changed; every other code is about the field itself.
12
+ */
13
+ export type DigestErrorCode = "missing" | "malformed" | "invalid" | "unsupported-algorithm" | "mismatch" | "crypto";
14
+ /** Returned inside a `Failure` by every function in this package, never thrown. */
15
+ export declare class DigestError extends Error {
16
+ name: string;
17
+ /** Machine-readable cause, for a caller that maps it to its own error or status. */
18
+ readonly code: DigestErrorCode;
19
+ /**
20
+ * @param code - Why the field failed.
21
+ * @param message - The explanation a log shows.
22
+ * @param options - The underlying error, when one was raised.
23
+ */
24
+ constructor(code: DigestErrorCode, message: string, options?: ErrorOptions);
25
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The one failure every function in this package answers with, carrying a code a caller
3
+ * branches on, so a missing field, a malformed one and a body that does not match stay
4
+ * distinguishable without parsing messages.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ /** Returned inside a `Failure` by every function in this package, never thrown. */
10
+ export class DigestError extends Error {
11
+ name = "DigestError";
12
+ /** Machine-readable cause, for a caller that maps it to its own error or status. */
13
+ code;
14
+ /**
15
+ * @param code - Why the field failed.
16
+ * @param message - The explanation a log shows.
17
+ * @param options - The underlying error, when one was raised.
18
+ */
19
+ constructor(code, message, options) {
20
+ super(message, options);
21
+ this.code = code;
22
+ }
23
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Reads, writes and verifies the HTTP digest fields: RFC 9530's `Content-Digest`,
3
+ * `Repr-Digest` and their `Want-*` preferences, and RFC 3230's legacy `Digest` that
4
+ * draft-cavage HTTP signatures still cover.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ export type { DigestErrorCode } from "./errors.js";
10
+ export type { DigestAlgorithm, DigestField, DigestValueField, Digests, FieldValues, Preferences, } from "./types.js";
11
+ export type { VerifyOptions } from "./verify.js";
12
+ export { DIGEST_ALGORITHMS, digest } from "./digest.js";
13
+ export { DigestError } from "./errors.js";
14
+ export { parse } from "./parse.js";
15
+ export { stringify } from "./stringify.js";
16
+ export { verify } from "./verify.js";
package/dist/index.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Reads, writes and verifies the HTTP digest fields: RFC 9530's `Content-Digest`,
3
+ * `Repr-Digest` and their `Want-*` preferences, and RFC 3230's legacy `Digest` that
4
+ * draft-cavage HTTP signatures still cover.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ export { DIGEST_ALGORITHMS, digest } from "./digest.js";
10
+ export { DigestError } from "./errors.js";
11
+ export { parse } from "./parse.js";
12
+ export { stringify } from "./stringify.js";
13
+ export { verify } from "./verify.js";
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Reads a digest field into its value model: RFC 9530 fields through the Structured Field
3
+ * grammar, and RFC 3230's `Digest` through its own comma-separated `ALG=base64` grammar,
4
+ * so a caller holds one shape whichever field a sender used.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Result } from "@sdxc/result";
10
+ import type { DigestField, FieldValues } from "./types.js";
11
+ import { DigestError } from "./errors.js";
12
+ /**
13
+ * Parses a field value into its model.
14
+ *
15
+ * `Content-Digest` and `Repr-Digest` answer digest bytes keyed by algorithm, and the
16
+ * `Want-*` fields answer preference weights. `Digest` reads algorithm names in any case
17
+ * and keys them lowercase; an entry for an algorithm outside `sha-256`/`sha-512` whose
18
+ * value is not base64 (RFC 3230's `UNIXsum`, say) is left out.
19
+ *
20
+ * @param value - The field value, several field lines joined with `, ` as `Headers#get` does.
21
+ * @param field - Which field the value came from, lowercase.
22
+ * @returns The model, or `malformed` naming why the value does not fit the field.
23
+ * @example parse("sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:", "content-digest")
24
+ * @example parse("SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=", "digest")
25
+ */
26
+ export declare function parse<Field extends DigestField>(value: string, field: Field): Result<FieldValues[Field], DigestError>;
27
+ /**
28
+ * Whether a bare item is a valid preference weight.
29
+ *
30
+ * @param value - The member's bare item.
31
+ */
32
+ export declare function isPreference(value: unknown): value is number;
package/dist/parse.js ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Reads a digest field into its value model: RFC 9530 fields through the Structured Field
3
+ * grammar, and RFC 3230's `Digest` through its own comma-separated `ALG=base64` grammar,
4
+ * so a caller holds one shape whichever field a sender used.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { Base64 } from "@sdxc/crypto";
10
+ import { failure, isFailure, success } from "@sdxc/result";
11
+ import { parse as parseStructured } from "@sdxc/structured-fields";
12
+ import { isDigestAlgorithm } from "./digest.js";
13
+ import { DigestError } from "./errors.js";
14
+ /** The highest weight a `Want-*-Digest` preference may carry (RFC 9530 §4). */
15
+ const MAX_PREFERENCE = 10;
16
+ export function parse(value, field) {
17
+ if (field === "digest")
18
+ return parseLegacy(value);
19
+ if (field === "content-digest" || field === "repr-digest")
20
+ return parseDigests(value, field);
21
+ return parsePreferences(value, field);
22
+ }
23
+ /**
24
+ * Reads an RFC 9530 Dictionary whose members are Byte Sequences.
25
+ *
26
+ * @param value - The field value.
27
+ * @param field - The field name, for the message.
28
+ */
29
+ function parseDigests(value, field) {
30
+ let parsed = parseDictionary(value, field);
31
+ if (isFailure(parsed))
32
+ return parsed;
33
+ let entries = [];
34
+ for (let [algorithm, member] of Object.entries(parsed.data)) {
35
+ if (!("value" in member) || !(member.value instanceof Uint8Array)) {
36
+ return failure(malformed(field, `${algorithm} is not a byte sequence`));
37
+ }
38
+ entries.push([algorithm, member.value]);
39
+ }
40
+ return success(Object.fromEntries(entries));
41
+ }
42
+ /**
43
+ * Reads an RFC 9530 `Want-*` Dictionary whose members are Integers from 0 to 10.
44
+ *
45
+ * @param value - The field value.
46
+ * @param field - The field name, for the message.
47
+ */
48
+ function parsePreferences(value, field) {
49
+ let parsed = parseDictionary(value, field);
50
+ if (isFailure(parsed))
51
+ return parsed;
52
+ let entries = [];
53
+ for (let [algorithm, member] of Object.entries(parsed.data)) {
54
+ if (!("value" in member) || !isPreference(member.value)) {
55
+ return failure(malformed(field, `${algorithm} is not an integer from 0 to 10`));
56
+ }
57
+ entries.push([algorithm, member.value]);
58
+ }
59
+ return success(Object.fromEntries(entries));
60
+ }
61
+ /**
62
+ * Parses the Structured Field Dictionary every RFC 9530 field is defined as.
63
+ *
64
+ * @param value - The field value.
65
+ * @param field - The field name, for the message.
66
+ */
67
+ function parseDictionary(value, field) {
68
+ let parsed = parseStructured(value, "dictionary");
69
+ if (isFailure(parsed)) {
70
+ return failure(new DigestError("malformed", `Malformed ${field}: ${parsed.error.message}`, {
71
+ cause: parsed.error,
72
+ }));
73
+ }
74
+ return parsed;
75
+ }
76
+ /**
77
+ * Reads RFC 3230's `Digest`: `ALG=base64` entries separated by commas. Empty entries,
78
+ * which a trailing comma leaves, are skipped; a repeated algorithm fails, so a second
79
+ * entry can never stand in for a mismatching first.
80
+ *
81
+ * @param value - The field value.
82
+ */
83
+ function parseLegacy(value) {
84
+ let entries = [];
85
+ for (let entry of value.split(",")) {
86
+ let trimmed = entry.trim();
87
+ if (trimmed === "")
88
+ continue;
89
+ let separator = trimmed.indexOf("=");
90
+ if (separator <= 0)
91
+ return failure(malformed("digest", "an entry has no algorithm"));
92
+ let algorithm = trimmed.slice(0, separator).trim().toLowerCase();
93
+ if (entries.some(([seen]) => seen === algorithm)) {
94
+ return failure(malformed("digest", `${algorithm} is repeated`));
95
+ }
96
+ let decoded = Base64.decode(trimmed.slice(separator + 1).trim());
97
+ if (isFailure(decoded)) {
98
+ if (isDigestAlgorithm(algorithm)) {
99
+ return failure(malformed("digest", `${algorithm} is not base64`));
100
+ }
101
+ continue;
102
+ }
103
+ entries.push([algorithm, decoded.data]);
104
+ }
105
+ return success(Object.fromEntries(entries));
106
+ }
107
+ /**
108
+ * Whether a bare item is a valid preference weight.
109
+ *
110
+ * @param value - The member's bare item.
111
+ */
112
+ export function isPreference(value) {
113
+ return (typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= MAX_PREFERENCE);
114
+ }
115
+ /**
116
+ * A `malformed` failure naming the field and the reason.
117
+ *
118
+ * @param field - The field name.
119
+ * @param reason - What is wrong with it.
120
+ */
121
+ function malformed(field, reason) {
122
+ return new DigestError("malformed", `Malformed ${field}: ${reason}`);
123
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Writes a digest field from its value model: RFC 9530 fields as canonical Structured
3
+ * Field Dictionaries, and RFC 3230's `Digest` with the uppercase algorithm names and
4
+ * padded base64 that every draft-cavage verifier reads.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Result } from "@sdxc/result";
10
+ import type { DigestField, FieldValues } from "./types.js";
11
+ import { DigestError } from "./errors.js";
12
+ /**
13
+ * Serializes a field value. An empty value answers `""`, which a caller reads as "do not
14
+ * send the field".
15
+ *
16
+ * @param value - Digest bytes, or preference weights for a `Want-*` field, keyed by algorithm.
17
+ * @param field - Which field to write, lowercase.
18
+ * @returns The field text, or `invalid` for an algorithm name the field cannot carry or a
19
+ * weight outside 0 to 10.
20
+ * @example stringify({ "sha-256": bytes }, "content-digest") // success("sha-256=:…:")
21
+ * @example stringify({ "sha-256": bytes }, "digest") // success("SHA-256=…")
22
+ */
23
+ export declare function stringify<Field extends DigestField>(value: FieldValues[Field], field: Field): Result<string, DigestError>;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Writes a digest field from its value model: RFC 9530 fields as canonical Structured
3
+ * Field Dictionaries, and RFC 3230's `Digest` with the uppercase algorithm names and
4
+ * padded base64 that every draft-cavage verifier reads.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { Base64 } from "@sdxc/crypto";
10
+ import { failure, isFailure, success } from "@sdxc/result";
11
+ import { stringify as stringifyStructured } from "@sdxc/structured-fields";
12
+ import { DigestError } from "./errors.js";
13
+ import { isPreference } from "./parse.js";
14
+ /** An RFC 3230 algorithm name: a token, which keeps `=` and `,` out of it. */
15
+ const LEGACY_ALGORITHM_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
16
+ export function stringify(value, field) {
17
+ if (field === "digest")
18
+ return stringifyLegacy(value);
19
+ if (field === "content-digest" || field === "repr-digest") {
20
+ return stringifyDictionary(value, field);
21
+ }
22
+ for (let [algorithm, weight] of Object.entries(value)) {
23
+ if (!isPreference(weight)) {
24
+ return failure(new DigestError("invalid", `${algorithm} must weigh an integer from 0 to 10`));
25
+ }
26
+ }
27
+ return stringifyDictionary(value, field);
28
+ }
29
+ /**
30
+ * Writes an RFC 9530 Dictionary, whose keys must be lowercase Structured Field keys.
31
+ *
32
+ * @param value - The members, byte sequences or integers.
33
+ * @param field - The field name, for the message.
34
+ */
35
+ function stringifyDictionary(value, field) {
36
+ let text = stringifyStructured(value, "dictionary");
37
+ if (isFailure(text)) {
38
+ return failure(new DigestError("invalid", `Invalid ${field}: ${text.error.message}`, { cause: text.error }));
39
+ }
40
+ return success(text.data);
41
+ }
42
+ /**
43
+ * Writes RFC 3230's `Digest`, with algorithm names uppercased as its registry spells them.
44
+ *
45
+ * @param value - Digest bytes keyed by algorithm.
46
+ */
47
+ function stringifyLegacy(value) {
48
+ let entries = [];
49
+ for (let [algorithm, bytes] of Object.entries(value)) {
50
+ if (!LEGACY_ALGORITHM_PATTERN.test(algorithm)) {
51
+ return failure(new DigestError("invalid", "Invalid digest: an algorithm is not a token"));
52
+ }
53
+ entries.push(`${algorithm.toUpperCase()}=${Base64.encode(bytes)}`);
54
+ }
55
+ return success(entries.join(", "));
56
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The value model of the digest fields: which fields exist, which algorithms this package
3
+ * computes, and what each field parses to, so `parse` and `stringify` agree on one shape
4
+ * per field.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ /** The algorithms `digest` and `verify` compute, spelled as RFC 9530 registers them. */
10
+ export type DigestAlgorithm = "sha-256" | "sha-512";
11
+ /**
12
+ * The fields this package reads and writes: the four of RFC 9530, and RFC 3230's legacy
13
+ * `Digest`, which draft-cavage HTTP signatures cover.
14
+ */
15
+ export type DigestField = "content-digest" | "repr-digest" | "want-content-digest" | "want-repr-digest" | "digest";
16
+ /** The fields that carry digests of a body, as opposed to preferences for one. */
17
+ export type DigestValueField = "content-digest" | "repr-digest" | "digest";
18
+ /**
19
+ * Digest bytes keyed by lowercase algorithm name. Algorithms this package does not compute
20
+ * are kept, so a field round-trips; `verify` checks only `sha-256` and `sha-512`.
21
+ */
22
+ export type Digests = Record<string, Uint8Array>;
23
+ /**
24
+ * Preference weights keyed by lowercase algorithm name, from `0` (not acceptable) to `10`
25
+ * (most preferred), as `Want-Content-Digest` and `Want-Repr-Digest` carry them.
26
+ */
27
+ export type Preferences = Record<string, number>;
28
+ /** What each field parses to and what `stringify` accepts for it. */
29
+ export interface FieldValues {
30
+ "content-digest": Digests;
31
+ "repr-digest": Digests;
32
+ digest: Digests;
33
+ "want-content-digest": Preferences;
34
+ "want-repr-digest": Preferences;
35
+ }
package/dist/types.js ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The value model of the digest fields: which fields exist, which algorithms this package
3
+ * computes, and what each field parses to, so `parse` and `stringify` agree on one shape
4
+ * per field.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Checks a body against the digest field a sender attached, the step that ties a signed
3
+ * header to the bytes it describes: an HTTP signature covers the field, and this proves
4
+ * the field matches the content.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { BinaryLike } from "@sdxc/crypto";
10
+ import type { Result } from "@sdxc/result";
11
+ import type { DigestValueField } from "./types.js";
12
+ import { DigestError } from "./errors.js";
13
+ /** What `verify` needs besides the headers and the body. */
14
+ export interface VerifyOptions {
15
+ /** The field to check, lowercase: `content-digest`, `repr-digest` or legacy `digest`. */
16
+ field: DigestValueField;
17
+ }
18
+ /**
19
+ * Verifies a body against a digest field. Every `sha-256` and `sha-512` entry the field
20
+ * carries must match, so one forged entry next to a valid one still fails; entries for
21
+ * other algorithms are skipped.
22
+ *
23
+ * @param headers - The message headers holding the field.
24
+ * @param body - The exact bytes received; text is read as UTF-8.
25
+ * @param options - Which field to check.
26
+ * @returns Success when every supported entry matches, else `missing`, `malformed`,
27
+ * `unsupported-algorithm` (no `sha-256` or `sha-512` entry), `mismatch` or `crypto`.
28
+ * @example await verify(request.headers, body, { field: "content-digest" })
29
+ */
30
+ export declare function verify(headers: Headers, body: BinaryLike, options: VerifyOptions): Promise<Result<void, DigestError>>;
package/dist/verify.js ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Checks a body against the digest field a sender attached, the step that ties a signed
3
+ * header to the bytes it describes: an HTTP signature covers the field, and this proves
4
+ * the field matches the content.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { timingSafeEqual } from "@sdxc/crypto";
10
+ import { failure, isFailure, success } from "@sdxc/result";
11
+ import { digest, isDigestAlgorithm } from "./digest.js";
12
+ import { DigestError } from "./errors.js";
13
+ import { parse } from "./parse.js";
14
+ /**
15
+ * Verifies a body against a digest field. Every `sha-256` and `sha-512` entry the field
16
+ * carries must match, so one forged entry next to a valid one still fails; entries for
17
+ * other algorithms are skipped.
18
+ *
19
+ * @param headers - The message headers holding the field.
20
+ * @param body - The exact bytes received; text is read as UTF-8.
21
+ * @param options - Which field to check.
22
+ * @returns Success when every supported entry matches, else `missing`, `malformed`,
23
+ * `unsupported-algorithm` (no `sha-256` or `sha-512` entry), `mismatch` or `crypto`.
24
+ * @example await verify(request.headers, body, { field: "content-digest" })
25
+ */
26
+ export async function verify(headers, body, options) {
27
+ let text = headers.get(options.field);
28
+ if (text === null)
29
+ return failure(new DigestError("missing", `Missing ${options.field}`));
30
+ let parsed = parse(text, options.field);
31
+ if (isFailure(parsed))
32
+ return parsed;
33
+ let checked = 0;
34
+ for (let [algorithm, expected] of Object.entries(parsed.data)) {
35
+ if (!isDigestAlgorithm(algorithm))
36
+ continue;
37
+ let actual = await digest(body, algorithm);
38
+ if (isFailure(actual))
39
+ return actual;
40
+ if (!timingSafeEqual(actual.data, expected)) {
41
+ return failure(new DigestError("mismatch", `The body does not match its ${options.field} ${algorithm}`));
42
+ }
43
+ checked++;
44
+ }
45
+ if (checked === 0) {
46
+ return failure(new DigestError("unsupported-algorithm", `${options.field} carries no sha-256 or sha-512`));
47
+ }
48
+ return success(undefined);
49
+ }
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
- "name": "@sdxc/digest-fields",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@sdxc/digest-fields",
3
+ "version": "2026.10.9",
4
+ "description": "RFC 9530 Content-Digest and Repr-Digest, plus the RFC 3230 Digest header",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./dist/index.js"
9
+ },
10
+ "dependencies": {
11
+ "@sdxc/crypto": "2026.10.9",
12
+ "@sdxc/result": "2026.10.6",
13
+ "@sdxc/structured-fields": "2026.10.6"
14
+ },
15
+ "gitHead": "64fa732ed0a8ca8693a35aeef6345ae5be2534ef",
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/sergiodxa/monorepo.git",
22
+ "directory": "packages/digest-fields"
23
+ },
24
+ "homepage": "https://sdxc.sergiodxa.com/api/digest-fields",
25
+ "author": {
26
+ "name": "Sergio Xalambrí",
27
+ "url": "https://sergiodxa.com"
28
+ },
29
+ "funding": {
30
+ "type": "github",
31
+ "url": "https://github.com/sponsors/sergiodxa"
32
+ }
33
+ }