@zadq/x402 0.0.0-stage → 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/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +35 -2
- package/dist/brand.d.ts +1 -0
- package/dist/brand.js +3 -0
- package/dist/explorer.d.ts +47 -0
- package/dist/explorer.js +76 -0
- package/dist/extension.d.ts +169 -0
- package/dist/extension.js +182 -0
- package/dist/header.d.ts +30 -0
- package/dist/header.js +84 -0
- package/dist/holder/chain.d.ts +28 -0
- package/dist/holder/chain.js +167 -0
- package/dist/holder/cli.d.ts +42 -0
- package/dist/holder/cli.js +173 -0
- package/dist/holder/custody.d.ts +20 -0
- package/dist/holder/custody.js +112 -0
- package/dist/holder/errors.d.ts +22 -0
- package/dist/holder/errors.js +38 -0
- package/dist/holder/holder.d.ts +115 -0
- package/dist/holder/holder.js +381 -0
- package/dist/holder/index.d.ts +8 -0
- package/dist/holder/index.js +14 -0
- package/dist/holder/killswitch.d.ts +139 -0
- package/dist/holder/killswitch.js +257 -0
- package/dist/holder/process.d.ts +31 -0
- package/dist/holder/process.js +84 -0
- package/dist/holder/types.d.ts +108 -0
- package/dist/holder/types.js +5 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +20 -0
- package/dist/keys.d.ts +17 -0
- package/dist/keys.js +45 -0
- package/dist/receipt.d.ts +50 -0
- package/dist/receipt.js +193 -0
- package/dist/verdict.d.ts +101 -0
- package/dist/verdict.js +179 -0
- package/dist/webhook.d.ts +29 -0
- package/dist/webhook.js +96 -0
- package/package.json +85 -4
package/dist/receipt.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Issuer-signed attestation receipt — the `attestation-ref/v0` payload of the
|
|
2
|
+
// X-PAYMENT-IDENTITY companion header (bIP-0002 §3A.3, cited by name).
|
|
3
|
+
//
|
|
4
|
+
// Vendor this file. Zero runtime dependencies; WebCrypto for the checks.
|
|
5
|
+
//
|
|
6
|
+
// What a receipt is, and is not: it is the issuer's signed statement that an
|
|
7
|
+
// attestation was ISSUED binding this chain-qualified payTo and resource URL
|
|
8
|
+
// to an accountable operator at a grade. It carries no standing: whether the
|
|
9
|
+
// attestation is active today is read from the free verdict path (or a
|
|
10
|
+
// verifier's proven standing read), never from the receipt. A receiver that
|
|
11
|
+
// cannot verify a receipt handles the request exactly as without it.
|
|
12
|
+
/** Format tag of the v0 seller-side proof. */
|
|
13
|
+
export const ATTESTATION_RECEIPT_FORMAT = 'attestation-ref/v0';
|
|
14
|
+
const HEX32 = /^[0-9a-f]{64}$/;
|
|
15
|
+
const HEX64 = /^[0-9a-f]{128}$/;
|
|
16
|
+
/** Sender side: the companion-header proof carrying this receipt. */
|
|
17
|
+
export function attestationReceiptProof(receipt) {
|
|
18
|
+
return {
|
|
19
|
+
version: 1,
|
|
20
|
+
format: ATTESTATION_RECEIPT_FORMAT,
|
|
21
|
+
payload: base64UrlEncode(canonicalJson(receipt)),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Receiver side: decodes a receipt from a companion-header proof. Returns
|
|
26
|
+
* `undefined` for any other format or a malformed payload — a normal state;
|
|
27
|
+
* proceed as without the header.
|
|
28
|
+
*/
|
|
29
|
+
export function readAttestationReceipt(proof) {
|
|
30
|
+
if (proof === undefined || proof.version !== 1 || proof.format !== ATTESTATION_RECEIPT_FORMAT) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
let parsed;
|
|
34
|
+
try {
|
|
35
|
+
parsed = JSON.parse(base64UrlDecode(proof.payload));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
return isReceipt(parsed) ? parsed : undefined;
|
|
41
|
+
}
|
|
42
|
+
/** Structural check against the canonical payload schema (closed object). */
|
|
43
|
+
export function isReceipt(value) {
|
|
44
|
+
if (typeof value !== 'object' || value === null) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
const r = value;
|
|
48
|
+
const keys = Object.keys(r).sort();
|
|
49
|
+
const expected = [
|
|
50
|
+
'assurance_grade',
|
|
51
|
+
'attestation_ref',
|
|
52
|
+
'chain',
|
|
53
|
+
'issued_at_unix',
|
|
54
|
+
'issuer_id',
|
|
55
|
+
'pay_to',
|
|
56
|
+
'resource_url',
|
|
57
|
+
'signature',
|
|
58
|
+
];
|
|
59
|
+
if (keys.length !== expected.length || keys.some((k, i) => k !== expected[i])) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
return (typeof r['attestation_ref'] === 'string' &&
|
|
63
|
+
HEX32.test(r['attestation_ref']) &&
|
|
64
|
+
typeof r['issuer_id'] === 'string' &&
|
|
65
|
+
HEX32.test(r['issuer_id']) &&
|
|
66
|
+
typeof r['chain'] === 'string' &&
|
|
67
|
+
r['chain'].length > 0 &&
|
|
68
|
+
typeof r['pay_to'] === 'string' &&
|
|
69
|
+
r['pay_to'].length > 0 &&
|
|
70
|
+
typeof r['resource_url'] === 'string' &&
|
|
71
|
+
r['resource_url'].startsWith('https://') &&
|
|
72
|
+
Number.isInteger(r['assurance_grade']) &&
|
|
73
|
+
r['assurance_grade'] >= 1 &&
|
|
74
|
+
r['assurance_grade'] <= 4 &&
|
|
75
|
+
Number.isInteger(r['issued_at_unix']) &&
|
|
76
|
+
r['issued_at_unix'] >= 0 &&
|
|
77
|
+
typeof r['signature'] === 'string' &&
|
|
78
|
+
HEX64.test(r['signature']));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Verifies the issuer's signature locally: Ed25519 over
|
|
82
|
+
* `"mintid/seller-attestation/v0" || 0x00 || SHA-256(canonical JSON of the
|
|
83
|
+
* seven signed fields)` with the issuer's registered status signing key (raw
|
|
84
|
+
* 32 bytes, hex). The key is read from the substrate's issuer registry and
|
|
85
|
+
* pinned by the consumer — never hard-coded here, never taken from the
|
|
86
|
+
* receipt itself.
|
|
87
|
+
*
|
|
88
|
+
* `valid` proves issuance by that key. It says nothing about standing.
|
|
89
|
+
*/
|
|
90
|
+
export async function verifyAttestationReceipt(receipt, issuerPublicKeyHex) {
|
|
91
|
+
if (!isReceipt(receipt)) {
|
|
92
|
+
return 'invalid';
|
|
93
|
+
}
|
|
94
|
+
let publicKey;
|
|
95
|
+
let signature;
|
|
96
|
+
try {
|
|
97
|
+
publicKey = hexDecode(issuerPublicKeyHex, 32);
|
|
98
|
+
signature = hexDecode(receipt.signature, 64);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return 'invalid';
|
|
102
|
+
}
|
|
103
|
+
let message;
|
|
104
|
+
try {
|
|
105
|
+
message = await receiptSigningInput(receipt);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return 'unverifiable';
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const key = await crypto.subtle.importKey('raw', publicKey, { name: 'Ed25519' }, false, [
|
|
112
|
+
'verify',
|
|
113
|
+
]);
|
|
114
|
+
const ok = await crypto.subtle.verify('Ed25519', key, signature, message);
|
|
115
|
+
return ok ? 'valid' : 'invalid';
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// Runtime without Ed25519 WebCrypto support: the integration keeps
|
|
119
|
+
// working; the receipt is simply not locally checked.
|
|
120
|
+
return 'unverifiable';
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const SIGNING_DOMAIN = 'mintid/seller-attestation/v0';
|
|
124
|
+
/** The exact bytes the issuer signed (domain-separated digest of the canonical payload). */
|
|
125
|
+
export async function receiptSigningInput(receipt) {
|
|
126
|
+
const payload = canonicalJson({
|
|
127
|
+
assurance_grade: receipt.assurance_grade,
|
|
128
|
+
attestation_ref: receipt.attestation_ref,
|
|
129
|
+
chain: receipt.chain,
|
|
130
|
+
issued_at_unix: receipt.issued_at_unix,
|
|
131
|
+
issuer_id: receipt.issuer_id,
|
|
132
|
+
pay_to: receipt.pay_to,
|
|
133
|
+
resource_url: receipt.resource_url,
|
|
134
|
+
});
|
|
135
|
+
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload)));
|
|
136
|
+
const domain = new TextEncoder().encode(SIGNING_DOMAIN);
|
|
137
|
+
const out = new Uint8Array(new ArrayBuffer(domain.length + 1 + digest.length));
|
|
138
|
+
out.set(domain, 0);
|
|
139
|
+
out[domain.length] = 0x00;
|
|
140
|
+
out.set(digest, domain.length + 1);
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Canonical JSON as the issuer produces it: keys sorted by code point, no
|
|
145
|
+
* whitespace, every non-ASCII code unit escaped as lowercase `\uXXXX`
|
|
146
|
+
* (the form Python's json.dumps emits with sort_keys and compact separators).
|
|
147
|
+
*/
|
|
148
|
+
export function canonicalJson(value) {
|
|
149
|
+
const keys = Object.keys(value).sort();
|
|
150
|
+
const parts = keys.map((k) => `${jsonString(k)}:${jsonScalar(value[k])}`);
|
|
151
|
+
return `{${parts.join(',')}}`;
|
|
152
|
+
}
|
|
153
|
+
function jsonScalar(v) {
|
|
154
|
+
if (typeof v === 'number') {
|
|
155
|
+
if (!Number.isInteger(v)) {
|
|
156
|
+
throw new Error('receipt fields are integers or strings');
|
|
157
|
+
}
|
|
158
|
+
return String(v);
|
|
159
|
+
}
|
|
160
|
+
return jsonString(v ?? '');
|
|
161
|
+
}
|
|
162
|
+
function jsonString(s) {
|
|
163
|
+
const escaped = JSON.stringify(s);
|
|
164
|
+
// JSON.stringify leaves non-ASCII as-is; the issuer escapes it.
|
|
165
|
+
return escaped.replace(/[\u0080-]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
166
|
+
}
|
|
167
|
+
function hexDecode(hex, length) {
|
|
168
|
+
if (!/^[0-9a-f]*$/.test(hex) || hex.length !== length * 2) {
|
|
169
|
+
throw new Error('bad hex');
|
|
170
|
+
}
|
|
171
|
+
const out = new Uint8Array(new ArrayBuffer(length));
|
|
172
|
+
for (let i = 0; i < length; i++) {
|
|
173
|
+
out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
function base64UrlEncode(value) {
|
|
178
|
+
const bytes = new TextEncoder().encode(value);
|
|
179
|
+
let binary = '';
|
|
180
|
+
for (const b of bytes) {
|
|
181
|
+
binary += String.fromCharCode(b);
|
|
182
|
+
}
|
|
183
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
184
|
+
}
|
|
185
|
+
function base64UrlDecode(value) {
|
|
186
|
+
const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
187
|
+
const binary = atob(base64);
|
|
188
|
+
const bytes = new Uint8Array(binary.length);
|
|
189
|
+
for (let i = 0; i < binary.length; i++) {
|
|
190
|
+
bytes[i] = binary.charCodeAt(i);
|
|
191
|
+
}
|
|
192
|
+
return new TextDecoder().decode(bytes);
|
|
193
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The payment rails the seller rail covers (Platform Decision 14): USDC on
|
|
3
|
+
* Base (`base`, an EVM address) or on Solana (`solana`). The contract is a
|
|
4
|
+
* closed enumeration — the service answers a 400 problem to any other value,
|
|
5
|
+
* and other EVM chains are not declared until a seller needs one — so the
|
|
6
|
+
* type is the same closed union, not a string.
|
|
7
|
+
*/
|
|
8
|
+
export type Chain = 'base' | 'solana';
|
|
9
|
+
/** The declared rails, in the order the contract lists them. */
|
|
10
|
+
export declare const CHAINS: readonly Chain[];
|
|
11
|
+
/** Runtime guard for the `Chain` enumeration (input from a wire, an agent or an env var). */
|
|
12
|
+
export declare function isChain(value: unknown): value is Chain;
|
|
13
|
+
/** Verdict outcomes, mirroring the public API contract vocabulary. */
|
|
14
|
+
export type VerdictOutcome = 'human_backed' | 'revoked' | 'stale' | 'no_signal';
|
|
15
|
+
/**
|
|
16
|
+
* Why a verdict is not `human_backed`: the standing read's closed vocabulary
|
|
17
|
+
* (MintID's, restated — never a flag of the service's own), or `suspended`
|
|
18
|
+
* when the witnessed status is suspended. Never present on `human_backed`.
|
|
19
|
+
*/
|
|
20
|
+
export type VerdictReason = 'state_unavailable' | 'issuer_unknown' | 'issuer_not_active' | 'status_root_stale' | 'height_stale' | 'witness_unavailable' | 'witness_malformed' | 'witness_invalid' | 'witness_root_mismatch' | 'bond_insufficient' | 'suspended';
|
|
21
|
+
/** The x402 endpoint a verdict speaks about: chain-qualified payTo + resource URL. */
|
|
22
|
+
export type VerdictSubject = {
|
|
23
|
+
chain: Chain;
|
|
24
|
+
payTo: string;
|
|
25
|
+
resourceUrl: string;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* The address as the service stores and echoes it. An EVM address (`base`)
|
|
29
|
+
* is case-insensitive — its checksum and lowercase spellings name the same
|
|
30
|
+
* account — and is kept lowercase; a Solana address is case-sensitive and
|
|
31
|
+
* kept as given. Use it wherever two spellings of one endpoint are compared
|
|
32
|
+
* (a cache key, a webhook match, a verdict echoed back). The service
|
|
33
|
+
* normalises its own side, so a lookup finds the seller under either
|
|
34
|
+
* spelling.
|
|
35
|
+
*/
|
|
36
|
+
export declare function canonicalPayTo(chain: Chain, payTo: string): string;
|
|
37
|
+
/** The subject with its `payTo` in canonical form (see `canonicalPayTo`). */
|
|
38
|
+
export declare function canonicalSubject(subject: VerdictSubject): VerdictSubject;
|
|
39
|
+
/** Signed verdict as served by the free verification path. */
|
|
40
|
+
export type Verdict = {
|
|
41
|
+
outcome: VerdictOutcome;
|
|
42
|
+
subject: VerdictSubject;
|
|
43
|
+
/** Assurance grade of the positive claim (e.g. "G"). Present on signed outcomes. */
|
|
44
|
+
assuranceLevel?: string;
|
|
45
|
+
/** RFC 3339 time of the last re-check. */
|
|
46
|
+
verifiedAt?: string;
|
|
47
|
+
/** The published cadence promise: the verdict is re-checked every N hours. */
|
|
48
|
+
recheckEveryHours: number;
|
|
49
|
+
/** Ed25519 over the canonical verdict payload, base64. Only positive claims are signed. */
|
|
50
|
+
signature?: string;
|
|
51
|
+
/** Identifies the verdict-signing key (first 8 bytes of the public key's SHA-256, hex). */
|
|
52
|
+
keyId?: string;
|
|
53
|
+
/** Present on every non-positive outcome, from the closed vocabulary; absent on `human_backed`. */
|
|
54
|
+
reason?: VerdictReason;
|
|
55
|
+
};
|
|
56
|
+
export declare const DEFAULT_TIMEOUT_MS = 3000;
|
|
57
|
+
/**
|
|
58
|
+
* Fetches the current verdict for an endpoint from the free verification path.
|
|
59
|
+
*
|
|
60
|
+
* Degradation path: resolves to `undefined` — never rejects — on timeout,
|
|
61
|
+
* network failure or a non-OK response. Treat `undefined` exactly like a
|
|
62
|
+
* `no_signal` outcome: show "unknown" and carry on.
|
|
63
|
+
*/
|
|
64
|
+
export declare function fetchVerdict(baseUrl: string, subject: VerdictSubject, options?: {
|
|
65
|
+
timeoutMs?: number;
|
|
66
|
+
}): Promise<Verdict | undefined>;
|
|
67
|
+
/** Freshness against the cadence promise the verdict itself carries. */
|
|
68
|
+
export type Freshness = 'fresh' | 'overdue' | 'unknown';
|
|
69
|
+
/**
|
|
70
|
+
* Compares the verdict's age with its own `recheckEveryHours` promise. The
|
|
71
|
+
* service additionally resolves a verdict older than twice the promise as
|
|
72
|
+
* `stale` server-side; this local check lets a consumer flag lateness without
|
|
73
|
+
* any further round-trip.
|
|
74
|
+
*/
|
|
75
|
+
export declare function freshness(verdict: Verdict, now?: Date): Freshness;
|
|
76
|
+
/**
|
|
77
|
+
* Renders the canonical signed statement byte-for-byte as the service signs
|
|
78
|
+
* it: fixed key order, RFC 3339 UTC at second precision, and Go-style JSON
|
|
79
|
+
* escaping of `<`, `>` and `&`. Returns `undefined` when the verdict lacks
|
|
80
|
+
* the fields a signed (positive) claim always carries.
|
|
81
|
+
*/
|
|
82
|
+
export declare function canonicalVerdictPayload(verdict: Verdict): Uint8Array<ArrayBuffer> | undefined;
|
|
83
|
+
/** Outcome of a local signature check. */
|
|
84
|
+
export type SignatureCheck = 'valid' | 'invalid' | 'unverifiable';
|
|
85
|
+
/**
|
|
86
|
+
* Verifies the verdict's Ed25519 signature locally against a known public key
|
|
87
|
+
* (base64, 32 raw bytes) — no round-trip involved.
|
|
88
|
+
*
|
|
89
|
+
* Degradation path: `unverifiable` (missing signature, or no Ed25519 support
|
|
90
|
+
* in the runtime's WebCrypto) is not a negative finding — display the verdict
|
|
91
|
+
* as usual, or fall back to "unknown" if your policy prefers only locally
|
|
92
|
+
* checked claims. `invalid` means the bytes do not match the key: treat as
|
|
93
|
+
* "unknown".
|
|
94
|
+
*/
|
|
95
|
+
export declare function verifyVerdictSignature(verdict: Verdict, publicKeyBase64: string): Promise<SignatureCheck>;
|
|
96
|
+
/**
|
|
97
|
+
* Computes the key id for a verification public key (base64): first 8 bytes
|
|
98
|
+
* of its SHA-256, hex — comparable with `Verdict.keyId`. Resolves to
|
|
99
|
+
* `undefined` where WebCrypto digests are unavailable.
|
|
100
|
+
*/
|
|
101
|
+
export declare function keyIdFor(publicKeyBase64: string): Promise<string | undefined>;
|
package/dist/verdict.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Verdict snippet — types and helpers for the free verification path.
|
|
2
|
+
//
|
|
3
|
+
// Vendor this file. It is self-contained, has zero runtime dependencies and
|
|
4
|
+
// matches the public OpenAPI contract of the seller-verification service
|
|
5
|
+
// (GET /v0/verifications): the explorer-consumable signed verdict —
|
|
6
|
+
// "human-backed at assurance >= G, verified at T, re-checked every N hours" —
|
|
7
|
+
// or an explicit no-signal outcome. The free path is never charged for.
|
|
8
|
+
//
|
|
9
|
+
// Graceful degradation, by design: every helper treats an unreachable service
|
|
10
|
+
// or an absent verdict as "no signal". Nothing here throws on network failure
|
|
11
|
+
// and nothing blocks your payment flow — your integration keeps working
|
|
12
|
+
// unchanged when no verification response is available.
|
|
13
|
+
/** The declared rails, in the order the contract lists them. */
|
|
14
|
+
export const CHAINS = ['base', 'solana'];
|
|
15
|
+
/** Runtime guard for the `Chain` enumeration (input from a wire, an agent or an env var). */
|
|
16
|
+
export function isChain(value) {
|
|
17
|
+
return typeof value === 'string' && CHAINS.includes(value);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The address as the service stores and echoes it. An EVM address (`base`)
|
|
21
|
+
* is case-insensitive — its checksum and lowercase spellings name the same
|
|
22
|
+
* account — and is kept lowercase; a Solana address is case-sensitive and
|
|
23
|
+
* kept as given. Use it wherever two spellings of one endpoint are compared
|
|
24
|
+
* (a cache key, a webhook match, a verdict echoed back). The service
|
|
25
|
+
* normalises its own side, so a lookup finds the seller under either
|
|
26
|
+
* spelling.
|
|
27
|
+
*/
|
|
28
|
+
export function canonicalPayTo(chain, payTo) {
|
|
29
|
+
const trimmed = payTo.trim();
|
|
30
|
+
return chain === 'base' ? trimmed.toLowerCase() : trimmed;
|
|
31
|
+
}
|
|
32
|
+
/** The subject with its `payTo` in canonical form (see `canonicalPayTo`). */
|
|
33
|
+
export function canonicalSubject(subject) {
|
|
34
|
+
return { ...subject, payTo: canonicalPayTo(subject.chain, subject.payTo) };
|
|
35
|
+
}
|
|
36
|
+
export const DEFAULT_TIMEOUT_MS = 3000;
|
|
37
|
+
/**
|
|
38
|
+
* Fetches the current verdict for an endpoint from the free verification path.
|
|
39
|
+
*
|
|
40
|
+
* Degradation path: resolves to `undefined` — never rejects — on timeout,
|
|
41
|
+
* network failure or a non-OK response. Treat `undefined` exactly like a
|
|
42
|
+
* `no_signal` outcome: show "unknown" and carry on.
|
|
43
|
+
*/
|
|
44
|
+
export async function fetchVerdict(baseUrl, subject, options) {
|
|
45
|
+
const url = new URL('/v0/verifications', baseUrl);
|
|
46
|
+
url.searchParams.set('chain', subject.chain);
|
|
47
|
+
url.searchParams.set('payTo', subject.payTo);
|
|
48
|
+
url.searchParams.set('resourceUrl', subject.resourceUrl);
|
|
49
|
+
try {
|
|
50
|
+
const res = await fetch(url, {
|
|
51
|
+
signal: AbortSignal.timeout(options?.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
52
|
+
});
|
|
53
|
+
if (!res.ok) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
return (await res.json());
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// No signal is a normal state, never an error surface.
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Compares the verdict's age with its own `recheckEveryHours` promise. The
|
|
65
|
+
* service additionally resolves a verdict older than twice the promise as
|
|
66
|
+
* `stale` server-side; this local check lets a consumer flag lateness without
|
|
67
|
+
* any further round-trip.
|
|
68
|
+
*/
|
|
69
|
+
export function freshness(verdict, now = new Date()) {
|
|
70
|
+
if (verdict.verifiedAt === undefined) {
|
|
71
|
+
return 'unknown';
|
|
72
|
+
}
|
|
73
|
+
const verified = Date.parse(verdict.verifiedAt);
|
|
74
|
+
if (Number.isNaN(verified)) {
|
|
75
|
+
return 'unknown';
|
|
76
|
+
}
|
|
77
|
+
const ageMs = now.getTime() - verified;
|
|
78
|
+
return ageMs <= verdict.recheckEveryHours * 3_600_000 ? 'fresh' : 'overdue';
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Renders the canonical signed statement byte-for-byte as the service signs
|
|
82
|
+
* it: fixed key order, RFC 3339 UTC at second precision, and Go-style JSON
|
|
83
|
+
* escaping of `<`, `>` and `&`. Returns `undefined` when the verdict lacks
|
|
84
|
+
* the fields a signed (positive) claim always carries.
|
|
85
|
+
*/
|
|
86
|
+
export function canonicalVerdictPayload(verdict) {
|
|
87
|
+
if (verdict.assuranceLevel === undefined || verdict.verifiedAt === undefined) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
const parsed = Date.parse(verdict.verifiedAt);
|
|
91
|
+
if (Number.isNaN(parsed)) {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
// Second precision: the canonical form drops any fractional seconds the
|
|
95
|
+
// transport representation may carry.
|
|
96
|
+
const verifiedAt = new Date(Math.floor(parsed / 1000) * 1000).toISOString().replace('.000Z', 'Z');
|
|
97
|
+
const json = `{"chain":${goJson(verdict.subject.chain)}` +
|
|
98
|
+
`,"payTo":${goJson(verdict.subject.payTo)}` +
|
|
99
|
+
`,"resourceUrl":${goJson(verdict.subject.resourceUrl)}` +
|
|
100
|
+
`,"outcome":${goJson(verdict.outcome)}` +
|
|
101
|
+
`,"assuranceLevel":${goJson(verdict.assuranceLevel)}` +
|
|
102
|
+
`,"verifiedAt":"${verifiedAt}"` +
|
|
103
|
+
`,"recheckEveryHours":${verdict.recheckEveryHours}}`;
|
|
104
|
+
// The cast pins the buffer type so the payload type-checks as a WebCrypto
|
|
105
|
+
// BufferSource under both DOM and Node typings.
|
|
106
|
+
return new TextEncoder().encode(json);
|
|
107
|
+
}
|
|
108
|
+
// Go's encoding/json escapes <, > and & (and U+2028/U+2029) inside strings;
|
|
109
|
+
// these characters never appear in JSON syntax outside strings, so a global
|
|
110
|
+
// replacement over the serialised value is safe.
|
|
111
|
+
function goJson(value) {
|
|
112
|
+
return JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, (c) => '\\u' + c.codePointAt(0).toString(16).padStart(4, '0'));
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Verifies the verdict's Ed25519 signature locally against a known public key
|
|
116
|
+
* (base64, 32 raw bytes) — no round-trip involved.
|
|
117
|
+
*
|
|
118
|
+
* Degradation path: `unverifiable` (missing signature, or no Ed25519 support
|
|
119
|
+
* in the runtime's WebCrypto) is not a negative finding — display the verdict
|
|
120
|
+
* as usual, or fall back to "unknown" if your policy prefers only locally
|
|
121
|
+
* checked claims. `invalid` means the bytes do not match the key: treat as
|
|
122
|
+
* "unknown".
|
|
123
|
+
*/
|
|
124
|
+
export async function verifyVerdictSignature(verdict, publicKeyBase64) {
|
|
125
|
+
if (verdict.signature === undefined) {
|
|
126
|
+
return 'unverifiable';
|
|
127
|
+
}
|
|
128
|
+
const payload = canonicalVerdictPayload(verdict);
|
|
129
|
+
if (payload === undefined) {
|
|
130
|
+
return 'invalid';
|
|
131
|
+
}
|
|
132
|
+
let signature;
|
|
133
|
+
let publicKey;
|
|
134
|
+
try {
|
|
135
|
+
signature = base64Decode(verdict.signature);
|
|
136
|
+
publicKey = base64Decode(publicKeyBase64);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return 'invalid';
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const key = await crypto.subtle.importKey('raw', publicKey, { name: 'Ed25519' }, false, [
|
|
143
|
+
'verify',
|
|
144
|
+
]);
|
|
145
|
+
const ok = await crypto.subtle.verify('Ed25519', key, signature, payload);
|
|
146
|
+
return ok ? 'valid' : 'invalid';
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// Runtime without Ed25519 WebCrypto support: the integration keeps
|
|
150
|
+
// working; the claim is simply not locally checked.
|
|
151
|
+
return 'unverifiable';
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Computes the key id for a verification public key (base64): first 8 bytes
|
|
156
|
+
* of its SHA-256, hex — comparable with `Verdict.keyId`. Resolves to
|
|
157
|
+
* `undefined` where WebCrypto digests are unavailable.
|
|
158
|
+
*/
|
|
159
|
+
export async function keyIdFor(publicKeyBase64) {
|
|
160
|
+
try {
|
|
161
|
+
const digest = await crypto.subtle.digest('SHA-256', base64Decode(publicKeyBase64));
|
|
162
|
+
return Array.from(new Uint8Array(digest).slice(0, 8))
|
|
163
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
164
|
+
.join('');
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// Returned as ArrayBuffer so the bytes type-check as WebCrypto BufferSource
|
|
171
|
+
// under both DOM and Node typings.
|
|
172
|
+
function base64Decode(value) {
|
|
173
|
+
const binary = atob(value);
|
|
174
|
+
const bytes = new Uint8Array(binary.length);
|
|
175
|
+
for (let i = 0; i < binary.length; i++) {
|
|
176
|
+
bytes[i] = binary.charCodeAt(i);
|
|
177
|
+
}
|
|
178
|
+
return bytes.buffer;
|
|
179
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type Verdict } from './verdict.js';
|
|
2
|
+
/** Header names used by deliveries. */
|
|
3
|
+
export declare const SIGNATURE_HEADER = "X-Signature";
|
|
4
|
+
export declare const SIGNATURE_KEY_HEADER = "X-Signature-Key";
|
|
5
|
+
export declare const WEBHOOK_VERSION_HEADER = "X-Webhook-Version";
|
|
6
|
+
/** The payload schema version this module understands. */
|
|
7
|
+
export declare const WEBHOOK_VERSION = "1";
|
|
8
|
+
/** A `verdict.updated` delivery: the Verdict schema plus the event name. */
|
|
9
|
+
export type VerdictEvent = Verdict & {
|
|
10
|
+
event: 'verdict.updated';
|
|
11
|
+
};
|
|
12
|
+
type HeaderSource = Headers | Record<string, string | string[] | undefined>;
|
|
13
|
+
/**
|
|
14
|
+
* Computes the key id of a subscription secret (first 8 bytes of its
|
|
15
|
+
* SHA-256, hex) — comparable with the `X-Signature-Key` header.
|
|
16
|
+
*/
|
|
17
|
+
export declare function webhookKeyId(secretHex: string): Promise<string>;
|
|
18
|
+
/**
|
|
19
|
+
* Verifies a delivery against the subscription secret: key id, schema
|
|
20
|
+
* version and HMAC over the raw body. Resolves to `false` on any mismatch or
|
|
21
|
+
* malformed input — never throws.
|
|
22
|
+
*/
|
|
23
|
+
export declare function verifyWebhookSignature(secretHex: string, rawBody: Uint8Array | string, headers: HeaderSource): Promise<boolean>;
|
|
24
|
+
/**
|
|
25
|
+
* Parses a delivery body into a `VerdictEvent`. Returns `undefined` for
|
|
26
|
+
* anything that is not a well-formed `verdict.updated` event.
|
|
27
|
+
*/
|
|
28
|
+
export declare function parseVerdictEvent(rawBody: Uint8Array | string): VerdictEvent | undefined;
|
|
29
|
+
export {};
|
package/dist/webhook.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Webhook receiver helpers — verify a `verdict.updated` delivery and read its
|
|
2
|
+
// payload. The service signs each delivery with HMAC-SHA256 over the raw body
|
|
3
|
+
// using the per-subscription secret returned at registration:
|
|
4
|
+
// X-Signature: sha256=<hex> X-Signature-Key: <key id> X-Webhook-Version: 1
|
|
5
|
+
//
|
|
6
|
+
// Zero runtime dependencies (WebCrypto). Graceful degradation: a delivery
|
|
7
|
+
// that does not verify is `false` — drop it and keep polling the free path;
|
|
8
|
+
// nothing in your flow depends on webhooks arriving.
|
|
9
|
+
import { isChain } from "./verdict.js";
|
|
10
|
+
/** Header names used by deliveries. */
|
|
11
|
+
export const SIGNATURE_HEADER = 'X-Signature';
|
|
12
|
+
export const SIGNATURE_KEY_HEADER = 'X-Signature-Key';
|
|
13
|
+
export const WEBHOOK_VERSION_HEADER = 'X-Webhook-Version';
|
|
14
|
+
/** The payload schema version this module understands. */
|
|
15
|
+
export const WEBHOOK_VERSION = '1';
|
|
16
|
+
function header(headers, name) {
|
|
17
|
+
if (headers instanceof Headers) {
|
|
18
|
+
return headers.get(name) ?? undefined;
|
|
19
|
+
}
|
|
20
|
+
const key = Object.keys(headers).find((k) => k.toLowerCase() === name.toLowerCase());
|
|
21
|
+
const value = key === undefined ? undefined : headers[key];
|
|
22
|
+
return Array.isArray(value) ? value[0] : value;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Computes the key id of a subscription secret (first 8 bytes of its
|
|
26
|
+
* SHA-256, hex) — comparable with the `X-Signature-Key` header.
|
|
27
|
+
*/
|
|
28
|
+
export async function webhookKeyId(secretHex) {
|
|
29
|
+
const digest = await crypto.subtle.digest('SHA-256', hexDecode(secretHex));
|
|
30
|
+
return hex(new Uint8Array(digest).slice(0, 8));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Verifies a delivery against the subscription secret: key id, schema
|
|
34
|
+
* version and HMAC over the raw body. Resolves to `false` on any mismatch or
|
|
35
|
+
* malformed input — never throws.
|
|
36
|
+
*/
|
|
37
|
+
export async function verifyWebhookSignature(secretHex, rawBody, headers) {
|
|
38
|
+
try {
|
|
39
|
+
if (header(headers, WEBHOOK_VERSION_HEADER) !== WEBHOOK_VERSION) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
if (header(headers, SIGNATURE_KEY_HEADER) !== (await webhookKeyId(secretHex))) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
const signature = header(headers, SIGNATURE_HEADER) ?? '';
|
|
46
|
+
if (!signature.startsWith('sha256=')) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
const key = await crypto.subtle.importKey('raw', hexDecode(secretHex), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
|
|
50
|
+
const body = typeof rawBody === 'string' ? new TextEncoder().encode(rawBody) : rawBody;
|
|
51
|
+
return await crypto.subtle.verify('HMAC', key, hexDecode(signature.slice('sha256='.length)), body);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Parses a delivery body into a `VerdictEvent`. Returns `undefined` for
|
|
59
|
+
* anything that is not a well-formed `verdict.updated` event.
|
|
60
|
+
*/
|
|
61
|
+
export function parseVerdictEvent(rawBody) {
|
|
62
|
+
try {
|
|
63
|
+
const text = typeof rawBody === 'string' ? rawBody : new TextDecoder().decode(rawBody);
|
|
64
|
+
const value = JSON.parse(text);
|
|
65
|
+
const subject = value['subject'];
|
|
66
|
+
if (value['event'] !== 'verdict.updated' ||
|
|
67
|
+
typeof value['outcome'] !== 'string' ||
|
|
68
|
+
typeof value['recheckEveryHours'] !== 'number' ||
|
|
69
|
+
typeof subject !== 'object' ||
|
|
70
|
+
subject === null ||
|
|
71
|
+
!isChain(subject['chain']) ||
|
|
72
|
+
typeof subject['payTo'] !== 'string' ||
|
|
73
|
+
typeof subject['resourceUrl'] !== 'string') {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function hexDecode(value) {
|
|
83
|
+
if (value.length % 2 !== 0 || /[^0-9a-f]/i.test(value)) {
|
|
84
|
+
throw new Error('not hex');
|
|
85
|
+
}
|
|
86
|
+
const bytes = new Uint8Array(value.length / 2);
|
|
87
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
88
|
+
bytes[i] = parseInt(value.slice(i * 2, i * 2 + 2), 16);
|
|
89
|
+
}
|
|
90
|
+
return bytes.buffer;
|
|
91
|
+
}
|
|
92
|
+
function hex(bytes) {
|
|
93
|
+
return Array.from(bytes)
|
|
94
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
95
|
+
.join('');
|
|
96
|
+
}
|