@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/README.md +69 -10
- package/dist/authorizer.d.ts +41 -4
- package/dist/authorizer.d.ts.map +1 -1
- package/dist/builder.d.ts +6 -1
- package/dist/builder.d.ts.map +1 -1
- package/dist/crypto.d.ts +7 -0
- package/dist/crypto.d.ts.map +1 -1
- package/dist/datalog.d.ts +2 -0
- package/dist/datalog.d.ts.map +1 -1
- package/dist/index.d.ts +10 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +377 -118
- package/dist/parser.d.ts +13 -2
- package/dist/parser.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/authorizer.ts +123 -27
- package/src/builder.ts +31 -9
- package/src/crypto.ts +164 -18
- package/src/datalog.ts +10 -0
- package/src/index.ts +26 -2
- package/src/parser.ts +69 -2
- package/LICENSE +0 -21
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
|
-
|
|
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
|
package/dist/parser.d.ts.map
CHANGED
|
@@ -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;
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
|
649
|
+
return settled(result);
|
|
567
650
|
} catch (e) {
|
|
568
|
-
if (e instanceof ExecutionError)
|
|
569
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|