@xemahq/xema 0.27.0 → 0.28.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.
@@ -0,0 +1,106 @@
1
+ // `entitlement.json`: the file `xema edition evidence statement entitlement
2
+ // accept` writes beside the signed statement, and the ONE reader of it
3
+ // (`verify-evidence --entitlement`). Writer and reader share this module so the
4
+ // shape is stated once.
5
+ //
6
+ // Every field but `evidence` is an UNAUTHENTICATED convenience for the operator
7
+ // and for `verify-evidence`'s early refusals: the signed statement stays the
8
+ // authority (`validateEntitlementStatementBinding`). The file names no workflow
9
+ // SHA: the commit the entitlement's certificate is held to is stated by the
10
+ // caller of the verifier (`--entitlement-workflow-sha`), never by the evidence.
11
+
12
+ import { dirname, join } from 'node:path';
13
+
14
+ export const ENTITLEMENT_DESCRIPTOR_FILE = 'entitlement.json';
15
+ export const ENTITLEMENT_DESCRIPTOR_SCHEMA_VERSION = 2;
16
+
17
+ const ENTITLEMENT_DESCRIPTOR_KEYS = Object.freeze([
18
+ 'schemaVersion',
19
+ 'distributionId',
20
+ 'customerId',
21
+ 'mode',
22
+ 'targetRegistry',
23
+ 'idempotencyKey',
24
+ 'distributionStatementDigest',
25
+ 'evidence',
26
+ ]);
27
+
28
+ /** The document `accept` writes. `evidence` is the kernel Sigstore evidence descriptor of the signed statement. */
29
+ export function entitlementDescriptorDocument({
30
+ distributionId,
31
+ customerId,
32
+ mode,
33
+ targetRegistry,
34
+ idempotencyKey,
35
+ distributionStatementDigest,
36
+ evidence,
37
+ }) {
38
+ return {
39
+ schemaVersion: ENTITLEMENT_DESCRIPTOR_SCHEMA_VERSION,
40
+ distributionId,
41
+ customerId,
42
+ mode,
43
+ targetRegistry,
44
+ idempotencyKey,
45
+ distributionStatementDigest,
46
+ evidence,
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Parse an `entitlement.json`: exactly the keys `accept` writes, at the one
52
+ * schema version, with `evidence` parsed by the kernel's Sigstore evidence
53
+ * descriptor schema. Any other shape is refused.
54
+ */
55
+ export function parseEntitlementDescriptorDocument(raw, schemas) {
56
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
57
+ throw new Error(`${ENTITLEMENT_DESCRIPTOR_FILE} must be a JSON object`);
58
+ }
59
+ if (raw.schemaVersion !== ENTITLEMENT_DESCRIPTOR_SCHEMA_VERSION) {
60
+ throw new Error(
61
+ `${ENTITLEMENT_DESCRIPTOR_FILE} has schemaVersion ${JSON.stringify(raw.schemaVersion)}; ` +
62
+ `this verifier reads schemaVersion ${ENTITLEMENT_DESCRIPTOR_SCHEMA_VERSION} only (accept the entitlement again with this CLI)`,
63
+ );
64
+ }
65
+ const keys = Object.keys(raw).sort();
66
+ const expected = [...ENTITLEMENT_DESCRIPTOR_KEYS].sort();
67
+ if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) {
68
+ throw new Error(
69
+ `${ENTITLEMENT_DESCRIPTOR_FILE} must hold exactly the keys ${ENTITLEMENT_DESCRIPTOR_KEYS.join(', ')}; it holds ${keys.join(', ')}`,
70
+ );
71
+ }
72
+ for (const key of ['distributionId', 'customerId', 'mode', 'idempotencyKey', 'distributionStatementDigest']) {
73
+ if (typeof raw[key] !== 'string' || raw[key] === '') {
74
+ throw new Error(`${ENTITLEMENT_DESCRIPTOR_FILE} "${key}" must be a non-empty string`);
75
+ }
76
+ }
77
+ if (raw.targetRegistry !== null && (typeof raw.targetRegistry !== 'string' || raw.targetRegistry === '')) {
78
+ throw new Error(`${ENTITLEMENT_DESCRIPTOR_FILE} "targetRegistry" must be null or a non-empty string`);
79
+ }
80
+ return { ...raw, evidence: schemas.SigstoreEvidenceDescriptorSchema.parse(raw.evidence) };
81
+ }
82
+
83
+ /**
84
+ * Refuse an `entitlement.json` that states another request than the one being
85
+ * verified. `expected` holds the caller's flags and the completeness statement
86
+ * digest the verifier has just verified.
87
+ */
88
+ export function assertEntitlementDescriptorStates(document, expected) {
89
+ for (const key of ['distributionId', 'customerId', 'mode', 'targetRegistry', 'distributionStatementDigest']) {
90
+ if (document[key] !== expected[key]) {
91
+ throw new Error(
92
+ `${ENTITLEMENT_DESCRIPTOR_FILE} states ${key} ${JSON.stringify(document[key])}, ` +
93
+ `not the ${JSON.stringify(expected[key])} this verification was asked for`,
94
+ );
95
+ }
96
+ }
97
+ }
98
+
99
+ /**
100
+ * The evidence descriptor's paths are relative to the directory `accept` sealed
101
+ * (the one holding `entitlement.json`), which lies inside the evidence root the
102
+ * descriptor file itself was read from.
103
+ */
104
+ export function entitlementEvidenceRoot(evidenceRoot, entitlementDescriptorPath) {
105
+ return dirname(join(evidenceRoot, entitlementDescriptorPath));
106
+ }
@@ -603,6 +603,14 @@ function assertEvidenceChannel(channel) {
603
603
  * The signers a policy authorizes for one predicate on ONE channel. Producer
604
604
  * evidence is further bound to the exact image repository the subject's
605
605
  * imageRef names (no prefix match).
606
+ *
607
+ * A signer belongs to one channel: the kernel policy schema, which every policy
608
+ * this plane loads is parsed by, holds a row's `githubWorkflow.ref` to the ref
609
+ * of its channel (`EVIDENCE_CHANNEL_REFS`) and its `certificateIdentity` to
610
+ * that ref, so no signer can be authorized on two channels. The signature
611
+ * therefore authenticates the channel: an edition descriptor that states the
612
+ * other channel selects signers whose certificates the evidence does not carry,
613
+ * or none at all.
606
614
  */
607
615
  function policyCandidates(policy, predicateType, channel, imageRepository) {
608
616
  return policy.policies.filter(
package/edition/lib.mjs CHANGED
@@ -722,6 +722,7 @@ export async function loadKernelDistributionSchemas() {
722
722
  parseDistributionImageEvidenceSet,
723
723
  parseDistributionImageReplayState,
724
724
  parseSigstoreOfflineTrustPolicy,
725
+ SigstoreEvidenceDescriptorSchema,
725
726
  parsePlatformServiceSourceIndex,
726
727
  parseRuntimeImageSourceIndex,
727
728
  resolveRuntimeImagePlatforms,
@@ -798,6 +799,7 @@ export async function loadKernelDistributionSchemas() {
798
799
  typeof parseDistributionImageEvidenceSet !== 'function' ||
799
800
  typeof parseDistributionImageReplayState !== 'function' ||
800
801
  typeof parseSigstoreOfflineTrustPolicy !== 'function' ||
802
+ typeof SigstoreEvidenceDescriptorSchema?.parse !== 'function' ||
801
803
  typeof parsePlatformServiceSourceIndex !== 'function' ||
802
804
  typeof parseRuntimeImageSourceIndex !== 'function' ||
803
805
  typeof resolveRuntimeImagePlatforms !== 'function' ||
@@ -892,6 +894,7 @@ export async function loadKernelDistributionSchemas() {
892
894
  parseDistributionImageEvidenceSet,
893
895
  parseDistributionImageReplayState,
894
896
  parseSigstoreOfflineTrustPolicy,
897
+ SigstoreEvidenceDescriptorSchema,
895
898
  parsePlatformServiceSourceIndex,
896
899
  parseRuntimeImageSourceIndex,
897
900
  resolveRuntimeImagePlatforms,
package/edition/oras.mjs CHANGED
@@ -173,7 +173,7 @@ function assertOrasVersion(bin, home) {
173
173
  /**
174
174
  * The oras digests the configured data root trusts: `trustedOrasBinaryDigests`
175
175
  * of its evidence-trust pin. `null` when the data root declares no evidence
176
- * trust at all (`evidenceTrustPolicy: {"required": false}` with no policy):
176
+ * trust at all (`evidenceTrustPolicy: null`):
177
177
  * such a data root has no reviewed pin to hold a binary to.
178
178
  */
179
179
  export function configuredOrasTrust() {
@@ -28,6 +28,7 @@ import {
28
28
  dataRoot,
29
29
  isEntryModule,
30
30
  } from './edition-workspace.mjs';
31
+ import { ENTITLEMENT_DESCRIPTOR_FILE, entitlementDescriptorDocument } from './image-delivery-entitlement-descriptor.mjs';
31
32
  import { canonicalJsonBytes, readCanonicalJsonFromRoot } from './image-evidence.mjs';
32
33
  import { serializeJson } from './lib.mjs';
33
34
  import { githubWorkflowSigner } from './produce-distribution-completeness-evidence.mjs';
@@ -38,7 +39,6 @@ import {
38
39
  } from './produce-image-source-evidence.mjs';
39
40
  import { assertStagedExactly, reserveSealing, sealAccepted } from './sealed-evidence-accept.mjs';
40
41
 
41
- const ENTITLEMENT_DESCRIPTOR_FILE = 'entitlement.json';
42
42
  const ENTITLEMENT_STATEMENT_PATH = 'entitlement/statement.json';
43
43
  const ENTITLEMENT_BUNDLE_PATH = 'entitlement/bundle.sigstore.json';
44
44
 
@@ -176,17 +176,17 @@ export function acceptImageDeliveryEntitlementEvidence(options) {
176
176
  const descriptor = acceptSignedEvidenceStatement({ ...signing, sealingRoot: sealing, runCosign });
177
177
  writeFileSync(
178
178
  join(sealing, ENTITLEMENT_DESCRIPTOR_FILE),
179
- canonicalJsonBytes({
180
- schemaVersion: 1,
181
- distributionId: options.distributionId,
182
- customerId: options.customerId,
183
- mode: options.mode,
184
- targetRegistry: options.targetRegistry,
185
- idempotencyKey: options.idempotencyKey,
186
- distributionStatementDigest: completenessStatementDigest,
187
- signingWorkflowSha: options.signingWorkflowSha,
188
- evidence: descriptor,
189
- }),
179
+ canonicalJsonBytes(
180
+ entitlementDescriptorDocument({
181
+ distributionId: options.distributionId,
182
+ customerId: options.customerId,
183
+ mode: options.mode,
184
+ targetRegistry: options.targetRegistry,
185
+ idempotencyKey: options.idempotencyKey,
186
+ distributionStatementDigest: completenessStatementDigest,
187
+ evidence: descriptor,
188
+ }),
189
+ ),
190
190
  { flag: 'wx', mode: 0o644 },
191
191
  );
192
192
  return { outputDirectory, statement, descriptor, completenessStatementDigest };
@@ -60,8 +60,18 @@ export function assertReleaseEditionTree(dir) {
60
60
  */
61
61
  export const RELEASE_COMPANION_FILES = Object.freeze([EDITION_CHART_SET_FILE]);
62
62
 
63
+ /**
64
+ * The release locks an edition directory holds (the distribution and the
65
+ * authorization lock). A directory holding either IS a release edition: a
66
+ * develop resolve writes nothing into an editions tree, so a lock committed
67
+ * there was written by the release resolve.
68
+ */
69
+ export function heldReleaseLocks(dir) {
70
+ return [EditionLockFile.Distribution, EditionLockFile.Authorization].filter((file) => existsSync(join(dir, file)));
71
+ }
72
+
63
73
  function assertReleaseLockSet(dir) {
64
- const held = [EditionLockFile.Distribution, EditionLockFile.Authorization].filter((file) => existsSync(join(dir, file)));
74
+ const held = heldReleaseLocks(dir);
65
75
  if (held.length === 0) {return;}
66
76
  const missing = RELEASE_COMPANION_FILES.filter((file) => !existsSync(join(dir, file)));
67
77
  if (missing.length > 0) {
@@ -131,10 +131,9 @@ async function run(argv, env) {
131
131
  const target = editionTarget(id, options['--registry'], options['--coordinate']);
132
132
  const workflow = options['--local'] ? LOCAL_WORKFLOW : workflowFromEnv(env);
133
133
  const locks = await readCommittedEdition(id);
134
+ // Refuses (EvidenceTrustRequiredError) unless the policy is ARMED on the
135
+ // release channel: a release artifact is never written not-armed.
134
136
  const evidenceTrustPolicy = await descriptorEvidenceTrust({ channel: EditionChannel.Release });
135
- if (evidenceTrustPolicy === 'not-armed') {
136
- console.warn('[edition-artifact] evidence trust policy: NOT ARMED — the descriptor records not-armed.');
137
- }
138
137
  const outDir = resolve(options['--out']);
139
138
  writeEditionArtifact({
140
139
  outDir,
@@ -49,6 +49,11 @@ import {
49
49
  trustPolicyDigest,
50
50
  verifierDigests,
51
51
  } from './evidence-trust-pin.mjs';
52
+ import {
53
+ assertEntitlementDescriptorStates,
54
+ entitlementEvidenceRoot,
55
+ parseEntitlementDescriptorDocument,
56
+ } from './image-delivery-entitlement-descriptor.mjs';
52
57
  import { verifyAndAdvanceReplayState } from './image-evidence-replay-state.mjs';
53
58
  import {
54
59
  readCanonicalJsonFromRoot,
@@ -64,13 +69,14 @@ import { loadVerifierKernelSchemas } from './verifier-kernel-schemas.mjs';
64
69
  const USAGE =
65
70
  'usage: verify-image-evidence <distributionId> --evidence-root <dir> --descriptor <absolute-path> --state <absolute-path> ' +
66
71
  '--cosign <absolute-path> --git <absolute-path> [--evidence-set <relative-path>] [--json] ' +
67
- '[--entitlement <relative-path> --customer <id> --mode <mode> [--target-registry <registry>]] ' +
72
+ '[--entitlement <relative-path> --entitlement-workflow-sha <40-hex git sha> --customer <id> --mode <mode> [--target-registry <registry>]] ' +
68
73
  '--data-root <dir> --editions-root <dir> --kernel <dir>';
69
74
  // The edition channel -> the kernel's evidence channel, stated once and explicitly.
70
75
  const EVIDENCE_CHANNEL_OF_EDITION_CHANNEL = Object.freeze({
71
76
  [EditionChannel.Release]: 'release',
72
77
  [EditionChannel.Develop]: 'develop',
73
78
  });
79
+ const GIT_COMMIT_SHA = /^[a-f0-9]{40}$/;
74
80
  const DATA_ROOT_FILES = Object.freeze(['edition-workspace.json', 'runtime-image-source-index.json']);
75
81
 
76
82
  function requireOptionValue(args, index, flag) {
@@ -101,6 +107,7 @@ function parseArgs(args) {
101
107
  cosignBinary: null,
102
108
  gitBinary: null,
103
109
  entitlementDescriptorPath: null,
110
+ entitlementWorkflowSha: null,
104
111
  customerId: null,
105
112
  mode: null,
106
113
  targetRegistry: null,
@@ -118,6 +125,7 @@ function parseArgs(args) {
118
125
  else if (flag === '--cosign') {options.cosignBinary = value;}
119
126
  else if (flag === '--git') {options.gitBinary = value;}
120
127
  else if (flag === '--entitlement') {options.entitlementDescriptorPath = value;}
128
+ else if (flag === '--entitlement-workflow-sha') {options.entitlementWorkflowSha = value;}
121
129
  else if (flag === '--customer') {options.customerId = value;}
122
130
  else if (flag === '--mode') {options.mode = value;}
123
131
  else if (flag === '--target-registry') {options.targetRegistry = value;}
@@ -137,9 +145,12 @@ function parseArgs(args) {
137
145
  }
138
146
  }
139
147
  // An entitlement is verified whole or not at all: the descriptor names the
140
- // signed statement, and the customer and mode are what it must be bound to.
148
+ // signed statement, the workflow SHA is the exact commit its certificate is
149
+ // held to (stated by the caller, never read from the evidence), and the
150
+ // customer and mode are what it must be bound to.
141
151
  const entitlementFlags = [
142
152
  ['--entitlement', options.entitlementDescriptorPath],
153
+ ['--entitlement-workflow-sha', options.entitlementWorkflowSha],
143
154
  ['--customer', options.customerId],
144
155
  ['--mode', options.mode],
145
156
  ];
@@ -150,7 +161,10 @@ function parseArgs(args) {
150
161
  );
151
162
  }
152
163
  if (stated.length === 0 && options.targetRegistry !== null) {
153
- throw new Error('--target-registry needs --entitlement, --customer and --mode');
164
+ throw new Error('--target-registry needs --entitlement, --entitlement-workflow-sha, --customer and --mode');
165
+ }
166
+ if (options.entitlementWorkflowSha !== null && !GIT_COMMIT_SHA.test(options.entitlementWorkflowSha)) {
167
+ throw new Error('--entitlement-workflow-sha must be a 40-character lowercase hexadecimal git commit SHA');
154
168
  }
155
169
  if (options.entitlementDescriptorPath !== null && isAbsolute(options.entitlementDescriptorPath)) {
156
170
  throw new Error('--entitlement must be an evidence-root-relative path');
@@ -185,6 +199,7 @@ export const EDITION_EVIDENCE_VERIFICATION_SCHEMA = 'xema.edition-evidence-verif
185
199
  * What `--json` prints once the evidence is verified: the channel it was verified
186
200
  * on, the release sequence it proved, and the statementDigest (the digest of the verified in-toto statement, not of the image) of every statement verified
187
201
  * (producers by subject, then completeness, then the entitlement if one was asked for).
202
+ * `entitlementDescriptor` is the Sigstore evidence descriptor `entitlement.json` holds.
188
203
  */
189
204
  export function evidenceVerificationDocument({ distributionId, channel, evidenceSet, verified, entitlementDescriptor }) {
190
205
  const producers = evidenceSet.producerEvidence
@@ -350,19 +365,32 @@ export async function verifyEvidence({ argv, env, bundlePath }) {
350
365
  cosignBinary: options.cosignBinary,
351
366
  schemas,
352
367
  });
353
- const entitlementDescriptor =
368
+ const entitlement =
354
369
  options.entitlementDescriptorPath === null
355
370
  ? null
356
- : readCanonicalJsonFromRoot(
357
- evidenceRoot,
358
- options.entitlementDescriptorPath,
359
- 'delivery entitlement evidence descriptor',
371
+ : parseEntitlementDescriptorDocument(
372
+ readCanonicalJsonFromRoot(
373
+ evidenceRoot,
374
+ options.entitlementDescriptorPath,
375
+ 'delivery entitlement descriptor',
376
+ ),
377
+ schemas,
360
378
  );
379
+ if (entitlement !== null) {
380
+ assertEntitlementDescriptorStates(entitlement, {
381
+ distributionId: options.distributionId,
382
+ customerId: options.customerId,
383
+ mode: options.mode,
384
+ targetRegistry: options.targetRegistry,
385
+ distributionStatementDigest: result.completenessStatementDigest,
386
+ });
387
+ }
388
+ const entitlementDescriptor = entitlement === null ? null : entitlement.evidence;
361
389
  const entitlementStatement =
362
390
  entitlementDescriptor === null
363
391
  ? null
364
392
  : await verifyImageDeliveryEntitlement({
365
- evidenceRoot,
393
+ evidenceRoot: entitlementEvidenceRoot(evidenceRoot, options.entitlementDescriptorPath),
366
394
  descriptor: entitlementDescriptor,
367
395
  policy: anchors.policy,
368
396
  policyRoot: dataRoot(),
@@ -373,9 +401,9 @@ export async function verifyEvidence({ argv, env, bundlePath }) {
373
401
  mode: options.mode,
374
402
  targetRegistry: options.targetRegistry,
375
403
  now: new Date(),
376
- // The entitlement predicate carries no producer run, so there
377
- // is no workflow SHA to demand and none is invented.
378
- expectedWorkflowSha: undefined,
404
+ // The entitlement predicate carries no producer run: the commit
405
+ // its certificate is held to is the caller's statement.
406
+ expectedWorkflowSha: options.entitlementWorkflowSha,
379
407
  // A nonce spent by a producer or completeness statement of
380
408
  // this release cannot be reused by its entitlement.
381
409
  consumedNonces: result.verifiedNonces,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xemahq/xema",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/xema-dev/xema-cli.git",
@@ -92,6 +92,6 @@
92
92
  "typecheck": "tsc --noEmit"
93
93
  },
94
94
  "xemaRelease": {
95
- "sourceDigest": "sha256:15b541996401a23867e6e902df97718dd508235377acf51ed81d915fd136d8d8"
95
+ "sourceDigest": "sha256:c33104cb26add433525e6447c931da38c0fc51a552b7f61f59b293734aeeb23c"
96
96
  }
97
97
  }