@pregen/verify 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -27
- package/index.d.ts +10 -2
- package/index.js +88 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,37 +6,43 @@ that owns its PG code, using the signed registry directory at
|
|
|
6
6
|
is built in; no dependencies (Web Crypto, Node 20+ or a modern browser).
|
|
7
7
|
|
|
8
8
|
```js
|
|
9
|
-
import { loadDirectory,
|
|
9
|
+
import { loadDirectory, checkDecision } from "@pregen/verify";
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
// Keep the last accepted directory (any storage) and pass it back each time.
|
|
12
|
+
const directory = await loadDirectory({ previous: saved });
|
|
13
|
+
saved = directory.toJSON();
|
|
13
14
|
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
if (!(await verifyDecision(decision, code, { directory }))) throw new Error("not genuine");
|
|
15
|
+
// request: what you sent to /v1/verify, plus provider_id and licensee_id
|
|
16
|
+
await checkDecision(decision, request, { directory }); // throws with the reason, or you may generate
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
19
|
+
`checkDecision` checks everything a provider must check before generating
|
|
20
|
+
(`PRE-GEN-SPEC.md` §5.4):
|
|
21
|
+
|
|
22
|
+
1. the decision is a license-backed `allow` (P-5);
|
|
23
|
+
2. every echoed member equals what you sent, empty ones included, and
|
|
24
|
+
intended-use members you did not send are empty (V-4);
|
|
25
|
+
3. `expires_at` is a positive integer, later than `issued_at`, not yet passed (V-3);
|
|
26
|
+
4. if you named a user, the binding is `verified` with a link id (V-13);
|
|
27
|
+
5. the signature chain: the directory is signed by the PRE-GEN steward key
|
|
28
|
+
(`pg-ed25519:dcdd244659e6d0e2426f2b504cb11590`), the license's namespace
|
|
29
|
+
belongs to one listed registry, the decision's key is one of that
|
|
30
|
+
registry's keys and hashes to its fingerprint, and the signature verifies.
|
|
31
|
+
|
|
32
|
+
The directory check also refuses an older directory, a newer one that removes
|
|
33
|
+
a registry or key, and a different one with the same sequence. The origin
|
|
34
|
+
registry's keys are pinned in the library as well, so a stolen steward key
|
|
35
|
+
cannot add its own key to bare `PG-…` codes; a new origin key comes with a
|
|
36
|
+
new release.
|
|
37
|
+
|
|
38
|
+
A listing in the directory proves which registry may issue a code, not that
|
|
39
|
+
it holds any right over a person or a work. A signed decision proves what the
|
|
40
|
+
registry answered, not that the output was lawful.
|
|
41
|
+
|
|
42
|
+
Also exported: `verifyDecision` (signatures only), `verifyDirectory`,
|
|
43
|
+
`Directory.checkSigner` / `registryFor` / `publicKey`, `verifyLicense` (subject
|
|
44
|
+
signature plus operator countersignature), `verifySigned`, `canonicalJson`,
|
|
45
|
+
`fingerprint`, `issuerOf`.
|
|
40
46
|
|
|
41
47
|
Tests: `npm test` runs the unit tests and the PRE-GEN Level 1 conformance
|
|
42
48
|
vectors with every verifier operation required.
|
package/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export const STEWARD_PUBLIC_KEY_HEX: string;
|
|
2
2
|
export const DIRECTORY_URL: string;
|
|
3
|
+
export const ORIGIN_KEY_FINGERPRINTS: readonly string[];
|
|
3
4
|
|
|
4
5
|
export class PregenError extends Error {}
|
|
5
6
|
|
|
@@ -19,6 +20,8 @@ export class Directory {
|
|
|
19
20
|
/** Store it and pass it back as `minSequence`, so an older directory is refused. */
|
|
20
21
|
readonly sequence: number;
|
|
21
22
|
readonly registries: RegistryEntry[];
|
|
23
|
+
/** The signed directory itself: persist it and pass it back as `previous`. */
|
|
24
|
+
toJSON(): Record<string, unknown>;
|
|
22
25
|
/** The registry that owns a code's namespace (resolve codes only there), or null. */
|
|
23
26
|
registryFor(code: string): RegistryEntry | null;
|
|
24
27
|
/** V-10: may this operator key sign objects carrying this code? */
|
|
@@ -38,6 +41,11 @@ export function verifyLicense(license: {
|
|
|
38
41
|
operatorPublicKeyHex: string; operatorSignatureHex: string;
|
|
39
42
|
}): Promise<{ subjectValid: boolean; operatorValid: boolean }>;
|
|
40
43
|
export function issuerOf(code: string): string;
|
|
41
|
-
export function verifyDirectory(directory: unknown, options?: { stewardPublicKeyHex?: string; minSequence?: number
|
|
42
|
-
|
|
44
|
+
export function verifyDirectory(directory: unknown, options?: { stewardPublicKeyHex?: string; minSequence?: number;
|
|
45
|
+
previous?: Directory | Record<string, unknown>; originKeyFingerprints?: string[] | null }): Promise<Directory>;
|
|
46
|
+
export function loadDirectory(options?: { url?: string; minSequence?: number; previous?: Directory | Record<string, unknown>; fetch?: Fetch }): Promise<Directory>;
|
|
43
47
|
export function verifyDecision(decision: Record<string, unknown>, code: string, options?: { directory?: Directory; fetch?: Fetch }): Promise<boolean>;
|
|
48
|
+
/** Everything to check before generating (V-1 to V-4, V-10, V-13, P-5). Throws PregenError with the
|
|
49
|
+
* reason; returns the decision when you may generate. `request`: the /v1/verify body plus provider_id and licensee_id. */
|
|
50
|
+
export function checkDecision<T extends Record<string, unknown>>(decision: T, request: Record<string, unknown>,
|
|
51
|
+
options?: { directory?: Directory; fetch?: Fetch; now?: number }): Promise<T>;
|
package/index.js
CHANGED
|
@@ -4,12 +4,16 @@
|
|
|
4
4
|
|
|
5
5
|
export const STEWARD_PUBLIC_KEY_HEX = "5cb949aab04186e3e216ec541b847c912fc3f78138c0ec3cb2560b2dad0d1f1b";
|
|
6
6
|
export const DIRECTORY_URL = "https://www.pregen.org/registries.json";
|
|
7
|
+
/** The origin registry's (PRAMPTA's) operator keys, pinned here as well as in the
|
|
8
|
+
* directory: a stolen steward key cannot add its own key to the bare namespace.
|
|
9
|
+
* A new origin key needs a new release of this library (threat model T4). */
|
|
10
|
+
export const ORIGIN_KEY_FINGERPRINTS = Object.freeze(["pg-ed25519:1904514fd2ac442f0a5388d13cc313a0"]);
|
|
7
11
|
const SCHEMA = "pregen.registries.v2";
|
|
8
12
|
const MAX_INT = 2 ** 53 - 1;
|
|
9
13
|
const PREFIX = /^[ABCDEFGHJKMNPQRSTVWXYZ]{4}$/;
|
|
10
14
|
const FINGERPRINT = /^pg-ed25519:[0-9a-f]{32}$/;
|
|
11
15
|
const ISSUER_IN_CODE = /^PG-([ABCDEFGHJKMNPQRSTVWXYZ]{4})-/;
|
|
12
|
-
const BARE_CODE = /^PG-(?:
|
|
16
|
+
const BARE_CODE = /^PG-(?:[A-Z]{3}-)?[0-9]/;
|
|
13
17
|
|
|
14
18
|
export class PregenError extends Error {
|
|
15
19
|
constructor(message) { super(message); this.name = "PregenError"; }
|
|
@@ -141,16 +145,20 @@ function checkInvariants(d) {
|
|
|
141
145
|
/** A directory accepted under V-12. Build one with verifyDirectory() or loadDirectory(). */
|
|
142
146
|
export class Directory {
|
|
143
147
|
#d;
|
|
144
|
-
|
|
148
|
+
#originPin;
|
|
149
|
+
constructor(token, directory, originPin) {
|
|
145
150
|
if (token !== Directory.#token) fail("use verifyDirectory() or loadDirectory()");
|
|
146
151
|
this.#d = structuredClone(directory);
|
|
152
|
+
this.#originPin = originPin;
|
|
147
153
|
}
|
|
148
154
|
static #token = Symbol();
|
|
149
|
-
static _make(directory) { return new Directory(Directory.#token, directory); }
|
|
155
|
+
static _make(directory, originPin) { return new Directory(Directory.#token, directory, originPin); }
|
|
150
156
|
|
|
151
157
|
/** Store this and pass it as minSequence next time, so an older copy is refused. */
|
|
152
158
|
get sequence() { return this.#d.sequence; }
|
|
153
159
|
get registries() { return structuredClone(this.#d.registries); }
|
|
160
|
+
/** The signed directory itself: persist it and pass it back as `previous`. */
|
|
161
|
+
toJSON() { return structuredClone(this.#d); }
|
|
154
162
|
|
|
155
163
|
/** The registry that owns a code's namespace (resolve codes only there, V-11), or null. */
|
|
156
164
|
registryFor(code) {
|
|
@@ -164,7 +172,9 @@ export class Directory {
|
|
|
164
172
|
checkSigner(code, signerKeyId) {
|
|
165
173
|
const r = this.registryFor(code);
|
|
166
174
|
if (!r) return "unknown_issuer";
|
|
167
|
-
|
|
175
|
+
if (!r.key_fingerprints.includes(signerKeyId)) return "issuer_mismatch";
|
|
176
|
+
if (r.prefix === "" && this.#originPin && !this.#originPin.includes(signerKeyId)) return "issuer_mismatch";
|
|
177
|
+
return "valid";
|
|
168
178
|
}
|
|
169
179
|
|
|
170
180
|
/** The public key behind `keyId`, which must belong to the owner of `code`'s namespace.
|
|
@@ -180,28 +190,93 @@ export class Directory {
|
|
|
180
190
|
}
|
|
181
191
|
}
|
|
182
192
|
|
|
183
|
-
/**
|
|
184
|
-
|
|
185
|
-
|
|
193
|
+
/** A newer directory may add registries and keys, never remove or move them (PG-CODE.md §9.1). */
|
|
194
|
+
function checkContinuity(next, previous) {
|
|
195
|
+
if (next.sequence < previous.sequence) fail(`sequence ${next.sequence} is older than ${previous.sequence} already seen`);
|
|
196
|
+
if (next.sequence === previous.sequence) {
|
|
197
|
+
if (canonicalJson(next) !== canonicalJson(previous)) fail("a different directory with the same sequence");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
for (const old of previous.registries) {
|
|
201
|
+
const now = next.registries.find((r) => r.prefix === old.prefix);
|
|
202
|
+
if (!now) fail(`namespace ${old.prefix || "(bare)"} was removed`);
|
|
203
|
+
for (const f of old.key_fingerprints) if (!now.key_fingerprints.includes(f)) fail(`key ${f} was removed from ${now.name}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** V-12: accept the directory only with the pinned steward's signature, its invariants,
|
|
208
|
+
* a sequence no lower than one already seen and, given the last accepted directory,
|
|
209
|
+
* nothing removed from it. With the built-in steward key the origin keys are pinned too. */
|
|
210
|
+
export async function verifyDirectory(directory, { stewardPublicKeyHex = STEWARD_PUBLIC_KEY_HEX, minSequence = 0,
|
|
211
|
+
previous, originKeyFingerprints } = {}) {
|
|
186
212
|
if (!Number.isSafeInteger(minSequence) || minSequence < 0) fail("minSequence must be a nonnegative integer");
|
|
187
213
|
checkInvariants(directory);
|
|
188
214
|
if (directory.steward_key_id !== (await fingerprint(stewardPublicKeyHex))) fail("signed by a different steward key");
|
|
189
215
|
if (!(await verifySigned(directory, stewardPublicKeyHex, "signature"))) fail("bad steward signature");
|
|
190
216
|
if (directory.sequence < minSequence) fail(`sequence ${directory.sequence} is older than ${minSequence} already seen`);
|
|
191
|
-
|
|
217
|
+
if (previous) checkContinuity(directory, previous instanceof Directory ? previous.toJSON() : previous);
|
|
218
|
+
const pin = originKeyFingerprints !== undefined ? originKeyFingerprints
|
|
219
|
+
: stewardPublicKeyHex === STEWARD_PUBLIC_KEY_HEX ? ORIGIN_KEY_FINGERPRINTS : null;
|
|
220
|
+
return Directory._make(directory, pin && [...pin]);
|
|
192
221
|
}
|
|
193
222
|
|
|
194
|
-
/** Download and verify the directory from pregen.org. */
|
|
195
|
-
export async function loadDirectory({ url = DIRECTORY_URL, minSequence = 0, fetch: fetchFn = globalThis.fetch } = {}) {
|
|
223
|
+
/** Download and verify the directory from pregen.org. Pass the last accepted one as `previous`. */
|
|
224
|
+
export async function loadDirectory({ url = DIRECTORY_URL, minSequence = 0, previous, fetch: fetchFn = globalThis.fetch } = {}) {
|
|
196
225
|
const res = await fetchFn(url, { redirect: "error" });
|
|
197
226
|
if (!res.ok) fail(`directory: HTTP ${res.status}`);
|
|
198
|
-
return verifyDirectory(await res.json(), { minSequence });
|
|
227
|
+
return verifyDirectory(await res.json(), { minSequence, previous });
|
|
199
228
|
}
|
|
200
229
|
|
|
201
|
-
/**
|
|
202
|
-
*
|
|
230
|
+
/** Is this registry decision genuine, and signed by a key of the registry that owns `code`?
|
|
231
|
+
* Signatures only: use checkDecision() before generating. */
|
|
203
232
|
export async function verifyDecision(decision, code, { directory, fetch: fetchFn = globalThis.fetch } = {}) {
|
|
204
233
|
const dir = directory || (await loadDirectory({ fetch: fetchFn }));
|
|
205
234
|
const key = await dir.publicKey(code, decision && decision.operator_key_id, { fetch: fetchFn });
|
|
206
235
|
return verifySigned(decision, key, "operator_signature");
|
|
207
236
|
}
|
|
237
|
+
|
|
238
|
+
const ECHOED = ["subject_id", "provider_id", "licensee_id", "prompt_hash", "model", "modality"];
|
|
239
|
+
const isEmpty = (v) => v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
|
|
240
|
+
const same = (a, b) => canonicalJson(a) === canonicalJson(b);
|
|
241
|
+
|
|
242
|
+
/** Everything a provider must check before generating on a decision (PRE-GEN-SPEC.md §5.4):
|
|
243
|
+
* a genuine signature by the owner of the license's namespace (V-1, V-2, V-10), the decision
|
|
244
|
+
* answers exactly this request (V-4), it is within its validity window (V-3), it carries a
|
|
245
|
+
* verified user binding when a user was named (V-13), and it is a license-backed allow (P-5).
|
|
246
|
+
* `request` is what you sent: the /v1/verify body plus provider_id and licensee_id.
|
|
247
|
+
* Throws PregenError with the reason; returns the decision when you may generate. */
|
|
248
|
+
export async function checkDecision(decision, request, { directory, fetch: fetchFn = globalThis.fetch,
|
|
249
|
+
now = Math.floor(Date.now() / 1000) } = {}) {
|
|
250
|
+
if (!decision || typeof decision !== "object") fail("decision must be an object");
|
|
251
|
+
if (!request || typeof request !== "object") fail("request must be an object");
|
|
252
|
+
if (decision.allowed !== true || decision.disposition !== "allow") fail(`not an allow: ${decision.disposition}`);
|
|
253
|
+
if (typeof decision.license_id !== "string" || !decision.license_id) fail("an allow must name its license");
|
|
254
|
+
|
|
255
|
+
const exp = decision.expires_at, iat = decision.issued_at;
|
|
256
|
+
if (!Number.isSafeInteger(exp) || exp <= 0) fail("expires_at must be a positive integer");
|
|
257
|
+
if (iat !== undefined && iat !== null && (!Number.isSafeInteger(iat) || iat <= 0 || iat >= exp || iat > now + 60)) {
|
|
258
|
+
fail("issued_at must be a positive integer before expires_at and not in the future");
|
|
259
|
+
}
|
|
260
|
+
if (exp <= now) fail("decision expired");
|
|
261
|
+
|
|
262
|
+
for (const f of ECHOED) {
|
|
263
|
+
if ((decision[f] ?? "") !== (request[f] ?? "")) fail(`${f} does not match the request`);
|
|
264
|
+
}
|
|
265
|
+
const got = decision.intended_use ?? {}, sent = request.intended_use ?? {};
|
|
266
|
+
if (typeof got !== "object" || typeof sent !== "object") fail("intended_use must be an object");
|
|
267
|
+
for (const k of new Set([...Object.keys(got), ...Object.keys(sent)])) {
|
|
268
|
+
const ok = isEmpty(sent[k]) ? isEmpty(got[k]) : same(got[k] ?? null, sent[k]); // empty and absent are the same
|
|
269
|
+
if (!ok) fail(`intended_use.${k} does not match the request`);
|
|
270
|
+
}
|
|
271
|
+
for (const f of ["generation_id", "provider_identity_link_id"]) {
|
|
272
|
+
if (!isEmpty(request[f]) && decision[f] !== request[f]) fail(`${f} does not match the request`);
|
|
273
|
+
}
|
|
274
|
+
if (!isEmpty(request.provider_user_id) || !isEmpty(request.provider_identity_link_id)) {
|
|
275
|
+
if (decision.provider_user_binding !== "verified" || isEmpty(decision.provider_identity_link_id)) {
|
|
276
|
+
fail("no verified binding to the user you named");
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (!(await verifyDecision(decision, decision.license_id, { directory, fetch: fetchFn }))) fail("bad signature");
|
|
281
|
+
return decision;
|
|
282
|
+
}
|