@algovoi/rfc9421-verifier 0.1.0
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/README.md +146 -0
- package/dist/content-digest.d.ts +10 -0
- package/dist/content-digest.js +72 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/parse.d.ts +19 -0
- package/dist/parse.js +107 -0
- package/dist/signing-base.d.ts +20 -0
- package/dist/signing-base.js +76 -0
- package/dist/verify.d.ts +33 -0
- package/dist/verify.js +170 -0
- package/package.json +62 -0
- package/src/content-digest.ts +97 -0
- package/src/index.ts +38 -0
- package/src/parse.ts +135 -0
- package/src/signing-base.ts +100 -0
- package/src/verify.ts +238 -0
package/README.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# algovoi-rfc9421-verifier
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/algovoi-rfc9421-verifier/)
|
|
4
|
+
[](https://www.npmjs.com/package/@algovoi/rfc9421-verifier)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
[](https://datatracker.ietf.org/doc/draft-hopley-x402-compliance-receipt/)
|
|
7
|
+
|
|
8
|
+
AlgoVoi-authored reference verifier for
|
|
9
|
+
[RFC 9421 (HTTP Message Signatures)](https://www.rfc-editor.org/rfc/rfc9421)
|
|
10
|
+
plus
|
|
11
|
+
[RFC 9530 (Digest Fields for HTTP)](https://www.rfc-editor.org/rfc/rfc9530).
|
|
12
|
+
Python and TypeScript, byte-for-byte parity, Apache 2.0.
|
|
13
|
+
|
|
14
|
+
Use cases:
|
|
15
|
+
|
|
16
|
+
- Verify an incoming RFC 9421-signed HTTP request against a known
|
|
17
|
+
public key.
|
|
18
|
+
- Re-validate a captured request after it traverses a TLS-re-terminating
|
|
19
|
+
proxy chain (the property pinned in the
|
|
20
|
+
[`rfc9421_proxy_chain_v0`](https://github.com/chopmob-cloud/algovoi-jcs-conformance-vectors/tree/main/vectors/rfc9421_proxy_chain_v0)
|
|
21
|
+
conformance fixture).
|
|
22
|
+
- Build conformance test harnesses anchored to the RFC 8032 Section
|
|
23
|
+
7.1 deterministic Ed25519 reference keypair.
|
|
24
|
+
|
|
25
|
+
## Packages
|
|
26
|
+
|
|
27
|
+
| Language | Package | Install |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| Python | [`algovoi-rfc9421-verifier`](https://pypi.org/project/algovoi-rfc9421-verifier/) | `pip install algovoi-rfc9421-verifier` |
|
|
30
|
+
| TypeScript | [`@algovoi/rfc9421-verifier`](https://www.npmjs.com/package/@algovoi/rfc9421-verifier) | `npm install @algovoi/rfc9421-verifier` |
|
|
31
|
+
|
|
32
|
+
Both packages are byte-deterministic on identical inputs and tested
|
|
33
|
+
against the same RFC 8032 Section 7.1 Test 1 reference fixture.
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
### Python
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from algovoi_rfc9421_verifier import verify_request
|
|
41
|
+
|
|
42
|
+
result = verify_request(
|
|
43
|
+
method="GET",
|
|
44
|
+
authority="api.algovoi.co.uk",
|
|
45
|
+
path="/compliance/attestation",
|
|
46
|
+
headers={
|
|
47
|
+
"content-digest": "sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:",
|
|
48
|
+
"signature-input": (
|
|
49
|
+
'sig=("@method" "@authority" "@path" "content-digest" "created");'
|
|
50
|
+
'created=1778955520;keyid="did:web:api.algovoi.co.uk";alg="ed25519"'
|
|
51
|
+
),
|
|
52
|
+
"signature": (
|
|
53
|
+
"sig=:Xj1peMjEYi75R/QQFYpU9q/gHwQKYwgt1etjAX1qc0zugTMJoJ86Uhy/jTZ175b3"
|
|
54
|
+
"zFhp0j8cLjmDJvGmySDBAQ==:"
|
|
55
|
+
),
|
|
56
|
+
},
|
|
57
|
+
body=b"",
|
|
58
|
+
public_key="d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
|
|
59
|
+
)
|
|
60
|
+
assert result.valid
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### TypeScript
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
import { verifyRequest } from "@algovoi/rfc9421-verifier";
|
|
67
|
+
|
|
68
|
+
const result = await verifyRequest({
|
|
69
|
+
method: "GET",
|
|
70
|
+
authority: "api.algovoi.co.uk",
|
|
71
|
+
path: "/compliance/attestation",
|
|
72
|
+
headers: {
|
|
73
|
+
"content-digest": "sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:",
|
|
74
|
+
"signature-input":
|
|
75
|
+
'sig=("@method" "@authority" "@path" "content-digest" "created");created=1778955520;keyid="did:web:api.algovoi.co.uk";alg="ed25519"',
|
|
76
|
+
signature:
|
|
77
|
+
"sig=:Xj1peMjEYi75R/QQFYpU9q/gHwQKYwgt1etjAX1qc0zugTMJoJ86Uhy/jTZ175b3zFhp0j8cLjmDJvGmySDBAQ==:",
|
|
78
|
+
},
|
|
79
|
+
body: new Uint8Array(),
|
|
80
|
+
publicKey:
|
|
81
|
+
"d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
|
|
82
|
+
});
|
|
83
|
+
if (result.valid) console.log("verified");
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## API surface (v0.1.0)
|
|
87
|
+
|
|
88
|
+
| Function | Purpose |
|
|
89
|
+
|---|---|
|
|
90
|
+
| `verify_request` / `verifyRequest` | High-level: parse all headers, build signing base, verify Content-Digest, verify Ed25519 signature, return a `VerifyResult` with per-step success flags. |
|
|
91
|
+
| `verify_signature` / `verifySignature` | Lower-level: caller supplies the signing base; library verifies Ed25519 only. |
|
|
92
|
+
| `verify_content_digest` / `verifyContentDigest` | Validate RFC 9530 `Content-Digest` header against a body. SHA-256 and SHA-512 supported. |
|
|
93
|
+
| `build_signing_base` / `buildSigningBase` | Construct the RFC 9421 §2.5 signing base from covered components + values. |
|
|
94
|
+
| `parse_signature_input` / `parseSignatureInput` | Parse a `Signature-Input` header. Accepts the strict labelled form and the unlabelled real-world form. |
|
|
95
|
+
| `parse_signature_value` / `parseSignatureValue` | Parse a `Signature` header. |
|
|
96
|
+
| `compute_content_digest` / `computeContentDigest` | Compute a `Content-Digest` header value for a body. |
|
|
97
|
+
|
|
98
|
+
## Scope (v0.1.0)
|
|
99
|
+
|
|
100
|
+
- **Algorithms**: Ed25519 only. ECDSA-P256 and RSA-PSS are roadmap.
|
|
101
|
+
- **Derived components**: `@method`, `@authority`, `@path`,
|
|
102
|
+
`@target-uri`, `@scheme`, `@status`, plus `created` and `expires`
|
|
103
|
+
parameters. `@request-target`, `@query`, `@query-param` are roadmap.
|
|
104
|
+
- **Header forms**: strict labelled `<label>=(...)` and unlabelled
|
|
105
|
+
`(...);created=...` real-world forms both accepted.
|
|
106
|
+
- **Content-Digest**: SHA-256 (mandatory per RFC 9530) and SHA-512.
|
|
107
|
+
Other algorithms in the IANA registry are roadmap.
|
|
108
|
+
|
|
109
|
+
The v0.1.0 surface is sufficient to verify any AlgoVoi production
|
|
110
|
+
compliance receipt and the `rfc9421_proxy_chain_v0` conformance
|
|
111
|
+
fixture. Multi-algorithm + multi-label support arrives in v0.2.0.
|
|
112
|
+
|
|
113
|
+
## Conformance fixture
|
|
114
|
+
|
|
115
|
+
The reference fixture for the verifier is at
|
|
116
|
+
[`chopmob-cloud/algovoi-jcs-conformance-vectors/vectors/rfc9421_proxy_chain_v0/`](https://github.com/chopmob-cloud/algovoi-jcs-conformance-vectors/tree/main/vectors/rfc9421_proxy_chain_v0).
|
|
117
|
+
It uses the RFC 8032 Section 7.1 Test 1 deterministic Ed25519 keypair
|
|
118
|
+
and includes a `tcpdump` wire-capture record (`E2E_PROOF.md`)
|
|
119
|
+
demonstrating that the RFC 9421 headers survive a 3-hop
|
|
120
|
+
TLS-re-terminating proxy chain (Cloudflare edge → nginx → FastAPI)
|
|
121
|
+
byte-identical.
|
|
122
|
+
|
|
123
|
+
## Companion IETF Internet-Draft
|
|
124
|
+
|
|
125
|
+
This library is part of the AlgoVoi substrate that anchors
|
|
126
|
+
[`draft-hopley-x402-compliance-receipt-00`](https://datatracker.ietf.org/doc/draft-hopley-x402-compliance-receipt/)
|
|
127
|
+
(Independent Submission, Informational; posted 2026-05-23). The
|
|
128
|
+
receipt-format audit-chain property in the I-D assumes signed
|
|
129
|
+
receipts can be transported and re-verified independently of the
|
|
130
|
+
originating gateway — exactly the property this verifier checks.
|
|
131
|
+
|
|
132
|
+
## Related AlgoVoi substrate packages
|
|
133
|
+
|
|
134
|
+
| Package | Purpose |
|
|
135
|
+
|---|---|
|
|
136
|
+
| [`algovoi-substrate`](https://pypi.org/project/algovoi-substrate/) / [`@algovoi/substrate`](https://www.npmjs.com/package/@algovoi/substrate) | JCS RFC 8785 canonicalisation, `action_ref`, transactional lifecycle, compliance receipt builder |
|
|
137
|
+
| [`algovoi-audit-verifier`](https://pypi.org/project/algovoi-audit-verifier/) / [`@algovoi/audit-verifier`](https://www.npmjs.com/package/@algovoi/audit-verifier) | Selective-disclosure audit bundle verifier; consumes substrate output |
|
|
138
|
+
| **`algovoi-rfc9421-verifier`** / `@algovoi/rfc9421-verifier` | **This package.** RFC 9421/9530 HTTP signature verifier |
|
|
139
|
+
|
|
140
|
+
## Licence
|
|
141
|
+
|
|
142
|
+
Apache 2.0. See [`LICENSE`](./LICENSE).
|
|
143
|
+
|
|
144
|
+
## Author
|
|
145
|
+
|
|
146
|
+
AlgoVoi (Christopher Hopley, GitHub [`chopmob-cloud`](https://github.com/chopmob-cloud)).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 9530 Content-Digest field implementation.
|
|
3
|
+
*
|
|
4
|
+
* Mirror of the Python algovoi_rfc9421_verifier.content_digest module.
|
|
5
|
+
*/
|
|
6
|
+
export declare class ContentDigestError extends Error {
|
|
7
|
+
constructor(message: string);
|
|
8
|
+
}
|
|
9
|
+
export declare function computeContentDigest(body: Uint8Array | Buffer | string, algorithm?: string): string;
|
|
10
|
+
export declare function verifyContentDigest(body: Uint8Array | Buffer | string, headerValue: string, requireAlgorithm?: string): boolean;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 9530 Content-Digest field implementation.
|
|
3
|
+
*
|
|
4
|
+
* Mirror of the Python algovoi_rfc9421_verifier.content_digest module.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
export class ContentDigestError extends Error {
|
|
8
|
+
constructor(message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "ContentDigestError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const DIGEST_ENTRY_RE = /([a-z0-9-]+)=:([A-Za-z0-9+/=]+):/g;
|
|
14
|
+
const SUPPORTED_ALGOS = {
|
|
15
|
+
"sha-256": "sha256",
|
|
16
|
+
"sha-512": "sha512",
|
|
17
|
+
};
|
|
18
|
+
function bodyBytes(body) {
|
|
19
|
+
if (typeof body === "string")
|
|
20
|
+
return Buffer.from(body, "utf-8");
|
|
21
|
+
if (Buffer.isBuffer(body))
|
|
22
|
+
return body;
|
|
23
|
+
return Buffer.from(body);
|
|
24
|
+
}
|
|
25
|
+
export function computeContentDigest(body, algorithm = "sha-256") {
|
|
26
|
+
const algoLower = algorithm.toLowerCase();
|
|
27
|
+
const nodeAlgo = SUPPORTED_ALGOS[algoLower];
|
|
28
|
+
if (!nodeAlgo) {
|
|
29
|
+
throw new ContentDigestError(`unsupported algorithm ${JSON.stringify(algorithm)}; supported: ${Object.keys(SUPPORTED_ALGOS)}`);
|
|
30
|
+
}
|
|
31
|
+
const hash = createHash(nodeAlgo).update(bodyBytes(body)).digest("base64");
|
|
32
|
+
return `${algoLower}=:${hash}:`;
|
|
33
|
+
}
|
|
34
|
+
export function verifyContentDigest(body, headerValue, requireAlgorithm) {
|
|
35
|
+
if (typeof headerValue !== "string") {
|
|
36
|
+
throw new ContentDigestError(`header must be string, got ${typeof headerValue}`);
|
|
37
|
+
}
|
|
38
|
+
const entries = [];
|
|
39
|
+
const localRe = new RegExp(DIGEST_ENTRY_RE.source, "g");
|
|
40
|
+
let m;
|
|
41
|
+
while ((m = localRe.exec(headerValue)) !== null) {
|
|
42
|
+
entries.push([m[1], m[2]]);
|
|
43
|
+
}
|
|
44
|
+
if (entries.length === 0) {
|
|
45
|
+
throw new ContentDigestError(`no valid digest entries in header: ${JSON.stringify(headerValue)}`);
|
|
46
|
+
}
|
|
47
|
+
const reqAlg = requireAlgorithm?.toLowerCase();
|
|
48
|
+
let requiredSeen = false;
|
|
49
|
+
for (const [algo, digestB64] of entries) {
|
|
50
|
+
const algoLower = algo.toLowerCase();
|
|
51
|
+
const nodeAlgo = SUPPORTED_ALGOS[algoLower];
|
|
52
|
+
if (!nodeAlgo)
|
|
53
|
+
continue;
|
|
54
|
+
if (reqAlg && algoLower === reqAlg)
|
|
55
|
+
requiredSeen = true;
|
|
56
|
+
let expected;
|
|
57
|
+
try {
|
|
58
|
+
expected = Buffer.from(digestB64, "base64");
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
throw new ContentDigestError(`digest entry ${algoLower} is not valid base64: ${e.message}`);
|
|
62
|
+
}
|
|
63
|
+
const actual = createHash(nodeAlgo).update(bodyBytes(body)).digest();
|
|
64
|
+
if (!expected.equals(actual)) {
|
|
65
|
+
throw new ContentDigestError(`digest mismatch on ${algoLower}: header claims ${digestB64} but body hashes to ${actual.toString("base64")}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (reqAlg && !requiredSeen) {
|
|
69
|
+
throw new ContentDigestError(`required algorithm ${reqAlg} not present in header`);
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @algovoi/rfc9421-verifier
|
|
3
|
+
*
|
|
4
|
+
* AlgoVoi RFC 9421 HTTP Message Signatures + RFC 9530 Content-Digest
|
|
5
|
+
* reference verifier (TypeScript). Byte-for-byte parity with the
|
|
6
|
+
* Python sibling `algovoi-rfc9421-verifier` on PyPI.
|
|
7
|
+
*
|
|
8
|
+
* Apache 2.0. Companion to IETF Internet-Draft
|
|
9
|
+
* draft-hopley-x402-compliance-receipt-00.
|
|
10
|
+
*/
|
|
11
|
+
export { SignatureInputParseError, parseSignatureInput, parseSignatureValue, type ParsedSignatureInput, } from "./parse.js";
|
|
12
|
+
export { SigningBaseError, buildSigningBase, type SigningBaseInput, } from "./signing-base.js";
|
|
13
|
+
export { ContentDigestError, computeContentDigest, verifyContentDigest, } from "./content-digest.js";
|
|
14
|
+
export { VerifyError, verifySignature, verifyRequest, type VerifyResult, type VerifyRequestInput, type PublicKey, } from "./verify.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @algovoi/rfc9421-verifier
|
|
3
|
+
*
|
|
4
|
+
* AlgoVoi RFC 9421 HTTP Message Signatures + RFC 9530 Content-Digest
|
|
5
|
+
* reference verifier (TypeScript). Byte-for-byte parity with the
|
|
6
|
+
* Python sibling `algovoi-rfc9421-verifier` on PyPI.
|
|
7
|
+
*
|
|
8
|
+
* Apache 2.0. Companion to IETF Internet-Draft
|
|
9
|
+
* draft-hopley-x402-compliance-receipt-00.
|
|
10
|
+
*/
|
|
11
|
+
export { SignatureInputParseError, parseSignatureInput, parseSignatureValue, } from "./parse.js";
|
|
12
|
+
export { SigningBaseError, buildSigningBase, } from "./signing-base.js";
|
|
13
|
+
export { ContentDigestError, computeContentDigest, verifyContentDigest, } from "./content-digest.js";
|
|
14
|
+
export { VerifyError, verifySignature, verifyRequest, } from "./verify.js";
|
package/dist/parse.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 9421 Signature-Input and Signature header parsers.
|
|
3
|
+
*
|
|
4
|
+
* Mirror of the Python algovoi_rfc9421_verifier.parse module.
|
|
5
|
+
*/
|
|
6
|
+
export declare class SignatureInputParseError extends Error {
|
|
7
|
+
constructor(message: string);
|
|
8
|
+
}
|
|
9
|
+
export interface ParsedSignatureInput {
|
|
10
|
+
label: string;
|
|
11
|
+
covered_components: string[];
|
|
12
|
+
parameters: Record<string, string | number>;
|
|
13
|
+
raw: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function parseSignatureInput(headerValue: string): ParsedSignatureInput;
|
|
16
|
+
export declare function parseSignatureValue(headerValue: string): {
|
|
17
|
+
label: string;
|
|
18
|
+
signature: Uint8Array;
|
|
19
|
+
};
|
package/dist/parse.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 9421 Signature-Input and Signature header parsers.
|
|
3
|
+
*
|
|
4
|
+
* Mirror of the Python algovoi_rfc9421_verifier.parse module.
|
|
5
|
+
*/
|
|
6
|
+
export class SignatureInputParseError extends Error {
|
|
7
|
+
constructor(message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "SignatureInputParseError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const LABEL_RE = /^\s*([A-Za-z][A-Za-z0-9_-]*)\s*=\s*/;
|
|
13
|
+
const COVERED_RE = /\(\s*(?:"[^"]*"\s*)*\)/;
|
|
14
|
+
const QUOTED_RE = /"([^"]*)"/g;
|
|
15
|
+
const PARAM_RE = /([A-Za-z][A-Za-z0-9_-]*)=([^;,\s]+|"[^"]*")/g;
|
|
16
|
+
export function parseSignatureInput(headerValue) {
|
|
17
|
+
if (typeof headerValue !== "string") {
|
|
18
|
+
throw new SignatureInputParseError(`header must be string, got ${typeof headerValue}`);
|
|
19
|
+
}
|
|
20
|
+
const trimmed = headerValue.trim();
|
|
21
|
+
if (trimmed.length === 0) {
|
|
22
|
+
throw new SignatureInputParseError("empty header value");
|
|
23
|
+
}
|
|
24
|
+
let label;
|
|
25
|
+
let rest;
|
|
26
|
+
const labelMatch = LABEL_RE.exec(trimmed);
|
|
27
|
+
if (labelMatch) {
|
|
28
|
+
label = labelMatch[1];
|
|
29
|
+
rest = trimmed.slice(labelMatch[0].length);
|
|
30
|
+
}
|
|
31
|
+
else if (trimmed.startsWith("(")) {
|
|
32
|
+
label = "";
|
|
33
|
+
rest = trimmed;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
throw new SignatureInputParseError(`no label or covered-components list found at start: ${JSON.stringify(trimmed.slice(0, 40))}`);
|
|
37
|
+
}
|
|
38
|
+
const coveredMatch = COVERED_RE.exec(rest);
|
|
39
|
+
if (!coveredMatch) {
|
|
40
|
+
throw new SignatureInputParseError("no covered-components list found");
|
|
41
|
+
}
|
|
42
|
+
const coveredRaw = coveredMatch[0];
|
|
43
|
+
const covered = [];
|
|
44
|
+
let qm;
|
|
45
|
+
const localQuoted = /"([^"]*)"/g;
|
|
46
|
+
while ((qm = localQuoted.exec(coveredRaw)) !== null) {
|
|
47
|
+
covered.push(qm[1]);
|
|
48
|
+
}
|
|
49
|
+
rest = rest.slice(coveredMatch.index + coveredRaw.length);
|
|
50
|
+
const parameters = {};
|
|
51
|
+
const localParam = new RegExp(PARAM_RE.source, "g");
|
|
52
|
+
let pm;
|
|
53
|
+
while ((pm = localParam.exec(rest)) !== null) {
|
|
54
|
+
const key = pm[1];
|
|
55
|
+
const raw = pm[2];
|
|
56
|
+
if (raw.startsWith('"') && raw.endsWith('"')) {
|
|
57
|
+
parameters[key] = raw.slice(1, -1);
|
|
58
|
+
}
|
|
59
|
+
else if (/^-?\d+$/.test(raw)) {
|
|
60
|
+
parameters[key] = parseInt(raw, 10);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
parameters[key] = raw;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
label,
|
|
68
|
+
covered_components: covered,
|
|
69
|
+
parameters,
|
|
70
|
+
raw: trimmed,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export function parseSignatureValue(headerValue) {
|
|
74
|
+
if (typeof headerValue !== "string") {
|
|
75
|
+
throw new SignatureInputParseError(`header must be string, got ${typeof headerValue}`);
|
|
76
|
+
}
|
|
77
|
+
const trimmed = headerValue.trim();
|
|
78
|
+
if (trimmed.length === 0) {
|
|
79
|
+
throw new SignatureInputParseError("empty Signature header value");
|
|
80
|
+
}
|
|
81
|
+
let label;
|
|
82
|
+
let rest;
|
|
83
|
+
const labelMatch = LABEL_RE.exec(trimmed);
|
|
84
|
+
if (labelMatch) {
|
|
85
|
+
label = labelMatch[1];
|
|
86
|
+
rest = trimmed.slice(labelMatch[0].length).trim();
|
|
87
|
+
}
|
|
88
|
+
else if (trimmed.startsWith(":")) {
|
|
89
|
+
label = "";
|
|
90
|
+
rest = trimmed;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
throw new SignatureInputParseError(`no label or signature-value prefix found at start: ${JSON.stringify(trimmed.slice(0, 40))}`);
|
|
94
|
+
}
|
|
95
|
+
if (!rest.startsWith(":") || !rest.endsWith(":")) {
|
|
96
|
+
throw new SignatureInputParseError("signature value must be wrapped in colons (RFC 8941 byte-sequence form)");
|
|
97
|
+
}
|
|
98
|
+
const sigB64 = rest.slice(1, -1);
|
|
99
|
+
let sigBytes;
|
|
100
|
+
try {
|
|
101
|
+
sigBytes = new Uint8Array(Buffer.from(sigB64, "base64"));
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
throw new SignatureInputParseError(`signature value is not valid base64: ${e.message}`);
|
|
105
|
+
}
|
|
106
|
+
return { label, signature: sigBytes };
|
|
107
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 9421 Section 2.5 signing-base construction.
|
|
3
|
+
*
|
|
4
|
+
* Mirror of the Python algovoi_rfc9421_verifier.signing_base module.
|
|
5
|
+
*/
|
|
6
|
+
export declare class SigningBaseError extends Error {
|
|
7
|
+
constructor(message: string);
|
|
8
|
+
}
|
|
9
|
+
export interface SigningBaseInput {
|
|
10
|
+
coveredComponents: string[];
|
|
11
|
+
method?: string;
|
|
12
|
+
authority?: string;
|
|
13
|
+
path?: string;
|
|
14
|
+
targetUri?: string;
|
|
15
|
+
scheme?: string;
|
|
16
|
+
status?: number;
|
|
17
|
+
headers?: Record<string, string>;
|
|
18
|
+
parameters?: Record<string, string | number>;
|
|
19
|
+
}
|
|
20
|
+
export declare function buildSigningBase(input: SigningBaseInput): string;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 9421 Section 2.5 signing-base construction.
|
|
3
|
+
*
|
|
4
|
+
* Mirror of the Python algovoi_rfc9421_verifier.signing_base module.
|
|
5
|
+
*/
|
|
6
|
+
export class SigningBaseError extends Error {
|
|
7
|
+
constructor(message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "SigningBaseError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function buildSigningBase(input) {
|
|
13
|
+
const normHeaders = {};
|
|
14
|
+
for (const [k, v] of Object.entries(input.headers ?? {})) {
|
|
15
|
+
normHeaders[k.toLowerCase()] = v;
|
|
16
|
+
}
|
|
17
|
+
const parameters = input.parameters ?? {};
|
|
18
|
+
const lines = [];
|
|
19
|
+
for (const component of input.coveredComponents) {
|
|
20
|
+
const c = component.toLowerCase();
|
|
21
|
+
let value;
|
|
22
|
+
switch (c) {
|
|
23
|
+
case "@method":
|
|
24
|
+
if (input.method === undefined)
|
|
25
|
+
throw new SigningBaseError("@method covered but method not supplied");
|
|
26
|
+
value = input.method.toLowerCase();
|
|
27
|
+
break;
|
|
28
|
+
case "@authority":
|
|
29
|
+
if (input.authority === undefined)
|
|
30
|
+
throw new SigningBaseError("@authority covered but authority not supplied");
|
|
31
|
+
value = input.authority.toLowerCase();
|
|
32
|
+
break;
|
|
33
|
+
case "@path":
|
|
34
|
+
if (input.path === undefined)
|
|
35
|
+
throw new SigningBaseError("@path covered but path not supplied");
|
|
36
|
+
value = input.path;
|
|
37
|
+
break;
|
|
38
|
+
case "@target-uri":
|
|
39
|
+
if (input.targetUri === undefined)
|
|
40
|
+
throw new SigningBaseError("@target-uri covered but targetUri not supplied");
|
|
41
|
+
value = input.targetUri;
|
|
42
|
+
break;
|
|
43
|
+
case "@scheme":
|
|
44
|
+
if (input.scheme === undefined)
|
|
45
|
+
throw new SigningBaseError("@scheme covered but scheme not supplied");
|
|
46
|
+
value = input.scheme.toLowerCase();
|
|
47
|
+
break;
|
|
48
|
+
case "@status":
|
|
49
|
+
if (input.status === undefined)
|
|
50
|
+
throw new SigningBaseError("@status covered but status not supplied");
|
|
51
|
+
value = String(input.status);
|
|
52
|
+
break;
|
|
53
|
+
case "created":
|
|
54
|
+
if (!("created" in parameters))
|
|
55
|
+
throw new SigningBaseError("'created' covered but no 'created' parameter in Signature-Input");
|
|
56
|
+
value = String(parameters["created"]);
|
|
57
|
+
break;
|
|
58
|
+
case "expires":
|
|
59
|
+
if (!("expires" in parameters))
|
|
60
|
+
throw new SigningBaseError("'expires' covered but no 'expires' parameter in Signature-Input");
|
|
61
|
+
value = String(parameters["expires"]);
|
|
62
|
+
break;
|
|
63
|
+
default:
|
|
64
|
+
if (c.startsWith("@")) {
|
|
65
|
+
throw new SigningBaseError(`unsupported derived component: ${component}`);
|
|
66
|
+
}
|
|
67
|
+
if (!(c in normHeaders)) {
|
|
68
|
+
throw new SigningBaseError(`covered header ${JSON.stringify(component)} not present in supplied headers`);
|
|
69
|
+
}
|
|
70
|
+
value = normHeaders[c];
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
lines.push(`"${c}": ${value}`);
|
|
74
|
+
}
|
|
75
|
+
return lines.join("\n");
|
|
76
|
+
}
|
package/dist/verify.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
export declare class VerifyError extends Error {
|
|
8
|
+
constructor(message: string);
|
|
9
|
+
}
|
|
10
|
+
export type PublicKey = string | Uint8Array;
|
|
11
|
+
export interface VerifyResult {
|
|
12
|
+
valid: boolean;
|
|
13
|
+
signature_valid: boolean;
|
|
14
|
+
content_digest_valid: boolean;
|
|
15
|
+
signing_base: string;
|
|
16
|
+
covered_components: string[];
|
|
17
|
+
parameters: Record<string, string | number>;
|
|
18
|
+
label: string;
|
|
19
|
+
errors: string[];
|
|
20
|
+
}
|
|
21
|
+
export interface VerifyRequestInput {
|
|
22
|
+
method: string;
|
|
23
|
+
authority: string;
|
|
24
|
+
path: string;
|
|
25
|
+
headers: Record<string, string>;
|
|
26
|
+
body: Uint8Array | Buffer | string;
|
|
27
|
+
publicKey: PublicKey;
|
|
28
|
+
scheme?: string;
|
|
29
|
+
requireContentDigest?: boolean;
|
|
30
|
+
requireAlgorithm?: string;
|
|
31
|
+
}
|
|
32
|
+
export declare function verifySignature(signingBase: string, signatureBytes: Uint8Array, publicKey: PublicKey, algorithm?: string): Promise<boolean>;
|
|
33
|
+
export declare function verifyRequest(input: VerifyRequestInput): Promise<VerifyResult>;
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
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
|
+
import * as ed25519 from "@noble/ed25519";
|
|
8
|
+
import { parseSignatureInput, parseSignatureValue, SignatureInputParseError, } from "./parse.js";
|
|
9
|
+
import { buildSigningBase, SigningBaseError, } from "./signing-base.js";
|
|
10
|
+
import { verifyContentDigest, ContentDigestError, } from "./content-digest.js";
|
|
11
|
+
export class VerifyError extends Error {
|
|
12
|
+
constructor(message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "VerifyError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function newResult() {
|
|
18
|
+
return {
|
|
19
|
+
valid: false,
|
|
20
|
+
signature_valid: false,
|
|
21
|
+
content_digest_valid: false,
|
|
22
|
+
signing_base: "",
|
|
23
|
+
covered_components: [],
|
|
24
|
+
parameters: {},
|
|
25
|
+
label: "",
|
|
26
|
+
errors: [],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function fail(result, msg) {
|
|
30
|
+
result.errors.push(msg);
|
|
31
|
+
result.valid = false;
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
function publicKeyBytes(pk) {
|
|
35
|
+
if (pk instanceof Uint8Array) {
|
|
36
|
+
if (pk.length !== 32) {
|
|
37
|
+
throw new VerifyError(`Ed25519 public key must be 32 bytes, got ${pk.length}`);
|
|
38
|
+
}
|
|
39
|
+
return pk;
|
|
40
|
+
}
|
|
41
|
+
if (typeof pk === "string") {
|
|
42
|
+
const hex = pk.startsWith("0x") ? pk.slice(2) : pk;
|
|
43
|
+
if (!/^[0-9a-fA-F]+$/.test(hex)) {
|
|
44
|
+
throw new VerifyError("public key hex is invalid");
|
|
45
|
+
}
|
|
46
|
+
if (hex.length !== 64) {
|
|
47
|
+
throw new VerifyError(`Ed25519 public key hex must decode to 32 bytes (64 hex chars), got ${hex.length}`);
|
|
48
|
+
}
|
|
49
|
+
const bytes = new Uint8Array(32);
|
|
50
|
+
for (let i = 0; i < 32; i++) {
|
|
51
|
+
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
52
|
+
}
|
|
53
|
+
return bytes;
|
|
54
|
+
}
|
|
55
|
+
throw new VerifyError("publicKey must be Uint8Array or hex string");
|
|
56
|
+
}
|
|
57
|
+
export async function verifySignature(signingBase, signatureBytes, publicKey, algorithm = "ed25519") {
|
|
58
|
+
if (algorithm.toLowerCase() !== "ed25519") {
|
|
59
|
+
throw new VerifyError(`v0.1.0 supports ed25519 only; got ${algorithm}`);
|
|
60
|
+
}
|
|
61
|
+
if (signatureBytes.length !== 64) {
|
|
62
|
+
throw new VerifyError(`Ed25519 signature must be 64 bytes, got ${signatureBytes.length}`);
|
|
63
|
+
}
|
|
64
|
+
const pkBytes = publicKeyBytes(publicKey);
|
|
65
|
+
const messageBytes = new TextEncoder().encode(signingBase);
|
|
66
|
+
try {
|
|
67
|
+
return await ed25519.verifyAsync(signatureBytes, messageBytes, pkBytes);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export async function verifyRequest(input) {
|
|
74
|
+
const result = newResult();
|
|
75
|
+
const normHeaders = {};
|
|
76
|
+
for (const [k, v] of Object.entries(input.headers)) {
|
|
77
|
+
normHeaders[k.toLowerCase()] = v;
|
|
78
|
+
}
|
|
79
|
+
const siValue = normHeaders["signature-input"];
|
|
80
|
+
if (!siValue)
|
|
81
|
+
return fail(result, "Signature-Input header missing");
|
|
82
|
+
const sValue = normHeaders["signature"];
|
|
83
|
+
if (!sValue)
|
|
84
|
+
return fail(result, "Signature header missing");
|
|
85
|
+
let parsedSi;
|
|
86
|
+
try {
|
|
87
|
+
parsedSi = parseSignatureInput(siValue);
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
if (e instanceof SignatureInputParseError) {
|
|
91
|
+
return fail(result, `Signature-Input parse error: ${e.message}`);
|
|
92
|
+
}
|
|
93
|
+
throw e;
|
|
94
|
+
}
|
|
95
|
+
result.label = parsedSi.label;
|
|
96
|
+
result.covered_components = parsedSi.covered_components;
|
|
97
|
+
result.parameters = parsedSi.parameters;
|
|
98
|
+
let sigParsed;
|
|
99
|
+
try {
|
|
100
|
+
sigParsed = parseSignatureValue(sValue);
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
if (e instanceof SignatureInputParseError) {
|
|
104
|
+
return fail(result, `Signature parse error: ${e.message}`);
|
|
105
|
+
}
|
|
106
|
+
throw e;
|
|
107
|
+
}
|
|
108
|
+
if (sigParsed.label &&
|
|
109
|
+
parsedSi.label &&
|
|
110
|
+
sigParsed.label !== parsedSi.label) {
|
|
111
|
+
return fail(result, `Signature label ${sigParsed.label} does not match Signature-Input label ${parsedSi.label}`);
|
|
112
|
+
}
|
|
113
|
+
const requireCd = input.requireContentDigest ?? true;
|
|
114
|
+
const requireAlg = input.requireAlgorithm ?? "sha-256";
|
|
115
|
+
if (requireCd) {
|
|
116
|
+
const cdHeader = normHeaders["content-digest"];
|
|
117
|
+
if (!cdHeader)
|
|
118
|
+
return fail(result, "Content-Digest header required but missing");
|
|
119
|
+
try {
|
|
120
|
+
verifyContentDigest(input.body, cdHeader, requireAlg);
|
|
121
|
+
result.content_digest_valid = true;
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
if (e instanceof ContentDigestError) {
|
|
125
|
+
return fail(result, `Content-Digest verification failed: ${e.message}`);
|
|
126
|
+
}
|
|
127
|
+
throw e;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
result.content_digest_valid = true;
|
|
132
|
+
}
|
|
133
|
+
let signingBase;
|
|
134
|
+
try {
|
|
135
|
+
signingBase = buildSigningBase({
|
|
136
|
+
coveredComponents: parsedSi.covered_components,
|
|
137
|
+
method: input.method,
|
|
138
|
+
authority: input.authority,
|
|
139
|
+
path: input.path,
|
|
140
|
+
scheme: input.scheme ?? "https",
|
|
141
|
+
headers: normHeaders,
|
|
142
|
+
parameters: parsedSi.parameters,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
if (e instanceof SigningBaseError) {
|
|
147
|
+
return fail(result, `Signing-base build error: ${e.message}`);
|
|
148
|
+
}
|
|
149
|
+
throw e;
|
|
150
|
+
}
|
|
151
|
+
result.signing_base = signingBase;
|
|
152
|
+
const alg = parsedSi.parameters["alg"];
|
|
153
|
+
const algStr = typeof alg === "string" ? alg : "ed25519";
|
|
154
|
+
let sigOk;
|
|
155
|
+
try {
|
|
156
|
+
sigOk = await verifySignature(signingBase, sigParsed.signature, input.publicKey, algStr);
|
|
157
|
+
}
|
|
158
|
+
catch (e) {
|
|
159
|
+
if (e instanceof VerifyError) {
|
|
160
|
+
return fail(result, `Signature verification setup error: ${e.message}`);
|
|
161
|
+
}
|
|
162
|
+
throw e;
|
|
163
|
+
}
|
|
164
|
+
if (!sigOk) {
|
|
165
|
+
return fail(result, "Ed25519 signature does not verify against signing base");
|
|
166
|
+
}
|
|
167
|
+
result.signature_valid = true;
|
|
168
|
+
result.valid = result.signature_valid && result.content_digest_valid;
|
|
169
|
+
return result;
|
|
170
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@algovoi/rfc9421-verifier",
|
|
3
|
+
"version": "0.1.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
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 9530 Content-Digest field implementation.
|
|
3
|
+
*
|
|
4
|
+
* Mirror of the Python algovoi_rfc9421_verifier.content_digest module.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
|
|
9
|
+
export class ContentDigestError extends Error {
|
|
10
|
+
constructor(message: string) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "ContentDigestError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DIGEST_ENTRY_RE = /([a-z0-9-]+)=:([A-Za-z0-9+/=]+):/g;
|
|
17
|
+
|
|
18
|
+
const SUPPORTED_ALGOS: Record<string, string> = {
|
|
19
|
+
"sha-256": "sha256",
|
|
20
|
+
"sha-512": "sha512",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function bodyBytes(body: Uint8Array | Buffer | string): Buffer {
|
|
24
|
+
if (typeof body === "string") return Buffer.from(body, "utf-8");
|
|
25
|
+
if (Buffer.isBuffer(body)) return body;
|
|
26
|
+
return Buffer.from(body);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function computeContentDigest(
|
|
30
|
+
body: Uint8Array | Buffer | string,
|
|
31
|
+
algorithm: string = "sha-256",
|
|
32
|
+
): string {
|
|
33
|
+
const algoLower = algorithm.toLowerCase();
|
|
34
|
+
const nodeAlgo = SUPPORTED_ALGOS[algoLower];
|
|
35
|
+
if (!nodeAlgo) {
|
|
36
|
+
throw new ContentDigestError(
|
|
37
|
+
`unsupported algorithm ${JSON.stringify(algorithm)}; supported: ${Object.keys(SUPPORTED_ALGOS)}`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
const hash = createHash(nodeAlgo).update(bodyBytes(body)).digest("base64");
|
|
41
|
+
return `${algoLower}=:${hash}:`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function verifyContentDigest(
|
|
45
|
+
body: Uint8Array | Buffer | string,
|
|
46
|
+
headerValue: string,
|
|
47
|
+
requireAlgorithm?: string,
|
|
48
|
+
): boolean {
|
|
49
|
+
if (typeof headerValue !== "string") {
|
|
50
|
+
throw new ContentDigestError(
|
|
51
|
+
`header must be string, got ${typeof headerValue}`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
const entries: Array<[string, string]> = [];
|
|
55
|
+
const localRe = new RegExp(DIGEST_ENTRY_RE.source, "g");
|
|
56
|
+
let m: RegExpExecArray | null;
|
|
57
|
+
while ((m = localRe.exec(headerValue)) !== null) {
|
|
58
|
+
entries.push([m[1], m[2]]);
|
|
59
|
+
}
|
|
60
|
+
if (entries.length === 0) {
|
|
61
|
+
throw new ContentDigestError(
|
|
62
|
+
`no valid digest entries in header: ${JSON.stringify(headerValue)}`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const reqAlg = requireAlgorithm?.toLowerCase();
|
|
67
|
+
let requiredSeen = false;
|
|
68
|
+
|
|
69
|
+
for (const [algo, digestB64] of entries) {
|
|
70
|
+
const algoLower = algo.toLowerCase();
|
|
71
|
+
const nodeAlgo = SUPPORTED_ALGOS[algoLower];
|
|
72
|
+
if (!nodeAlgo) continue;
|
|
73
|
+
if (reqAlg && algoLower === reqAlg) requiredSeen = true;
|
|
74
|
+
|
|
75
|
+
let expected: Buffer;
|
|
76
|
+
try {
|
|
77
|
+
expected = Buffer.from(digestB64, "base64");
|
|
78
|
+
} catch (e) {
|
|
79
|
+
throw new ContentDigestError(
|
|
80
|
+
`digest entry ${algoLower} is not valid base64: ${(e as Error).message}`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const actual = createHash(nodeAlgo).update(bodyBytes(body)).digest();
|
|
84
|
+
if (!expected.equals(actual)) {
|
|
85
|
+
throw new ContentDigestError(
|
|
86
|
+
`digest mismatch on ${algoLower}: header claims ${digestB64} but body hashes to ${actual.toString("base64")}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (reqAlg && !requiredSeen) {
|
|
92
|
+
throw new ContentDigestError(
|
|
93
|
+
`required algorithm ${reqAlg} not present in header`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return true;
|
|
97
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @algovoi/rfc9421-verifier
|
|
3
|
+
*
|
|
4
|
+
* AlgoVoi RFC 9421 HTTP Message Signatures + RFC 9530 Content-Digest
|
|
5
|
+
* reference verifier (TypeScript). Byte-for-byte parity with the
|
|
6
|
+
* Python sibling `algovoi-rfc9421-verifier` on PyPI.
|
|
7
|
+
*
|
|
8
|
+
* Apache 2.0. Companion to IETF Internet-Draft
|
|
9
|
+
* draft-hopley-x402-compliance-receipt-00.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
SignatureInputParseError,
|
|
14
|
+
parseSignatureInput,
|
|
15
|
+
parseSignatureValue,
|
|
16
|
+
type ParsedSignatureInput,
|
|
17
|
+
} from "./parse.js";
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
SigningBaseError,
|
|
21
|
+
buildSigningBase,
|
|
22
|
+
type SigningBaseInput,
|
|
23
|
+
} from "./signing-base.js";
|
|
24
|
+
|
|
25
|
+
export {
|
|
26
|
+
ContentDigestError,
|
|
27
|
+
computeContentDigest,
|
|
28
|
+
verifyContentDigest,
|
|
29
|
+
} from "./content-digest.js";
|
|
30
|
+
|
|
31
|
+
export {
|
|
32
|
+
VerifyError,
|
|
33
|
+
verifySignature,
|
|
34
|
+
verifyRequest,
|
|
35
|
+
type VerifyResult,
|
|
36
|
+
type VerifyRequestInput,
|
|
37
|
+
type PublicKey,
|
|
38
|
+
} from "./verify.js";
|
package/src/parse.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
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
|
+
|
|
21
|
+
const LABEL_RE = /^\s*([A-Za-z][A-Za-z0-9_-]*)\s*=\s*/;
|
|
22
|
+
const COVERED_RE = /\(\s*(?:"[^"]*"\s*)*\)/;
|
|
23
|
+
const QUOTED_RE = /"([^"]*)"/g;
|
|
24
|
+
const PARAM_RE = /([A-Za-z][A-Za-z0-9_-]*)=([^;,\s]+|"[^"]*")/g;
|
|
25
|
+
|
|
26
|
+
export function parseSignatureInput(headerValue: string): ParsedSignatureInput {
|
|
27
|
+
if (typeof headerValue !== "string") {
|
|
28
|
+
throw new SignatureInputParseError(
|
|
29
|
+
`header must be string, got ${typeof headerValue}`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
const trimmed = headerValue.trim();
|
|
33
|
+
if (trimmed.length === 0) {
|
|
34
|
+
throw new SignatureInputParseError("empty header value");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let label: string;
|
|
38
|
+
let rest: string;
|
|
39
|
+
|
|
40
|
+
const labelMatch = LABEL_RE.exec(trimmed);
|
|
41
|
+
if (labelMatch) {
|
|
42
|
+
label = labelMatch[1];
|
|
43
|
+
rest = trimmed.slice(labelMatch[0].length);
|
|
44
|
+
} else if (trimmed.startsWith("(")) {
|
|
45
|
+
label = "";
|
|
46
|
+
rest = trimmed;
|
|
47
|
+
} else {
|
|
48
|
+
throw new SignatureInputParseError(
|
|
49
|
+
`no label or covered-components list found at start: ${JSON.stringify(trimmed.slice(0, 40))}`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const coveredMatch = COVERED_RE.exec(rest);
|
|
54
|
+
if (!coveredMatch) {
|
|
55
|
+
throw new SignatureInputParseError("no covered-components list found");
|
|
56
|
+
}
|
|
57
|
+
const coveredRaw = coveredMatch[0];
|
|
58
|
+
const covered: string[] = [];
|
|
59
|
+
let qm: RegExpExecArray | null;
|
|
60
|
+
const localQuoted = /"([^"]*)"/g;
|
|
61
|
+
while ((qm = localQuoted.exec(coveredRaw)) !== null) {
|
|
62
|
+
covered.push(qm[1]);
|
|
63
|
+
}
|
|
64
|
+
rest = rest.slice(coveredMatch.index + coveredRaw.length);
|
|
65
|
+
|
|
66
|
+
const parameters: Record<string, string | number> = {};
|
|
67
|
+
const localParam = new RegExp(PARAM_RE.source, "g");
|
|
68
|
+
let pm: RegExpExecArray | null;
|
|
69
|
+
while ((pm = localParam.exec(rest)) !== null) {
|
|
70
|
+
const key = pm[1];
|
|
71
|
+
const raw = pm[2];
|
|
72
|
+
if (raw.startsWith('"') && raw.endsWith('"')) {
|
|
73
|
+
parameters[key] = raw.slice(1, -1);
|
|
74
|
+
} else if (/^-?\d+$/.test(raw)) {
|
|
75
|
+
parameters[key] = parseInt(raw, 10);
|
|
76
|
+
} else {
|
|
77
|
+
parameters[key] = raw;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
label,
|
|
83
|
+
covered_components: covered,
|
|
84
|
+
parameters,
|
|
85
|
+
raw: trimmed,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function parseSignatureValue(headerValue: string): {
|
|
90
|
+
label: string;
|
|
91
|
+
signature: Uint8Array;
|
|
92
|
+
} {
|
|
93
|
+
if (typeof headerValue !== "string") {
|
|
94
|
+
throw new SignatureInputParseError(
|
|
95
|
+
`header must be string, got ${typeof headerValue}`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
const trimmed = headerValue.trim();
|
|
99
|
+
if (trimmed.length === 0) {
|
|
100
|
+
throw new SignatureInputParseError("empty Signature header value");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let label: string;
|
|
104
|
+
let rest: string;
|
|
105
|
+
|
|
106
|
+
const labelMatch = LABEL_RE.exec(trimmed);
|
|
107
|
+
if (labelMatch) {
|
|
108
|
+
label = labelMatch[1];
|
|
109
|
+
rest = trimmed.slice(labelMatch[0].length).trim();
|
|
110
|
+
} else if (trimmed.startsWith(":")) {
|
|
111
|
+
label = "";
|
|
112
|
+
rest = trimmed;
|
|
113
|
+
} else {
|
|
114
|
+
throw new SignatureInputParseError(
|
|
115
|
+
`no label or signature-value prefix found at start: ${JSON.stringify(trimmed.slice(0, 40))}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!rest.startsWith(":") || !rest.endsWith(":")) {
|
|
120
|
+
throw new SignatureInputParseError(
|
|
121
|
+
"signature value must be wrapped in colons (RFC 8941 byte-sequence form)",
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const sigB64 = rest.slice(1, -1);
|
|
126
|
+
let sigBytes: Uint8Array;
|
|
127
|
+
try {
|
|
128
|
+
sigBytes = new Uint8Array(Buffer.from(sigB64, "base64"));
|
|
129
|
+
} catch (e) {
|
|
130
|
+
throw new SignatureInputParseError(
|
|
131
|
+
`signature value is not valid base64: ${(e as Error).message}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return { label, signature: sigBytes };
|
|
135
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
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 interface SigningBaseInput {
|
|
15
|
+
coveredComponents: string[];
|
|
16
|
+
method?: string;
|
|
17
|
+
authority?: string;
|
|
18
|
+
path?: string;
|
|
19
|
+
targetUri?: string;
|
|
20
|
+
scheme?: string;
|
|
21
|
+
status?: number;
|
|
22
|
+
headers?: Record<string, string>;
|
|
23
|
+
parameters?: Record<string, string | number>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function buildSigningBase(input: SigningBaseInput): string {
|
|
27
|
+
const normHeaders: Record<string, string> = {};
|
|
28
|
+
for (const [k, v] of Object.entries(input.headers ?? {})) {
|
|
29
|
+
normHeaders[k.toLowerCase()] = v;
|
|
30
|
+
}
|
|
31
|
+
const parameters = input.parameters ?? {};
|
|
32
|
+
const lines: string[] = [];
|
|
33
|
+
|
|
34
|
+
for (const component of input.coveredComponents) {
|
|
35
|
+
const c = component.toLowerCase();
|
|
36
|
+
let value: string;
|
|
37
|
+
|
|
38
|
+
switch (c) {
|
|
39
|
+
case "@method":
|
|
40
|
+
if (input.method === undefined)
|
|
41
|
+
throw new SigningBaseError("@method covered but method not supplied");
|
|
42
|
+
value = input.method.toLowerCase();
|
|
43
|
+
break;
|
|
44
|
+
case "@authority":
|
|
45
|
+
if (input.authority === undefined)
|
|
46
|
+
throw new SigningBaseError("@authority covered but authority not supplied");
|
|
47
|
+
value = input.authority.toLowerCase();
|
|
48
|
+
break;
|
|
49
|
+
case "@path":
|
|
50
|
+
if (input.path === undefined)
|
|
51
|
+
throw new SigningBaseError("@path covered but path not supplied");
|
|
52
|
+
value = input.path;
|
|
53
|
+
break;
|
|
54
|
+
case "@target-uri":
|
|
55
|
+
if (input.targetUri === undefined)
|
|
56
|
+
throw new SigningBaseError("@target-uri covered but targetUri not supplied");
|
|
57
|
+
value = input.targetUri;
|
|
58
|
+
break;
|
|
59
|
+
case "@scheme":
|
|
60
|
+
if (input.scheme === undefined)
|
|
61
|
+
throw new SigningBaseError("@scheme covered but scheme not supplied");
|
|
62
|
+
value = input.scheme.toLowerCase();
|
|
63
|
+
break;
|
|
64
|
+
case "@status":
|
|
65
|
+
if (input.status === undefined)
|
|
66
|
+
throw new SigningBaseError("@status covered but status not supplied");
|
|
67
|
+
value = String(input.status);
|
|
68
|
+
break;
|
|
69
|
+
case "created":
|
|
70
|
+
if (!("created" in parameters))
|
|
71
|
+
throw new SigningBaseError(
|
|
72
|
+
"'created' covered but no 'created' parameter in Signature-Input",
|
|
73
|
+
);
|
|
74
|
+
value = String(parameters["created"]);
|
|
75
|
+
break;
|
|
76
|
+
case "expires":
|
|
77
|
+
if (!("expires" in parameters))
|
|
78
|
+
throw new SigningBaseError(
|
|
79
|
+
"'expires' covered but no 'expires' parameter in Signature-Input",
|
|
80
|
+
);
|
|
81
|
+
value = String(parameters["expires"]);
|
|
82
|
+
break;
|
|
83
|
+
default:
|
|
84
|
+
if (c.startsWith("@")) {
|
|
85
|
+
throw new SigningBaseError(`unsupported derived component: ${component}`);
|
|
86
|
+
}
|
|
87
|
+
if (!(c in normHeaders)) {
|
|
88
|
+
throw new SigningBaseError(
|
|
89
|
+
`covered header ${JSON.stringify(component)} not present in supplied headers`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
value = normHeaders[c];
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
lines.push(`"${c}": ${value}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return lines.join("\n");
|
|
100
|
+
}
|
package/src/verify.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
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
|
+
} from "./signing-base.js";
|
|
19
|
+
import {
|
|
20
|
+
verifyContentDigest,
|
|
21
|
+
ContentDigestError,
|
|
22
|
+
} from "./content-digest.js";
|
|
23
|
+
|
|
24
|
+
export class VerifyError extends Error {
|
|
25
|
+
constructor(message: string) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = "VerifyError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type PublicKey = string | Uint8Array;
|
|
32
|
+
|
|
33
|
+
export interface VerifyResult {
|
|
34
|
+
valid: boolean;
|
|
35
|
+
signature_valid: boolean;
|
|
36
|
+
content_digest_valid: boolean;
|
|
37
|
+
signing_base: string;
|
|
38
|
+
covered_components: string[];
|
|
39
|
+
parameters: Record<string, string | number>;
|
|
40
|
+
label: string;
|
|
41
|
+
errors: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface VerifyRequestInput {
|
|
45
|
+
method: string;
|
|
46
|
+
authority: string;
|
|
47
|
+
path: string;
|
|
48
|
+
headers: Record<string, string>;
|
|
49
|
+
body: Uint8Array | Buffer | string;
|
|
50
|
+
publicKey: PublicKey;
|
|
51
|
+
scheme?: string;
|
|
52
|
+
requireContentDigest?: boolean;
|
|
53
|
+
requireAlgorithm?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function newResult(): VerifyResult {
|
|
57
|
+
return {
|
|
58
|
+
valid: false,
|
|
59
|
+
signature_valid: false,
|
|
60
|
+
content_digest_valid: false,
|
|
61
|
+
signing_base: "",
|
|
62
|
+
covered_components: [],
|
|
63
|
+
parameters: {},
|
|
64
|
+
label: "",
|
|
65
|
+
errors: [],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function fail(result: VerifyResult, msg: string): VerifyResult {
|
|
70
|
+
result.errors.push(msg);
|
|
71
|
+
result.valid = false;
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function publicKeyBytes(pk: PublicKey): Uint8Array {
|
|
76
|
+
if (pk instanceof Uint8Array) {
|
|
77
|
+
if (pk.length !== 32) {
|
|
78
|
+
throw new VerifyError(
|
|
79
|
+
`Ed25519 public key must be 32 bytes, got ${pk.length}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return pk;
|
|
83
|
+
}
|
|
84
|
+
if (typeof pk === "string") {
|
|
85
|
+
const hex = pk.startsWith("0x") ? pk.slice(2) : pk;
|
|
86
|
+
if (!/^[0-9a-fA-F]+$/.test(hex)) {
|
|
87
|
+
throw new VerifyError("public key hex is invalid");
|
|
88
|
+
}
|
|
89
|
+
if (hex.length !== 64) {
|
|
90
|
+
throw new VerifyError(
|
|
91
|
+
`Ed25519 public key hex must decode to 32 bytes (64 hex chars), got ${hex.length}`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
const bytes = new Uint8Array(32);
|
|
95
|
+
for (let i = 0; i < 32; i++) {
|
|
96
|
+
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
97
|
+
}
|
|
98
|
+
return bytes;
|
|
99
|
+
}
|
|
100
|
+
throw new VerifyError("publicKey must be Uint8Array or hex string");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function verifySignature(
|
|
104
|
+
signingBase: string,
|
|
105
|
+
signatureBytes: Uint8Array,
|
|
106
|
+
publicKey: PublicKey,
|
|
107
|
+
algorithm: string = "ed25519",
|
|
108
|
+
): Promise<boolean> {
|
|
109
|
+
if (algorithm.toLowerCase() !== "ed25519") {
|
|
110
|
+
throw new VerifyError(`v0.1.0 supports ed25519 only; got ${algorithm}`);
|
|
111
|
+
}
|
|
112
|
+
if (signatureBytes.length !== 64) {
|
|
113
|
+
throw new VerifyError(
|
|
114
|
+
`Ed25519 signature must be 64 bytes, got ${signatureBytes.length}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
const pkBytes = publicKeyBytes(publicKey);
|
|
118
|
+
const messageBytes = new TextEncoder().encode(signingBase);
|
|
119
|
+
try {
|
|
120
|
+
return await ed25519.verifyAsync(signatureBytes, messageBytes, pkBytes);
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function verifyRequest(
|
|
127
|
+
input: VerifyRequestInput,
|
|
128
|
+
): Promise<VerifyResult> {
|
|
129
|
+
const result = newResult();
|
|
130
|
+
|
|
131
|
+
const normHeaders: Record<string, string> = {};
|
|
132
|
+
for (const [k, v] of Object.entries(input.headers)) {
|
|
133
|
+
normHeaders[k.toLowerCase()] = v;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const siValue = normHeaders["signature-input"];
|
|
137
|
+
if (!siValue) return fail(result, "Signature-Input header missing");
|
|
138
|
+
const sValue = normHeaders["signature"];
|
|
139
|
+
if (!sValue) return fail(result, "Signature header missing");
|
|
140
|
+
|
|
141
|
+
let parsedSi;
|
|
142
|
+
try {
|
|
143
|
+
parsedSi = parseSignatureInput(siValue);
|
|
144
|
+
} catch (e) {
|
|
145
|
+
if (e instanceof SignatureInputParseError) {
|
|
146
|
+
return fail(result, `Signature-Input parse error: ${e.message}`);
|
|
147
|
+
}
|
|
148
|
+
throw e;
|
|
149
|
+
}
|
|
150
|
+
result.label = parsedSi.label;
|
|
151
|
+
result.covered_components = parsedSi.covered_components;
|
|
152
|
+
result.parameters = parsedSi.parameters;
|
|
153
|
+
|
|
154
|
+
let sigParsed;
|
|
155
|
+
try {
|
|
156
|
+
sigParsed = parseSignatureValue(sValue);
|
|
157
|
+
} catch (e) {
|
|
158
|
+
if (e instanceof SignatureInputParseError) {
|
|
159
|
+
return fail(result, `Signature parse error: ${e.message}`);
|
|
160
|
+
}
|
|
161
|
+
throw e;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (
|
|
165
|
+
sigParsed.label &&
|
|
166
|
+
parsedSi.label &&
|
|
167
|
+
sigParsed.label !== parsedSi.label
|
|
168
|
+
) {
|
|
169
|
+
return fail(
|
|
170
|
+
result,
|
|
171
|
+
`Signature label ${sigParsed.label} does not match Signature-Input label ${parsedSi.label}`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const requireCd = input.requireContentDigest ?? true;
|
|
176
|
+
const requireAlg = input.requireAlgorithm ?? "sha-256";
|
|
177
|
+
|
|
178
|
+
if (requireCd) {
|
|
179
|
+
const cdHeader = normHeaders["content-digest"];
|
|
180
|
+
if (!cdHeader)
|
|
181
|
+
return fail(result, "Content-Digest header required but missing");
|
|
182
|
+
try {
|
|
183
|
+
verifyContentDigest(input.body, cdHeader, requireAlg);
|
|
184
|
+
result.content_digest_valid = true;
|
|
185
|
+
} catch (e) {
|
|
186
|
+
if (e instanceof ContentDigestError) {
|
|
187
|
+
return fail(result, `Content-Digest verification failed: ${e.message}`);
|
|
188
|
+
}
|
|
189
|
+
throw e;
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
result.content_digest_valid = true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let signingBase: string;
|
|
196
|
+
try {
|
|
197
|
+
signingBase = buildSigningBase({
|
|
198
|
+
coveredComponents: parsedSi.covered_components,
|
|
199
|
+
method: input.method,
|
|
200
|
+
authority: input.authority,
|
|
201
|
+
path: input.path,
|
|
202
|
+
scheme: input.scheme ?? "https",
|
|
203
|
+
headers: normHeaders,
|
|
204
|
+
parameters: parsedSi.parameters,
|
|
205
|
+
});
|
|
206
|
+
} catch (e) {
|
|
207
|
+
if (e instanceof SigningBaseError) {
|
|
208
|
+
return fail(result, `Signing-base build error: ${e.message}`);
|
|
209
|
+
}
|
|
210
|
+
throw e;
|
|
211
|
+
}
|
|
212
|
+
result.signing_base = signingBase;
|
|
213
|
+
|
|
214
|
+
const alg = parsedSi.parameters["alg"];
|
|
215
|
+
const algStr = typeof alg === "string" ? alg : "ed25519";
|
|
216
|
+
|
|
217
|
+
let sigOk: boolean;
|
|
218
|
+
try {
|
|
219
|
+
sigOk = await verifySignature(
|
|
220
|
+
signingBase,
|
|
221
|
+
sigParsed.signature,
|
|
222
|
+
input.publicKey,
|
|
223
|
+
algStr,
|
|
224
|
+
);
|
|
225
|
+
} catch (e) {
|
|
226
|
+
if (e instanceof VerifyError) {
|
|
227
|
+
return fail(result, `Signature verification setup error: ${e.message}`);
|
|
228
|
+
}
|
|
229
|
+
throw e;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!sigOk) {
|
|
233
|
+
return fail(result, "Ed25519 signature does not verify against signing base");
|
|
234
|
+
}
|
|
235
|
+
result.signature_valid = true;
|
|
236
|
+
result.valid = result.signature_valid && result.content_digest_valid;
|
|
237
|
+
return result;
|
|
238
|
+
}
|