@treeship/verify 0.31.9 → 0.31.10
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 +30 -1
- package/dist/index.d.ts +79 -0
- package/dist/index.js +499 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ The only dependency is `@treeship/core-wasm` (the compiled Rust core, under 170
|
|
|
12
12
|
|
|
13
13
|
## API
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
`verifyReceipt`, `verifyCertificate` and `crossVerify` accept a parsed object, a JSON string, or a URL. `verifyPackage` takes the files of a `.treeship` package.
|
|
16
16
|
|
|
17
17
|
### `verifyReceipt(target)`
|
|
18
18
|
|
|
@@ -66,6 +66,35 @@ if (result.ok) {
|
|
|
66
66
|
}
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
+
### `verifyPackage(files, { pinnedKeys? })`
|
|
70
|
+
|
|
71
|
+
Verifies a whole `.treeship` package, not just its receipt:
|
|
72
|
+
- every artifact envelope's Ed25519 signatures, in core-wasm (the same code as the CLI)
|
|
73
|
+
- each envelope against the receipt's `artifact_id` and full `digest`
|
|
74
|
+
- the signed close record (`record.json`) against the exact bytes of `receipt.json`
|
|
75
|
+
|
|
76
|
+
`files` maps each path inside the package (`receipt.json`, `record.json`, `keys.json`, `artifacts/<id>.json`, ...) to its unmodified bytes.
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
import { verifyPackage } from '@treeship/verify';
|
|
80
|
+
|
|
81
|
+
const result = await verifyPackage(files, {
|
|
82
|
+
// Keys you got from a source you trust, not from the package.
|
|
83
|
+
pinnedKeys: { key_57e0c8ba2b2bc32c: 'ed25519:HwiNphKvOWVcuI_-kv8hNz6Ahtt8n1LzngHDJpl5WJI' },
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The verdict uses the CLI's vocabulary:
|
|
88
|
+
|
|
89
|
+
| verdict | means |
|
|
90
|
+
|---|---|
|
|
91
|
+
| `verified` | Every signature holds under a key you pinned, every artifact matches the receipt, and the close record binds this receipt.json. |
|
|
92
|
+
| `signatures-pass` | The same, but at least one signer is known only from the package's own `keys.json`, which comes from the same place as the signatures. |
|
|
93
|
+
| `structural-pass` | Structure only. Either no envelopes were given (`scope: 'receipt-only'`, which includes passing `receipt.json` alone), or the package holds kinds whose rules this library doesn't evaluate: approvals, endorsements, room invitations and participants (`scope: 'partial'`). Run `treeship package verify` for those. |
|
|
94
|
+
| `failed` | A checked signature, id, digest or binding does not hold; a listed envelope or the close record is missing; or `keys.json` contradicts a key you pinned. |
|
|
95
|
+
|
|
96
|
+
`tests/vectors/packages` runs every honest and tampered package through it: the `verify_js` and `verify_js_pinned` columns of `expected.json`. A tampered package is never `signatures-pass` or `verified`.
|
|
97
|
+
|
|
69
98
|
## Runtime compatibility
|
|
70
99
|
|
|
71
100
|
| Runtime | Supported |
|
package/dist/index.d.ts
CHANGED
|
@@ -23,6 +23,23 @@ export interface TrustRootsBundle {
|
|
|
23
23
|
}
|
|
24
24
|
/** Accepted input shapes across all exported functions. */
|
|
25
25
|
export type VerifyTarget = string | URL | Record<string, unknown>;
|
|
26
|
+
/**
|
|
27
|
+
* The JSON API URL for a receipt URL a person pasted.
|
|
28
|
+
*
|
|
29
|
+
* `/receipt/<id>` (the human page) becomes `/v1/receipt/<id>`; `/v1/receipt/<id>`
|
|
30
|
+
* and the site's `/api/receipt/<id>` mirror are already the API and are kept.
|
|
31
|
+
* The host is never rewritten, a trailing slash is dropped, the query string
|
|
32
|
+
* is kept and the fragment dropped. Anything else throws rather than being
|
|
33
|
+
* fetched. Through 0.31.9 this was a blind `replace('/receipt/', …)`, which
|
|
34
|
+
* turned the documented `https://api.treeship.dev/v1/receipt/<id>` into
|
|
35
|
+
* `/v1/v1/receipt/<id>` and a 404. The rule is shared with the CLI
|
|
36
|
+
* (`receipt_api_url` in verify_external.rs) through
|
|
37
|
+
* `tests/vectors/receipt-urls.json`; change both or neither.
|
|
38
|
+
*
|
|
39
|
+
* @internal Exported for the shared-vector test; not part of the package's
|
|
40
|
+
* supported API.
|
|
41
|
+
*/
|
|
42
|
+
export declare function receiptApiUrl(raw: string): string;
|
|
26
43
|
export interface VerifyCheck {
|
|
27
44
|
step: string;
|
|
28
45
|
status: 'pass' | 'fail' | 'warn';
|
|
@@ -229,3 +246,65 @@ export declare function verifyPresentation(presentation: Record<string, unknown>
|
|
|
229
246
|
nonce?: string;
|
|
230
247
|
now?: Date | string;
|
|
231
248
|
}): Promise<PresentationVerdict>;
|
|
249
|
+
/**
|
|
250
|
+
* The files of a `.treeship` package directory, keyed by their path inside
|
|
251
|
+
* it (`receipt.json`, `record.json`, `keys.json`, `artifacts/<id>.json`, ...).
|
|
252
|
+
* Values are the exact bytes; `receipt.json` is hashed as given, so pass file
|
|
253
|
+
* contents unmodified. A map containing only `receipt.json` is valid input and
|
|
254
|
+
* verifies structure only.
|
|
255
|
+
*/
|
|
256
|
+
export type PackageFiles = Record<string, string | Uint8Array>;
|
|
257
|
+
export interface VerifyPackageOptions {
|
|
258
|
+
/**
|
|
259
|
+
* Keys you trust, by key id: `{ key_...: 'ed25519:<base64url>' }` (the bare
|
|
260
|
+
* base64url also works). A signature counts toward `verified` only under a
|
|
261
|
+
* key pinned here. Keys the package carries in keys.json are checked too,
|
|
262
|
+
* but they come from the same place as the signatures, so they can at most
|
|
263
|
+
* give `signatures-pass`.
|
|
264
|
+
*/
|
|
265
|
+
pinnedKeys?: Record<string, string>;
|
|
266
|
+
}
|
|
267
|
+
export interface PackageArtifactResult {
|
|
268
|
+
artifact_id: string;
|
|
269
|
+
payload_type?: string;
|
|
270
|
+
/** `pass`: every signature verifies and the envelope matches the receipt's
|
|
271
|
+
* id and full digest. `skipped`: a kind signed over its own canonical
|
|
272
|
+
* bytes (a countersigned room participant) that only the CLI checks. */
|
|
273
|
+
status: 'pass' | 'fail' | 'missing' | 'skipped';
|
|
274
|
+
signers: {
|
|
275
|
+
keyid: string;
|
|
276
|
+
key: 'pinned' | 'package' | 'unknown';
|
|
277
|
+
}[];
|
|
278
|
+
detail?: string;
|
|
279
|
+
}
|
|
280
|
+
export interface VerifyPackageResult {
|
|
281
|
+
/**
|
|
282
|
+
* Same vocabulary as `treeship package verify`:
|
|
283
|
+
* - `verified`: every envelope and the close record verify under keys you
|
|
284
|
+
* pinned, every artifact matches the receipt, and the receipt is the one
|
|
285
|
+
* the close record signed.
|
|
286
|
+
* - `signatures-pass`: the same, but at least one signer is known only
|
|
287
|
+
* from the package's own keys.json.
|
|
288
|
+
* - `structural-pass`: structure only. Either no envelopes were given
|
|
289
|
+
* (`scope: 'receipt-only'`), or the package holds artifact kinds whose
|
|
290
|
+
* rules this library doesn't evaluate (`scope: 'partial'`; run
|
|
291
|
+
* `treeship package verify` for those).
|
|
292
|
+
* - `failed`: something checkable did not hold.
|
|
293
|
+
*/
|
|
294
|
+
verdict: 'verified' | 'signatures-pass' | 'structural-pass' | 'failed';
|
|
295
|
+
scope: 'receipt-only' | 'partial' | 'signatures';
|
|
296
|
+
checks: VerifyCheck[];
|
|
297
|
+
artifacts: PackageArtifactResult[];
|
|
298
|
+
/** Payload kinds present whose semantics are not checked here. */
|
|
299
|
+
unevaluated_kinds: string[];
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Verify a `.treeship` package: every artifact envelope's Ed25519
|
|
303
|
+
* signatures, each envelope's id and full digest against the receipt, and the
|
|
304
|
+
* signed close record (record.json) against the exact bytes of receipt.json.
|
|
305
|
+
* Signature math runs in core-wasm, the same code as the CLI.
|
|
306
|
+
*
|
|
307
|
+
* Given only receipt.json, the result is `structural-pass` with
|
|
308
|
+
* `scope: 'receipt-only'`: no signature was checked.
|
|
309
|
+
*/
|
|
310
|
+
export declare function verifyPackage(files: PackageFiles, opts?: VerifyPackageOptions): Promise<VerifyPackageResult>;
|
package/dist/index.js
CHANGED
|
@@ -27,14 +27,72 @@ async function loadWasm() {
|
|
|
27
27
|
wasmBindings = mod;
|
|
28
28
|
return mod;
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* The JSON API URL for a receipt URL a person pasted.
|
|
32
|
+
*
|
|
33
|
+
* `/receipt/<id>` (the human page) becomes `/v1/receipt/<id>`; `/v1/receipt/<id>`
|
|
34
|
+
* and the site's `/api/receipt/<id>` mirror are already the API and are kept.
|
|
35
|
+
* The host is never rewritten, a trailing slash is dropped, the query string
|
|
36
|
+
* is kept and the fragment dropped. Anything else throws rather than being
|
|
37
|
+
* fetched. Through 0.31.9 this was a blind `replace('/receipt/', …)`, which
|
|
38
|
+
* turned the documented `https://api.treeship.dev/v1/receipt/<id>` into
|
|
39
|
+
* `/v1/v1/receipt/<id>` and a 404. The rule is shared with the CLI
|
|
40
|
+
* (`receipt_api_url` in verify_external.rs) through
|
|
41
|
+
* `tests/vectors/receipt-urls.json`; change both or neither.
|
|
42
|
+
*
|
|
43
|
+
* @internal Exported for the shared-vector test; not part of the package's
|
|
44
|
+
* supported API.
|
|
45
|
+
*/
|
|
46
|
+
export function receiptApiUrl(raw) {
|
|
47
|
+
const refuse = () => new Error(`not a receipt URL: ${raw} (expected …/receipt/<session id> or …/v1/receipt/<session id>)`);
|
|
48
|
+
const schemeEnd = raw.indexOf('://');
|
|
49
|
+
if (schemeEnd < 0)
|
|
50
|
+
throw refuse();
|
|
51
|
+
const scheme = raw.slice(0, schemeEnd).toLowerCase();
|
|
52
|
+
if (scheme !== 'http' && scheme !== 'https')
|
|
53
|
+
throw refuse();
|
|
54
|
+
const afterScheme = raw.slice(schemeEnd + 3);
|
|
55
|
+
const slash = afterScheme.indexOf('/');
|
|
56
|
+
const host = slash < 0 ? afterScheme : afterScheme.slice(0, slash);
|
|
57
|
+
const pathAndQuery = slash < 0 ? '' : afterScheme.slice(slash);
|
|
58
|
+
// Userinfo (`treeship.dev@evil.example`) reads as one host and fetches
|
|
59
|
+
// another; a pasted receipt link never carries it.
|
|
60
|
+
if (host.length === 0 || host.includes('@'))
|
|
61
|
+
throw refuse();
|
|
62
|
+
const noFragment = pathAndQuery.split('#')[0];
|
|
63
|
+
const q = noFragment.indexOf('?');
|
|
64
|
+
const query = q < 0 ? null : noFragment.slice(q + 1);
|
|
65
|
+
const path = (q < 0 ? noFragment : noFragment.slice(0, q)).replace(/\/+$/, '');
|
|
66
|
+
// The id is whatever follows the receipt segment: exactly one path
|
|
67
|
+
// segment of id characters, so `..`, `%2F` and friends never reach the
|
|
68
|
+
// request.
|
|
69
|
+
const idAfter = (marker) => {
|
|
70
|
+
const i = path.indexOf(marker);
|
|
71
|
+
if (i < 0)
|
|
72
|
+
return null;
|
|
73
|
+
const id = path.slice(i + marker.length);
|
|
74
|
+
return /^[A-Za-z0-9_-]+$/.test(id) ? [i, id] : null;
|
|
75
|
+
};
|
|
76
|
+
let apiPath;
|
|
77
|
+
if (idAfter('/v1/receipt/'))
|
|
78
|
+
apiPath = path;
|
|
79
|
+
else if (idAfter('/api/receipt/'))
|
|
80
|
+
apiPath = path;
|
|
81
|
+
else {
|
|
82
|
+
const hit = idAfter('/receipt/');
|
|
83
|
+
if (!hit)
|
|
84
|
+
throw refuse();
|
|
85
|
+
apiPath = `${path.slice(0, hit[0])}/v1/receipt/${hit[1]}`;
|
|
86
|
+
}
|
|
87
|
+
return `${scheme}://${host}${apiPath}${query === null ? '' : `?${query}`}`;
|
|
88
|
+
}
|
|
30
89
|
async function normalizeToJson(target) {
|
|
31
90
|
if (typeof target === 'object' && !(target instanceof URL)) {
|
|
32
91
|
return JSON.stringify(target);
|
|
33
92
|
}
|
|
34
93
|
const raw = target instanceof URL ? target.toString() : target;
|
|
35
94
|
if (raw.startsWith('http://') || raw.startsWith('https://')) {
|
|
36
|
-
|
|
37
|
-
const apiUrl = raw.replace('/receipt/', '/v1/receipt/');
|
|
95
|
+
const apiUrl = receiptApiUrl(raw);
|
|
38
96
|
const res = await fetch(apiUrl, { headers: { accept: 'application/json' } });
|
|
39
97
|
if (!res.ok)
|
|
40
98
|
throw new Error(`fetch ${apiUrl} returned HTTP ${res.status}`);
|
|
@@ -162,3 +220,442 @@ export async function verifyPresentation(presentation, trustRoots, opts) {
|
|
|
162
220
|
const wasm = await loadWasm();
|
|
163
221
|
return JSON.parse(wasm.verify_presentation(JSON.stringify(presentation), serializeTrustRoots(trustRoots), opts?.nonce ?? '', nowStr));
|
|
164
222
|
}
|
|
223
|
+
// Kinds whose rules (single-use approvals, endorsement linkage, room
|
|
224
|
+
// invitations and countersigns) live in the Rust package verifier and are not
|
|
225
|
+
// re-implemented here. A package carrying any of them is capped at
|
|
226
|
+
// structural-pass: its signatures can all hold while the set breaks a rule
|
|
227
|
+
// (a single-use invitation redeemed twice, for example).
|
|
228
|
+
const UNEVALUATED_KINDS = new Set([
|
|
229
|
+
'approval.v1',
|
|
230
|
+
'endorsement.v1',
|
|
231
|
+
'invitation.v1',
|
|
232
|
+
'session-participant.v1',
|
|
233
|
+
'session-liveness.v1',
|
|
234
|
+
]);
|
|
235
|
+
// Kinds this library evaluates fully: signature, id and digest binding.
|
|
236
|
+
const EVALUATED_KINDS = new Set(['action.v1', 'receipt.v1']);
|
|
237
|
+
// Kinds not signed as plain DSSE: a room participant carries the joiner's
|
|
238
|
+
// signature and the host's countersign over the participant's canonical
|
|
239
|
+
// bytes, and its id comes from the pending envelope. Only the CLI checks
|
|
240
|
+
// them; here they are skipped, which also caps the verdict.
|
|
241
|
+
const SIGNED_ELSEWHERE = new Set(['session-participant.v1']);
|
|
242
|
+
function kindOf(payloadType) {
|
|
243
|
+
return payloadType.replace(/^application\/vnd\.treeship\./, '').replace(/\+json$/, '');
|
|
244
|
+
}
|
|
245
|
+
function bytesOf(v) {
|
|
246
|
+
return typeof v === 'string' ? new TextEncoder().encode(v) : v;
|
|
247
|
+
}
|
|
248
|
+
function textOf(v) {
|
|
249
|
+
return typeof v === 'string' ? v : new TextDecoder().decode(v);
|
|
250
|
+
}
|
|
251
|
+
async function sha256Hex(bytes) {
|
|
252
|
+
const d = await crypto.subtle.digest('SHA-256', bytes);
|
|
253
|
+
return Array.from(new Uint8Array(d), (b) => b.toString(16).padStart(2, '0')).join('');
|
|
254
|
+
}
|
|
255
|
+
function stripKeyPrefix(k) {
|
|
256
|
+
return k.startsWith('ed25519:') ? k.slice('ed25519:'.length) : k;
|
|
257
|
+
}
|
|
258
|
+
function parseEnvelope(raw) {
|
|
259
|
+
try {
|
|
260
|
+
const e = JSON.parse(raw);
|
|
261
|
+
if (typeof e?.payload === 'string' &&
|
|
262
|
+
typeof e?.payloadType === 'string' &&
|
|
263
|
+
Array.isArray(e?.signatures) &&
|
|
264
|
+
e.signatures.length > 0 &&
|
|
265
|
+
e.signatures.every((s) => typeof s?.keyid === 'string' &&
|
|
266
|
+
typeof s?.sig === 'string')) {
|
|
267
|
+
return e;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
// fall through
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Verify a `.treeship` package: every artifact envelope's Ed25519
|
|
277
|
+
* signatures, each envelope's id and full digest against the receipt, and the
|
|
278
|
+
* signed close record (record.json) against the exact bytes of receipt.json.
|
|
279
|
+
* Signature math runs in core-wasm, the same code as the CLI.
|
|
280
|
+
*
|
|
281
|
+
* Given only receipt.json, the result is `structural-pass` with
|
|
282
|
+
* `scope: 'receipt-only'`: no signature was checked.
|
|
283
|
+
*/
|
|
284
|
+
export async function verifyPackage(files, opts = {}) {
|
|
285
|
+
const wasm = await loadWasm();
|
|
286
|
+
const checks = [];
|
|
287
|
+
const artifacts = [];
|
|
288
|
+
const unevaluated = new Set();
|
|
289
|
+
const done = (verdict, scope) => ({
|
|
290
|
+
verdict,
|
|
291
|
+
scope,
|
|
292
|
+
checks,
|
|
293
|
+
artifacts,
|
|
294
|
+
unevaluated_kinds: [...unevaluated].sort(),
|
|
295
|
+
});
|
|
296
|
+
const failed = () => checks.some((c) => c.status === 'fail');
|
|
297
|
+
// 1. receipt.json and its structure (Merkle root, inclusion proofs).
|
|
298
|
+
const receiptRaw = files['receipt.json'];
|
|
299
|
+
if (receiptRaw === undefined) {
|
|
300
|
+
checks.push({ step: 'receipt.json', status: 'fail', detail: 'missing' });
|
|
301
|
+
return done('failed', 'receipt-only');
|
|
302
|
+
}
|
|
303
|
+
let receipt;
|
|
304
|
+
try {
|
|
305
|
+
receipt = JSON.parse(textOf(receiptRaw));
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
checks.push({ step: 'receipt.json', status: 'fail', detail: 'not JSON' });
|
|
309
|
+
return done('failed', 'receipt-only');
|
|
310
|
+
}
|
|
311
|
+
const structural = JSON.parse(wasm.verify_receipt(textOf(receiptRaw)));
|
|
312
|
+
if (structural.outcome === 'fail' || structural.outcome === 'error') {
|
|
313
|
+
checks.push({
|
|
314
|
+
step: 'structure',
|
|
315
|
+
status: 'fail',
|
|
316
|
+
detail: structural.message ?? 'receipt structure does not verify',
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
checks.push({ step: 'structure', status: 'pass', detail: 'Merkle root and inclusion proofs hold' });
|
|
321
|
+
}
|
|
322
|
+
// 2. Which envelopes were given. None at all is a receipt-only read (or a
|
|
323
|
+
// package from before 0.31.2): structure is all there is to check.
|
|
324
|
+
const envelopePaths = Object.keys(files).filter((p) => /^artifacts\/[^/]+\.json$/.test(p));
|
|
325
|
+
if (envelopePaths.length === 0 && files['record.json'] === undefined) {
|
|
326
|
+
checks.push({
|
|
327
|
+
step: 'signatures',
|
|
328
|
+
status: 'warn',
|
|
329
|
+
detail: 'no artifact envelopes given: structure only, no signature was checked',
|
|
330
|
+
});
|
|
331
|
+
return done(failed() ? 'failed' : 'structural-pass', 'receipt-only');
|
|
332
|
+
}
|
|
333
|
+
// 3. Keys: pinned by the caller, or carried by the package.
|
|
334
|
+
const pinned = new Map();
|
|
335
|
+
for (const [kid, key] of Object.entries(opts.pinnedKeys ?? {}))
|
|
336
|
+
pinned.set(kid, stripKeyPrefix(key));
|
|
337
|
+
const packageKeys = new Map();
|
|
338
|
+
if (files['keys.json'] !== undefined) {
|
|
339
|
+
try {
|
|
340
|
+
const k = JSON.parse(textOf(files['keys.json']));
|
|
341
|
+
for (const [kid, key] of Object.entries(k.keys ?? {}))
|
|
342
|
+
packageKeys.set(kid, stripKeyPrefix(key));
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
checks.push({ step: 'keys.json', status: 'fail', detail: 'not JSON' });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
for (const [kid, key] of packageKeys) {
|
|
349
|
+
if (pinned.has(kid) && pinned.get(kid) !== key) {
|
|
350
|
+
checks.push({
|
|
351
|
+
step: 'keys.json',
|
|
352
|
+
status: 'fail',
|
|
353
|
+
detail: `keys.json names a different key for ${kid} than the one you pinned`,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
let allPinned = true;
|
|
358
|
+
let anyPinned = false;
|
|
359
|
+
// A signer is its key id AND the key that id resolved to, so a reused
|
|
360
|
+
// key id under a different key is a different signer.
|
|
361
|
+
const signerOf = (env) => {
|
|
362
|
+
if (env.signatures.length !== 1)
|
|
363
|
+
return null;
|
|
364
|
+
const kid = env.signatures[0].keyid;
|
|
365
|
+
const key = pinned.get(kid) ?? packageKeys.get(kid);
|
|
366
|
+
return key ? `${kid}:${key}` : null;
|
|
367
|
+
};
|
|
368
|
+
const decode = (env) => {
|
|
369
|
+
try {
|
|
370
|
+
const v = JSON.parse(textOf(base64urlDecode(env.payload)));
|
|
371
|
+
return v && typeof v === 'object' ? v : null;
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
// Verified artifacts of the kinds evaluated here, for the session rules,
|
|
378
|
+
// and the signed statements of every verified envelope, for chain walks.
|
|
379
|
+
const verifiedStmts = new Map();
|
|
380
|
+
const chainStmts = new Map();
|
|
381
|
+
// Verify one envelope: every signature must hold under a known key.
|
|
382
|
+
const checkEnvelope = (env) => {
|
|
383
|
+
const trusted = {};
|
|
384
|
+
const signers = [];
|
|
385
|
+
for (const s of env.signatures) {
|
|
386
|
+
const key = pinned.get(s.keyid) ?? packageKeys.get(s.keyid);
|
|
387
|
+
signers.push({
|
|
388
|
+
keyid: s.keyid,
|
|
389
|
+
key: pinned.has(s.keyid) ? 'pinned' : packageKeys.has(s.keyid) ? 'package' : 'unknown',
|
|
390
|
+
});
|
|
391
|
+
if (key)
|
|
392
|
+
trusted[s.keyid] = key;
|
|
393
|
+
}
|
|
394
|
+
const r = JSON.parse(wasm.verify_envelope(JSON.stringify(env), JSON.stringify(trusted)));
|
|
395
|
+
const everySigner = new Set(r.verified_keys ?? []);
|
|
396
|
+
const distinct = new Set(env.signatures.map((s) => s.keyid)).size === env.signatures.length;
|
|
397
|
+
const ok = r.valid === true &&
|
|
398
|
+
distinct &&
|
|
399
|
+
(r.verified_keys ?? []).length === env.signatures.length &&
|
|
400
|
+
signers.every((s) => s.key !== 'unknown' && everySigner.has(s.keyid));
|
|
401
|
+
if (ok && signers.some((s) => s.key !== 'pinned'))
|
|
402
|
+
allPinned = false;
|
|
403
|
+
if (ok && signers.some((s) => s.key === 'pinned'))
|
|
404
|
+
anyPinned = true;
|
|
405
|
+
return { ok, signers, r };
|
|
406
|
+
};
|
|
407
|
+
// 4. Every artifact the receipt lists: present, signed, and the envelope
|
|
408
|
+
// that hashes to exactly the listed id and digest.
|
|
409
|
+
const listed = receipt.artifacts ?? [];
|
|
410
|
+
const seen = new Set();
|
|
411
|
+
for (const a of listed) {
|
|
412
|
+
const id = a.artifact_id ?? '';
|
|
413
|
+
if (!/^art_[0-9a-f]{32}$/.test(id)) {
|
|
414
|
+
checks.push({ step: 'artifacts', status: 'fail', detail: `malformed artifact id ${JSON.stringify(id)}` });
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (seen.has(id)) {
|
|
418
|
+
checks.push({ step: 'artifacts', status: 'fail', detail: `${id} is listed more than once` });
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
seen.add(id);
|
|
422
|
+
const raw = files[`artifacts/${id}.json`];
|
|
423
|
+
if (raw === undefined) {
|
|
424
|
+
artifacts.push({ artifact_id: id, status: 'missing', signers: [], detail: 'envelope not in the package' });
|
|
425
|
+
checks.push({ step: 'artifacts', status: 'fail', detail: `${id}: envelope missing from the package` });
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
const env = parseEnvelope(textOf(raw));
|
|
429
|
+
if (!env) {
|
|
430
|
+
artifacts.push({ artifact_id: id, status: 'fail', signers: [], detail: 'not a DSSE envelope' });
|
|
431
|
+
checks.push({ step: 'artifacts', status: 'fail', detail: `${id}: not a DSSE envelope` });
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
const kind = kindOf(env.payloadType);
|
|
435
|
+
if (!EVALUATED_KINDS.has(kind))
|
|
436
|
+
unevaluated.add(kind);
|
|
437
|
+
if (SIGNED_ELSEWHERE.has(kind)) {
|
|
438
|
+
artifacts.push({
|
|
439
|
+
artifact_id: id,
|
|
440
|
+
payload_type: env.payloadType,
|
|
441
|
+
status: 'skipped',
|
|
442
|
+
signers: env.signatures.map((s) => ({
|
|
443
|
+
keyid: s.keyid,
|
|
444
|
+
key: pinned.has(s.keyid) ? 'pinned' : packageKeys.has(s.keyid) ? 'package' : 'unknown',
|
|
445
|
+
})),
|
|
446
|
+
detail: 'countersigned over canonical bytes; checked by `treeship package verify`',
|
|
447
|
+
});
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
const { ok, signers, r } = checkEnvelope(env);
|
|
451
|
+
let detail;
|
|
452
|
+
if (!ok)
|
|
453
|
+
detail = r.error ?? 'a signature does not verify under a known key';
|
|
454
|
+
else if (r.artifact_id !== id)
|
|
455
|
+
detail = `envelope hashes to ${r.artifact_id}, not ${id}`;
|
|
456
|
+
else if (r.digest !== a.digest)
|
|
457
|
+
detail = `envelope digest ${r.digest} does not match the receipt's ${a.digest}`;
|
|
458
|
+
artifacts.push({
|
|
459
|
+
artifact_id: id,
|
|
460
|
+
payload_type: env.payloadType,
|
|
461
|
+
status: detail ? 'fail' : 'pass',
|
|
462
|
+
signers,
|
|
463
|
+
detail,
|
|
464
|
+
});
|
|
465
|
+
if (detail)
|
|
466
|
+
checks.push({ step: 'artifacts', status: 'fail', detail: `${id}: ${detail}` });
|
|
467
|
+
else {
|
|
468
|
+
const stmt = decode(env);
|
|
469
|
+
if (!stmt)
|
|
470
|
+
checks.push({ step: 'artifacts', status: 'fail', detail: `${id}: payload is not a JSON statement` });
|
|
471
|
+
else {
|
|
472
|
+
chainStmts.set(id, stmt);
|
|
473
|
+
if (EVALUATED_KINDS.has(kind))
|
|
474
|
+
verifiedStmts.set(id, { env, stmt, kind });
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (!failed() && listed.length > 0) {
|
|
479
|
+
const passed = artifacts.filter((a) => a.status === 'pass').length;
|
|
480
|
+
const skipped = artifacts.length - passed;
|
|
481
|
+
checks.push({
|
|
482
|
+
step: 'artifacts',
|
|
483
|
+
status: 'pass',
|
|
484
|
+
detail: `${passed} envelope(s) verify and match the receipt's ids and digests` +
|
|
485
|
+
(skipped ? `; ${skipped} skipped (checked by the CLI only)` : ''),
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
// 5. The session: exactly one chain-root session.start and exactly one
|
|
489
|
+
// session.close for this session, the close chained back to the start
|
|
490
|
+
// by signed parent ids, and both signed by the same signer. Every
|
|
491
|
+
// evaluated artifact's signed parent must be in the package.
|
|
492
|
+
const sid = receipt.session?.id;
|
|
493
|
+
const metaSid = (st) => (st.meta && typeof st.meta === 'object' ? st.meta.session_id : undefined);
|
|
494
|
+
const parentOf = (st) => {
|
|
495
|
+
// Mirrors treeship_core::verify::signed_parent for the kinds evaluated
|
|
496
|
+
// here: a present parentId decides (non-string = broken); a receipt.v1
|
|
497
|
+
// names its subject; otherwise no parent (a chain root).
|
|
498
|
+
if ('parentId' in st || 'parent_id' in st) {
|
|
499
|
+
const v = st.parentId ?? st.parent_id;
|
|
500
|
+
return typeof v === 'string' ? v : undefined;
|
|
501
|
+
}
|
|
502
|
+
const subj = st.subject?.artifactId;
|
|
503
|
+
if (typeof subj === 'string' && subj.startsWith('art_'))
|
|
504
|
+
return subj;
|
|
505
|
+
return null;
|
|
506
|
+
};
|
|
507
|
+
let closeId = null;
|
|
508
|
+
let closeSigner = null;
|
|
509
|
+
let closeStmt = null;
|
|
510
|
+
if (verifiedStmts.size > 0) {
|
|
511
|
+
const starts = [...verifiedStmts].filter(([, v]) => v.stmt.action === 'session.start' && metaSid(v.stmt) === sid);
|
|
512
|
+
const closes = [...verifiedStmts].filter(([, v]) => v.stmt.action === 'session.close' && metaSid(v.stmt) === sid);
|
|
513
|
+
if (starts.length !== 1 || parentOf(starts[0][1].stmt) !== null) {
|
|
514
|
+
checks.push({ step: 'session', status: 'fail', detail: `expected one chain-root session.start for ${sid}, found ${starts.length}` });
|
|
515
|
+
}
|
|
516
|
+
else if (closes.length !== 1) {
|
|
517
|
+
checks.push({ step: 'session', status: 'fail', detail: `expected one session.close for ${sid}, found ${closes.length}` });
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
const [startId, start] = starts[0];
|
|
521
|
+
const [cId, close] = closes[0];
|
|
522
|
+
const startSigner = signerOf(start.env);
|
|
523
|
+
const cSigner = signerOf(close.env);
|
|
524
|
+
// Walk the close back to the start through signed parents.
|
|
525
|
+
let cur = cId;
|
|
526
|
+
const visited = new Set();
|
|
527
|
+
while (cur && cur !== startId && !visited.has(cur)) {
|
|
528
|
+
visited.add(cur);
|
|
529
|
+
const st = chainStmts.get(cur);
|
|
530
|
+
cur = st ? parentOf(st) : undefined;
|
|
531
|
+
}
|
|
532
|
+
if (cur !== startId) {
|
|
533
|
+
checks.push({ step: 'session', status: 'fail', detail: `session.close ${cId} does not chain back to session.start ${startId}` });
|
|
534
|
+
}
|
|
535
|
+
else if (!startSigner || startSigner !== cSigner) {
|
|
536
|
+
checks.push({ step: 'session', status: 'fail', detail: 'session.close is not signed by the signer of session.start' });
|
|
537
|
+
}
|
|
538
|
+
else {
|
|
539
|
+
closeId = cId;
|
|
540
|
+
closeSigner = cSigner;
|
|
541
|
+
closeStmt = close.stmt;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
for (const [id, v] of verifiedStmts) {
|
|
545
|
+
const par = parentOf(v.stmt);
|
|
546
|
+
if (par === undefined || (par !== null && !seen.has(par))) {
|
|
547
|
+
checks.push({ step: 'linkage', status: 'fail', detail: `${id}: signed parent ${String(par)} is not in the package` });
|
|
548
|
+
}
|
|
549
|
+
else if (par === null && !(v.stmt.action === 'session.start' && metaSid(v.stmt) === sid)) {
|
|
550
|
+
checks.push({ step: 'linkage', status: 'fail', detail: `${id}: signs no parent but is not this session's session.start` });
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
else if (envelopePaths.length > 0 || files['record.json'] !== undefined) {
|
|
555
|
+
checks.push({ step: 'session', status: 'fail', detail: 'no verified session artifacts to bind the close record to' });
|
|
556
|
+
}
|
|
557
|
+
// 6. The close record: signed, names this session, and binds the exact
|
|
558
|
+
// bytes of receipt.json. Without it, nothing binds the receipt body
|
|
559
|
+
// (timeline, narrative, side effects) to a signature.
|
|
560
|
+
const recordRaw = files['record.json'];
|
|
561
|
+
if (recordRaw === undefined) {
|
|
562
|
+
checks.push({
|
|
563
|
+
step: 'receipt_binding',
|
|
564
|
+
status: 'fail',
|
|
565
|
+
detail: 'record.json (the signed close record) is missing, so nothing binds receipt.json',
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
else {
|
|
569
|
+
const env = parseEnvelope(textOf(recordRaw));
|
|
570
|
+
if (!env) {
|
|
571
|
+
checks.push({ step: 'receipt_binding', status: 'fail', detail: 'record.json is not a DSSE envelope' });
|
|
572
|
+
}
|
|
573
|
+
else {
|
|
574
|
+
// The record may be signed by the close's signer, or by the record key
|
|
575
|
+
// the close names in its own signed statement, at meta.record_key, in
|
|
576
|
+
// the CLI's encoding (ed25519:<base64url>). A record under that key is
|
|
577
|
+
// checked against the key the close vouches for, never keys.json, and
|
|
578
|
+
// is as trusted as the close's signer, so it adds no signer of its own.
|
|
579
|
+
const rk = closeStmt?.meta?.record_key;
|
|
580
|
+
const rkKey = rk && typeof rk.key_id === 'string' && typeof rk.public_key === 'string' &&
|
|
581
|
+
/^ed25519:[A-Za-z0-9_-]{43}$/.test(rk.public_key)
|
|
582
|
+
? { kid: rk.key_id, key: rk.public_key.slice('ed25519:'.length) }
|
|
583
|
+
: null;
|
|
584
|
+
const byRecordKey = rkKey !== null && env.signatures.length === 1 && env.signatures[0].keyid === rkKey.kid;
|
|
585
|
+
let ok;
|
|
586
|
+
if (byRecordKey) {
|
|
587
|
+
const conflict = (pinned.has(rkKey.kid) && pinned.get(rkKey.kid) !== rkKey.key) ||
|
|
588
|
+
(packageKeys.has(rkKey.kid) && packageKeys.get(rkKey.kid) !== rkKey.key);
|
|
589
|
+
const r = JSON.parse(wasm.verify_envelope(JSON.stringify(env), JSON.stringify({ [rkKey.kid]: rkKey.key })));
|
|
590
|
+
ok = !conflict && r.valid === true && (r.verified_keys ?? []).length === 1;
|
|
591
|
+
}
|
|
592
|
+
else {
|
|
593
|
+
ok = checkEnvelope(env).ok;
|
|
594
|
+
}
|
|
595
|
+
const stmt = (decode(env) ?? {});
|
|
596
|
+
const actual = `sha256:${await sha256Hex(bytesOf(receiptRaw))}`;
|
|
597
|
+
const recSigner = byRecordKey ? 'record_key' : signerOf(env);
|
|
598
|
+
if (!ok) {
|
|
599
|
+
checks.push({ step: 'receipt_binding', status: 'fail', detail: 'the close record signature does not verify under a known key' });
|
|
600
|
+
}
|
|
601
|
+
else if (env.payloadType !== 'application/vnd.treeship.receipt.v1+json' ||
|
|
602
|
+
stmt.type !== 'treeship/receipt/v1' ||
|
|
603
|
+
stmt.kind !== 'session.v1' ||
|
|
604
|
+
typeof stmt.payload?.receipt_digest !== 'string' ||
|
|
605
|
+
typeof stmt.payload?.session_id !== 'string') {
|
|
606
|
+
checks.push({ step: 'receipt_binding', status: 'fail', detail: 'record.json is not a session.v1 close record' });
|
|
607
|
+
}
|
|
608
|
+
else if (!closeId || stmt.subject?.artifactId !== closeId) {
|
|
609
|
+
checks.push({ step: 'receipt_binding', status: 'fail', detail: "the close record does not seal this session's close" });
|
|
610
|
+
}
|
|
611
|
+
else if (!recSigner || (recSigner !== closeSigner && !byRecordKey)) {
|
|
612
|
+
checks.push({ step: 'receipt_binding', status: 'fail', detail: "the close record is not signed by the session's signer" });
|
|
613
|
+
}
|
|
614
|
+
else if (stmt.payload?.session_id !== receipt.session?.id) {
|
|
615
|
+
checks.push({
|
|
616
|
+
step: 'receipt_binding',
|
|
617
|
+
status: 'fail',
|
|
618
|
+
detail: `the close record names session ${stmt.payload?.session_id} but this receipt is ${receipt.session?.id}`,
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
else if (stmt.payload?.receipt_digest !== actual) {
|
|
622
|
+
checks.push({
|
|
623
|
+
step: 'receipt_binding',
|
|
624
|
+
status: 'fail',
|
|
625
|
+
detail: `the close record signed receipt digest ${stmt.payload?.receipt_digest} but receipt.json digests to ${actual}`,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
else {
|
|
629
|
+
checks.push({ step: 'receipt_binding', status: 'pass', detail: `the close record binds receipt.json (${actual})` });
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
// Some signers pinned and others only package-known: a pin covers part
|
|
634
|
+
// of the package, so the package as a whole is not what was pinned.
|
|
635
|
+
if (anyPinned && !allPinned) {
|
|
636
|
+
checks.push({ step: 'keys', status: 'fail', detail: 'some signers are pinned and others are known only from keys.json' });
|
|
637
|
+
}
|
|
638
|
+
if (failed())
|
|
639
|
+
return done('failed', 'signatures');
|
|
640
|
+
// Approval evidence travels beside the sealed set: use records and grants
|
|
641
|
+
// carried in approvals/ (an approval minted before the session is there,
|
|
642
|
+
// not sealed). Their rules live in the Rust verifier, so their presence
|
|
643
|
+
// caps the verdict like a sealed approval does.
|
|
644
|
+
if (Object.keys(files).some((k) => k.startsWith('approvals/'))) {
|
|
645
|
+
unevaluated.add('approvals/ (use records and carried grants)');
|
|
646
|
+
}
|
|
647
|
+
if (unevaluated.size > 0) {
|
|
648
|
+
checks.push({
|
|
649
|
+
step: 'semantics',
|
|
650
|
+
status: 'warn',
|
|
651
|
+
detail: `not evaluated here: ${[...unevaluated].sort().join(', ')}. Run \`treeship package verify\` for their rules.`,
|
|
652
|
+
});
|
|
653
|
+
return done('structural-pass', 'partial');
|
|
654
|
+
}
|
|
655
|
+
return done(allPinned ? 'verified' : 'signatures-pass', 'signatures');
|
|
656
|
+
}
|
|
657
|
+
function base64urlDecode(s) {
|
|
658
|
+
const b64 = s.replace(/-/g, '+').replace(/_/g, '/');
|
|
659
|
+
const bin = atob(b64 + '='.repeat((4 - (b64.length % 4)) % 4));
|
|
660
|
+
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
|
661
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@treeship/verify",
|
|
3
|
-
"version": "0.31.
|
|
3
|
+
"version": "0.31.10",
|
|
4
4
|
"description": "Zero-dependency cryptographic verification for Treeship receipts and certificates. Runs anywhere WASM runs: Node, browser, Vercel Edge, Cloudflare Workers, AWS Lambda.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"test": "vitest run"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@treeship/core-wasm": "0.31.
|
|
43
|
+
"@treeship/core-wasm": "0.31.10"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@types/node": "^25.5.0",
|