@treeship/verify 0.31.9 → 0.31.11

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 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
- Three functions. Each accepts a parsed object, a JSON string, or a URL.
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
 
@@ -32,23 +32,27 @@ if (result.outcome === 'pass') {
32
32
  }
33
33
  ```
34
34
 
35
- ### `verifyCertificate(target, now?)`
35
+ ### `verifyCertificate(target, now?, trustRoots?)`
36
36
 
37
- Verifies the Ed25519 signature on an Agent Certificate against the public key embedded in the certificate. With `now` supplied (Date or RFC 3339 string), also classifies the validity window.
37
+ Verifies the Ed25519 signature on an Agent Certificate against a trust root the caller pins via `trustRoots` -- as of the v0.10.3 trust-root audit fix, the certificate's own embedded public key is never trusted on its own; that would make every certificate self-signed. `trustRoots` is required for the signature to be accepted; omit it to get a deliberate fail-closed result for diagnostic UIs. With `now` supplied (Date or RFC 3339 string), also classifies the validity window.
38
38
 
39
39
  ```typescript
40
40
  import { verifyCertificate } from '@treeship/verify';
41
41
 
42
- const result = await verifyCertificate('./researcher.agent/certificate.json', new Date());
42
+ const result = await verifyCertificate(
43
+ './researcher.agent/certificate.json',
44
+ new Date(),
45
+ trustRootsJson, // e.g. the contents of ~/.treeship/trust_roots.json
46
+ );
43
47
 
44
48
  if (result.outcome === 'pass' && result.validity === 'valid') {
45
49
  console.log(`certificate valid for ${result.certificate.agent_name}`);
46
50
  }
47
51
  ```
48
52
 
49
- ### `crossVerify(receipt, certificate, now?)`
53
+ ### `crossVerify(receipt, certificate, now?, trustRoots?)`
50
54
 
51
- Answers three questions: do the receipt and certificate reference the same ship, was the certificate valid at `now`, was every tool the session called authorized by the certificate. The `ok` field is the roll-up.
55
+ Answers three questions: do the receipt and certificate reference the same ship, was the certificate valid at `now`, was every tool the session called authorized by the certificate. The `ok` field is the roll-up. Like `verifyCertificate`, the certificate's signature is only accepted against a pinned `trustRoots` set -- omit it for a deliberate fail-closed result.
52
56
 
53
57
  ```typescript
54
58
  import { crossVerify } from '@treeship/verify';
@@ -56,6 +60,8 @@ import { crossVerify } from '@treeship/verify';
56
60
  const result = await crossVerify(
57
61
  'https://treeship.dev/receipt/ssn_abc',
58
62
  'https://example.com/researcher.agent.json',
63
+ new Date(),
64
+ trustRootsJson,
59
65
  );
60
66
 
61
67
  if (result.ok) {
@@ -66,6 +72,35 @@ if (result.ok) {
66
72
  }
67
73
  ```
68
74
 
75
+ ### `verifyPackage(files, { pinnedKeys? })`
76
+
77
+ Verifies a whole `.treeship` package, not just its receipt:
78
+ - every artifact envelope's Ed25519 signatures, in core-wasm (the same code as the CLI)
79
+ - each envelope against the receipt's `artifact_id` and full `digest`
80
+ - the signed close record (`record.json`) against the exact bytes of `receipt.json`
81
+
82
+ `files` maps each path inside the package (`receipt.json`, `record.json`, `keys.json`, `artifacts/<id>.json`, ...) to its unmodified bytes.
83
+
84
+ ```typescript
85
+ import { verifyPackage } from '@treeship/verify';
86
+
87
+ const result = await verifyPackage(files, {
88
+ // Keys you got from a source you trust, not from the package.
89
+ pinnedKeys: { key_57e0c8ba2b2bc32c: 'ed25519:HwiNphKvOWVcuI_-kv8hNz6Ahtt8n1LzngHDJpl5WJI' },
90
+ });
91
+ ```
92
+
93
+ The verdict uses the CLI's vocabulary:
94
+
95
+ | verdict | means |
96
+ |---|---|
97
+ | `verified` | Every signature holds under a key you pinned, every artifact matches the receipt, and the close record binds this receipt.json. |
98
+ | `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. |
99
+ | `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. |
100
+ | `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. |
101
+
102
+ `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`.
103
+
69
104
  ## Runtime compatibility
70
105
 
71
106
  | Runtime | Supported |
@@ -137,7 +172,7 @@ renderChecks(result.checks);
137
172
  ## What this package is NOT
138
173
 
139
174
  - **Not an attestation SDK.** For signing artifacts, session management, Hub push/pull, or agent registration, use [`@treeship/sdk`](../sdk-ts/) which shells out to the `treeship` CLI.
140
- - **Not a trust anchor.** The embedded Ed25519 signature on an Agent Certificate is verified against the certificate's own public key. Chaining a certificate to a trusted issuer is the caller's responsibility.
175
+ - **Not a trust anchor.** `verifyCertificate` checks the embedded Ed25519 signature against a `trustRoots` set the caller pins and passes in -- not against the certificate's own embedded key, which would make every certificate self-signed. Deciding *which* roots to trust, and chaining further to an issuer, is the caller's responsibility.
141
176
  - **Not a drop-in for local-chain verification.** Some signature verification needs the original envelope bytes, which a URL-fetched receipt does not carry. Use `treeship verify <artifact-id>` on the CLI for that.
142
177
 
143
178
  ## License
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
- // Accept both the Hub JSON API path and the human-readable mirror.
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.9",
3
+ "version": "0.31.11",
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.9"
43
+ "@treeship/core-wasm": "0.31.11"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/node": "^25.5.0",