@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.
Files changed (4) hide show
  1. package/README.md +33 -27
  2. package/index.d.ts +10 -2
  3. package/index.js +88 -13
  4. 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, verifyDecision } from "@pregen/verify";
9
+ import { loadDirectory, checkDecision } from "@pregen/verify";
10
10
 
11
- const directory = await loadDirectory({ minSequence: savedSequence });
12
- saveSequence(directory.sequence); // refuse older copies next time
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
- // decision: the JSON a registry returned from /v1/verify
15
- // code: the PG code you asked about (the license id, or the subject code)
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
- The chain it checks:
20
-
21
- 1. the directory is signed by the PRE-GEN steward key
22
- (`pg-ed25519:dcdd244659e6d0e2426f2b504cb11590`) and is not older than one
23
- you have seen;
24
- 2. the code's namespace (bare `PG-…` or a four-letter prefix) belongs to one
25
- listed registry;
26
- 3. the decision's `operator_key_id` is one of that registry's listed keys, and
27
- the key its key set serves hashes to that fingerprint;
28
- 4. the Ed25519 signature over the canonical JSON verifies.
29
-
30
- What it does **not** check, and you still must (`PRE-GEN-SPEC.md` §5.4):
31
- that the decision answers your own request (V-4), has not expired (V-3),
32
- carries a verified user binding when you named a user (V-13), and that
33
- `disposition` is `allow` before you generate (P-5). A listing in the directory
34
- proves which registry may issue a code, not that it holds any right over a
35
- person or a work.
36
-
37
- Also exported: `verifyDirectory`, `Directory.checkSigner` / `registryFor` /
38
- `publicKey`, `verifyLicense` (subject signature plus operator countersignature),
39
- `verifySigned`, `canonicalJson`, `fingerprint`, `issuerOf`.
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 }): Promise<Directory>;
42
- export function loadDirectory(options?: { url?: string; minSequence?: number; fetch?: Fetch }): Promise<Directory>;
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-(?:(?:STD|SUB|PRM|RND)-)?[0-9]/;
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
- constructor(token, directory) {
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
- return r.key_fingerprints.includes(signerKeyId) ? "valid" : "issuer_mismatch";
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
- /** V-12: accept the directory only with the pinned steward's signature, its
184
- * invariants, and a sequence no lower than one already seen. */
185
- export async function verifyDirectory(directory, { stewardPublicKeyHex = STEWARD_PUBLIC_KEY_HEX, minSequence = 0 } = {}) {
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
- return Directory._make(directory);
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
- /** Step 4 in one call: is this registry decision genuine, and signed by a key of the
202
- * registry that owns `code`? Request matching and expiry (V-3, V-4, V-13) stay with the caller. */
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pregen/verify",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Verify PRE-GEN signatures against the signed registry directory, with the PRE-GEN steward key built in",
5
5
  "type": "module",
6
6
  "exports": {