openpay-x402-sdk 0.7.1 → 0.8.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/CHANGELOG.md +16 -0
- package/README.md +125 -0
- package/delivery.d.ts +68 -0
- package/examples/cloudflare-r2-delivery-gate/README.md +62 -0
- package/examples/cloudflare-r2-delivery-gate/worker.mjs +85 -0
- package/examples/cloudflare-r2-delivery-gate/wrangler.toml +23 -0
- package/examples/node-delivery-gate.mjs +54 -0
- package/package.json +8 -2
- package/src/delivery.mjs +305 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0
|
|
4
|
+
|
|
5
|
+
- Add the typed `openpay-x402-sdk/delivery` Web-API-only subpath: strict Ed25519
|
|
6
|
+
delivery-ticket verification, request extraction, startup readiness and RFC 7638
|
|
7
|
+
thumbprints. Preserve all root exports and dependencies.
|
|
8
|
+
- Bound JWKS fetches, enforce complete key-set validation, honor Age in a 300-second
|
|
9
|
+
cache, share concurrent fetches, throttle unknown-kid refresh and reject stale
|
|
10
|
+
trust. Supplied keys never fetch or automatically refresh.
|
|
11
|
+
- Add optional atomic replay consumption with fail-closed errors and final expiry
|
|
12
|
+
rechecks; ship private R2/Durable Object and Node presigned-redirect templates.
|
|
13
|
+
- Cross-check shared fixtures and fresh server signatures, packed subpath imports,
|
|
14
|
+
types, runtime capability failures, and template authorization boundaries.
|
|
15
|
+
- Document bearer/session-wallet semantics and the Node/Workers acceptance matrix.
|
|
16
|
+
No dependencies added. Initial generation only: human review and real private R2
|
|
17
|
+
deployment acceptance remain required before adoption; publication is separate.
|
|
18
|
+
|
|
3
19
|
## 0.7.1
|
|
4
20
|
|
|
5
21
|
- Add `resolveLicense({ product, origin?, fetch? })` for validated v1 product
|
package/README.md
CHANGED
|
@@ -374,6 +374,131 @@ and real EOA signatures. Existing buyer/seller regression and tarball tests
|
|
|
374
374
|
remain in the root Vitest suite; `npm run typecheck` also checks license API
|
|
375
375
|
consumer types.
|
|
376
376
|
|
|
377
|
+
## 保護配布 (Delivery ticket)
|
|
378
|
+
|
|
379
|
+
SDK 0.8.0 is an **initial generation** workspace release, not yet published or
|
|
380
|
+
production-adopted. OpenPay signs a 60-second bearer ticket after checking
|
|
381
|
+
entitlement. Sellers verify it with the public JWKS; no secret is shared with
|
|
382
|
+
OpenPay. The token is signed, not encrypted, and its claims are readable.
|
|
383
|
+
Possession authorizes admission during its lifetime; it is access control, not
|
|
384
|
+
copy protection or an allowance/payment balance.
|
|
385
|
+
|
|
386
|
+
Import the dedicated typed subpath. The existing package root remains Node-only
|
|
387
|
+
and does **not** re-export delivery helpers or types.
|
|
388
|
+
|
|
389
|
+
```js
|
|
390
|
+
import { createDeliveryGate, DeliveryError } from 'openpay-x402-sdk/delivery';
|
|
391
|
+
|
|
392
|
+
const delivery = createDeliveryGate({
|
|
393
|
+
product: 'h_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
394
|
+
audience: 'https://files.example', // Trusted seller configuration, not Host/header input.
|
|
395
|
+
});
|
|
396
|
+
await delivery.ready(); // Probe standard Ed25519 and prefetch validated JWKS.
|
|
397
|
+
|
|
398
|
+
async function authorize(request) {
|
|
399
|
+
try {
|
|
400
|
+
const { product, revision, exp, address } = await delivery.verifyRequest(request);
|
|
401
|
+
// Resolve (product, revision) using YOUR trusted map, then serve private bytes.
|
|
402
|
+
return { product, revision, exp, address };
|
|
403
|
+
} catch (error) {
|
|
404
|
+
if (error instanceof DeliveryError) return null; // Deny; never log the request/ticket.
|
|
405
|
+
throw error;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
`verifyDeliveryTicket({ ticket, product, audience, issuer?, origin?, fetch?, now?,
|
|
411
|
+
keys?, maxSkewSeconds?, replayStore? })` returns
|
|
412
|
+
`{ address, product, revision, basis, exp, iat, jti, kid }`.
|
|
413
|
+
`createDeliveryGate` takes the same options except `ticket`, and exposes async
|
|
414
|
+
`ready()`, `verify(ticket)`, and `verifyRequest(request)`.
|
|
415
|
+
`ticketFromRequest(request)` reads exactly one `?ticket=` or
|
|
416
|
+
`Authorization: Bearer <ticket>` and returns `null` if absent. Duplicate/query-plus-
|
|
417
|
+
authorization credentials, empty tickets and malformed authorization are rejected.
|
|
418
|
+
`deliveryKeyThumbprint(x)` computes the RFC 7638 public-key ID.
|
|
419
|
+
|
|
420
|
+
The expected `issuer` defaults to `https://open-pay.jp`; `origin` defaults to
|
|
421
|
+
`issuer` and controls only trusted HTTPS JWKS transport. Configure these yourself,
|
|
422
|
+
never from token headers/claims. Issuer/audience configuration is normalized with
|
|
423
|
+
`new URL(value).origin`; the signed claims must exactly match that normalized
|
|
424
|
+
origin. `now` returns Unix **milliseconds** (default `Date.now`); returned `iat`
|
|
425
|
+
and `exp` are Unix **seconds**. Future `iat` allows `maxSkewSeconds` (default 30);
|
|
426
|
+
expiry is strict, never extended by skew, and rechecked after async verification
|
|
427
|
+
and immediately before returning. `sub`/`address` means the session wallet at
|
|
428
|
+
issuance, **not proof that the presenter controls that wallet**. The SDK checks
|
|
429
|
+
`0x` plus 40 hex characters and preserves spelling; EIP-55 checksum is enforced
|
|
430
|
+
server-side at issuance, without adding a crypto dependency to the subpath.
|
|
431
|
+
|
|
432
|
+
Optional single-use storage must implement:
|
|
433
|
+
|
|
434
|
+
```ts
|
|
435
|
+
consume(jti: string, expSeconds: number): Promise<boolean>;
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
Reserve the jti **atomically across instances** until its absolute Unix-second
|
|
439
|
+
expiry: true for the first consume, false for a replay, throw on storage failure.
|
|
440
|
+
Namespace storage per issuer/product/audience. The SDK calls it only after full
|
|
441
|
+
verification, and denies on false (`replay`) or exceptions (`replay_store_error`).
|
|
442
|
+
Other return values also deny. Concurrent calls must yield exactly one true.
|
|
443
|
+
Without a store, reuse within TTL is allowed. After consumption, a downstream
|
|
444
|
+
failure, HEAD request or retry needs a fresh ticket; no consume is rolled back.
|
|
445
|
+
Workers KV get/put is not equivalent to atomic consume; the bundled example uses
|
|
446
|
+
a Durable Object. An admitted stream may finish after expiry; subsequent requests
|
|
447
|
+
(including resume/Range) must authenticate again.
|
|
448
|
+
|
|
449
|
+
Public keys are validated as a whole (at most 8, public Ed25519 only, matching
|
|
450
|
+
thumbprints, no duplicate kids). Fetches use an 8-second deadline, manual redirect
|
|
451
|
+
rejection and a 16 KiB response cap. Cache is scoped by issuer and configured key
|
|
452
|
+
origin for at most 300 seconds, subtracting upstream `Age` and fetch elapsed time.
|
|
453
|
+
Age >= 300 is rejected; expired cache entries are refetched and **never** used on
|
|
454
|
+
failure. Concurrent fetches share a request. Unknown kids trigger at most one
|
|
455
|
+
extra refresh per 60 seconds per scope, including failed attempts. Invalid refreshes
|
|
456
|
+
never replace good keys: still-fresh known cached keys remain usable, while the
|
|
457
|
+
failed refresh request denies. `keys` supplied directly are validated and used
|
|
458
|
+
exclusively, never fetched or automatically refreshed (even if empty/invalid).
|
|
459
|
+
|
|
460
|
+
Rotation must publish `old,new` before signing with new, wait at least 15 minutes,
|
|
461
|
+
switch to `new,old`, and retain old through propagation plus ticket lifetime.
|
|
462
|
+
Emergency removal requires CDN purge and verifier refresh/reconfiguration; leaked
|
|
463
|
+
keys can sign new tickets while cached public keys remain trusted. Turning off
|
|
464
|
+
issuance does not revoke an attacker's signing ability or recall downloaded bytes.
|
|
465
|
+
|
|
466
|
+
| Runtime | Delivery subpath requirement / acceptance |
|
|
467
|
+
| --- | --- |
|
|
468
|
+
| Node 20.19+ | Global WebCrypto with standard Ed25519; package engine remains Node >=20. |
|
|
469
|
+
| Node 22.13+ | Same Web API entry point. |
|
|
470
|
+
| Node 24 | Same Web API entry point. |
|
|
471
|
+
| Cloudflare Workers | Standard `Ed25519`, no `nodejs_compat`; run a real deployment smoke on the template's pinned compatibility date. |
|
|
472
|
+
|
|
473
|
+
`ready()` detects missing Ed25519 support as `unsupported_crypto`; there is no
|
|
474
|
+
algorithm downgrade. The matrix is a release target, not proof that every runtime
|
|
475
|
+
was executed by package tests. A real Worker deployment/private R2 smoke is an
|
|
476
|
+
acceptance step. See [Node WebCrypto](https://nodejs.org/api/webcrypto.html) and
|
|
477
|
+
[Workers WebCrypto](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/).
|
|
478
|
+
|
|
479
|
+
Errors are `DeliveryError` with codes: `invalid_ticket`, `unsupported_algorithm`,
|
|
480
|
+
`unknown_key`, `keys_unavailable`, `ticket_expired`, `ticket_not_yet_valid`,
|
|
481
|
+
`wrong_issuer`, `wrong_audience`, `wrong_product`, `unsupported_crypto`, `replay`,
|
|
482
|
+
`replay_store_error`. SDK errors omit raw tickets, URLs and upstream bodies.
|
|
483
|
+
|
|
484
|
+
Start from the packaged [private R2 Worker template](examples/cloudflare-r2-delivery-gate/README.md)
|
|
485
|
+
or [Node presigned-redirect example](examples/node-delivery-gate.mjs). The Node
|
|
486
|
+
example uses `OPENPAY_PRODUCT_ID`, `AUDIENCE`, and optional `OBJECT_KEYS` (default
|
|
487
|
+
`{ "1": "file-v1.zip" }`), listens on 127.0.0.1:8787 behind HTTPS, and requires you
|
|
488
|
+
to implement the seller storage-SDK presigning stub. Its signature's absolute
|
|
489
|
+
expiry must be <= the ticket's `exp`, even if presigning is slow; a duration alone
|
|
490
|
+
must not extend that deadline. Presigned URLs are separate bearer capabilities.
|
|
491
|
+
|
|
492
|
+
“Product ID only” is an onboarding simplification: audience, a trusted revision
|
|
493
|
+
map, a private bucket binding and deployment compatibility still need configuration.
|
|
494
|
+
Reject unmapped revisions instead of serving the latest file. Authenticate before
|
|
495
|
+
all file/HEAD/Range/conditional paths and before any Cache API access. Close old
|
|
496
|
+
unsigned/public-bucket URLs. Success, error and redirect responses need
|
|
497
|
+
`Cache-Control: private, no-store`, `Referrer-Policy: no-referrer`, and attachment
|
|
498
|
+
disposition. Redact tickets/URLs in seller/CDN logs, Location, JSON and exceptions;
|
|
499
|
+
no-referrer does not erase history or existing logs. Use a stable gate destination,
|
|
500
|
+
not a presigned URL that may be broken by query reserialization.
|
|
501
|
+
|
|
377
502
|
## Money guards
|
|
378
503
|
|
|
379
504
|
| Option | Default | Guard |
|
package/delivery.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export type DeliveryErrorCode =
|
|
2
|
+
| 'invalid_ticket' | 'unsupported_algorithm' | 'unknown_key' | 'keys_unavailable'
|
|
3
|
+
| 'ticket_expired' | 'ticket_not_yet_valid' | 'wrong_issuer' | 'wrong_audience'
|
|
4
|
+
| 'wrong_product' | 'unsupported_crypto' | 'replay' | 'replay_store_error';
|
|
5
|
+
|
|
6
|
+
export class DeliveryError extends Error {
|
|
7
|
+
constructor(code: DeliveryErrorCode);
|
|
8
|
+
code: DeliveryErrorCode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface DeliveryPublicJwk {
|
|
12
|
+
readonly kty: 'OKP';
|
|
13
|
+
readonly crv: 'Ed25519';
|
|
14
|
+
readonly x: string;
|
|
15
|
+
readonly kid: string;
|
|
16
|
+
readonly use: 'sig';
|
|
17
|
+
readonly alg: 'EdDSA';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DeliveryReplayStore {
|
|
21
|
+
/** Atomically reserve jti until expSeconds (Unix seconds). Failure must throw. */
|
|
22
|
+
consume(jti: string, expSeconds: number): Promise<boolean>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface DeliveryOptions {
|
|
26
|
+
product: string;
|
|
27
|
+
/** Trusted seller HTTPS URL; compared using new URL(audience).origin. */
|
|
28
|
+
audience: string;
|
|
29
|
+
/** Expected issuer; defaults to https://open-pay.jp. Never take it from a ticket. */
|
|
30
|
+
issuer?: string;
|
|
31
|
+
/** Trusted HTTPS key transport origin; defaults to issuer. */
|
|
32
|
+
origin?: string;
|
|
33
|
+
fetch?: typeof globalThis.fetch;
|
|
34
|
+
/** Unix milliseconds; defaults to Date.now. */
|
|
35
|
+
now?: () => number;
|
|
36
|
+
/** Use only these keys, without network access, caching or automatic rotation. */
|
|
37
|
+
keys?: readonly DeliveryPublicJwk[];
|
|
38
|
+
/** Nonnegative future-iat allowance in seconds, default 30; never extends exp. */
|
|
39
|
+
maxSkewSeconds?: number;
|
|
40
|
+
replayStore?: DeliveryReplayStore;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface DeliveryVerification {
|
|
44
|
+
/** Signed session wallet at issuance, preserved as-is; no presenter identity proof. */
|
|
45
|
+
address: string;
|
|
46
|
+
product: string;
|
|
47
|
+
revision: number;
|
|
48
|
+
basis: 'purchase' | 'holder';
|
|
49
|
+
/** Unix seconds. */
|
|
50
|
+
exp: number;
|
|
51
|
+
/** Unix seconds. */
|
|
52
|
+
iat: number;
|
|
53
|
+
jti: string;
|
|
54
|
+
kid: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface DeliveryGate {
|
|
58
|
+
/** Probe standard Ed25519 and validate supplied keys or prefetch the public JWKS. */
|
|
59
|
+
ready(): Promise<void>;
|
|
60
|
+
verify(ticket: string): Promise<DeliveryVerification>;
|
|
61
|
+
verifyRequest(request: Request): Promise<DeliveryVerification>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function verifyDeliveryTicket(options: DeliveryOptions & { ticket: string }): Promise<DeliveryVerification>;
|
|
65
|
+
export function ticketFromRequest(request: Request): string | null;
|
|
66
|
+
export function createDeliveryGate(options: DeliveryOptions): DeliveryGate;
|
|
67
|
+
/** RFC 7638 SHA-256 thumbprint of a canonical base64url 32-byte Ed25519 x. */
|
|
68
|
+
export function deliveryKeyThumbprint(x: string): Promise<string>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Private R2 delivery gate (SDK 0.8.0 initial generation)
|
|
2
|
+
|
|
3
|
+
This template verifies an OpenPay 60-second bearer ticket before accessing R2.
|
|
4
|
+
It uses `openpay-x402-sdk/delivery`, standard WebCrypto `Ed25519`, and no Node
|
|
5
|
+
compatibility flag. Package source/tests are not a real Workers deployment proof.
|
|
6
|
+
|
|
7
|
+
1. Use the reviewed 0.8.0 package artifact in a seller project; it is not assumed
|
|
8
|
+
published. After publication, the dependency can be installed from the official
|
|
9
|
+
npm registry. Copy `worker.mjs` and `wrangler.toml` together.
|
|
10
|
+
2. Create a **private** R2 bucket, upload each immutable revision, and replace
|
|
11
|
+
`bucket_name`. Disable public r2.dev access and public bucket custom domains.
|
|
12
|
+
Remove any old unsigned object URL; ordinary product content should contain
|
|
13
|
+
instructions or a safe landing page, not a file bypass.
|
|
14
|
+
3. Configure the variables below and bind the worker's public HTTPS hostname.
|
|
15
|
+
Set that stable gate URL as the OpenPay product's delivery destination.
|
|
16
|
+
4. For single-use admission, uncomment both `REPLAY` and its SQLite migration in
|
|
17
|
+
`wrangler.toml`. Deploy through your reviewed release process. The compatibility
|
|
18
|
+
date is pinned to `2026-09-09`; test that date on a real Worker before adoption.
|
|
19
|
+
|
|
20
|
+
| Setting | Meaning |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| `OPENPAY_PRODUCT_ID` | Exact `h_` + 32 lowercase hex product ID; never request-controlled. |
|
|
23
|
+
| `AUDIENCE` | Worker's public HTTPS origin, e.g. `https://files.example`; must equal the configured destination's origin. |
|
|
24
|
+
| `OBJECT_KEYS` | Optional JSON revision map, e.g. `{ "1": "file-v1.zip" }`. Default is exactly that map. Every unmapped revision is denied, with no latest-version fallback. |
|
|
25
|
+
| `FILES` | R2 binding to the private bucket. |
|
|
26
|
+
| `REPLAY` | Optional private Durable Object binding, one object per product/audience/jti. |
|
|
27
|
+
|
|
28
|
+
`ready()` runs at isolate startup on its first request (network I/O is unavailable
|
|
29
|
+
at module evaluation), before any file access. Failed startup denies and can
|
|
30
|
+
retry. All GET/HEAD/Range/conditional requests authenticate first. This deliberately
|
|
31
|
+
small template ignores Range and conditional headers: GET sends full content with
|
|
32
|
+
200, HEAD sends metadata only. Add resumable/conditional responses only behind the
|
|
33
|
+
same gate. No Cache API is consulted. All responses use `private, no-store`,
|
|
34
|
+
`no-referrer`, and `Content-Disposition: attachment`; failures are generic 403 JSON.
|
|
35
|
+
|
|
36
|
+
The Durable Object uses `blockConcurrencyWhile` around storage read/put/alarm,
|
|
37
|
+
so simultaneous consumes cannot both succeed; an alarm removes state at expiry.
|
|
38
|
+
Storage or alarm failures deny admission. **Workers KV is NOT equivalent** to
|
|
39
|
+
atomic consume. Omit `REPLAY` only if replay during the ticket's TTL is acceptable.
|
|
40
|
+
A consumed ticket stays consumed after R2 failure. HEAD consumes it too: acquire
|
|
41
|
+
another ticket for GET, retries, ranges or restarts. A stream admitted before
|
|
42
|
+
expiry may finish after expiry; the SDK does not cut it off at 60 seconds.
|
|
43
|
+
|
|
44
|
+
`sub` is the issuance session's wallet, not proof of the presenter's identity.
|
|
45
|
+
The token is readable and bearer-authorized, not DRM. Suppress or redact query
|
|
46
|
+
strings, Authorization, Location and ticket-bearing errors throughout seller/CDN
|
|
47
|
+
logs. `no-referrer` does not remove browser history or already stored logs.
|
|
48
|
+
|
|
49
|
+
Release acceptance must run a private R2 end-to-end download on the pinned Worker:
|
|
50
|
+
valid/expired/wrong-product/unmapped-revision tickets; missing/duplicate/conflicting
|
|
51
|
+
credentials; HEAD/Range/conditional access; simultaneous replay (exactly one
|
|
52
|
+
success); storage/downstream failure; and no unsigned/public/cache bypass. Run a
|
|
53
|
+
cold-JWKS request and staged key rotation too. These are deployment checks, not
|
|
54
|
+
claims established by mocked package tests. See the [SDK delivery documentation](../../README.md#保護配布-delivery-ticket)
|
|
55
|
+
for cache/rotation and runtime boundaries. The [Node example](../node-delivery-gate.mjs)
|
|
56
|
+
uses the same env names and map, but its seller presigning stub must be implemented;
|
|
57
|
+
the storage signature's absolute deadline must be at most the ticket's `exp`.
|
|
58
|
+
|
|
59
|
+
References: [standard WebCrypto](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/),
|
|
60
|
+
[Durable Object event isolation](https://developers.cloudflare.com/durable-objects/api/state/),
|
|
61
|
+
[alarms](https://developers.cloudflare.com/durable-objects/api/alarms/),
|
|
62
|
+
[Workers KV consistency](https://developers.cloudflare.com/kv/concepts/how-kv-works/).
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createDeliveryGate } from 'openpay-x402-sdk/delivery';
|
|
2
|
+
|
|
3
|
+
const PRIVATE_HEADERS = {
|
|
4
|
+
'Cache-Control': 'private, no-store',
|
|
5
|
+
'Referrer-Policy': 'no-referrer',
|
|
6
|
+
'Content-Disposition': 'attachment',
|
|
7
|
+
};
|
|
8
|
+
const instances = new WeakMap();
|
|
9
|
+
|
|
10
|
+
async function startup(env) {
|
|
11
|
+
if (!instances.has(env)) {
|
|
12
|
+
const pending = (async () => {
|
|
13
|
+
const objects = JSON.parse(env.OBJECT_KEYS ?? '{"1":"file-v1.zip"}');
|
|
14
|
+
if (!objects || Array.isArray(objects) || typeof objects !== 'object' ||
|
|
15
|
+
Object.entries(objects).some(([rev, key]) => !/^[1-9][0-9]*$/.test(rev) || typeof key !== 'string' || !key)) {
|
|
16
|
+
throw new Error('invalid_object_map');
|
|
17
|
+
}
|
|
18
|
+
const gate = createDeliveryGate({
|
|
19
|
+
product: env.OPENPAY_PRODUCT_ID, audience: env.AUDIENCE,
|
|
20
|
+
replayStore: env.REPLAY ? {
|
|
21
|
+
async consume(jti, expSeconds) {
|
|
22
|
+
const id = env.REPLAY.idFromName(`${env.OPENPAY_PRODUCT_ID}:${new URL(env.AUDIENCE).origin}:${jti}`);
|
|
23
|
+
const response = await env.REPLAY.get(id).fetch('https://replay.internal/consume', {
|
|
24
|
+
method: 'POST', body: JSON.stringify({ expSeconds }),
|
|
25
|
+
});
|
|
26
|
+
if (response.status !== 200) throw new Error('replay_store_error');
|
|
27
|
+
return response.json();
|
|
28
|
+
},
|
|
29
|
+
} : undefined,
|
|
30
|
+
});
|
|
31
|
+
// Workers cannot fetch at module evaluation: initialize on the first event,
|
|
32
|
+
// before any file operation. Failed startup can retry on the next request.
|
|
33
|
+
await gate.ready();
|
|
34
|
+
return { gate, objects };
|
|
35
|
+
})();
|
|
36
|
+
instances.set(env, pending);
|
|
37
|
+
pending.catch(() => instances.delete(env));
|
|
38
|
+
}
|
|
39
|
+
return instances.get(env);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const worker = {
|
|
43
|
+
async fetch(request, env) {
|
|
44
|
+
try {
|
|
45
|
+
const { gate, objects } = await startup(env);
|
|
46
|
+
const verified = await gate.verifyRequest(request);
|
|
47
|
+
if (new URL(request.url).origin !== new URL(env.AUDIENCE).origin ||
|
|
48
|
+
!['GET', 'HEAD'].includes(request.method) || !Object.hasOwn(objects, String(verified.revision))) {
|
|
49
|
+
throw new Error('denied');
|
|
50
|
+
}
|
|
51
|
+
// Product is pinned by the gate; revision selects only this trusted map.
|
|
52
|
+
// No Cache API lookup, request-derived object key, or public bucket URL.
|
|
53
|
+
const key = objects[String(verified.revision)];
|
|
54
|
+
const file = await (request.method === 'HEAD' ? env.FILES.head(key) : env.FILES.get(key));
|
|
55
|
+
if (!file || verified.exp * 1000 <= Date.now()) throw new Error('denied');
|
|
56
|
+
// This small template ignores Range/conditional headers and serves a full
|
|
57
|
+
// 200 (HEAD returns metadata). Every such request still requires a ticket.
|
|
58
|
+
return new Response(request.method === 'HEAD' ? null : file.body, {
|
|
59
|
+
headers: { ...PRIVATE_HEADERS, 'Content-Type': 'application/octet-stream', 'Content-Length': String(file.size) },
|
|
60
|
+
});
|
|
61
|
+
} catch {
|
|
62
|
+
// Do not expose request URLs, tickets, R2 keys or upstream exceptions.
|
|
63
|
+
return Response.json({ error: 'delivery_denied' }, { status: 403, headers: PRIVATE_HEADERS });
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export default worker;
|
|
69
|
+
|
|
70
|
+
// One private Durable Object per namespaced jti. Only the binding can reach it.
|
|
71
|
+
export class Replay {
|
|
72
|
+
constructor(state) { this.state = state; }
|
|
73
|
+
async fetch(request) {
|
|
74
|
+
const { expSeconds } = await request.json();
|
|
75
|
+
const consumed = await this.state.blockConcurrencyWhile(async () => {
|
|
76
|
+
if (!Number.isSafeInteger(expSeconds) || expSeconds * 1000 <= Date.now() ||
|
|
77
|
+
await this.state.storage.get('consumed')) return false;
|
|
78
|
+
await this.state.storage.put('consumed', true);
|
|
79
|
+
await this.state.storage.setAlarm(expSeconds * 1000);
|
|
80
|
+
return true;
|
|
81
|
+
});
|
|
82
|
+
return Response.json(consumed);
|
|
83
|
+
}
|
|
84
|
+
async alarm() { await this.state.storage.deleteAll(); }
|
|
85
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
name = "openpay-delivery-gate"
|
|
2
|
+
main = "worker.mjs"
|
|
3
|
+
compatibility_date = "2026-09-09"
|
|
4
|
+
# No nodejs_compat flag: delivery uses standard Web APIs and Ed25519.
|
|
5
|
+
|
|
6
|
+
[vars]
|
|
7
|
+
OPENPAY_PRODUCT_ID = "h_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
8
|
+
AUDIENCE = "https://files.example"
|
|
9
|
+
# Optional; default is revision 1 -> file-v1.zip. Reject every unmapped revision.
|
|
10
|
+
OBJECT_KEYS = '{ "1": "file-v1.zip", "2": "file-v2.zip" }'
|
|
11
|
+
|
|
12
|
+
[[r2_buckets]]
|
|
13
|
+
binding = "FILES"
|
|
14
|
+
bucket_name = "replace-with-your-private-bucket"
|
|
15
|
+
|
|
16
|
+
# Optional single-use tickets: uncomment BOTH sections together.
|
|
17
|
+
# [[durable_objects.bindings]]
|
|
18
|
+
# name = "REPLAY"
|
|
19
|
+
# class_name = "Replay"
|
|
20
|
+
#
|
|
21
|
+
# [[migrations]]
|
|
22
|
+
# tag = "v1"
|
|
23
|
+
# new_sqlite_classes = ["Replay"]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { createDeliveryGate } from 'openpay-x402-sdk/delivery';
|
|
4
|
+
|
|
5
|
+
const PRIVATE_HEADERS = {
|
|
6
|
+
'Cache-Control': 'private, no-store', 'Referrer-Policy': 'no-referrer', 'Content-Disposition': 'attachment',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
async function presignObject({ key, method, expiresAt, expiresInSeconds }) {
|
|
10
|
+
// Implement with YOUR storage SDK and private bucket. Bind key and method (GET/HEAD), and
|
|
11
|
+
// set the signature's absolute expiry <= expiresAt (not "now + 60"). If the
|
|
12
|
+
// SDK only takes a duration, anchor its signing time before this function's
|
|
13
|
+
// async work and cap it to expiresInSeconds. Never log the URL or ticket.
|
|
14
|
+
void key; void method; void expiresAt; void expiresInSeconds;
|
|
15
|
+
throw new Error('Implement seller presigning before starting this example');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createDeliveryHandler({ gate, audience, objectKeys, presign = presignObject, now = Date.now }) {
|
|
19
|
+
return async (req, res) => {
|
|
20
|
+
try {
|
|
21
|
+
// Preserve duplicate Authorization fields so the SDK rejects ambiguity.
|
|
22
|
+
const headers = new Headers();
|
|
23
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) headers.append(req.rawHeaders[i], req.rawHeaders[i + 1]);
|
|
24
|
+
const request = new Request(new URL(req.url, audience), { method: req.method, headers });
|
|
25
|
+
const verified = await gate.verifyRequest(request);
|
|
26
|
+
if (new URL(request.url).origin !== new URL(audience).origin ||
|
|
27
|
+
!['GET', 'HEAD'].includes(req.method) || !Object.hasOwn(objectKeys, String(verified.revision))) throw new Error('denied');
|
|
28
|
+
const key = objectKeys[String(verified.revision)];
|
|
29
|
+
if (typeof key !== 'string' || !key) throw new Error('denied');
|
|
30
|
+
const expiresAt = verified.exp * 1000;
|
|
31
|
+
const expiresInSeconds = Math.floor((expiresAt - now()) / 1000);
|
|
32
|
+
if (expiresInSeconds <= 0) throw new Error('expired');
|
|
33
|
+
const location = new URL(await presign({ key, method: req.method, expiresAt, expiresInSeconds }));
|
|
34
|
+
if (location.protocol !== 'https:' || location.username || location.password || now() >= expiresAt) throw new Error('denied');
|
|
35
|
+
// HEAD/Range/conditional requests are authorized here too. A presigned URL
|
|
36
|
+
// is a separate bearer capability; its expiry must satisfy the stub above.
|
|
37
|
+
res.writeHead(302, { ...PRIVATE_HEADERS, Location: location.href });
|
|
38
|
+
res.end();
|
|
39
|
+
} catch {
|
|
40
|
+
res.writeHead(403, { ...PRIVATE_HEADERS, 'Content-Type': 'application/json' });
|
|
41
|
+
res.end(JSON.stringify({ error: 'delivery_denied' }));
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
47
|
+
const audience = process.env.AUDIENCE;
|
|
48
|
+
const gate = createDeliveryGate({ product: process.env.OPENPAY_PRODUCT_ID, audience });
|
|
49
|
+
await gate.ready(); // Fail startup on unsupported Ed25519 or unavailable JWKS.
|
|
50
|
+
const objectKeys = JSON.parse(process.env.OBJECT_KEYS ?? '{"1":"file-v1.zip"}');
|
|
51
|
+
createServer(createDeliveryHandler({ gate, audience, objectKeys })).listen(8787, '127.0.0.1');
|
|
52
|
+
// Place behind HTTPS at AUDIENCE; keep the bucket private. This example permits
|
|
53
|
+
// ticket replay within its TTL; inject an atomic replayStore to make it single-use.
|
|
54
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openpay-x402-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Guarded Node.js buyer SDK for OpenPay x402 JPYC resources",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.mjs",
|
|
@@ -9,13 +9,19 @@
|
|
|
9
9
|
".": {
|
|
10
10
|
"types": "./index.d.ts",
|
|
11
11
|
"import": "./src/index.mjs"
|
|
12
|
+
},
|
|
13
|
+
"./delivery": {
|
|
14
|
+
"types": "./delivery.d.ts",
|
|
15
|
+
"import": "./src/delivery.mjs"
|
|
12
16
|
}
|
|
13
17
|
},
|
|
14
18
|
"files": [
|
|
15
19
|
"src",
|
|
16
20
|
"index.d.ts",
|
|
17
21
|
"README.md",
|
|
18
|
-
"CHANGELOG.md"
|
|
22
|
+
"CHANGELOG.md",
|
|
23
|
+
"delivery.d.ts",
|
|
24
|
+
"examples"
|
|
19
25
|
],
|
|
20
26
|
"engines": {
|
|
21
27
|
"node": ">=20"
|
package/src/delivery.mjs
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
// Standalone Web API entry point: keep the Node-only package root out of this graph.
|
|
2
|
+
const DEFAULT_ISSUER = 'https://open-pay.jp';
|
|
3
|
+
const HEADER_FIELDS = ['alg', 'typ', 'kid'];
|
|
4
|
+
const CLAIM_FIELDS = ['v', 'iss', 'aud', 'sub', 'product', 'rev', 'basis', 'iat', 'exp', 'jti'];
|
|
5
|
+
const KEY_FIELDS = ['kty', 'crv', 'x', 'kid', 'use', 'alg'];
|
|
6
|
+
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
|
7
|
+
const encoder = new TextEncoder();
|
|
8
|
+
const caches = new Map();
|
|
9
|
+
const cryptoProbes = new WeakMap();
|
|
10
|
+
|
|
11
|
+
export class DeliveryError extends Error {
|
|
12
|
+
constructor(code) {
|
|
13
|
+
// Never retain a ticket, key response, request URL or upstream exception.
|
|
14
|
+
super(code);
|
|
15
|
+
this.name = 'DeliveryError';
|
|
16
|
+
this.code = code;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function fail(code = 'invalid_ticket') { throw new DeliveryError(code); }
|
|
21
|
+
function object(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|
|
22
|
+
function exactFields(value, fields) {
|
|
23
|
+
return object(value) && Object.keys(value).length === fields.length && fields.every((key) => Object.hasOwn(value, key));
|
|
24
|
+
}
|
|
25
|
+
function encode(bytes) {
|
|
26
|
+
let result = '';
|
|
27
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
28
|
+
const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0);
|
|
29
|
+
result += ALPHABET[(n >>> 18) & 63] + ALPHABET[(n >>> 12) & 63];
|
|
30
|
+
if (i + 1 < bytes.length) result += ALPHABET[(n >>> 6) & 63];
|
|
31
|
+
if (i + 2 < bytes.length) result += ALPHABET[n & 63];
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
function decode(raw, max, code = 'invalid_ticket') {
|
|
36
|
+
if (typeof raw !== 'string' || !raw.length || raw.length > max || !/^[A-Za-z0-9_-]+$/.test(raw)) fail(code);
|
|
37
|
+
const bytes = new Uint8Array(Math.floor(raw.length * 6 / 8));
|
|
38
|
+
let bits = 0; let n = 0; let offset = 0;
|
|
39
|
+
for (const char of raw) {
|
|
40
|
+
n = (n << 6) | ALPHABET.indexOf(char);
|
|
41
|
+
bits += 6;
|
|
42
|
+
if (bits >= 8) { bits -= 8; bytes[offset++] = (n >>> bits) & 255; }
|
|
43
|
+
}
|
|
44
|
+
if (encode(bytes) !== raw) fail(code);
|
|
45
|
+
return bytes;
|
|
46
|
+
}
|
|
47
|
+
function parseJson(bytes, flat, code = 'invalid_ticket') {
|
|
48
|
+
try {
|
|
49
|
+
const raw = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
50
|
+
const value = JSON.parse(raw);
|
|
51
|
+
if (!object(value) || (flat && Object.values(value).some((v) => v !== null && typeof v === 'object'))) fail(code);
|
|
52
|
+
// JSON.parse validates grammar; scan complete string tokens to detect even
|
|
53
|
+
// escaped duplicate member names before accepting its last-member-wins result.
|
|
54
|
+
const stack = [];
|
|
55
|
+
for (const token of raw.matchAll(/"(?:[^"\\]|\\[\s\S])*"|[{}\[\]]/g)) {
|
|
56
|
+
const part = token[0];
|
|
57
|
+
if (part === '{') stack.push(new Set());
|
|
58
|
+
else if (part === '[') stack.push(null);
|
|
59
|
+
else if (part === '}' || part === ']') stack.pop();
|
|
60
|
+
else if (raw.slice(token.index + part.length).trimStart().startsWith(':')) {
|
|
61
|
+
const key = JSON.parse(part);
|
|
62
|
+
const members = stack[stack.length - 1];
|
|
63
|
+
if (members.has(key)) fail(code);
|
|
64
|
+
members.add(key);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
} catch { fail(code); }
|
|
69
|
+
}
|
|
70
|
+
function subtleCrypto() {
|
|
71
|
+
const subtle = globalThis.crypto?.subtle;
|
|
72
|
+
if (!subtle || typeof subtle.importKey !== 'function' || typeof subtle.verify !== 'function' ||
|
|
73
|
+
typeof subtle.digest !== 'function') fail('unsupported_crypto');
|
|
74
|
+
return subtle;
|
|
75
|
+
}
|
|
76
|
+
export async function deliveryKeyThumbprint(x) {
|
|
77
|
+
if (decode(x, 43, 'keys_unavailable').length !== 32) fail('keys_unavailable');
|
|
78
|
+
try {
|
|
79
|
+
return encode(new Uint8Array(await subtleCrypto().digest('SHA-256', encoder.encode(JSON.stringify({ crv: 'Ed25519', kty: 'OKP', x })))));
|
|
80
|
+
} catch { fail('unsupported_crypto'); }
|
|
81
|
+
}
|
|
82
|
+
async function importPublicKey(x) {
|
|
83
|
+
try {
|
|
84
|
+
return await subtleCrypto().importKey('jwk', { kty: 'OKP', crv: 'Ed25519', x }, { name: 'Ed25519' }, false, ['verify']);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (error?.name === 'NotSupportedError' || error?.code === 'unsupported_crypto') fail('unsupported_crypto');
|
|
87
|
+
fail('keys_unavailable');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function probeCrypto() {
|
|
91
|
+
const subtle = subtleCrypto();
|
|
92
|
+
if (!cryptoProbes.has(subtle)) {
|
|
93
|
+
// RFC 8032 test 1: public verification only, no seed or runtime key generation.
|
|
94
|
+
const probe = (async () => {
|
|
95
|
+
try {
|
|
96
|
+
const key = await importPublicKey('11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo');
|
|
97
|
+
const hex = 'e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b';
|
|
98
|
+
const signature = Uint8Array.from(hex.match(/../g), (byte) => Number.parseInt(byte, 16));
|
|
99
|
+
if (!await subtle.verify('Ed25519', key, signature, new Uint8Array())) fail('unsupported_crypto');
|
|
100
|
+
} catch { fail('unsupported_crypto'); }
|
|
101
|
+
})();
|
|
102
|
+
cryptoProbes.set(subtle, probe);
|
|
103
|
+
// Allow a later startup retry after a failed capability probe.
|
|
104
|
+
probe.catch(() => cryptoProbes.delete(subtle));
|
|
105
|
+
}
|
|
106
|
+
await cryptoProbes.get(subtle);
|
|
107
|
+
}
|
|
108
|
+
function normalizedOrigin(value, code) {
|
|
109
|
+
try {
|
|
110
|
+
const url = new URL(value);
|
|
111
|
+
if (url.protocol !== 'https:' || url.username || url.password) fail(code);
|
|
112
|
+
return url.origin;
|
|
113
|
+
} catch { fail(code); }
|
|
114
|
+
}
|
|
115
|
+
function options({ product, audience, issuer = DEFAULT_ISSUER, origin = issuer, fetch: fetchImpl = globalThis.fetch,
|
|
116
|
+
now = Date.now, keys, maxSkewSeconds = 30, replayStore }) {
|
|
117
|
+
if (typeof product !== 'string' || !/^h_[0-9a-f]{32}$/.test(product)) fail('wrong_product');
|
|
118
|
+
if (!Number.isFinite(maxSkewSeconds) || maxSkewSeconds < 0 || typeof now !== 'function') fail();
|
|
119
|
+
if (replayStore !== undefined && typeof replayStore?.consume !== 'function') fail('replay_store_error');
|
|
120
|
+
return { product, audience: normalizedOrigin(audience, 'wrong_audience'), issuer: normalizedOrigin(issuer, 'wrong_issuer'),
|
|
121
|
+
origin: normalizedOrigin(origin, 'keys_unavailable'), fetchImpl, now, keys, maxSkewSeconds, replayStore };
|
|
122
|
+
}
|
|
123
|
+
function timeMs(config) {
|
|
124
|
+
const time = config.now();
|
|
125
|
+
if (!Number.isFinite(time)) fail();
|
|
126
|
+
return time;
|
|
127
|
+
}
|
|
128
|
+
function checkTime(claims, config) {
|
|
129
|
+
const now = timeMs(config) / 1000;
|
|
130
|
+
if (claims.exp <= now) fail('ticket_expired');
|
|
131
|
+
if (claims.iat > now + config.maxSkewSeconds) fail('ticket_not_yet_valid');
|
|
132
|
+
}
|
|
133
|
+
async function validateKeys(keys) {
|
|
134
|
+
if (!Array.isArray(keys) || keys.length === 0 || keys.length > 8) fail('keys_unavailable');
|
|
135
|
+
const result = new Map();
|
|
136
|
+
for (const key of keys) {
|
|
137
|
+
if (!exactFields(key, KEY_FIELDS) || key.kty !== 'OKP' || key.crv !== 'Ed25519' || key.use !== 'sig' || key.alg !== 'EdDSA') fail('keys_unavailable');
|
|
138
|
+
// Copy before awaiting so supplied mutable objects cannot change validated trust.
|
|
139
|
+
const { x, kid } = key;
|
|
140
|
+
if (typeof kid !== 'string' || kid.length !== 43 || kid !== await deliveryKeyThumbprint(x) || result.has(kid)) fail('keys_unavailable');
|
|
141
|
+
result.set(kid, x);
|
|
142
|
+
}
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
async function readKeysResponse(response, config) {
|
|
146
|
+
if (response.status !== 200 || response.redirected || response.type === 'opaqueredirect' ||
|
|
147
|
+
(response.url && new URL(response.url).origin !== config.origin)) fail('keys_unavailable');
|
|
148
|
+
const length = response.headers.get('content-length');
|
|
149
|
+
if (length !== null && (!/^\d+$/.test(length) || Number(length) > 16_384)) fail('keys_unavailable');
|
|
150
|
+
const ageHeader = response.headers.get('age');
|
|
151
|
+
const age = ageHeader === null ? 0 : Number(ageHeader);
|
|
152
|
+
if (ageHeader !== null && (!/^\d+$/.test(ageHeader) || !Number.isSafeInteger(age))) fail('keys_unavailable');
|
|
153
|
+
if (age >= 300 || !response.body) fail('keys_unavailable');
|
|
154
|
+
const reader = response.body.getReader();
|
|
155
|
+
const chunks = []; let size = 0;
|
|
156
|
+
try {
|
|
157
|
+
while (true) {
|
|
158
|
+
const { done, value } = await reader.read();
|
|
159
|
+
if (done) break;
|
|
160
|
+
size += value.byteLength;
|
|
161
|
+
if (size > 16_384) fail('keys_unavailable');
|
|
162
|
+
chunks.push(value);
|
|
163
|
+
}
|
|
164
|
+
} finally {
|
|
165
|
+
// Do not let cancellation failure mask the bounded-read denial or hang it.
|
|
166
|
+
reader.cancel().catch(() => {});
|
|
167
|
+
}
|
|
168
|
+
const bytes = new Uint8Array(size); let offset = 0;
|
|
169
|
+
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
|
|
170
|
+
const body = parseJson(bytes, false, 'keys_unavailable');
|
|
171
|
+
if (!exactFields(body, ['keys'])) fail('keys_unavailable');
|
|
172
|
+
return { keys: await validateKeys(body.keys), age };
|
|
173
|
+
}
|
|
174
|
+
function cacheFor(config) {
|
|
175
|
+
// Isolate both the expected issuer and explicitly configured transport origin.
|
|
176
|
+
const id = `${config.issuer}\n${config.origin}`;
|
|
177
|
+
if (!caches.has(id)) caches.set(id, { current: null, flight: null, lastUnknownRefresh: -Infinity });
|
|
178
|
+
return caches.get(id);
|
|
179
|
+
}
|
|
180
|
+
function fresh(set, config) {
|
|
181
|
+
const now = timeMs(config);
|
|
182
|
+
return set && now >= set.fetchedAt && now < set.expiresAt;
|
|
183
|
+
}
|
|
184
|
+
async function refresh(cache, config) {
|
|
185
|
+
if (cache.flight) return cache.flight;
|
|
186
|
+
cache.flight = (async () => {
|
|
187
|
+
const fetchedAt = timeMs(config);
|
|
188
|
+
const controller = new AbortController();
|
|
189
|
+
let timer;
|
|
190
|
+
const timeout = new Promise((_, reject) => {
|
|
191
|
+
timer = setTimeout(() => { controller.abort(); reject(new DeliveryError('keys_unavailable')); }, 8_000);
|
|
192
|
+
});
|
|
193
|
+
try {
|
|
194
|
+
const { keys, age } = await Promise.race([
|
|
195
|
+
(async () => readKeysResponse(await config.fetchImpl(`${config.origin}/.well-known/openpay-delivery-keys.json`, {
|
|
196
|
+
method: 'GET', redirect: 'manual', signal: controller.signal, headers: { accept: 'application/json' },
|
|
197
|
+
}), config))(), timeout,
|
|
198
|
+
]);
|
|
199
|
+
const set = { keys, fetchedAt, expiresAt: fetchedAt + (300 - age) * 1000 };
|
|
200
|
+
if (!fresh(set, config)) fail('keys_unavailable');
|
|
201
|
+
cache.current = set;
|
|
202
|
+
return set;
|
|
203
|
+
} catch (error) {
|
|
204
|
+
// Transport/parser failures remain confined to delivery and disclose no body/URL.
|
|
205
|
+
if (error?.code === 'unsupported_crypto') throw error;
|
|
206
|
+
fail('keys_unavailable');
|
|
207
|
+
} finally {
|
|
208
|
+
clearTimeout(timer);
|
|
209
|
+
// Stop unread transport bodies on early status/size/age rejection as well.
|
|
210
|
+
controller.abort();
|
|
211
|
+
}
|
|
212
|
+
})();
|
|
213
|
+
try { return await cache.flight; } finally { cache.flight = null; }
|
|
214
|
+
}
|
|
215
|
+
async function selectKey(kid, config) {
|
|
216
|
+
if (config.keys !== undefined) {
|
|
217
|
+
const keys = await validateKeys(config.keys);
|
|
218
|
+
if (!keys.has(kid)) fail('unknown_key');
|
|
219
|
+
return keys.get(kid);
|
|
220
|
+
}
|
|
221
|
+
const cache = cacheFor(config);
|
|
222
|
+
let set = fresh(cache.current, config) ? cache.current : await refresh(cache, config);
|
|
223
|
+
if (!set.keys.has(kid)) {
|
|
224
|
+
const now = timeMs(config);
|
|
225
|
+
if (cache.flight) set = await cache.flight;
|
|
226
|
+
else if (now - cache.lastUnknownRefresh >= 60_000) {
|
|
227
|
+
cache.lastUnknownRefresh = now;
|
|
228
|
+
set = await refresh(cache, config);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (!fresh(set, config)) fail('keys_unavailable');
|
|
232
|
+
if (!set.keys.has(kid)) fail('unknown_key');
|
|
233
|
+
return set.keys.get(kid);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function verifyDeliveryTicket({ ticket, ...input }) {
|
|
237
|
+
const config = options(input);
|
|
238
|
+
if (typeof ticket !== 'string' || ticket.length > 1024 + 4096 + 88) fail();
|
|
239
|
+
const segments = ticket.split('.');
|
|
240
|
+
if (segments.length !== 3) fail();
|
|
241
|
+
const header = parseJson(decode(segments[0], 1024), true);
|
|
242
|
+
const claims = parseJson(decode(segments[1], 4096), true);
|
|
243
|
+
const signature = decode(segments[2], 86);
|
|
244
|
+
if (!exactFields(header, HEADER_FIELDS)) fail();
|
|
245
|
+
if (header.alg !== 'EdDSA') fail('unsupported_algorithm');
|
|
246
|
+
if (header.typ !== 'openpay-delivery+jwt' || decode(header.kid, 43).length !== 32 || signature.length !== 64) fail();
|
|
247
|
+
if (!exactFields(claims, CLAIM_FIELDS) || claims.v !== 1 ||
|
|
248
|
+
typeof claims.sub !== 'string' || !/^0x[0-9a-fA-F]{40}$/.test(claims.sub) ||
|
|
249
|
+
!Number.isSafeInteger(claims.rev) || claims.rev <= 0 || !['purchase', 'holder'].includes(claims.basis) ||
|
|
250
|
+
typeof claims.jti !== 'string' || !/^[0-9a-f]{32}$/.test(claims.jti) ||
|
|
251
|
+
!Number.isSafeInteger(claims.iat) || !Number.isSafeInteger(claims.exp) || claims.exp !== claims.iat + 60) fail();
|
|
252
|
+
if (claims.iss !== config.issuer) fail('wrong_issuer');
|
|
253
|
+
if (claims.aud !== config.audience) fail('wrong_audience');
|
|
254
|
+
if (typeof claims.product !== 'string' || !/^h_[0-9a-f]{32}$/.test(claims.product) || claims.product !== config.product) fail('wrong_product');
|
|
255
|
+
const key = await importPublicKey(await selectKey(header.kid, config));
|
|
256
|
+
let verified;
|
|
257
|
+
try { verified = await subtleCrypto().verify('Ed25519', key, signature, encoder.encode(`${segments[0]}.${segments[1]}`)); } catch (error) {
|
|
258
|
+
if (error?.name === 'NotSupportedError' || error?.code === 'unsupported_crypto') fail('unsupported_crypto');
|
|
259
|
+
fail();
|
|
260
|
+
}
|
|
261
|
+
if (!verified) fail();
|
|
262
|
+
checkTime(claims, config);
|
|
263
|
+
if (config.replayStore !== undefined) {
|
|
264
|
+
let consumed;
|
|
265
|
+
try { consumed = await config.replayStore.consume(claims.jti, claims.exp); } catch { fail('replay_store_error'); }
|
|
266
|
+
if (consumed === false) fail('replay');
|
|
267
|
+
if (consumed !== true) fail('replay_store_error');
|
|
268
|
+
}
|
|
269
|
+
checkTime(claims, config);
|
|
270
|
+
// EIP-55 checksum is enforced at issuance; preserve the signed address as-is.
|
|
271
|
+
return { address: claims.sub, product: claims.product, revision: claims.rev, basis: claims.basis,
|
|
272
|
+
exp: claims.exp, iat: claims.iat, jti: claims.jti, kid: header.kid };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function ticketFromRequest(request) {
|
|
276
|
+
try {
|
|
277
|
+
const tickets = new URL(request.url).searchParams.getAll('ticket');
|
|
278
|
+
const auth = request.headers.get('authorization');
|
|
279
|
+
if (tickets.length > 1 || (tickets.length && auth !== null)) fail();
|
|
280
|
+
if (tickets.length) { if (!tickets[0]) fail(); return tickets[0]; }
|
|
281
|
+
if (auth === null) return null;
|
|
282
|
+
// A combined duplicate Authorization field contains a comma and cannot match.
|
|
283
|
+
const match = /^Bearer ([^\s,]+)$/i.exec(auth);
|
|
284
|
+
if (!match) fail();
|
|
285
|
+
return match[1];
|
|
286
|
+
} catch { fail(); }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function createDeliveryGate(input) {
|
|
290
|
+
input = { ...input };
|
|
291
|
+
const config = options(input);
|
|
292
|
+
const verify = (ticket) => verifyDeliveryTicket({ ...input, ticket });
|
|
293
|
+
return {
|
|
294
|
+
async ready() {
|
|
295
|
+
await probeCrypto();
|
|
296
|
+
if (config.keys !== undefined) await validateKeys(config.keys);
|
|
297
|
+
else {
|
|
298
|
+
const cache = cacheFor(config);
|
|
299
|
+
if (!fresh(cache.current, config)) await refresh(cache, config);
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
verify,
|
|
303
|
+
async verifyRequest(request) { return verify(ticketFromRequest(request)); },
|
|
304
|
+
};
|
|
305
|
+
}
|