@algovoi/rfc9421-verifier 0.2.0 → 0.2.1

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/dist/verify.js CHANGED
@@ -111,10 +111,10 @@ export async function verifyRequest(input) {
111
111
  return fail(result, `Signature label ${sigParsed.label} does not match Signature-Input label ${parsedSi.label}`);
112
112
  }
113
113
  const requireCd = input.requireContentDigest ?? true;
114
- // null = no algorithm requirement (accept any); undefined = default to sha-256
115
- const requireAlg = input.requireAlgorithm === null
116
- ? undefined
117
- : (input.requireAlgorithm ?? "sha-256");
114
+ // string = enforce that specific algorithm; null or undefined = accept any
115
+ // supported algorithm present in the Content-Digest header (SHA-256 or
116
+ // SHA-512 in this v0.2.x). Default is permissive.
117
+ const requireAlg = input.requireAlgorithm == null ? undefined : input.requireAlgorithm;
118
118
  if (requireCd) {
119
119
  const cdHeader = normHeaders["content-digest"];
120
120
  if (!cdHeader)
package/package.json CHANGED
@@ -1,62 +1,62 @@
1
- {
2
- "name": "@algovoi/rfc9421-verifier",
3
- "version": "0.2.0",
4
- "description": "AlgoVoi RFC 9421 HTTP Message Signatures + RFC 9530 Content-Digest reference verifier",
5
- "keywords": [
6
- "rfc9421",
7
- "rfc9530",
8
- "http-signatures",
9
- "ed25519",
10
- "agentic-payments",
11
- "compliance"
12
- ],
13
- "homepage": "https://github.com/chopmob-cloud/algovoi-rfc9421-verifier",
14
- "repository": {
15
- "type": "git",
16
- "url": "https://github.com/chopmob-cloud/algovoi-rfc9421-verifier.git",
17
- "directory": "typescript"
18
- },
19
- "bugs": {
20
- "url": "https://github.com/chopmob-cloud/algovoi-rfc9421-verifier/issues"
21
- },
22
- "license": "Apache-2.0",
23
- "author": "AlgoVoi <chopmob@gmail.com>",
24
- "type": "module",
25
- "main": "./dist/index.js",
26
- "module": "./dist/index.js",
27
- "types": "./dist/index.d.ts",
28
- "exports": {
29
- ".": {
30
- "import": "./dist/index.js",
31
- "types": "./dist/index.d.ts"
32
- }
33
- },
34
- "files": [
35
- "dist/**/*",
36
- "src/**/*",
37
- "README.md",
38
- "LICENSE"
39
- ],
40
- "scripts": {
41
- "build": "tsc",
42
- "test": "vitest run",
43
- "test:watch": "vitest",
44
- "clean": "rimraf dist",
45
- "prepublishOnly": "npm run clean && npm run build && npm test"
46
- },
47
- "engines": {
48
- "node": ">=18"
49
- },
50
- "dependencies": {
51
- "@noble/ed25519": "^2.1.0"
52
- },
53
- "devDependencies": {
54
- "@types/node": "^20.0.0",
55
- "rimraf": "^5.0.0",
56
- "typescript": "^5.4.0",
57
- "vitest": "^1.6.0"
58
- },
59
- "publishConfig": {
60
- "access": "public"
61
- }
62
- }
1
+ {
2
+ "name": "@algovoi/rfc9421-verifier",
3
+ "version": "0.2.1",
4
+ "description": "AlgoVoi RFC 9421 HTTP Message Signatures + RFC 9530 Content-Digest reference verifier",
5
+ "keywords": [
6
+ "rfc9421",
7
+ "rfc9530",
8
+ "http-signatures",
9
+ "ed25519",
10
+ "agentic-payments",
11
+ "compliance"
12
+ ],
13
+ "homepage": "https://github.com/chopmob-cloud/algovoi-rfc9421-verifier",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/chopmob-cloud/algovoi-rfc9421-verifier.git",
17
+ "directory": "typescript"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/chopmob-cloud/algovoi-rfc9421-verifier/issues"
21
+ },
22
+ "license": "Apache-2.0",
23
+ "author": "AlgoVoi <chopmob@gmail.com>",
24
+ "type": "module",
25
+ "main": "./dist/index.js",
26
+ "module": "./dist/index.js",
27
+ "types": "./dist/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "import": "./dist/index.js",
31
+ "types": "./dist/index.d.ts"
32
+ }
33
+ },
34
+ "files": [
35
+ "dist/**/*",
36
+ "src/**/*",
37
+ "README.md",
38
+ "LICENSE"
39
+ ],
40
+ "scripts": {
41
+ "build": "tsc",
42
+ "test": "vitest run",
43
+ "test:watch": "vitest",
44
+ "clean": "rimraf dist",
45
+ "prepublishOnly": "npm run clean && npm run build && npm test"
46
+ },
47
+ "engines": {
48
+ "node": ">=18"
49
+ },
50
+ "dependencies": {
51
+ "@noble/ed25519": "^2.1.0"
52
+ },
53
+ "devDependencies": {
54
+ "@types/node": "^20.0.0",
55
+ "rimraf": "^5.0.0",
56
+ "typescript": "^5.4.0",
57
+ "vitest": "^1.6.0"
58
+ },
59
+ "publishConfig": {
60
+ "access": "public"
61
+ }
62
+ }
package/src/parse.ts CHANGED
@@ -1,148 +1,148 @@
1
- /**
2
- * RFC 9421 Signature-Input and Signature header parsers.
3
- *
4
- * Mirror of the Python algovoi_rfc9421_verifier.parse module.
5
- */
6
-
7
- export class SignatureInputParseError extends Error {
8
- constructor(message: string) {
9
- super(message);
10
- this.name = "SignatureInputParseError";
11
- }
12
- }
13
-
14
- export interface ParsedSignatureInput {
15
- label: string;
16
- covered_components: string[];
17
- parameters: Record<string, string | number>;
18
- raw: string;
19
- /**
20
- * The post-label portion of the Signature-Input header value, i.e.
21
- * the Inner List + parameters block exactly as it appeared on the
22
- * wire. This is the value that the @signature-params line in the
23
- * RFC 9421 §2.5 signing base must carry. Empty string if the input
24
- * was the unlabelled form.
25
- */
26
- params_block: string;
27
- }
28
-
29
- const LABEL_RE = /^\s*([A-Za-z][A-Za-z0-9_-]*)\s*=\s*/;
30
- const COVERED_RE = /\(\s*(?:"[^"]*"\s*)*\)/;
31
- const QUOTED_RE = /"([^"]*)"/g;
32
- const PARAM_RE = /([A-Za-z][A-Za-z0-9_-]*)=([^;,\s]+|"[^"]*")/g;
33
-
34
- export function parseSignatureInput(headerValue: string): ParsedSignatureInput {
35
- if (typeof headerValue !== "string") {
36
- throw new SignatureInputParseError(
37
- `header must be string, got ${typeof headerValue}`,
38
- );
39
- }
40
- const trimmed = headerValue.trim();
41
- if (trimmed.length === 0) {
42
- throw new SignatureInputParseError("empty header value");
43
- }
44
-
45
- let label: string;
46
- let rest: string;
47
-
48
- const labelMatch = LABEL_RE.exec(trimmed);
49
- if (labelMatch) {
50
- label = labelMatch[1];
51
- rest = trimmed.slice(labelMatch[0].length);
52
- } else if (trimmed.startsWith("(")) {
53
- label = "";
54
- rest = trimmed;
55
- } else {
56
- throw new SignatureInputParseError(
57
- `no label or covered-components list found at start: ${JSON.stringify(trimmed.slice(0, 40))}`,
58
- );
59
- }
60
-
61
- // Capture the post-label portion verbatim. This is what the
62
- // @signature-params line of the RFC 9421 signing base must contain.
63
- const paramsBlock = rest;
64
-
65
- const coveredMatch = COVERED_RE.exec(rest);
66
- if (!coveredMatch) {
67
- throw new SignatureInputParseError("no covered-components list found");
68
- }
69
- const coveredRaw = coveredMatch[0];
70
- const covered: string[] = [];
71
- let qm: RegExpExecArray | null;
72
- const localQuoted = /"([^"]*)"/g;
73
- while ((qm = localQuoted.exec(coveredRaw)) !== null) {
74
- covered.push(qm[1]);
75
- }
76
- rest = rest.slice(coveredMatch.index + coveredRaw.length);
77
-
78
- const parameters: Record<string, string | number> = {};
79
- const localParam = new RegExp(PARAM_RE.source, "g");
80
- let pm: RegExpExecArray | null;
81
- while ((pm = localParam.exec(rest)) !== null) {
82
- const key = pm[1];
83
- const raw = pm[2];
84
- if (raw.startsWith('"') && raw.endsWith('"')) {
85
- parameters[key] = raw.slice(1, -1);
86
- } else if (/^-?\d+$/.test(raw)) {
87
- parameters[key] = parseInt(raw, 10);
88
- } else {
89
- parameters[key] = raw;
90
- }
91
- }
92
-
93
- return {
94
- label,
95
- covered_components: covered,
96
- parameters,
97
- raw: trimmed,
98
- params_block: paramsBlock,
99
- };
100
- }
101
-
102
- export function parseSignatureValue(headerValue: string): {
103
- label: string;
104
- signature: Uint8Array;
105
- } {
106
- if (typeof headerValue !== "string") {
107
- throw new SignatureInputParseError(
108
- `header must be string, got ${typeof headerValue}`,
109
- );
110
- }
111
- const trimmed = headerValue.trim();
112
- if (trimmed.length === 0) {
113
- throw new SignatureInputParseError("empty Signature header value");
114
- }
115
-
116
- let label: string;
117
- let rest: string;
118
-
119
- const labelMatch = LABEL_RE.exec(trimmed);
120
- if (labelMatch) {
121
- label = labelMatch[1];
122
- rest = trimmed.slice(labelMatch[0].length).trim();
123
- } else if (trimmed.startsWith(":")) {
124
- label = "";
125
- rest = trimmed;
126
- } else {
127
- throw new SignatureInputParseError(
128
- `no label or signature-value prefix found at start: ${JSON.stringify(trimmed.slice(0, 40))}`,
129
- );
130
- }
131
-
132
- if (!rest.startsWith(":") || !rest.endsWith(":")) {
133
- throw new SignatureInputParseError(
134
- "signature value must be wrapped in colons (RFC 8941 byte-sequence form)",
135
- );
136
- }
137
-
138
- const sigB64 = rest.slice(1, -1);
139
- let sigBytes: Uint8Array;
140
- try {
141
- sigBytes = new Uint8Array(Buffer.from(sigB64, "base64"));
142
- } catch (e) {
143
- throw new SignatureInputParseError(
144
- `signature value is not valid base64: ${(e as Error).message}`,
145
- );
146
- }
147
- return { label, signature: sigBytes };
148
- }
1
+ /**
2
+ * RFC 9421 Signature-Input and Signature header parsers.
3
+ *
4
+ * Mirror of the Python algovoi_rfc9421_verifier.parse module.
5
+ */
6
+
7
+ export class SignatureInputParseError extends Error {
8
+ constructor(message: string) {
9
+ super(message);
10
+ this.name = "SignatureInputParseError";
11
+ }
12
+ }
13
+
14
+ export interface ParsedSignatureInput {
15
+ label: string;
16
+ covered_components: string[];
17
+ parameters: Record<string, string | number>;
18
+ raw: string;
19
+ /**
20
+ * The post-label portion of the Signature-Input header value, i.e.
21
+ * the Inner List + parameters block exactly as it appeared on the
22
+ * wire. This is the value that the @signature-params line in the
23
+ * RFC 9421 §2.5 signing base must carry. Empty string if the input
24
+ * was the unlabelled form.
25
+ */
26
+ params_block: string;
27
+ }
28
+
29
+ const LABEL_RE = /^\s*([A-Za-z][A-Za-z0-9_-]*)\s*=\s*/;
30
+ const COVERED_RE = /\(\s*(?:"[^"]*"\s*)*\)/;
31
+ const QUOTED_RE = /"([^"]*)"/g;
32
+ const PARAM_RE = /([A-Za-z][A-Za-z0-9_-]*)=([^;,\s]+|"[^"]*")/g;
33
+
34
+ export function parseSignatureInput(headerValue: string): ParsedSignatureInput {
35
+ if (typeof headerValue !== "string") {
36
+ throw new SignatureInputParseError(
37
+ `header must be string, got ${typeof headerValue}`,
38
+ );
39
+ }
40
+ const trimmed = headerValue.trim();
41
+ if (trimmed.length === 0) {
42
+ throw new SignatureInputParseError("empty header value");
43
+ }
44
+
45
+ let label: string;
46
+ let rest: string;
47
+
48
+ const labelMatch = LABEL_RE.exec(trimmed);
49
+ if (labelMatch) {
50
+ label = labelMatch[1];
51
+ rest = trimmed.slice(labelMatch[0].length);
52
+ } else if (trimmed.startsWith("(")) {
53
+ label = "";
54
+ rest = trimmed;
55
+ } else {
56
+ throw new SignatureInputParseError(
57
+ `no label or covered-components list found at start: ${JSON.stringify(trimmed.slice(0, 40))}`,
58
+ );
59
+ }
60
+
61
+ // Capture the post-label portion verbatim. This is what the
62
+ // @signature-params line of the RFC 9421 signing base must contain.
63
+ const paramsBlock = rest;
64
+
65
+ const coveredMatch = COVERED_RE.exec(rest);
66
+ if (!coveredMatch) {
67
+ throw new SignatureInputParseError("no covered-components list found");
68
+ }
69
+ const coveredRaw = coveredMatch[0];
70
+ const covered: string[] = [];
71
+ let qm: RegExpExecArray | null;
72
+ const localQuoted = /"([^"]*)"/g;
73
+ while ((qm = localQuoted.exec(coveredRaw)) !== null) {
74
+ covered.push(qm[1]);
75
+ }
76
+ rest = rest.slice(coveredMatch.index + coveredRaw.length);
77
+
78
+ const parameters: Record<string, string | number> = {};
79
+ const localParam = new RegExp(PARAM_RE.source, "g");
80
+ let pm: RegExpExecArray | null;
81
+ while ((pm = localParam.exec(rest)) !== null) {
82
+ const key = pm[1];
83
+ const raw = pm[2];
84
+ if (raw.startsWith('"') && raw.endsWith('"')) {
85
+ parameters[key] = raw.slice(1, -1);
86
+ } else if (/^-?\d+$/.test(raw)) {
87
+ parameters[key] = parseInt(raw, 10);
88
+ } else {
89
+ parameters[key] = raw;
90
+ }
91
+ }
92
+
93
+ return {
94
+ label,
95
+ covered_components: covered,
96
+ parameters,
97
+ raw: trimmed,
98
+ params_block: paramsBlock,
99
+ };
100
+ }
101
+
102
+ export function parseSignatureValue(headerValue: string): {
103
+ label: string;
104
+ signature: Uint8Array;
105
+ } {
106
+ if (typeof headerValue !== "string") {
107
+ throw new SignatureInputParseError(
108
+ `header must be string, got ${typeof headerValue}`,
109
+ );
110
+ }
111
+ const trimmed = headerValue.trim();
112
+ if (trimmed.length === 0) {
113
+ throw new SignatureInputParseError("empty Signature header value");
114
+ }
115
+
116
+ let label: string;
117
+ let rest: string;
118
+
119
+ const labelMatch = LABEL_RE.exec(trimmed);
120
+ if (labelMatch) {
121
+ label = labelMatch[1];
122
+ rest = trimmed.slice(labelMatch[0].length).trim();
123
+ } else if (trimmed.startsWith(":")) {
124
+ label = "";
125
+ rest = trimmed;
126
+ } else {
127
+ throw new SignatureInputParseError(
128
+ `no label or signature-value prefix found at start: ${JSON.stringify(trimmed.slice(0, 40))}`,
129
+ );
130
+ }
131
+
132
+ if (!rest.startsWith(":") || !rest.endsWith(":")) {
133
+ throw new SignatureInputParseError(
134
+ "signature value must be wrapped in colons (RFC 8941 byte-sequence form)",
135
+ );
136
+ }
137
+
138
+ const sigB64 = rest.slice(1, -1);
139
+ let sigBytes: Uint8Array;
140
+ try {
141
+ sigBytes = new Uint8Array(Buffer.from(sigB64, "base64"));
142
+ } catch (e) {
143
+ throw new SignatureInputParseError(
144
+ `signature value is not valid base64: ${(e as Error).message}`,
145
+ );
146
+ }
147
+ return { label, signature: sigBytes };
148
+ }
@@ -1,139 +1,139 @@
1
- /**
2
- * RFC 9421 Section 2.5 signing-base construction.
3
- *
4
- * Mirror of the Python algovoi_rfc9421_verifier.signing_base module.
5
- */
6
-
7
- export class SigningBaseError extends Error {
8
- constructor(message: string) {
9
- super(message);
10
- this.name = "SigningBaseError";
11
- }
12
- }
13
-
14
- export type SigningBaseMode = "algovoi-v0" | "rfc9421";
15
-
16
- export interface SigningBaseInput {
17
- coveredComponents: string[];
18
- method?: string;
19
- authority?: string;
20
- path?: string;
21
- targetUri?: string;
22
- scheme?: string;
23
- status?: number;
24
- headers?: Record<string, string>;
25
- parameters?: Record<string, string | number>;
26
- /**
27
- * Signing-base mode.
28
- * - "algovoi-v0" (default): preserves the v0.1.0 behaviour for
29
- * backward compatibility with the AlgoVoi internal fixture and
30
- * the rfc9421_proxy_chain_v0 conformance set. @method is
31
- * lowercased and no @signature-params line is appended.
32
- * - "rfc9421": full RFC 9421 §2.5 compliance. @method is preserved
33
- * as-supplied (HTTP convention is uppercase), and a final
34
- * "@signature-params" line is appended carrying
35
- * signatureParamsRaw verbatim. This is the shape required to
36
- * verify external fixtures (Envoys envoys-rfc9421, Hippo
37
- * hippo-rfc9421, RFC 9421 §B test vectors, and any other
38
- * RFC-compliant implementation).
39
- */
40
- mode?: SigningBaseMode;
41
- /**
42
- * The post-label portion of the Signature-Input header value, i.e.
43
- * the Inner List + parameters block exactly as it appeared on the
44
- * wire. Required when mode is "rfc9421".
45
- */
46
- signatureParamsRaw?: string;
47
- }
48
-
49
- export function buildSigningBase(input: SigningBaseInput): string {
50
- const mode: SigningBaseMode = input.mode ?? "algovoi-v0";
51
- if (mode !== "algovoi-v0" && mode !== "rfc9421") {
52
- throw new SigningBaseError(
53
- `mode must be "algovoi-v0" or "rfc9421", got ${JSON.stringify(mode)}`,
54
- );
55
- }
56
- if (mode === "rfc9421" && input.signatureParamsRaw === undefined) {
57
- throw new SigningBaseError(
58
- "rfc9421 mode requires signatureParamsRaw (the post-label portion of the Signature-Input header)",
59
- );
60
- }
61
-
62
- const normHeaders: Record<string, string> = {};
63
- for (const [k, v] of Object.entries(input.headers ?? {})) {
64
- normHeaders[k.toLowerCase()] = v;
65
- }
66
- const parameters = input.parameters ?? {};
67
- const lines: string[] = [];
68
-
69
- for (const component of input.coveredComponents) {
70
- const c = component.toLowerCase();
71
- let value: string;
72
-
73
- switch (c) {
74
- case "@method":
75
- if (input.method === undefined)
76
- throw new SigningBaseError("@method covered but method not supplied");
77
- value = mode === "rfc9421" ? input.method : input.method.toLowerCase();
78
- break;
79
- case "@authority":
80
- if (input.authority === undefined)
81
- throw new SigningBaseError("@authority covered but authority not supplied");
82
- value = input.authority.toLowerCase();
83
- break;
84
- case "@path":
85
- if (input.path === undefined)
86
- throw new SigningBaseError("@path covered but path not supplied");
87
- value = input.path;
88
- break;
89
- case "@target-uri":
90
- if (input.targetUri === undefined)
91
- throw new SigningBaseError("@target-uri covered but targetUri not supplied");
92
- value = input.targetUri;
93
- break;
94
- case "@scheme":
95
- if (input.scheme === undefined)
96
- throw new SigningBaseError("@scheme covered but scheme not supplied");
97
- value = input.scheme.toLowerCase();
98
- break;
99
- case "@status":
100
- if (input.status === undefined)
101
- throw new SigningBaseError("@status covered but status not supplied");
102
- value = String(input.status);
103
- break;
104
- case "created":
105
- if (!("created" in parameters))
106
- throw new SigningBaseError(
107
- "'created' covered but no 'created' parameter in Signature-Input",
108
- );
109
- value = String(parameters["created"]);
110
- break;
111
- case "expires":
112
- if (!("expires" in parameters))
113
- throw new SigningBaseError(
114
- "'expires' covered but no 'expires' parameter in Signature-Input",
115
- );
116
- value = String(parameters["expires"]);
117
- break;
118
- default:
119
- if (c.startsWith("@")) {
120
- throw new SigningBaseError(`unsupported derived component: ${component}`);
121
- }
122
- if (!(c in normHeaders)) {
123
- throw new SigningBaseError(
124
- `covered header ${JSON.stringify(component)} not present in supplied headers`,
125
- );
126
- }
127
- value = normHeaders[c];
128
- break;
129
- }
130
-
131
- lines.push(`"${c}": ${value}`);
132
- }
133
-
134
- if (mode === "rfc9421") {
135
- lines.push(`"@signature-params": ${input.signatureParamsRaw}`);
136
- }
137
-
138
- return lines.join("\n");
139
- }
1
+ /**
2
+ * RFC 9421 Section 2.5 signing-base construction.
3
+ *
4
+ * Mirror of the Python algovoi_rfc9421_verifier.signing_base module.
5
+ */
6
+
7
+ export class SigningBaseError extends Error {
8
+ constructor(message: string) {
9
+ super(message);
10
+ this.name = "SigningBaseError";
11
+ }
12
+ }
13
+
14
+ export type SigningBaseMode = "algovoi-v0" | "rfc9421";
15
+
16
+ export interface SigningBaseInput {
17
+ coveredComponents: string[];
18
+ method?: string;
19
+ authority?: string;
20
+ path?: string;
21
+ targetUri?: string;
22
+ scheme?: string;
23
+ status?: number;
24
+ headers?: Record<string, string>;
25
+ parameters?: Record<string, string | number>;
26
+ /**
27
+ * Signing-base mode.
28
+ * - "algovoi-v0" (default): preserves the v0.1.0 behaviour for
29
+ * backward compatibility with the AlgoVoi internal fixture and
30
+ * the rfc9421_proxy_chain_v0 conformance set. @method is
31
+ * lowercased and no @signature-params line is appended.
32
+ * - "rfc9421": full RFC 9421 §2.5 compliance. @method is preserved
33
+ * as-supplied (HTTP convention is uppercase), and a final
34
+ * "@signature-params" line is appended carrying
35
+ * signatureParamsRaw verbatim. This is the shape required to
36
+ * verify external fixtures (Envoys envoys-rfc9421, Hippo
37
+ * hippo-rfc9421, RFC 9421 §B test vectors, and any other
38
+ * RFC-compliant implementation).
39
+ */
40
+ mode?: SigningBaseMode;
41
+ /**
42
+ * The post-label portion of the Signature-Input header value, i.e.
43
+ * the Inner List + parameters block exactly as it appeared on the
44
+ * wire. Required when mode is "rfc9421".
45
+ */
46
+ signatureParamsRaw?: string;
47
+ }
48
+
49
+ export function buildSigningBase(input: SigningBaseInput): string {
50
+ const mode: SigningBaseMode = input.mode ?? "algovoi-v0";
51
+ if (mode !== "algovoi-v0" && mode !== "rfc9421") {
52
+ throw new SigningBaseError(
53
+ `mode must be "algovoi-v0" or "rfc9421", got ${JSON.stringify(mode)}`,
54
+ );
55
+ }
56
+ if (mode === "rfc9421" && input.signatureParamsRaw === undefined) {
57
+ throw new SigningBaseError(
58
+ "rfc9421 mode requires signatureParamsRaw (the post-label portion of the Signature-Input header)",
59
+ );
60
+ }
61
+
62
+ const normHeaders: Record<string, string> = {};
63
+ for (const [k, v] of Object.entries(input.headers ?? {})) {
64
+ normHeaders[k.toLowerCase()] = v;
65
+ }
66
+ const parameters = input.parameters ?? {};
67
+ const lines: string[] = [];
68
+
69
+ for (const component of input.coveredComponents) {
70
+ const c = component.toLowerCase();
71
+ let value: string;
72
+
73
+ switch (c) {
74
+ case "@method":
75
+ if (input.method === undefined)
76
+ throw new SigningBaseError("@method covered but method not supplied");
77
+ value = mode === "rfc9421" ? input.method : input.method.toLowerCase();
78
+ break;
79
+ case "@authority":
80
+ if (input.authority === undefined)
81
+ throw new SigningBaseError("@authority covered but authority not supplied");
82
+ value = input.authority.toLowerCase();
83
+ break;
84
+ case "@path":
85
+ if (input.path === undefined)
86
+ throw new SigningBaseError("@path covered but path not supplied");
87
+ value = input.path;
88
+ break;
89
+ case "@target-uri":
90
+ if (input.targetUri === undefined)
91
+ throw new SigningBaseError("@target-uri covered but targetUri not supplied");
92
+ value = input.targetUri;
93
+ break;
94
+ case "@scheme":
95
+ if (input.scheme === undefined)
96
+ throw new SigningBaseError("@scheme covered but scheme not supplied");
97
+ value = input.scheme.toLowerCase();
98
+ break;
99
+ case "@status":
100
+ if (input.status === undefined)
101
+ throw new SigningBaseError("@status covered but status not supplied");
102
+ value = String(input.status);
103
+ break;
104
+ case "created":
105
+ if (!("created" in parameters))
106
+ throw new SigningBaseError(
107
+ "'created' covered but no 'created' parameter in Signature-Input",
108
+ );
109
+ value = String(parameters["created"]);
110
+ break;
111
+ case "expires":
112
+ if (!("expires" in parameters))
113
+ throw new SigningBaseError(
114
+ "'expires' covered but no 'expires' parameter in Signature-Input",
115
+ );
116
+ value = String(parameters["expires"]);
117
+ break;
118
+ default:
119
+ if (c.startsWith("@")) {
120
+ throw new SigningBaseError(`unsupported derived component: ${component}`);
121
+ }
122
+ if (!(c in normHeaders)) {
123
+ throw new SigningBaseError(
124
+ `covered header ${JSON.stringify(component)} not present in supplied headers`,
125
+ );
126
+ }
127
+ value = normHeaders[c];
128
+ break;
129
+ }
130
+
131
+ lines.push(`"${c}": ${value}`);
132
+ }
133
+
134
+ if (mode === "rfc9421") {
135
+ lines.push(`"@signature-params": ${input.signatureParamsRaw}`);
136
+ }
137
+
138
+ return lines.join("\n");
139
+ }
package/src/verify.ts CHANGED
@@ -1,256 +1,256 @@
1
- /**
2
- * RFC 9421 + RFC 9530 verification top-level.
3
- *
4
- * Mirror of the Python algovoi_rfc9421_verifier.verify module.
5
- * Uses @noble/ed25519 for Ed25519 verification.
6
- */
7
-
8
- import * as ed25519 from "@noble/ed25519";
9
-
10
- import {
11
- parseSignatureInput,
12
- parseSignatureValue,
13
- SignatureInputParseError,
14
- } from "./parse.js";
15
- import {
16
- buildSigningBase,
17
- SigningBaseError,
18
- type SigningBaseMode,
19
- } from "./signing-base.js";
20
- import {
21
- verifyContentDigest,
22
- ContentDigestError,
23
- } from "./content-digest.js";
24
-
25
- export class VerifyError extends Error {
26
- constructor(message: string) {
27
- super(message);
28
- this.name = "VerifyError";
29
- }
30
- }
31
-
32
- export type PublicKey = string | Uint8Array;
33
-
34
- export interface VerifyResult {
35
- valid: boolean;
36
- signature_valid: boolean;
37
- content_digest_valid: boolean;
38
- signing_base: string;
39
- covered_components: string[];
40
- parameters: Record<string, string | number>;
41
- label: string;
42
- errors: string[];
43
- }
44
-
45
- export interface VerifyRequestInput {
46
- method: string;
47
- authority: string;
48
- path: string;
49
- headers: Record<string, string>;
50
- body: Uint8Array | Buffer | string;
51
- publicKey: PublicKey;
52
- scheme?: string;
53
- requireContentDigest?: boolean;
54
- requireAlgorithm?: string | null;
55
- /**
56
- * Signing-base mode. Default "algovoi-v0" preserves backward
57
- * compatibility with the v0.1.0 internal fixture and the
58
- * rfc9421_proxy_chain_v0 conformance set. Set to "rfc9421" to
59
- * verify external RFC 9421-compliant fixtures (Envoys, Hippo,
60
- * RFC 9421 §B test vectors).
61
- */
62
- mode?: SigningBaseMode;
63
- }
64
-
65
- function newResult(): VerifyResult {
66
- return {
67
- valid: false,
68
- signature_valid: false,
69
- content_digest_valid: false,
70
- signing_base: "",
71
- covered_components: [],
72
- parameters: {},
73
- label: "",
74
- errors: [],
75
- };
76
- }
77
-
78
- function fail(result: VerifyResult, msg: string): VerifyResult {
79
- result.errors.push(msg);
80
- result.valid = false;
81
- return result;
82
- }
83
-
84
- function publicKeyBytes(pk: PublicKey): Uint8Array {
85
- if (pk instanceof Uint8Array) {
86
- if (pk.length !== 32) {
87
- throw new VerifyError(
88
- `Ed25519 public key must be 32 bytes, got ${pk.length}`,
89
- );
90
- }
91
- return pk;
92
- }
93
- if (typeof pk === "string") {
94
- const hex = pk.startsWith("0x") ? pk.slice(2) : pk;
95
- if (!/^[0-9a-fA-F]+$/.test(hex)) {
96
- throw new VerifyError("public key hex is invalid");
97
- }
98
- if (hex.length !== 64) {
99
- throw new VerifyError(
100
- `Ed25519 public key hex must decode to 32 bytes (64 hex chars), got ${hex.length}`,
101
- );
102
- }
103
- const bytes = new Uint8Array(32);
104
- for (let i = 0; i < 32; i++) {
105
- bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
106
- }
107
- return bytes;
108
- }
109
- throw new VerifyError("publicKey must be Uint8Array or hex string");
110
- }
111
-
112
- export async function verifySignature(
113
- signingBase: string,
114
- signatureBytes: Uint8Array,
115
- publicKey: PublicKey,
116
- algorithm: string = "ed25519",
117
- ): Promise<boolean> {
118
- if (algorithm.toLowerCase() !== "ed25519") {
119
- throw new VerifyError(`v0.1.0 supports ed25519 only; got ${algorithm}`);
120
- }
121
- if (signatureBytes.length !== 64) {
122
- throw new VerifyError(
123
- `Ed25519 signature must be 64 bytes, got ${signatureBytes.length}`,
124
- );
125
- }
126
- const pkBytes = publicKeyBytes(publicKey);
127
- const messageBytes = new TextEncoder().encode(signingBase);
128
- try {
129
- return await ed25519.verifyAsync(signatureBytes, messageBytes, pkBytes);
130
- } catch {
131
- return false;
132
- }
133
- }
134
-
135
- export async function verifyRequest(
136
- input: VerifyRequestInput,
137
- ): Promise<VerifyResult> {
138
- const result = newResult();
139
-
140
- const normHeaders: Record<string, string> = {};
141
- for (const [k, v] of Object.entries(input.headers)) {
142
- normHeaders[k.toLowerCase()] = v;
143
- }
144
-
145
- const siValue = normHeaders["signature-input"];
146
- if (!siValue) return fail(result, "Signature-Input header missing");
147
- const sValue = normHeaders["signature"];
148
- if (!sValue) return fail(result, "Signature header missing");
149
-
150
- let parsedSi;
151
- try {
152
- parsedSi = parseSignatureInput(siValue);
153
- } catch (e) {
154
- if (e instanceof SignatureInputParseError) {
155
- return fail(result, `Signature-Input parse error: ${e.message}`);
156
- }
157
- throw e;
158
- }
159
- result.label = parsedSi.label;
160
- result.covered_components = parsedSi.covered_components;
161
- result.parameters = parsedSi.parameters;
162
-
163
- let sigParsed;
164
- try {
165
- sigParsed = parseSignatureValue(sValue);
166
- } catch (e) {
167
- if (e instanceof SignatureInputParseError) {
168
- return fail(result, `Signature parse error: ${e.message}`);
169
- }
170
- throw e;
171
- }
172
-
173
- if (
174
- sigParsed.label &&
175
- parsedSi.label &&
176
- sigParsed.label !== parsedSi.label
177
- ) {
178
- return fail(
179
- result,
180
- `Signature label ${sigParsed.label} does not match Signature-Input label ${parsedSi.label}`,
181
- );
182
- }
183
-
184
- const requireCd = input.requireContentDigest ?? true;
185
- // null = no algorithm requirement (accept any); undefined = default to sha-256
186
- const requireAlg: string | undefined =
187
- input.requireAlgorithm === null
188
- ? undefined
189
- : (input.requireAlgorithm ?? "sha-256");
190
-
191
- if (requireCd) {
192
- const cdHeader = normHeaders["content-digest"];
193
- if (!cdHeader)
194
- return fail(result, "Content-Digest header required but missing");
195
- try {
196
- verifyContentDigest(input.body, cdHeader, requireAlg);
197
- result.content_digest_valid = true;
198
- } catch (e) {
199
- if (e instanceof ContentDigestError) {
200
- return fail(result, `Content-Digest verification failed: ${e.message}`);
201
- }
202
- throw e;
203
- }
204
- } else {
205
- result.content_digest_valid = true;
206
- }
207
-
208
- const mode: SigningBaseMode = input.mode ?? "algovoi-v0";
209
-
210
- let signingBase: string;
211
- try {
212
- signingBase = buildSigningBase({
213
- coveredComponents: parsedSi.covered_components,
214
- method: input.method,
215
- authority: input.authority,
216
- path: input.path,
217
- scheme: input.scheme ?? "https",
218
- headers: normHeaders,
219
- parameters: parsedSi.parameters,
220
- mode,
221
- signatureParamsRaw:
222
- mode === "rfc9421" ? parsedSi.params_block : undefined,
223
- });
224
- } catch (e) {
225
- if (e instanceof SigningBaseError) {
226
- return fail(result, `Signing-base build error: ${e.message}`);
227
- }
228
- throw e;
229
- }
230
- result.signing_base = signingBase;
231
-
232
- const alg = parsedSi.parameters["alg"];
233
- const algStr = typeof alg === "string" ? alg : "ed25519";
234
-
235
- let sigOk: boolean;
236
- try {
237
- sigOk = await verifySignature(
238
- signingBase,
239
- sigParsed.signature,
240
- input.publicKey,
241
- algStr,
242
- );
243
- } catch (e) {
244
- if (e instanceof VerifyError) {
245
- return fail(result, `Signature verification setup error: ${e.message}`);
246
- }
247
- throw e;
248
- }
249
-
250
- if (!sigOk) {
251
- return fail(result, "Ed25519 signature does not verify against signing base");
252
- }
253
- result.signature_valid = true;
254
- result.valid = result.signature_valid && result.content_digest_valid;
255
- return result;
256
- }
1
+ /**
2
+ * RFC 9421 + RFC 9530 verification top-level.
3
+ *
4
+ * Mirror of the Python algovoi_rfc9421_verifier.verify module.
5
+ * Uses @noble/ed25519 for Ed25519 verification.
6
+ */
7
+
8
+ import * as ed25519 from "@noble/ed25519";
9
+
10
+ import {
11
+ parseSignatureInput,
12
+ parseSignatureValue,
13
+ SignatureInputParseError,
14
+ } from "./parse.js";
15
+ import {
16
+ buildSigningBase,
17
+ SigningBaseError,
18
+ type SigningBaseMode,
19
+ } from "./signing-base.js";
20
+ import {
21
+ verifyContentDigest,
22
+ ContentDigestError,
23
+ } from "./content-digest.js";
24
+
25
+ export class VerifyError extends Error {
26
+ constructor(message: string) {
27
+ super(message);
28
+ this.name = "VerifyError";
29
+ }
30
+ }
31
+
32
+ export type PublicKey = string | Uint8Array;
33
+
34
+ export interface VerifyResult {
35
+ valid: boolean;
36
+ signature_valid: boolean;
37
+ content_digest_valid: boolean;
38
+ signing_base: string;
39
+ covered_components: string[];
40
+ parameters: Record<string, string | number>;
41
+ label: string;
42
+ errors: string[];
43
+ }
44
+
45
+ export interface VerifyRequestInput {
46
+ method: string;
47
+ authority: string;
48
+ path: string;
49
+ headers: Record<string, string>;
50
+ body: Uint8Array | Buffer | string;
51
+ publicKey: PublicKey;
52
+ scheme?: string;
53
+ requireContentDigest?: boolean;
54
+ requireAlgorithm?: string | null;
55
+ /**
56
+ * Signing-base mode. Default "algovoi-v0" preserves backward
57
+ * compatibility with the v0.1.0 internal fixture and the
58
+ * rfc9421_proxy_chain_v0 conformance set. Set to "rfc9421" to
59
+ * verify external RFC 9421-compliant fixtures (Envoys, Hippo,
60
+ * RFC 9421 §B test vectors).
61
+ */
62
+ mode?: SigningBaseMode;
63
+ }
64
+
65
+ function newResult(): VerifyResult {
66
+ return {
67
+ valid: false,
68
+ signature_valid: false,
69
+ content_digest_valid: false,
70
+ signing_base: "",
71
+ covered_components: [],
72
+ parameters: {},
73
+ label: "",
74
+ errors: [],
75
+ };
76
+ }
77
+
78
+ function fail(result: VerifyResult, msg: string): VerifyResult {
79
+ result.errors.push(msg);
80
+ result.valid = false;
81
+ return result;
82
+ }
83
+
84
+ function publicKeyBytes(pk: PublicKey): Uint8Array {
85
+ if (pk instanceof Uint8Array) {
86
+ if (pk.length !== 32) {
87
+ throw new VerifyError(
88
+ `Ed25519 public key must be 32 bytes, got ${pk.length}`,
89
+ );
90
+ }
91
+ return pk;
92
+ }
93
+ if (typeof pk === "string") {
94
+ const hex = pk.startsWith("0x") ? pk.slice(2) : pk;
95
+ if (!/^[0-9a-fA-F]+$/.test(hex)) {
96
+ throw new VerifyError("public key hex is invalid");
97
+ }
98
+ if (hex.length !== 64) {
99
+ throw new VerifyError(
100
+ `Ed25519 public key hex must decode to 32 bytes (64 hex chars), got ${hex.length}`,
101
+ );
102
+ }
103
+ const bytes = new Uint8Array(32);
104
+ for (let i = 0; i < 32; i++) {
105
+ bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
106
+ }
107
+ return bytes;
108
+ }
109
+ throw new VerifyError("publicKey must be Uint8Array or hex string");
110
+ }
111
+
112
+ export async function verifySignature(
113
+ signingBase: string,
114
+ signatureBytes: Uint8Array,
115
+ publicKey: PublicKey,
116
+ algorithm: string = "ed25519",
117
+ ): Promise<boolean> {
118
+ if (algorithm.toLowerCase() !== "ed25519") {
119
+ throw new VerifyError(`v0.1.0 supports ed25519 only; got ${algorithm}`);
120
+ }
121
+ if (signatureBytes.length !== 64) {
122
+ throw new VerifyError(
123
+ `Ed25519 signature must be 64 bytes, got ${signatureBytes.length}`,
124
+ );
125
+ }
126
+ const pkBytes = publicKeyBytes(publicKey);
127
+ const messageBytes = new TextEncoder().encode(signingBase);
128
+ try {
129
+ return await ed25519.verifyAsync(signatureBytes, messageBytes, pkBytes);
130
+ } catch {
131
+ return false;
132
+ }
133
+ }
134
+
135
+ export async function verifyRequest(
136
+ input: VerifyRequestInput,
137
+ ): Promise<VerifyResult> {
138
+ const result = newResult();
139
+
140
+ const normHeaders: Record<string, string> = {};
141
+ for (const [k, v] of Object.entries(input.headers)) {
142
+ normHeaders[k.toLowerCase()] = v;
143
+ }
144
+
145
+ const siValue = normHeaders["signature-input"];
146
+ if (!siValue) return fail(result, "Signature-Input header missing");
147
+ const sValue = normHeaders["signature"];
148
+ if (!sValue) return fail(result, "Signature header missing");
149
+
150
+ let parsedSi;
151
+ try {
152
+ parsedSi = parseSignatureInput(siValue);
153
+ } catch (e) {
154
+ if (e instanceof SignatureInputParseError) {
155
+ return fail(result, `Signature-Input parse error: ${e.message}`);
156
+ }
157
+ throw e;
158
+ }
159
+ result.label = parsedSi.label;
160
+ result.covered_components = parsedSi.covered_components;
161
+ result.parameters = parsedSi.parameters;
162
+
163
+ let sigParsed;
164
+ try {
165
+ sigParsed = parseSignatureValue(sValue);
166
+ } catch (e) {
167
+ if (e instanceof SignatureInputParseError) {
168
+ return fail(result, `Signature parse error: ${e.message}`);
169
+ }
170
+ throw e;
171
+ }
172
+
173
+ if (
174
+ sigParsed.label &&
175
+ parsedSi.label &&
176
+ sigParsed.label !== parsedSi.label
177
+ ) {
178
+ return fail(
179
+ result,
180
+ `Signature label ${sigParsed.label} does not match Signature-Input label ${parsedSi.label}`,
181
+ );
182
+ }
183
+
184
+ const requireCd = input.requireContentDigest ?? true;
185
+ // string = enforce that specific algorithm; null or undefined = accept any
186
+ // supported algorithm present in the Content-Digest header (SHA-256 or
187
+ // SHA-512 in this v0.2.x). Default is permissive.
188
+ const requireAlg: string | undefined =
189
+ input.requireAlgorithm == null ? undefined : input.requireAlgorithm;
190
+
191
+ if (requireCd) {
192
+ const cdHeader = normHeaders["content-digest"];
193
+ if (!cdHeader)
194
+ return fail(result, "Content-Digest header required but missing");
195
+ try {
196
+ verifyContentDigest(input.body, cdHeader, requireAlg);
197
+ result.content_digest_valid = true;
198
+ } catch (e) {
199
+ if (e instanceof ContentDigestError) {
200
+ return fail(result, `Content-Digest verification failed: ${e.message}`);
201
+ }
202
+ throw e;
203
+ }
204
+ } else {
205
+ result.content_digest_valid = true;
206
+ }
207
+
208
+ const mode: SigningBaseMode = input.mode ?? "algovoi-v0";
209
+
210
+ let signingBase: string;
211
+ try {
212
+ signingBase = buildSigningBase({
213
+ coveredComponents: parsedSi.covered_components,
214
+ method: input.method,
215
+ authority: input.authority,
216
+ path: input.path,
217
+ scheme: input.scheme ?? "https",
218
+ headers: normHeaders,
219
+ parameters: parsedSi.parameters,
220
+ mode,
221
+ signatureParamsRaw:
222
+ mode === "rfc9421" ? parsedSi.params_block : undefined,
223
+ });
224
+ } catch (e) {
225
+ if (e instanceof SigningBaseError) {
226
+ return fail(result, `Signing-base build error: ${e.message}`);
227
+ }
228
+ throw e;
229
+ }
230
+ result.signing_base = signingBase;
231
+
232
+ const alg = parsedSi.parameters["alg"];
233
+ const algStr = typeof alg === "string" ? alg : "ed25519";
234
+
235
+ let sigOk: boolean;
236
+ try {
237
+ sigOk = await verifySignature(
238
+ signingBase,
239
+ sigParsed.signature,
240
+ input.publicKey,
241
+ algStr,
242
+ );
243
+ } catch (e) {
244
+ if (e instanceof VerifyError) {
245
+ return fail(result, `Signature verification setup error: ${e.message}`);
246
+ }
247
+ throw e;
248
+ }
249
+
250
+ if (!sigOk) {
251
+ return fail(result, "Ed25519 signature does not verify against signing base");
252
+ }
253
+ result.signature_valid = true;
254
+ result.valid = result.signature_valid && result.content_digest_valid;
255
+ return result;
256
+ }