@authorizedretailers/spec 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/LICENSE-SPEC +396 -0
- package/README.md +130 -0
- package/dist/constants.d.ts +6 -0
- package/dist/constants.js +7 -0
- package/dist/identity.d.ts +27 -0
- package/dist/identity.js +92 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/jcs.d.ts +5 -0
- package/dist/jcs.js +47 -0
- package/dist/jws.d.ts +42 -0
- package/dist/jws.js +90 -0
- package/dist/published.d.ts +15 -0
- package/dist/published.js +15 -0
- package/dist/schemas.d.ts +519 -0
- package/dist/schemas.js +662 -0
- package/dist/timestamp.d.ts +6 -0
- package/dist/timestamp.js +26 -0
- package/dist/types.d.ts +116 -0
- package/dist/types.js +2 -0
- package/dist/validate.d.ts +28 -0
- package/dist/validate.js +147 -0
- package/fixtures/file/invalid/full-all-mixed.json +47 -0
- package/fixtures/file/invalid/full-amazon-missing-seller-id.json +45 -0
- package/fixtures/file/invalid/full-attached-jws.json +50 -0
- package/fixtures/file/invalid/full-authorization-expires-after-file.json +46 -0
- package/fixtures/file/invalid/full-duplicate-authorization-id.json +80 -0
- package/fixtures/file/invalid/full-empty-territories.json +43 -0
- package/fixtures/file/invalid/full-expires-before-issued.json +46 -0
- package/fixtures/file/invalid/full-expiry-over-180-days.json +46 -0
- package/fixtures/file/invalid/full-extra-property.json +47 -0
- package/fixtures/file/invalid/full-impossible-date.json +46 -0
- package/fixtures/file/invalid/full-lowercase-territory.json +45 -0
- package/fixtures/file/invalid/full-missing-authorization-expires.json +45 -0
- package/fixtures/file/invalid/full-missing-file-expires.json +45 -0
- package/fixtures/file/invalid/full-missing-product-lines.json +43 -0
- package/fixtures/file/invalid/full-missing-scope.json +37 -0
- package/fixtures/file/invalid/full-missing-territories.json +42 -0
- package/fixtures/file/invalid/full-name-only-retailer.json +30 -0
- package/fixtures/file/invalid/full-no-channels.json +31 -0
- package/fixtures/file/invalid/full-non-utc-timestamp.json +46 -0
- package/fixtures/file/invalid/full-physical-missing-country.json +50 -0
- package/fixtures/file/invalid/full-signature-missing-kid.json +49 -0
- package/fixtures/file/invalid/full-unknown-channel-type.json +50 -0
- package/fixtures/file/invalid/full-web-domain-is-url.json +46 -0
- package/fixtures/file/invalid/full-web-missing-domain.json +45 -0
- package/fixtures/file/invalid/full-wildcard-mixed.json +46 -0
- package/fixtures/file/invalid/full-wrong-spec.json +46 -0
- package/fixtures/file/invalid/pointer-http-url.json +9 -0
- package/fixtures/file/invalid/private-missing-verify.json +8 -0
- package/fixtures/file/invalid/private-with-authorizations.json +45 -0
- package/fixtures/file/invalid/unknown-form.json +9 -0
- package/fixtures/file/valid/full-fractional-seconds.json +46 -0
- package/fixtures/file/valid/full-max-expiry.json +46 -0
- package/fixtures/file/valid/full-no-authorizations.json +11 -0
- package/fixtures/file/valid/full-signed.json +50 -0
- package/fixtures/file/valid/full-unsigned-handwritten.json +46 -0
- package/fixtures/file/valid/full-worldwide-physical-proposed.json +82 -0
- package/fixtures/file/valid/pointer.json +9 -0
- package/fixtures/file/valid/private.json +9 -0
- package/fixtures/manifest.json +434 -0
- package/fixtures/verify-request/invalid/domain-with-scheme.json +10 -0
- package/fixtures/verify-request/invalid/missing-channel.json +5 -0
- package/fixtures/verify-request/invalid/physical-channel.json +10 -0
- package/fixtures/verify-request/invalid/retailer-name-only.json +6 -0
- package/fixtures/verify-request/invalid/wildcard-territory.json +10 -0
- package/fixtures/verify-request/valid/amazon.json +10 -0
- package/fixtures/verify-request/valid/web-no-product-line.json +8 -0
- package/fixtures/verify-response/invalid/authorized-missing-authorization-id.json +12 -0
- package/fixtures/verify-response/invalid/missing-observed.json +12 -0
- package/fixtures/verify-response/invalid/missing-signature.json +9 -0
- package/fixtures/verify-response/invalid/missing-valid-until.json +12 -0
- package/fixtures/verify-response/invalid/observed-without-last-seen.json +13 -0
- package/fixtures/verify-response/invalid/unknown-status.json +13 -0
- package/fixtures/verify-response/invalid/unlisted-observed.json +13 -0
- package/fixtures/verify-response/invalid/unlisted-with-authorization-id.json +12 -0
- package/fixtures/verify-response/invalid/unlisted-with-reason.json +12 -0
- package/fixtures/verify-response/invalid/valid-until-before-checked.json +13 -0
- package/fixtures/verify-response/invalid/valid-until-over-24h.json +13 -0
- package/fixtures/verify-response/invalid/with-tier.json +14 -0
- package/fixtures/verify-response/valid/authorized-observed.json +15 -0
- package/fixtures/verify-response/valid/authorized.json +13 -0
- package/fixtures/verify-response/valid/brand-unverified-no-reason.json +11 -0
- package/fixtures/verify-response/valid/brand-unverified-reason.json +12 -0
- package/fixtures/verify-response/valid/disputed.json +13 -0
- package/fixtures/verify-response/valid/expired.json +13 -0
- package/fixtures/verify-response/valid/unlisted.json +11 -0
- package/fixtures/verify-response/valid/with-signal.json +19 -0
- package/package.json +76 -0
- package/schemas/common.json +170 -0
- package/schemas/file-full.json +23 -0
- package/schemas/file-pointer.json +14 -0
- package/schemas/file-private.json +14 -0
- package/schemas/file.json +13 -0
- package/schemas/verify-request.json +18 -0
- package/schemas/verify-response.json +61 -0
- package/spec/spec-v0.1.md +282 -0
package/README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# authorized-retailers.json
|
|
2
|
+
|
|
3
|
+
An open standard for brands to publish which retailers they have authorized, and for software to check a seller against that list.
|
|
4
|
+
|
|
5
|
+
> **Status: draft v0.1.** Breaking changes are possible before v1.0. See [GOVERNANCE.md](GOVERNANCE.md) for how changes are made.
|
|
6
|
+
|
|
7
|
+
The standard answers one question: has this brand authorized this seller, on this channel, in this territory, for this product line, as of this date? The format is built for AI shopping agents to check before they recommend or buy, and is ready for them as they adopt it. It works just as well for marketplaces, brand protection teams and anyone else who needs the answer.
|
|
8
|
+
|
|
9
|
+
- **Read the spec:** [`spec/spec-v0.1.md`](spec/spec-v0.1.md), also at [authorizedretailers.ai/spec](https://authorizedretailers.ai/spec)
|
|
10
|
+
- **Reference registry:** [authorizedretailers.ai](https://authorizedretailers.ai)
|
|
11
|
+
|
|
12
|
+
## Core principles
|
|
13
|
+
|
|
14
|
+
- **Every authorization comes from the brand.** No one else can authorize a retailer: not a distributor, not a retailer and not the registry.
|
|
15
|
+
- **Distributors can propose, only the brand approves.** A proposal changes nothing until the brand says yes.
|
|
16
|
+
- **Authorization is scoped and expires.** Each one names its channels, territories and product lines, and lapses unless the brand reconfirms it.
|
|
17
|
+
- **Authorization is not authenticity.** An answer says what the brand has approved. It does not say whether any item is genuine.
|
|
18
|
+
- **The registry doesn't rule on pricing.** Pricing and commercial terms are between the brand and its retailers.
|
|
19
|
+
- **Listing is free.** Brands publish and retailers are listed at no cost.
|
|
20
|
+
- **Retailers can see and dispute their status.** A retailer can check how it is listed and dispute an entry it believes is wrong (spec section 11).
|
|
21
|
+
|
|
22
|
+
## Who it's for
|
|
23
|
+
|
|
24
|
+
| You are | You use it to |
|
|
25
|
+
| --- | --- |
|
|
26
|
+
| A brand | Publish which retailers you've authorized, where and for what |
|
|
27
|
+
| A retailer | Show that the brands you sell have authorized you, and see how you're listed |
|
|
28
|
+
| An agent builder | Check a seller before recommending or buying, with an answer you can verify |
|
|
29
|
+
|
|
30
|
+
## Publish a file (brands)
|
|
31
|
+
|
|
32
|
+
A brand verifies control of its domain with a registry, which signs its list. The brand then serves a file at:
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
https://<brand-domain>/.well-known/authorized-retailers.json
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The file takes one of three forms:
|
|
39
|
+
|
|
40
|
+
| Form | What it holds | Use when |
|
|
41
|
+
| --- | --- | --- |
|
|
42
|
+
| `full` | The complete signed list | You want your list public |
|
|
43
|
+
| `pointer` | A URL to your signed list on a registry | You want the file to stay current without re-uploading |
|
|
44
|
+
| `private` | Your identity and a verify endpoint, no retailers | You don't want your distribution network public |
|
|
45
|
+
|
|
46
|
+
Only files signed by a registry count. An unsigned file is well formed but treated as no file (spec section 5). The simplest choice is the `pointer` form:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"spec": "authorized-retailers/0.1",
|
|
51
|
+
"form": "pointer",
|
|
52
|
+
"brand": { "name": "Example Brand", "domain": "brand.example" },
|
|
53
|
+
"list": "https://authorizedretailers.ai/v0/brands/brand.example/list"
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Validate a file
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npm install @authorizedretailers/spec
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { validateFile, validAuthorizations } from "@authorizedretailers/spec";
|
|
65
|
+
|
|
66
|
+
const result = validateFile(file);
|
|
67
|
+
if (!result.valid) console.log(result.errors);
|
|
68
|
+
else if (result.value.form === "full") {
|
|
69
|
+
const current = validAuthorizations(result.value); // an expired file yields none
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
To accept a file you fetched, check its signature and that it vouches for the domain it came from:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { checkPublishedFile } from "@authorizedretailers/spec";
|
|
77
|
+
|
|
78
|
+
const check = await checkPublishedFile("brand.example", file, jwks);
|
|
79
|
+
// { counts: true, kid } or { counts: false, reason }
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The package works in Node 20+, Cloudflare Workers, Deno and browsers. It uses only Web Crypto, makes no network calls and sends no telemetry.
|
|
83
|
+
|
|
84
|
+
## Verify a signed registry answer (agent builders)
|
|
85
|
+
|
|
86
|
+
Ask a registry whether a seller is authorized, then verify the answer against the registry's published keys:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import { verifyDocument } from "@authorizedretailers/spec";
|
|
90
|
+
|
|
91
|
+
const answer = await fetch("https://authorizedretailers.ai/v0/verify", {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { "Content-Type": "application/json" },
|
|
94
|
+
body: JSON.stringify({
|
|
95
|
+
brand_domain: "brand.example",
|
|
96
|
+
channel: { type: "amazon", marketplace: "US", seller_id: "A1B2C3D4E5F6G7" },
|
|
97
|
+
territory: "US",
|
|
98
|
+
}),
|
|
99
|
+
}).then((r) => r.json());
|
|
100
|
+
|
|
101
|
+
const jwks = await fetch("https://authorizedretailers.ai/.well-known/jwks.json").then((r) => r.json());
|
|
102
|
+
const check = await verifyDocument(answer, jwks);
|
|
103
|
+
// { valid: true, kid } or { valid: false, reason }
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The verifier rejects keys no longer in the key set and answers past their `valid_until`. On `unknown_kid`, fetch the key set again once. Match sellers on the identifier you see, never on a retailer name: `channelKey()` normalizes identifiers so every reader agrees.
|
|
107
|
+
|
|
108
|
+
## What's in this repository
|
|
109
|
+
|
|
110
|
+
| Path | What |
|
|
111
|
+
| --- | --- |
|
|
112
|
+
| `spec/` | The specification |
|
|
113
|
+
| `schemas/` | JSON Schemas (draft 2020-12) for the three file forms and the verify request and response |
|
|
114
|
+
| `fixtures/` | Valid and invalid examples, with `manifest.json` saying what each should produce. Usable as a conformance suite |
|
|
115
|
+
| `src/` | Types, validator, identifier matching, RFC 8785 canonicalization (JCS) and detached EdDSA JWS signing and verification |
|
|
116
|
+
| `test/` | Tests for all of the above |
|
|
117
|
+
| `docs/MUST-coverage.md` | Every MUST in the spec and where it is tested |
|
|
118
|
+
|
|
119
|
+
Anyone may run a registry that implements this standard. See [TRADEMARKS.md](TRADEMARKS.md) for how to describe it.
|
|
120
|
+
|
|
121
|
+
## Feedback
|
|
122
|
+
|
|
123
|
+
Send feedback on the spec at [authorizedretailers.ai/spec](https://authorizedretailers.ai/spec#feedback). This repository doesn't accept pull requests. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
- The specification and documents in `spec/` and `docs/`: [CC BY 4.0](LICENSE-SPEC)
|
|
128
|
+
- Schemas, code and fixtures: [Apache 2.0](LICENSE)
|
|
129
|
+
|
|
130
|
+
"authorizedretailers.ai" and "Authorized Retailers" are trademarks. See [TRADEMARKS.md](TRADEMARKS.md).
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Section 10: maximum expiry is 180 days from issue. */
|
|
2
|
+
export declare const MAX_EXPIRY_MS: number;
|
|
3
|
+
/** Section 10: recommended default expiry. */
|
|
4
|
+
export declare const DEFAULT_EXPIRY_MS: number;
|
|
5
|
+
/** Section 9: a verify answer's valid_until is at most 24 hours after checked. */
|
|
6
|
+
export declare const MAX_ANSWER_VALIDITY_MS: number;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
2
|
+
/** Section 10: maximum expiry is 180 days from issue. */
|
|
3
|
+
export const MAX_EXPIRY_MS = 180 * DAY_MS;
|
|
4
|
+
/** Section 10: recommended default expiry. */
|
|
5
|
+
export const DEFAULT_EXPIRY_MS = 90 * DAY_MS;
|
|
6
|
+
/** Section 9: a verify answer's valid_until is at most 24 hours after checked. */
|
|
7
|
+
export const MAX_ANSWER_VALIDITY_MS = DAY_MS;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Authorization, Channel, FullFile } from "./types.js";
|
|
2
|
+
export declare function normalizeDomain(domain: string): string;
|
|
3
|
+
export declare function normalizeCountry(code: string): string;
|
|
4
|
+
/**
|
|
5
|
+
* The identity an agent matches on (section 6: match on the channel identifier, never the
|
|
6
|
+
* retailer name). Returns null for physical channels, which agents cannot verify.
|
|
7
|
+
*/
|
|
8
|
+
export declare function channelKey(channel: Channel): string | null;
|
|
9
|
+
/** Authorizations naming this channel. Matching uses channelKey only; retailer names are ignored. */
|
|
10
|
+
export declare function authorizationsForChannel(authorizations: Authorization[], channel: Channel): Authorization[];
|
|
11
|
+
/**
|
|
12
|
+
* The authorizations a reader may rely on at `now`. Section 5: an expired file contains no valid
|
|
13
|
+
* authorizations. Individually expired authorizations are dropped too.
|
|
14
|
+
*/
|
|
15
|
+
export declare function validAuthorizations(file: FullFile, now?: number): Authorization[];
|
|
16
|
+
/**
|
|
17
|
+
* Brings raw agent input into the canonical form validateVerifyRequest expects: trims strings,
|
|
18
|
+
* lowercases domains (dropping "www."), uppercases country codes. Leaves unexpected shapes alone
|
|
19
|
+
* so the validator reports them.
|
|
20
|
+
*/
|
|
21
|
+
export declare function normalizeVerifyRequest(input: unknown): unknown;
|
|
22
|
+
/**
|
|
23
|
+
* Canonical form of a channel as typed by a person or agent: trims, lowercases the type and
|
|
24
|
+
* domains (dropping "www."), uppercases country codes. Unexpected shapes pass through untouched.
|
|
25
|
+
* Identifier case is kept; channelKey decides how case compares.
|
|
26
|
+
*/
|
|
27
|
+
export declare function normalizeChannel(input: unknown): unknown;
|
package/dist/identity.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { parseTimestamp } from "./timestamp.js";
|
|
2
|
+
/** Marketplace codes some platforms use in place of ISO 3166-1 alpha-2. */
|
|
3
|
+
const MARKETPLACE_ALIASES = { UK: "GB" };
|
|
4
|
+
export function normalizeDomain(domain) {
|
|
5
|
+
return domain.trim().toLowerCase().replace(/\.$/, "").replace(/^www\./, "");
|
|
6
|
+
}
|
|
7
|
+
export function normalizeCountry(code) {
|
|
8
|
+
const upper = code.trim().toUpperCase();
|
|
9
|
+
return MARKETPLACE_ALIASES[upper] ?? upper;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The identity an agent matches on (section 6: match on the channel identifier, never the
|
|
13
|
+
* retailer name). Returns null for physical channels, which agents cannot verify.
|
|
14
|
+
*/
|
|
15
|
+
export function channelKey(channel) {
|
|
16
|
+
switch (channel.type) {
|
|
17
|
+
case "amazon":
|
|
18
|
+
return `amazon:${normalizeCountry(channel.marketplace)}:${channel.seller_id.trim().toUpperCase()}`;
|
|
19
|
+
case "walmart":
|
|
20
|
+
return `walmart:${normalizeCountry(channel.marketplace)}:${channel.seller_id.trim()}`;
|
|
21
|
+
case "ebay":
|
|
22
|
+
return `ebay:${normalizeCountry(channel.marketplace)}:${channel.seller_id.trim().toLowerCase()}`;
|
|
23
|
+
case "web":
|
|
24
|
+
return `web:${normalizeDomain(channel.domain)}`;
|
|
25
|
+
case "physical":
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Authorizations naming this channel. Matching uses channelKey only; retailer names are ignored. */
|
|
30
|
+
export function authorizationsForChannel(authorizations, channel) {
|
|
31
|
+
const key = channelKey(channel);
|
|
32
|
+
if (key === null)
|
|
33
|
+
return [];
|
|
34
|
+
return authorizations.filter((a) => a.channels.some((c) => channelKey(c) === key));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The authorizations a reader may rely on at `now`. Section 5: an expired file contains no valid
|
|
38
|
+
* authorizations. Individually expired authorizations are dropped too.
|
|
39
|
+
*/
|
|
40
|
+
export function validAuthorizations(file, now = Date.now()) {
|
|
41
|
+
const fileExpires = parseTimestamp(file.expires);
|
|
42
|
+
if (fileExpires === null || now >= fileExpires)
|
|
43
|
+
return [];
|
|
44
|
+
return file.authorizations.filter((a) => {
|
|
45
|
+
const exp = parseTimestamp(a.expires);
|
|
46
|
+
return exp !== null && now < exp;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Brings raw agent input into the canonical form validateVerifyRequest expects: trims strings,
|
|
51
|
+
* lowercases domains (dropping "www."), uppercases country codes. Leaves unexpected shapes alone
|
|
52
|
+
* so the validator reports them.
|
|
53
|
+
*/
|
|
54
|
+
export function normalizeVerifyRequest(input) {
|
|
55
|
+
if (!isRecord(input))
|
|
56
|
+
return input;
|
|
57
|
+
const out = { ...input };
|
|
58
|
+
if (typeof out.brand_domain === "string")
|
|
59
|
+
out.brand_domain = normalizeDomain(out.brand_domain);
|
|
60
|
+
if (typeof out.territory === "string")
|
|
61
|
+
out.territory = normalizeCountry(out.territory);
|
|
62
|
+
if (typeof out.product_line === "string")
|
|
63
|
+
out.product_line = out.product_line.trim();
|
|
64
|
+
out.channel = normalizeChannel(out.channel);
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Canonical form of a channel as typed by a person or agent: trims, lowercases the type and
|
|
69
|
+
* domains (dropping "www."), uppercases country codes. Unexpected shapes pass through untouched.
|
|
70
|
+
* Identifier case is kept; channelKey decides how case compares.
|
|
71
|
+
*/
|
|
72
|
+
export function normalizeChannel(input) {
|
|
73
|
+
if (!isRecord(input))
|
|
74
|
+
return input;
|
|
75
|
+
const ch = { ...input };
|
|
76
|
+
if (typeof ch.type === "string")
|
|
77
|
+
ch.type = ch.type.trim().toLowerCase();
|
|
78
|
+
if (typeof ch.marketplace === "string")
|
|
79
|
+
ch.marketplace = normalizeCountry(ch.marketplace);
|
|
80
|
+
if (typeof ch.seller_id === "string")
|
|
81
|
+
ch.seller_id = ch.seller_id.trim();
|
|
82
|
+
if (typeof ch.domain === "string")
|
|
83
|
+
ch.domain = normalizeDomain(ch.domain);
|
|
84
|
+
if (typeof ch.address === "string")
|
|
85
|
+
ch.address = ch.address.trim();
|
|
86
|
+
if (typeof ch.country === "string")
|
|
87
|
+
ch.country = ch.country.trim().toUpperCase();
|
|
88
|
+
return ch;
|
|
89
|
+
}
|
|
90
|
+
function isRecord(v) {
|
|
91
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
92
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/jcs.d.ts
ADDED
package/dist/jcs.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// JSON Canonicalization Scheme, RFC 8785.
|
|
2
|
+
//
|
|
3
|
+
// ECMAScript already defines the two hard parts the RFC relies on: JSON.stringify's string
|
|
4
|
+
// escaping (RFC 8785 §3.2.2.2) and Number.prototype.toString's shortest round-trip form
|
|
5
|
+
// (§3.2.2.3). What remains is recursive key sorting by UTF-16 code units (§3.2.3), which is
|
|
6
|
+
// what Array.prototype.sort does by default on strings.
|
|
7
|
+
export class CanonicalizationError extends Error {
|
|
8
|
+
constructor(message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "CanonicalizationError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function canonicalize(value) {
|
|
14
|
+
if (value === null)
|
|
15
|
+
return "null";
|
|
16
|
+
switch (typeof value) {
|
|
17
|
+
case "boolean":
|
|
18
|
+
return value ? "true" : "false";
|
|
19
|
+
case "number":
|
|
20
|
+
if (!Number.isFinite(value))
|
|
21
|
+
throw new CanonicalizationError(`cannot canonicalize ${value}`);
|
|
22
|
+
return JSON.stringify(value); // -0 serializes as "0", as RFC 8785 requires
|
|
23
|
+
case "string":
|
|
24
|
+
if (/\p{Surrogate}/u.test(value.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, ""))) {
|
|
25
|
+
throw new CanonicalizationError("string contains a lone surrogate");
|
|
26
|
+
}
|
|
27
|
+
return JSON.stringify(value);
|
|
28
|
+
case "object": {
|
|
29
|
+
if (Array.isArray(value))
|
|
30
|
+
return `[${value.map(canonicalize).join(",")}]`;
|
|
31
|
+
const obj = value;
|
|
32
|
+
const proto = Object.getPrototypeOf(obj);
|
|
33
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
34
|
+
throw new CanonicalizationError("only plain objects can be canonicalized");
|
|
35
|
+
}
|
|
36
|
+
const keys = Object.keys(obj)
|
|
37
|
+
.filter((k) => obj[k] !== undefined)
|
|
38
|
+
.sort();
|
|
39
|
+
return `{${keys.map((k) => `${canonicalize(k)}:${canonicalize(obj[k])}`).join(",")}}`;
|
|
40
|
+
}
|
|
41
|
+
default:
|
|
42
|
+
throw new CanonicalizationError(`cannot canonicalize a ${typeof value}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function canonicalBytes(value) {
|
|
46
|
+
return new TextEncoder().encode(canonicalize(value));
|
|
47
|
+
}
|
package/dist/jws.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export declare const JWS_ALG = "EdDSA";
|
|
2
|
+
export interface PublicJwk {
|
|
3
|
+
kty: "OKP";
|
|
4
|
+
crv: "Ed25519";
|
|
5
|
+
x: string;
|
|
6
|
+
kid: string;
|
|
7
|
+
alg?: "EdDSA";
|
|
8
|
+
use?: "sig";
|
|
9
|
+
}
|
|
10
|
+
export interface Jwks {
|
|
11
|
+
keys: PublicJwk[];
|
|
12
|
+
}
|
|
13
|
+
export declare function base64url(bytes: Uint8Array): string;
|
|
14
|
+
export declare function base64urlDecode(s: string): Uint8Array<ArrayBuffer>;
|
|
15
|
+
/** The bytes a signature covers: the document minus "signature", canonicalized. */
|
|
16
|
+
export declare function signingPayload(doc: object): Uint8Array<ArrayBuffer>;
|
|
17
|
+
/**
|
|
18
|
+
* Produces a compact detached JWS over `payload`. Holders of the private key (the signer Worker,
|
|
19
|
+
* or a future managed key service) call this; nothing else in the registry sees the key.
|
|
20
|
+
*/
|
|
21
|
+
export declare function signDetached(privateKey: CryptoKey, kid: string, payload: Uint8Array): Promise<string>;
|
|
22
|
+
export type VerifyFailure = "missing_signature" | "malformed_jws" | "alg_not_allowed" | "kid_mismatch" | "unknown_kid" | "bad_signature" | "answer_expired" | "list_expired";
|
|
23
|
+
export type SignatureCheck = {
|
|
24
|
+
valid: true;
|
|
25
|
+
kid: string;
|
|
26
|
+
} | {
|
|
27
|
+
valid: false;
|
|
28
|
+
reason: VerifyFailure;
|
|
29
|
+
};
|
|
30
|
+
export interface VerifyOptions {
|
|
31
|
+
/** Epoch ms to check validity against. Defaults to Date.now(). */
|
|
32
|
+
now?: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Reference verifier for agents (§8, §9, §12). Checks, in order:
|
|
36
|
+
* - the signature is a well-formed detached EdDSA JWS whose header kid matches signature.kid;
|
|
37
|
+
* - the kid is in the current key set: signatures from keys no longer in the set are rejected (§8 MUST);
|
|
38
|
+
* - the signature verifies over the canonical document;
|
|
39
|
+
* - a verify answer is not past its valid_until (§9, §12 MUST), and a list is not past its expires (§5).
|
|
40
|
+
* Callers SHOULD refetch the key set once on "unknown_kid" before giving up (§8).
|
|
41
|
+
*/
|
|
42
|
+
export declare function verifyDocument(doc: object, jwks: Jwks, opts?: VerifyOptions): Promise<SignatureCheck>;
|
package/dist/jws.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Detached JWS (RFC 7515 Appendix F) with EdDSA / Ed25519 over RFC 8785 canonical JSON (§8).
|
|
2
|
+
//
|
|
3
|
+
// The signed payload is the canonical form of the document with its "signature" member removed.
|
|
4
|
+
// The compact serialization is "<header>..<signature>": the payload segment is empty because the
|
|
5
|
+
// reader recomputes it from the document it already has.
|
|
6
|
+
import { canonicalBytes, canonicalize } from "./jcs.js";
|
|
7
|
+
import { parseTimestamp } from "./timestamp.js";
|
|
8
|
+
export const JWS_ALG = "EdDSA";
|
|
9
|
+
const ED25519 = { name: "Ed25519" };
|
|
10
|
+
export function base64url(bytes) {
|
|
11
|
+
let bin = "";
|
|
12
|
+
for (const b of bytes)
|
|
13
|
+
bin += String.fromCharCode(b);
|
|
14
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
15
|
+
}
|
|
16
|
+
export function base64urlDecode(s) {
|
|
17
|
+
if (!/^[A-Za-z0-9_-]*$/.test(s))
|
|
18
|
+
throw new Error("not base64url");
|
|
19
|
+
const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (s.length % 4)) % 4));
|
|
20
|
+
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
|
21
|
+
}
|
|
22
|
+
/** The bytes a signature covers: the document minus "signature", canonicalized. */
|
|
23
|
+
export function signingPayload(doc) {
|
|
24
|
+
const { signature: _omit, ...rest } = doc;
|
|
25
|
+
return canonicalBytes(rest);
|
|
26
|
+
}
|
|
27
|
+
function protectedHeader(kid) {
|
|
28
|
+
return base64url(new TextEncoder().encode(canonicalize({ alg: JWS_ALG, kid })));
|
|
29
|
+
}
|
|
30
|
+
function signingInput(headerB64, payload) {
|
|
31
|
+
return new TextEncoder().encode(`${headerB64}.${base64url(payload)}`);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Produces a compact detached JWS over `payload`. Holders of the private key (the signer Worker,
|
|
35
|
+
* or a future managed key service) call this; nothing else in the registry sees the key.
|
|
36
|
+
*/
|
|
37
|
+
export async function signDetached(privateKey, kid, payload) {
|
|
38
|
+
const header = protectedHeader(kid);
|
|
39
|
+
const sig = new Uint8Array(await crypto.subtle.sign(ED25519, privateKey, signingInput(header, payload)));
|
|
40
|
+
return `${header}..${base64url(sig)}`;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Reference verifier for agents (§8, §9, §12). Checks, in order:
|
|
44
|
+
* - the signature is a well-formed detached EdDSA JWS whose header kid matches signature.kid;
|
|
45
|
+
* - the kid is in the current key set: signatures from keys no longer in the set are rejected (§8 MUST);
|
|
46
|
+
* - the signature verifies over the canonical document;
|
|
47
|
+
* - a verify answer is not past its valid_until (§9, §12 MUST), and a list is not past its expires (§5).
|
|
48
|
+
* Callers SHOULD refetch the key set once on "unknown_kid" before giving up (§8).
|
|
49
|
+
*/
|
|
50
|
+
export async function verifyDocument(doc, jwks, opts = {}) {
|
|
51
|
+
const now = opts.now ?? Date.now();
|
|
52
|
+
const sig = doc.signature;
|
|
53
|
+
if (!sig || typeof sig.jws !== "string" || typeof sig.kid !== "string")
|
|
54
|
+
return { valid: false, reason: "missing_signature" };
|
|
55
|
+
const parts = sig.jws.split(".");
|
|
56
|
+
if (parts.length !== 3 || parts[1] !== "" || !parts[0] || !parts[2])
|
|
57
|
+
return { valid: false, reason: "malformed_jws" };
|
|
58
|
+
let header;
|
|
59
|
+
let signature;
|
|
60
|
+
try {
|
|
61
|
+
header = JSON.parse(new TextDecoder().decode(base64urlDecode(parts[0])));
|
|
62
|
+
signature = base64urlDecode(parts[2]);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return { valid: false, reason: "malformed_jws" };
|
|
66
|
+
}
|
|
67
|
+
if (header.alg !== JWS_ALG || header.crit !== undefined)
|
|
68
|
+
return { valid: false, reason: "alg_not_allowed" };
|
|
69
|
+
if (header.kid !== sig.kid)
|
|
70
|
+
return { valid: false, reason: "kid_mismatch" };
|
|
71
|
+
const jwk = jwks.keys.find((k) => k.kid === sig.kid);
|
|
72
|
+
if (!jwk || jwk.kty !== "OKP" || jwk.crv !== "Ed25519")
|
|
73
|
+
return { valid: false, reason: "unknown_kid" };
|
|
74
|
+
const key = await crypto.subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: jwk.x }, ED25519, false, ["verify"]);
|
|
75
|
+
const ok = await crypto.subtle.verify(ED25519, key, signature, signingInput(parts[0], signingPayload(doc)));
|
|
76
|
+
if (!ok)
|
|
77
|
+
return { valid: false, reason: "bad_signature" };
|
|
78
|
+
const d = doc;
|
|
79
|
+
if (typeof d.valid_until === "string") {
|
|
80
|
+
const until = parseTimestamp(d.valid_until);
|
|
81
|
+
if (until === null || now > until)
|
|
82
|
+
return { valid: false, reason: "answer_expired" };
|
|
83
|
+
}
|
|
84
|
+
else if (d.form === "full" && typeof d.expires === "string") {
|
|
85
|
+
const expires = parseTimestamp(d.expires);
|
|
86
|
+
if (expires === null || now >= expires)
|
|
87
|
+
return { valid: false, reason: "list_expired" };
|
|
88
|
+
}
|
|
89
|
+
return { valid: true, kid: sig.kid };
|
|
90
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type Jwks, type VerifyFailure } from "./jws.js";
|
|
2
|
+
import type { AuthorizedRetailersFile } from "./types.js";
|
|
3
|
+
export type PublishedFileCheck = {
|
|
4
|
+
counts: true;
|
|
5
|
+
kid: string;
|
|
6
|
+
} | {
|
|
7
|
+
counts: false;
|
|
8
|
+
reason: VerifyFailure | "domain_mismatch";
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Whether a published file counts, given the domain it was fetched from and the registry's keys.
|
|
12
|
+
* An unsigned file, a bad signature, an expired list, or a file naming another brand's domain
|
|
13
|
+
* doesn't count: treat it as no file at all.
|
|
14
|
+
*/
|
|
15
|
+
export declare function checkPublishedFile(fetchedFrom: string, file: AuthorizedRetailersFile, jwks: Jwks, now?: number): Promise<PublishedFileCheck>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Section 5: which published files count. Only a file signed by a registry, and only for the domain
|
|
2
|
+
// it was fetched from. Indexes and agents reading brands' files directly both apply this.
|
|
3
|
+
import { normalizeDomain } from "./identity.js";
|
|
4
|
+
import { verifyDocument } from "./jws.js";
|
|
5
|
+
/**
|
|
6
|
+
* Whether a published file counts, given the domain it was fetched from and the registry's keys.
|
|
7
|
+
* An unsigned file, a bad signature, an expired list, or a file naming another brand's domain
|
|
8
|
+
* doesn't count: treat it as no file at all.
|
|
9
|
+
*/
|
|
10
|
+
export async function checkPublishedFile(fetchedFrom, file, jwks, now = Date.now()) {
|
|
11
|
+
if (normalizeDomain(file.brand.domain) !== normalizeDomain(fetchedFrom))
|
|
12
|
+
return { counts: false, reason: "domain_mismatch" };
|
|
13
|
+
const sig = await verifyDocument(file, jwks, { now });
|
|
14
|
+
return sig.valid ? { counts: true, kid: sig.kid } : { counts: false, reason: sig.reason };
|
|
15
|
+
}
|