@statewalker/webrun-biscuit 0.1.0 → 0.2.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/dist/parser.d.ts CHANGED
@@ -6,6 +6,12 @@
6
6
  import { type Check, type Fact, type Op, type Predicate, type Rule, type Scope, type Term } from "./datalog.js";
7
7
  export declare class ParseError extends Error {
8
8
  }
9
+ /**
10
+ * A value bound to a `{name}` parameter. It becomes a TERM, never source text,
11
+ * so no string can change the shape of the program it is bound into.
12
+ */
13
+ export type ParamValue = string | number | bigint | boolean | null | Date | Uint8Array | readonly ParamValue[] | ReadonlySet<ParamValue>;
14
+ export type Params = Readonly<Record<string, ParamValue>>;
9
15
  export type Statement = {
10
16
  k: "fact";
11
17
  fact: Fact;
@@ -25,9 +31,13 @@ export type Statement = {
25
31
  };
26
32
  export declare class Parser {
27
33
  private readonly src;
34
+ private readonly params;
28
35
  private i;
29
36
  private readonly vars;
30
- constructor(src: string, vars?: Map<string, number>);
37
+ private readonly usedParams;
38
+ constructor(src: string, vars?: Map<string, number>, params?: Params);
39
+ /** names in `params` that the source never referred to */
40
+ unusedParameters(): string[];
31
41
  /** id -> name, for printing rules back out */
32
42
  variableNames(): Map<number, string>;
33
43
  varId(name: string): number;
@@ -45,6 +55,7 @@ export declare class Parser {
45
55
  private tryDate;
46
56
  /** a term that may be a variable (rule/check context) */
47
57
  term(allowVariables?: boolean): Term;
58
+ private parameter;
48
59
  private array;
49
60
  private setOrMap;
50
61
  private asMapKey;
@@ -76,5 +87,5 @@ export declare class Parser {
76
87
  statement(): Statement;
77
88
  parse(): Statement[];
78
89
  }
79
- export declare const parse: (src: string) => Statement[];
90
+ export declare const parse: (src: string, params?: Params) => Statement[];
80
91
  //# sourceMappingURL=parser.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../src/parser.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAEL,KAAK,KAAK,EAEV,KAAK,IAAI,EAGT,KAAK,EAAE,EACP,KAAK,SAAS,EACd,KAAK,IAAI,EACT,KAAK,KAAK,EACV,KAAK,IAAI,EAEV,MAAM,cAAc,CAAC;AAEtB,qBAAa,UAAW,SAAQ,KAAK;CAAG;AAExC,MAAM,MAAM,SAAS,GACjB;IAAE,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,IAAI,CAAA;CAAE,GACzB;IAAE,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,IAAI,CAAA;CAAE,GACzB;IAAE,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,KAAK,CAAA;CAAE,GAC5B;IAAE,CAAC,EAAE,QAAQ,CAAC;IAAC,IAAI,EAAE,OAAO,GAAG,MAAM,CAAC;IAAC,OAAO,EAAE,IAAI,EAAE,CAAA;CAAE,GACxD;IAAE,CAAC,EAAE,YAAY,CAAC;IAAC,MAAM,EAAE,KAAK,EAAE,CAAA;CAAE,CAAC;AAKzC,qBAAa,MAAM;IAIf,OAAO,CAAC,QAAQ,CAAC,GAAG;IAHtB,OAAO,CAAC,CAAC,CAAK;IACd,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAsB;IAC3C,YACmB,GAAG,EAAE,MAAM,EAC5B,IAAI,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,EAG3B;IAED,8CAA8C;IAC9C,aAAa,IAAI,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAInC;IAED,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAO1B;IAID,OAAO,CAAC,EAAE;IAgBV,OAAO,KAAK,GAAG,GAGd;IACD,OAAO,CAAC,IAAI;IAIZ,OAAO,CAAC,GAAG;IAKX,OAAO,CAAC,MAAM;IAGd,gEAAgE;IAChE,OAAO,CAAC,OAAO;IAQf,0EAA0E;IAC1E,OAAO,CAAC,YAAY;IAQpB,OAAO,CAAC,IAAI;IAYZ,OAAO,CAAC,MAAM;IA+Bd,OAAO,CAAC,OAAO;IAYf,yDAAyD;IACzD,IAAI,CAAC,cAAc,UAAO,GAAG,IAAI,CAkChC;IAED,OAAO,CAAC,KAAK;IAWb,OAAO,CAAC,QAAQ;IA4BhB,OAAO,CAAC,QAAQ;IAQhB,SAAS,CAAC,cAAc,UAAO,GAAG,SAAS,CAW1C;IAID,uEAAuE;IACvE,UAAU,IAAI,EAAE,EAAE,CAEjB;IAED,OAAO,CAAC,MAAM;IAcd,OAAO,CAAC,OAAO;IAcf,OAAO,CAAC,UAAU;IAsBlB,4DAA4D;IAC5D,OAAO,CAAC,YAAY;IASpB,OAAO,CAAC,MAAM;IAQd,OAAO,CAAC,KAAK;IAQb,OAAO,CAAC,MAAM;IAQd,OAAO,CAAC,QAAQ;IAYhB,0EAA0E;IAC1E,OAAO,CAAC,QAAQ;IAIhB,OAAO,CAAC,cAAc;IAatB,OAAO,CAAC,KAAK;IAQb,OAAO,CAAC,OAAO;IAWf,OAAO,CAAC,OAAO;IAiEf,OAAO,CAAC,OAAO;IAiBf,OAAO,CAAC,MAAM;IAsBd,yEAAyE;IACzE,OAAO,CAAC,QAAQ;IA0BhB,0EAA0E;IAC1E,OAAO,CAAC,YAAY;IAiBpB,OAAO,CAAC,OAAO;IASf,SAAS,IAAI,SAAS,CAgCrB;IAED,KAAK,IAAI,SAAS,EAAE,CAQnB;CACF;AAED,eAAO,MAAM,KAAK,QAAS,MAAM,KAAG,SAAS,EAA6B,CAAC"}
1
+ {"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../src/parser.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-biscuit",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Biscuit authorization tokens in pure TypeScript: proto2 codec, Ed25519 and secp256r1 signature chain, Datalog engine, parser, authorizer and builder. No WASM, no Node built-ins — runs in Node, browsers, Workers and Durable Objects.",
package/src/authorizer.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * mirroring the evaluation order of the reference implementation.
4
4
  */
5
5
 
6
- import { revocationIds, verifyToken } from "./crypto.js";
6
+ import { revocationIds, verifyToken, verifyTokenAsync } from "./crypto.js";
7
7
  import {
8
8
  AUTHORIZER,
9
9
  type Check,
@@ -24,9 +24,10 @@ import {
24
24
  trustedOriginsFromScopes,
25
25
  World,
26
26
  } from "./datalog.js";
27
- import { Parser, type Statement } from "./parser.js";
27
+ import { type Params, ParseError, Parser, type Statement } from "./parser.js";
28
28
  import { printCheck, printPolicy, printPredicate, printRule } from "./print.js";
29
29
  import {
30
+ type BiscuitMsg,
30
31
  type BlockMsg,
31
32
  decodeBiscuit,
32
33
  decodeBlock,
@@ -258,7 +259,24 @@ export function loadToken(
258
259
  ): LoadedToken {
259
260
  const token = decodeBiscuit(bytes);
260
261
  verifyToken(token, rootPublicKey, rootAlgorithm);
262
+ return toLoadedToken(token);
263
+ }
264
+
265
+ /**
266
+ * `loadToken`, verifying signatures with the platform's WebCrypto Ed25519 where
267
+ * available — see `verifyTokenAsync`. Same result, same errors.
268
+ */
269
+ export async function loadTokenAsync(
270
+ bytes: Uint8Array,
271
+ rootPublicKey: Uint8Array,
272
+ rootAlgorithm: 0 | 1 = 0,
273
+ ): Promise<LoadedToken> {
274
+ const token = decodeBiscuit(bytes);
275
+ await verifyTokenAsync(token, rootPublicKey, rootAlgorithm);
276
+ return toLoadedToken(token);
277
+ }
261
278
 
279
+ function toLoadedToken(token: BiscuitMsg): LoadedToken {
262
280
  const signed = [token.authority, ...token.blocks];
263
281
  const decoded = signed.map((sb) => decodeBlock(sb.block));
264
282
 
@@ -327,8 +345,8 @@ function headVariablesAreBound(rule: Rule): boolean {
327
345
  /* ------------------------------------------------------------- authorizer */
328
346
 
329
347
  export type FailedCheck =
330
- | { source: "authorizer"; checkId: number }
331
- | { source: "block"; blockId: number; checkId: number };
348
+ | { source: "authorizer"; checkId: number; rule: string }
349
+ | { source: "block"; blockId: number; checkId: number; rule: string };
332
350
 
333
351
  export type AuthorizationResult =
334
352
  | { kind: "ok"; policy: number }
@@ -360,7 +378,12 @@ interface AuthorizerCode {
360
378
  varNames: Map<number, string>;
361
379
  }
362
380
 
363
- export function parseAuthorizer(src: string): AuthorizerCode {
381
+ /**
382
+ * Parse authorizer (or block) source. `{name}` parameters are bound from
383
+ * `params` as terms; an unbound or an unused parameter is a `ParseError`, as it
384
+ * is in the reference.
385
+ */
386
+ export function parseAuthorizer(src: string, params?: Params): AuthorizerCode {
364
387
  const out: AuthorizerCode = {
365
388
  facts: [],
366
389
  rules: [],
@@ -369,8 +392,11 @@ export function parseAuthorizer(src: string): AuthorizerCode {
369
392
  scopes: [],
370
393
  varNames: new Map(),
371
394
  };
372
- const parser = new Parser(src);
395
+ const parser = new Parser(src, undefined, params);
373
396
  const statements: Statement[] = parser.parse();
397
+ const unused = parser.unusedParameters();
398
+ if (unused.length > 0)
399
+ throw new ParseError(`unused parameter${unused.length > 1 ? "s" : ""} {${unused.join("}, {")}}`);
374
400
  out.varNames = parser.variableNames();
375
401
  for (const st of statements) {
376
402
  if (st.k === "fact") out.facts.push(st.fact);
@@ -386,14 +412,35 @@ export interface AuthorizeOptions {
386
412
  limits?: RunLimits;
387
413
  /** extern functions callable as `.extern::name(...)` */
388
414
  externs?: Map<string, ExternFn>;
415
+ /** values for `{name}` parameters in the authorizer code */
416
+ params?: Params;
417
+ }
418
+
419
+ export interface QueryOptions {
420
+ /** values for `{name}` parameters in the query rule */
421
+ params?: Params;
422
+ }
423
+
424
+ /** One authorizer run: its result, plus read access to the world it produced. */
425
+ export interface Evaluation {
426
+ readonly result: AuthorizationResult;
427
+ /**
428
+ * Every distinct fact `rule` derives from the evaluated world — the
429
+ * reference's `Authorizer::query`. The rule sees what the authorizer sees
430
+ * (authority block and authorizer facts) unless it says `trusting ...`.
431
+ * Throws when the evaluation itself did not complete.
432
+ */
433
+ query(rule: string, options?: QueryOptions): Predicate[];
434
+ /** The post-run world, in the shape the official sample corpus records it. */
435
+ snapshot(): WorldSnapshot;
389
436
  }
390
437
 
391
438
  export function authorize(
392
- token: LoadedToken,
439
+ token: LoadedToken | null,
393
440
  authorizerSrc: string,
394
441
  options: AuthorizeOptions = {},
395
442
  ): AuthorizationResult {
396
- return authorizeDetailed(token, authorizerSrc, options).result;
443
+ return evaluate(token, authorizerSrc, options).result;
397
444
  }
398
445
 
399
446
  const EMPTY_WORLD: WorldSnapshot = { facts: [], rules: [], checks: [], policies: [] };
@@ -401,16 +448,32 @@ const EMPTY_WORLD: WorldSnapshot = { facts: [], rules: [], checks: [], policies:
401
448
  /** Same as `authorize`, and also returns the post-run world, in the shape the
402
449
  * official sample corpus records it. */
403
450
  export function authorizeDetailed(
404
- token: LoadedToken,
451
+ token: LoadedToken | null,
405
452
  authorizerSrc: string,
406
453
  options: AuthorizeOptions = {},
407
454
  ): AuthorizeDetails {
455
+ const evaluation = evaluate(token, authorizerSrc, options);
456
+ return { result: evaluation.result, world: evaluation.snapshot() };
457
+ }
458
+
459
+ const NO_TOKEN: LoadedToken = { blocks: [], publicKeyToBlockIds: new Map(), revocationIds: [] };
460
+
461
+ /**
462
+ * Run the authorizer against a verified token — or against no token at all,
463
+ * for a decision made from the authorizer's own facts and rules.
464
+ */
465
+ export function evaluate(
466
+ loadedToken: LoadedToken | null,
467
+ authorizerSrc: string,
468
+ options: AuthorizeOptions = {},
469
+ ): Evaluation {
470
+ const token = loadedToken ?? NO_TOKEN;
408
471
  const limits = options.limits ?? DEFAULT_LIMITS;
409
472
  let code: AuthorizerCode;
410
473
  try {
411
- code = parseAuthorizer(authorizerSrc);
474
+ code = parseAuthorizer(authorizerSrc, options.params);
412
475
  } catch (e) {
413
- return { result: { kind: "format", error: (e as Error).message }, world: EMPTY_WORLD };
476
+ return unusable({ kind: "format", error: (e as Error).message });
414
477
  }
415
478
 
416
479
  const world = new World();
@@ -426,14 +489,11 @@ export function authorizeDetailed(
426
489
  for (const f of block.facts) world.addFact(origin, f);
427
490
  for (const r of block.rules) {
428
491
  if (!headVariablesAreBound(r))
429
- return {
430
- result: {
431
- kind: "invalidBlockRule",
432
- blockId: 0, // the reference implementation always reports 0 here
433
- rule: printRule(r, (v) => block.varNames.get(v) ?? String(v)),
434
- },
435
- world: EMPTY_WORLD,
436
- };
492
+ return unusable({
493
+ kind: "invalidBlockRule",
494
+ blockId: 0, // the reference implementation always reports 0 here
495
+ rule: printRule(r, (v) => block.varNames.get(v) ?? String(v)),
496
+ });
437
497
  world.addRule(id, trustedOriginsFromScopes(r.scopes, trusted, id, keys), r);
438
498
  }
439
499
  }
@@ -501,11 +561,30 @@ export function authorizeDetailed(
501
561
  };
502
562
  };
503
563
 
564
+ let failure: ExecutionError | undefined;
565
+ const settled = (result: AuthorizationResult): Evaluation => ({
566
+ result,
567
+ snapshot,
568
+ query(src: string, queryOptions: QueryOptions = {}): Predicate[] {
569
+ if (failure) throw failure;
570
+ const parsed = parseAuthorizer(`${src};`, queryOptions.params);
571
+ const count =
572
+ parsed.facts.length + parsed.rules.length + parsed.checks.length + parsed.policies.length;
573
+ if (count !== 1 || parsed.rules.length !== 1)
574
+ throw new ParseError("a query must be exactly one rule");
575
+ const rule = parsed.rules[0];
576
+ const trusted = trustedOriginsFromScopes(rule.scopes, authorizerTrusted, AUTHORIZER, keys);
577
+ return world.query(rule, trusted);
578
+ },
579
+ });
580
+
504
581
  try {
505
582
  world.run(limits);
506
583
  } catch (e) {
507
- if (e instanceof ExecutionError)
508
- return { result: { kind: "execution", error: e.kind }, world: snapshot() };
584
+ if (e instanceof ExecutionError) {
585
+ failure = e;
586
+ return settled({ kind: "execution", error: e.kind });
587
+ }
509
588
  throw e;
510
589
  }
511
590
 
@@ -528,13 +607,17 @@ export function authorizeDetailed(
528
607
  // 1. authorizer checks
529
608
  code.checks.forEach((check, i) => {
530
609
  if (!runCheck(check, AUTHORIZER, authorizerTrusted))
531
- errors.push({ source: "authorizer", checkId: i });
610
+ errors.push({
611
+ source: "authorizer",
612
+ checkId: i,
613
+ rule: printCheck(check, name(AUTHORIZER)),
614
+ });
532
615
  });
533
616
 
534
617
  // 2. authority block checks
535
618
  token.blocks[0]?.checks.forEach((check, j) => {
536
619
  if (!runCheck(check, 0, blockTrusted[0]))
537
- errors.push({ source: "block", blockId: 0, checkId: j });
620
+ errors.push({ source: "block", blockId: 0, checkId: j, rule: printCheck(check, name(0)) });
538
621
  });
539
622
 
540
623
  // 3. policies (first match wins)
@@ -553,7 +636,7 @@ export function authorizeDetailed(
553
636
  for (let id = 1; id < token.blocks.length; id++) {
554
637
  token.blocks[id].checks.forEach((check, j) => {
555
638
  if (!runCheck(check, id, blockTrusted[id]))
556
- errors.push({ source: "block", blockId: id, checkId: j });
639
+ errors.push({ source: "block", blockId: id, checkId: j, rule: printCheck(check, name(id)) });
557
640
  });
558
641
  }
559
642
 
@@ -563,14 +646,27 @@ export function authorizeDetailed(
563
646
  : "allow" in policyResult && errors.length === 0
564
647
  ? { kind: "ok", policy: policyResult.allow }
565
648
  : { kind: "unauthorized", policy: policyResult, checks: errors };
566
- return { result, world: snapshot() };
649
+ return settled(result);
567
650
  } catch (e) {
568
- if (e instanceof ExecutionError)
569
- return { result: { kind: "execution", error: e.kind }, world: snapshot() };
651
+ if (e instanceof ExecutionError) {
652
+ failure = e;
653
+ return settled({ kind: "execution", error: e.kind });
654
+ }
570
655
  throw e;
571
656
  }
572
657
  }
573
658
 
659
+ /** An evaluation that never produced a world: the result says why, and queries refuse. */
660
+ function unusable(result: AuthorizationResult): Evaluation {
661
+ return {
662
+ result,
663
+ snapshot: () => EMPTY_WORLD,
664
+ query() {
665
+ throw new Error(`cannot query an evaluation that ended in ${result.kind}`);
666
+ },
667
+ };
668
+ }
669
+
574
670
  function compareOrigins(a: (number | null)[], b: (number | null)[]): number {
575
671
  for (let i = 0; i < Math.max(a.length, b.length); i++) {
576
672
  const x = a[i] === undefined ? -Infinity : (a[i] ?? -1);
package/src/builder.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  type ScopeMsg,
31
31
  type TermMsg,
32
32
  } from "./proto.js";
33
+ import type { Params } from "./parser.js";
33
34
  import { DATALOG_3_2, requiredVersion } from "./version.js";
34
35
 
35
36
  const SIGNATURE_VERSION = 1;
@@ -52,8 +53,14 @@ class SymbolWriter {
52
53
  constructor(
53
54
  private readonly known: string[] = [],
54
55
  private readonly knownKeys: string[] = [],
56
+ private readonly varNames: ReadonlyMap<number, string> = new Map(),
55
57
  ) {}
56
58
 
59
+ /** A variable's wire id is the symbol index of its NAME, not the parser's local id. */
60
+ variable(id: number): number {
61
+ return this.insert(this.varNames.get(id) ?? String(id));
62
+ }
63
+
57
64
  insert(s: string): number {
58
65
  const d = DEFAULT_SYMBOLS.indexOf(s);
59
66
  if (d >= 0) return d;
@@ -85,7 +92,7 @@ const parseKeyString = (key: string): PublicKeyMsg => {
85
92
  function toTerm(t: Term, w: SymbolWriter): TermMsg {
86
93
  switch (t.t) {
87
94
  case "var":
88
- return { kind: "variable", value: t.v };
95
+ return { kind: "variable", value: w.variable(t.v) };
89
96
  case "int":
90
97
  return { kind: "integer", value: t.v };
91
98
  case "str":
@@ -130,7 +137,11 @@ const toOps = (ops: Op[], w: SymbolWriter): OpMsg[] =>
130
137
  case "binary":
131
138
  return { kind: "binary", op: op.op, ffiName: op.ffi ? w.insert(op.ffi) : undefined };
132
139
  case "closure":
133
- return { kind: "closure", params: op.params, ops: toOps(op.ops, w) };
140
+ return {
141
+ kind: "closure",
142
+ params: op.params.map((p) => w.variable(p)),
143
+ ops: toOps(op.ops, w),
144
+ };
134
145
  default:
135
146
  // unreachable for a well-typed Op; loud rather than `undefined`
136
147
  throw new Error(`unknown expression op kind ${(op as { kind: string }).kind}`);
@@ -158,6 +169,8 @@ export interface BlockContent {
158
169
  rules: Rule[];
159
170
  checks: Check[];
160
171
  scopes: Scope[];
172
+ /** variable id -> name; ids without a name are written as their decimal id */
173
+ varNames?: ReadonlyMap<number, string>;
161
174
  }
162
175
 
163
176
  export function buildBlockMsg(
@@ -166,7 +179,7 @@ export function buildBlockMsg(
166
179
  knownKeys: string[] = [],
167
180
  minVersion = 0,
168
181
  ): BlockMsg {
169
- const w = new SymbolWriter(knownSymbols, knownKeys);
182
+ const w = new SymbolWriter(knownSymbols, knownKeys, content.varNames);
170
183
  const facts = content.facts.map((f) => toPredicate(f.predicate, w));
171
184
  const rules = content.rules.map((r) => toRule(r, w));
172
185
  const checks = content.checks.map((c) => ({
@@ -185,11 +198,17 @@ export function buildBlockMsg(
185
198
  };
186
199
  }
187
200
 
188
- const contentFromCode = (code: string): BlockContent => {
189
- const parsed = parseAuthorizer(code);
201
+ const contentFromCode = (code: string, params?: Params): BlockContent => {
202
+ const parsed = parseAuthorizer(code, params);
190
203
  if (parsed.policies.length)
191
204
  throw new BuilderError("allow/deny policies belong to the authorizer, not to a block");
192
- return { facts: parsed.facts, rules: parsed.rules, checks: parsed.checks, scopes: parsed.scopes };
205
+ return {
206
+ facts: parsed.facts,
207
+ rules: parsed.rules,
208
+ checks: parsed.checks,
209
+ scopes: parsed.scopes,
210
+ varNames: parsed.varNames,
211
+ };
193
212
  };
194
213
 
195
214
  /* ---------------------------------------------------- accumulated tables */
@@ -216,6 +235,8 @@ export interface BuildOptions {
216
235
  /** supply a next keypair instead of generating one (tests, determinism) */
217
236
  nextKeypair?: Keypair;
218
237
  algorithm?: 0 | 1;
238
+ /** values for `{name}` parameters in the block code */
239
+ params?: Params;
219
240
  }
220
241
 
221
242
  /** Mint a new token whose authority block holds `code`. */
@@ -225,7 +246,7 @@ export function buildToken(
225
246
  options: BuildOptions = {},
226
247
  ): Uint8Array {
227
248
  const algorithm = options.algorithm ?? 0;
228
- const content = typeof code === "string" ? contentFromCode(code) : code;
249
+ const content = typeof code === "string" ? contentFromCode(code, options.params) : code;
229
250
  const blockBytes = encodeBlock(buildBlockMsg(content));
230
251
  const next = options.nextKeypair ?? generateKeypair(algorithm);
231
252
  const nextKey: PublicKeyMsg = { algorithm, key: next.publicKey };
@@ -257,7 +278,7 @@ export function attenuate(
257
278
  const currentAlgorithm = previous.nextKey.algorithm;
258
279
 
259
280
  const tables = knownTables(token);
260
- const content = typeof code === "string" ? contentFromCode(code) : code;
281
+ const content = typeof code === "string" ? contentFromCode(code, options.params) : code;
261
282
  const blockBytes = encodeBlock(buildBlockMsg(content, tables.symbols, tables.keys));
262
283
 
263
284
  const next = options.nextKeypair ?? generateKeypair(algorithm);
@@ -312,8 +333,9 @@ export function thirdPartyBlock(
312
333
  externalSecret: Uint8Array,
313
334
  code: string | BlockContent,
314
335
  algorithm: 0 | 1 = 0,
336
+ params?: Params,
315
337
  ): ThirdPartyResponse {
316
- const content = typeof code === "string" ? contentFromCode(code) : code;
338
+ const content = typeof code === "string" ? contentFromCode(code, params) : code;
317
339
  // a third-party block carries its own symbol table, so it starts from empty,
318
340
  // and third-party blocks themselves require datalog v3.2+
319
341
  const blockBytes = encodeBlock(buildBlockMsg(content, [], [], DATALOG_3_2));
package/src/crypto.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { ed25519 } from "@noble/curves/ed25519.js";
6
6
  import { p256 } from "@noble/curves/nist.js";
7
+ import { fromBase64 } from "./base64.js";
7
8
  import type { BiscuitMsg, PublicKeyMsg, SignedBlock } from "./proto.js";
8
9
 
9
10
  export class SignatureError extends Error {}
@@ -144,9 +145,27 @@ export { bytesEqual };
144
145
 
145
146
  /* --------------------------------------------------------------- verifying */
146
147
 
147
- /** Verifies the full signature chain and the proof. Throws on failure. */
148
- export function verifyToken(token: BiscuitMsg, rootPublicKey: Uint8Array, rootAlgorithm = 0): void {
148
+ interface SignatureCheck {
149
+ key: PublicKeyMsg;
150
+ payload: Uint8Array;
151
+ signature: Uint8Array;
152
+ error: string;
153
+ }
154
+
155
+ /**
156
+ * Everything verifying a token requires, as data: the signatures to check, in
157
+ * chain order, and — for an unsealed token — the proof secret whose public key
158
+ * must equal the last block's next key. Structural problems throw here, before
159
+ * any signature is looked at. `verifyToken` and `verifyTokenAsync` both consume
160
+ * this, so neither can skip a check the other makes.
161
+ */
162
+ function chainChecks(
163
+ token: BiscuitMsg,
164
+ rootPublicKey: Uint8Array,
165
+ rootAlgorithm: number,
166
+ ): { checks: SignatureCheck[]; proof?: { secret: Uint8Array; key: PublicKeyMsg } } {
149
167
  const root: PublicKeyMsg = { algorithm: rootAlgorithm as 0 | 1, key: rootPublicKey };
168
+ const checks: SignatureCheck[] = [];
150
169
 
151
170
  if (token.authority.externalSignature)
152
171
  throw new SignatureError("the authority block must not carry an external signature");
@@ -160,8 +179,12 @@ export function verifyToken(token: BiscuitMsg, rootPublicKey: Uint8Array, rootAl
160
179
  : (() => {
161
180
  throw new SignatureError(`unsupported block version ${authVersion}`);
162
181
  })();
163
- if (!verifySignature(root, authPayload, token.authority.signature))
164
- throw new SignatureError("invalid authority block signature");
182
+ checks.push({
183
+ key: root,
184
+ payload: authPayload,
185
+ signature: token.authority.signature,
186
+ error: "invalid authority block signature",
187
+ });
165
188
 
166
189
  let currentKey = token.authority.nextKey;
167
190
  let previousSignature = token.authority.signature;
@@ -175,16 +198,21 @@ export function verifyToken(token: BiscuitMsg, rootPublicKey: Uint8Array, rootAl
175
198
  payload = blockPayloadV1(block.block, block.nextKey, externalSig, previousSignature, version);
176
199
  else throw new SignatureError(`unsupported block version ${version}`);
177
200
 
178
- if (!verifySignature(currentKey, payload, block.signature))
179
- throw new SignatureError("invalid block signature");
201
+ checks.push({
202
+ key: currentKey,
203
+ payload,
204
+ signature: block.signature,
205
+ error: "invalid block signature",
206
+ });
180
207
 
181
208
  if (block.externalSignature) {
182
209
  if (version !== 1) throw new SignatureError("unsupported third party block version");
183
- const ext = externalPayloadV1(block.block, previousSignature, version);
184
- if (
185
- !verifySignature(block.externalSignature.publicKey, ext, block.externalSignature.signature)
186
- )
187
- throw new SignatureError("invalid external signature");
210
+ checks.push({
211
+ key: block.externalSignature.publicKey,
212
+ payload: externalPayloadV1(block.block, previousSignature, version),
213
+ signature: block.externalSignature.signature,
214
+ error: "invalid external signature",
215
+ });
188
216
  }
189
217
 
190
218
  currentKey = block.nextKey;
@@ -192,13 +220,131 @@ export function verifyToken(token: BiscuitMsg, rootPublicKey: Uint8Array, rootAl
192
220
  }
193
221
 
194
222
  if (token.proof.kind === "nextSecret") {
195
- const derived = publicKeyFromSecret(token.proof.value, currentKey.algorithm);
196
- if (!bytesEqual(derived, currentKey.key))
197
- throw new SignatureError("the last public key does not match the private key");
198
- } else {
199
- const last = token.blocks.length ? token.blocks[token.blocks.length - 1] : token.authority;
200
- if (!verifySignature(currentKey, sealPayloadV0(last), token.proof.value))
201
- throw new SignatureError("invalid seal signature");
223
+ return { checks, proof: { secret: token.proof.value, key: currentKey } };
224
+ }
225
+ const last = token.blocks.length ? token.blocks[token.blocks.length - 1] : token.authority;
226
+ checks.push({
227
+ key: currentKey,
228
+ payload: sealPayloadV0(last),
229
+ signature: token.proof.value,
230
+ error: "invalid seal signature",
231
+ });
232
+ return { checks };
233
+ }
234
+
235
+ /** Verifies the full signature chain and the proof. Throws on failure. */
236
+ export function verifyToken(token: BiscuitMsg, rootPublicKey: Uint8Array, rootAlgorithm = 0): void {
237
+ const { checks, proof } = chainChecks(token, rootPublicKey, rootAlgorithm);
238
+ for (const check of checks)
239
+ if (!verifySignature(check.key, check.payload, check.signature))
240
+ throw new SignatureError(check.error);
241
+ if (proof && !bytesEqual(publicKeyFromSecret(proof.secret, proof.key.algorithm), proof.key.key))
242
+ throw new SignatureError("the last public key does not match the private key");
243
+ }
244
+
245
+ /**
246
+ * `verifyToken`, using the platform's WebCrypto for Ed25519 where it offers it
247
+ * — about ten times faster than the pure-JS path in Node and current browsers.
248
+ * secp256r1, and any runtime without WebCrypto Ed25519, fall back to
249
+ * `@noble/curves`, so the result never depends on where it runs.
250
+ */
251
+ export async function verifyTokenAsync(
252
+ token: BiscuitMsg,
253
+ rootPublicKey: Uint8Array,
254
+ rootAlgorithm = 0,
255
+ ): Promise<void> {
256
+ const { checks, proof } = chainChecks(token, rootPublicKey, rootAlgorithm);
257
+ const [verdicts, derived] = await Promise.all([
258
+ Promise.all(checks.map((c) => verifySignatureAsync(c.key, c.payload, c.signature))),
259
+ proof ? publicKeyFromSecretAsync(proof.secret, proof.key.algorithm) : undefined,
260
+ ]);
261
+ verdicts.forEach((ok, i) => {
262
+ if (!ok) throw new SignatureError(checks[i].error);
263
+ });
264
+ if (proof && !bytesEqual(derived as Uint8Array, proof.key.key))
265
+ throw new SignatureError("the last public key does not match the private key");
266
+ }
267
+
268
+ /* ------------------------------------------------------ WebCrypto Ed25519 */
269
+
270
+ type Subtle = {
271
+ importKey(
272
+ format: string,
273
+ data: Uint8Array,
274
+ algorithm: { name: string },
275
+ extractable: boolean,
276
+ usages: string[],
277
+ ): Promise<unknown>;
278
+ verify(algorithm: string, key: unknown, signature: Uint8Array, data: Uint8Array): Promise<boolean>;
279
+ exportKey(format: "jwk", key: unknown): Promise<{ x?: string }>;
280
+ };
281
+
282
+ const subtle = (globalThis as { crypto?: { subtle?: Subtle } }).crypto?.subtle;
283
+
284
+ /** PKCS#8 wrapping of a raw 32-byte Ed25519 seed (RFC 8410) */
285
+ const PKCS8_ED25519 = Uint8Array.from([
286
+ 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
287
+ ]);
288
+
289
+ let ed25519Native: Promise<boolean> | undefined;
290
+
291
+ /** Whether this runtime's WebCrypto verifies Ed25519, and does so correctly. Probed once. */
292
+ function nativeEd25519(): Promise<boolean> {
293
+ ed25519Native ??= (async () => {
294
+ if (!subtle) return false;
295
+ try {
296
+ const secret = new Uint8Array(32).fill(7);
297
+ const message = ascii("webrun-biscuit");
298
+ const signature = ed25519.sign(message, secret);
299
+ const key = await subtle.importKey(
300
+ "raw",
301
+ ed25519.getPublicKey(secret),
302
+ { name: "Ed25519" },
303
+ false,
304
+ ["verify"],
305
+ );
306
+ const forged = signature.slice();
307
+ forged[0] ^= 1;
308
+ return (
309
+ (await subtle.verify("Ed25519", key, signature, message)) &&
310
+ !(await subtle.verify("Ed25519", key, forged, message))
311
+ );
312
+ } catch {
313
+ return false;
314
+ }
315
+ })();
316
+ return ed25519Native;
317
+ }
318
+
319
+ async function verifySignatureAsync(
320
+ key: PublicKeyMsg,
321
+ payload: Uint8Array,
322
+ sig: Uint8Array,
323
+ ): Promise<boolean> {
324
+ if (key.algorithm !== ALG_ED25519 || !subtle || !(await nativeEd25519()))
325
+ return verifySignature(key, payload, sig);
326
+ if (key.key.length !== 32 || sig.length !== 64) return false;
327
+ try {
328
+ const imported = await subtle.importKey("raw", key.key, { name: "Ed25519" }, false, ["verify"]);
329
+ return await subtle.verify("Ed25519", imported, sig, payload);
330
+ } catch {
331
+ return false;
332
+ }
333
+ }
334
+
335
+ async function publicKeyFromSecretAsync(secret: Uint8Array, algorithm: number): Promise<Uint8Array> {
336
+ if (algorithm !== ALG_ED25519 || secret.length !== 32 || !subtle || !(await nativeEd25519()))
337
+ return publicKeyFromSecret(secret, algorithm);
338
+ try {
339
+ const pkcs8 = new Uint8Array(PKCS8_ED25519.length + 32);
340
+ pkcs8.set(PKCS8_ED25519);
341
+ pkcs8.set(secret, PKCS8_ED25519.length);
342
+ const key = await subtle.importKey("pkcs8", pkcs8, { name: "Ed25519" }, true, ["sign"]);
343
+ const { x } = await subtle.exportKey("jwk", key);
344
+ if (x === undefined) return publicKeyFromSecret(secret, algorithm);
345
+ return fromBase64(x);
346
+ } catch {
347
+ return publicKeyFromSecret(secret, algorithm);
202
348
  }
203
349
  }
204
350