@xemahq/xema 0.29.1 → 0.30.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.
Files changed (39) hide show
  1. package/README.md +60 -389
  2. package/dist/commands/edition.js +3 -3
  3. package/dist/commands/edition.js.map +1 -1
  4. package/dist/edition/verify-image-evidence.bundle.mjs +8715 -807
  5. package/dist/edition/verify-image-evidence.bundle.provenance.json +873 -405
  6. package/edition/README.md +53 -273
  7. package/edition/adapter-bundled-versions.mjs +107 -0
  8. package/edition/adapter-host-versions.mjs +41 -0
  9. package/edition/authorization-base.mjs +18 -9
  10. package/edition/build-runtime-image-source-index.mjs +77 -4
  11. package/edition/catalogue.mjs +15 -12
  12. package/edition/customer-definition.mjs +3 -20
  13. package/edition/develop-customer-edition.mjs +2 -2
  14. package/edition/develop-edition-resolve.mjs +10 -2
  15. package/edition/develop-edition.mjs +56 -62
  16. package/edition/develop-freeze.mjs +14 -3
  17. package/edition/dist-check.mjs +1 -0
  18. package/edition/distribution-evidence-set.mjs +32 -5
  19. package/edition/edition-channel.mjs +15 -1
  20. package/edition/edition-locks.mjs +16 -6
  21. package/edition/edition-protocol.mjs +30 -8
  22. package/edition/edition-workspace.mjs +8 -0
  23. package/edition/image-evidence.mjs +133 -25
  24. package/edition/kernel-package.mjs +193 -0
  25. package/edition/lib.mjs +73 -1
  26. package/edition/oci-evidence-verbs.mjs +2 -2
  27. package/edition/plan-image-evidence-refresh.mjs +2 -2
  28. package/edition/produce-distribution-completeness-evidence.mjs +5 -0
  29. package/edition/produce-image-delivery-entitlement-evidence.mjs +11 -1
  30. package/edition/release-catalogue.mjs +10 -3
  31. package/edition/resolve-chart-set.mjs +1 -1
  32. package/edition/resolve-customer-edition.mjs +29 -33
  33. package/edition/resolve-distribution.mjs +5 -1
  34. package/edition/resolve-edition.mjs +3 -2
  35. package/edition/seal-image-digests.mjs +16 -4
  36. package/edition/trust-policy-arming-verb.mjs +2 -10
  37. package/edition/verify-image-evidence.mjs +1 -0
  38. package/examples/README.md +3 -2
  39. package/package.json +3 -3
@@ -112,6 +112,7 @@ function assertCheckedOutPin({ key, sha, flag, what, overrides }) {
112
112
  * @param {string} request.kernelSha the commit the kernel checkout is at
113
113
  * @param {string} request.deploySha the commit the deploy checkout is at
114
114
  * @param {string} [request.editionsRootUrl] https URL of the editions root; absent = this repository
115
+ * @param {(source: {name: string, url: string}) => void} request.holdSource the hold of one source; runs for ALL graph sources before the first head
115
116
  */
116
117
  export function freezeGraph({
117
118
  runtimeSourceIndex,
@@ -121,6 +122,7 @@ export function freezeGraph({
121
122
  editionsRootUrl,
122
123
  overrides = {},
123
124
  resolveHead,
125
+ holdSource,
124
126
  }) {
125
127
  assertNoToolingOverride(overrides);
126
128
  assertOverrides(overrides, GRAPH_PHASE);
@@ -137,6 +139,12 @@ export function freezeGraph({
137
139
  { key: deployPinKey(), sha: deploySha, flag: '--deploy-sha', what: 'the deploy checkout' },
138
140
  ];
139
141
  for (const { key, sha, flag, what } of checkedOut) {assertCheckedOutPin({ key, sha, flag, what, overrides });}
142
+ const toResolve = runtimeSourceIndex.repositoryPins.map((pin) => ({ name: pin.repo, url: pin.repositoryUrl }));
143
+ if (editionsRootUrl !== undefined) {
144
+ toResolve.push({ name: EDITIONS_ROOT_PIN_KEY, url: canonicalPublicHttpsRepositoryUrl(editionsRootUrl, '--editions-root-url') });
145
+ }
146
+ // Every source is held before the first head is asked: a refusal of the last leaves no git call behind.
147
+ for (const source of toResolve) {holdSource(source);}
140
148
  if (editionsRootUrl === undefined && overrides[EDITIONS_ROOT_PIN_KEY] !== undefined) {
141
149
  throw new Error(`override names "${EDITIONS_ROOT_PIN_KEY}", but the editions root is this repository`);
142
150
  }
@@ -155,8 +163,9 @@ export function freezeGraph({
155
163
  * @param {object} request
156
164
  * @param {Record<string,string>} request.pins the graph phase's pins
157
165
  * @param {object[]} request.declaredSources every `sources[]` entry in the chain (`{ name, url, ... }`)
166
+ * @param {(source: {name: string, url: string}) => void} request.holdSource the hold of one source (`holdSource` of git-source-auth with the workspace and env); runs for ALL sources before the first head
158
167
  */
159
- export function freezeSources({ pins, declaredSources, overrides = {}, resolveHead }) {
168
+ export function freezeSources({ pins, declaredSources, overrides = {}, resolveHead, holdSource }) {
160
169
  assertOverrides(overrides, SOURCE_PHASE);
161
170
  const head = memoizedHeads(resolveHead);
162
171
  const sourceByName = new Map();
@@ -172,6 +181,8 @@ export function freezeSources({ pins, declaredSources, overrides = {}, resolveHe
172
181
  throw new Error(`override names "${key}", but the chain declares no such source`);
173
182
  }
174
183
  }
184
+ // Every source is held before the first head is asked: a refusal of the last leaves no git call behind.
185
+ for (const source of sourceByName.values()) {holdSource(source);}
175
186
  const frozen = { ...pins };
176
187
  for (const [name, source] of sourceByName) {
177
188
  frozen[sourcePinKey(name)] = overrides[sourcePinKey(name)] ?? head(source);
@@ -207,11 +218,11 @@ export function assertCustomerSourceUrls(sources, sourceRepositoryUrlPrefix) {
207
218
  * each the head of refs/heads/develop of that source's repository, resolved once. Every URL is held by
208
219
  * `assertCustomerSourceUrls` before the first head is asked.
209
220
  */
210
- export function freezeCustomerSources({ definition, resolveHead, sourceRepositoryUrlPrefix }) {
221
+ export function freezeCustomerSources({ definition, resolveHead, holdSource, sourceRepositoryUrlPrefix }) {
211
222
  const sources = definition?.sources ?? [];
212
223
  if (!Array.isArray(sources) || sources.some((source) => typeof source?.name !== 'string' || typeof source?.url !== 'string')) {
213
224
  throw new Error('the definition\'s sources must be an array of {name, url}');
214
225
  }
215
226
  assertCustomerSourceUrls(sources, sourceRepositoryUrlPrefix);
216
- return freezeSources({ pins: {}, declaredSources: sources, resolveHead });
227
+ return freezeSources({ pins: {}, declaredSources: sources, resolveHead, holdSource });
217
228
  }
@@ -194,6 +194,7 @@ async function checkAuthorizationLock(id, kernel, index) {
194
194
  distributionId: id,
195
195
  editionDir,
196
196
  lockBiomes: lock.biomes,
197
+ biomeOrigins: (await loadKernelDistributionSchemas()).biomeOriginsOfLock(lock),
197
198
  index,
198
199
  kernel,
199
200
  serialize: serializeJson,
@@ -464,6 +464,7 @@ export async function resolveProducerEvidence({
464
464
  schemas,
465
465
  expectedContractAuthority,
466
466
  registryClientFactory,
467
+ channel,
467
468
  }) {
468
469
  assertCanonicalDistributionId(distributionId);
469
470
  assertAbsolutePath(dataRoot, 'data root');
@@ -472,7 +473,7 @@ export async function resolveProducerEvidence({
472
473
  throw new Error('the expected kernel contract authority is required');
473
474
  }
474
475
  const { distributionLock, imageLock, repositoryPins, runtimeSourceIndex } =
475
- await loadValidatedLocks(distributionId, dataRoot, schemas, { editionsRoot });
476
+ await loadValidatedLocks(distributionId, dataRoot, schemas, { editionsRoot, channel });
476
477
  assertKernelContractAuthorityEqual(
477
478
  runtimeSourceIndex.contractAuthority,
478
479
  expectedContractAuthority,
@@ -581,6 +582,22 @@ export async function resolveProducerEvidence({
581
582
  // Statement shapes
582
583
  // ─────────────────────────────────────────────────────────────────────────────
583
584
 
585
+ /**
586
+ * The evidence channel a signer signs for: the one whose kernel-assigned ref (`EVIDENCE_CHANNEL_REFS`) is the
587
+ * signer's workflow ref. The kernel's policy schema ties each policy row to exactly that ref, so the signer's ref
588
+ * IS the channel the verifying row will state. A ref no channel owns is refused.
589
+ */
590
+ export function evidenceChannelOfSigner(signer, schemas) {
591
+ const ref = signer?.githubWorkflow?.ref;
592
+ const channel = schemas.EVIDENCE_CHANNELS.find((candidate) => schemas.EVIDENCE_CHANNEL_REFS[candidate] === ref);
593
+ if (channel === undefined) {
594
+ throw new Error(
595
+ `the signer workflow ref "${String(ref)}" is the ref of no evidence channel (${schemas.EVIDENCE_CHANNELS.map((candidate) => `${candidate}: ${schemas.EVIDENCE_CHANNEL_REFS[candidate]}`).join(', ')})`,
596
+ );
597
+ }
598
+ return channel;
599
+ }
600
+
584
601
  /**
585
602
  * The distribution-completeness statement: "these EXACT images, produced by
586
603
  * these EXACT bundles, are the complete image set of this edition at this
@@ -590,6 +607,7 @@ export function buildDistributionImagesStatement({
590
607
  distributionLock,
591
608
  imageLock,
592
609
  images,
610
+ channel,
593
611
  releaseSequence,
594
612
  releaseSourceCommit,
595
613
  runId,
@@ -599,7 +617,7 @@ export function buildDistributionImagesStatement({
599
617
  maxTtlSeconds,
600
618
  schemas,
601
619
  }) {
602
- if (!schemas?.parseDistributionImagesStatement) {
620
+ if (!schemas?.parseDistributionImagesStatement || typeof schemas.distributionLockDigest !== 'function') {
603
621
  throw new Error('trusted kernel distribution schemas are required');
604
622
  }
605
623
  if (
@@ -664,7 +682,9 @@ export function buildDistributionImagesStatement({
664
682
  ],
665
683
  predicateType: schemas.DISTRIBUTION_IMAGES_PREDICATE_TYPE,
666
684
  predicate: {
667
- schemaVersion: 1,
685
+ schemaVersion: 2,
686
+ channel,
687
+ lockDigest: schemas.distributionLockDigest(distributionLock),
668
688
  distributionId: imageLock.distributionId,
669
689
  distributionInputHash: distributionLock.inputHash,
670
690
  normalizedImageLockDigest: normalizedLockDigest,
@@ -706,6 +726,7 @@ export function buildDistributionImagesStatement({
706
726
  * decision that already has an owner.
707
727
  */
708
728
  export function buildImageDeliveryEntitlementStatement({
729
+ distributionLock,
709
730
  distributionId,
710
731
  completenessStatementDigest,
711
732
  customerId,
@@ -719,10 +740,13 @@ export function buildImageDeliveryEntitlementStatement({
719
740
  maxTtlSeconds,
720
741
  schemas,
721
742
  }) {
722
- if (!schemas?.parseImageDeliveryEntitlementStatement) {
743
+ if (!schemas?.parseImageDeliveryEntitlementStatement || typeof schemas.distributionLockDigest !== 'function') {
723
744
  throw new Error('trusted kernel distribution schemas are required');
724
745
  }
725
746
  assertCanonicalDistributionId(distributionId);
747
+ if (distributionLock?.distributionId !== distributionId) {
748
+ throw new Error(`delivery entitlement for ${String(customerId)} requires the distribution lock of ${distributionId}`);
749
+ }
726
750
  const label = `delivery entitlement for ${String(customerId)}`;
727
751
  if (!SHA256_DIGEST_PATTERN.test(completenessStatementDigest ?? '')) {
728
752
  throw new Error(
@@ -751,9 +775,12 @@ export function buildImageDeliveryEntitlementStatement({
751
775
  ],
752
776
  predicateType: schemas.IMAGE_DELIVERY_ENTITLEMENT_PREDICATE_TYPE,
753
777
  predicate: {
754
- schemaVersion: 1,
778
+ schemaVersion: 2,
755
779
  customerId,
756
780
  distributionId,
781
+ lockDigest: schemas.distributionLockDigest(distributionLock),
782
+ // Present-and-nullable: null states the lock carries no catalogue; the lock digest binds the lock either way.
783
+ catalogueDigest: distributionLock.catalogueDigest ?? null,
757
784
  distributionStatementDigest: completenessStatementDigest,
758
785
  mode,
759
786
  targetRegistry,
@@ -2,5 +2,19 @@
2
2
  // validator (which a channel selects) and edition-constants (which imports the
3
3
  // chart-set file name) do not import each other.
4
4
 
5
- /** Where an edition was produced. Recorded in the descriptor's provenance; readers never branch on it. */
5
+ /**
6
+ * Where an edition was produced. Recorded in the descriptor's provenance. verify-evidence selects its policy row by the
7
+ * descriptor's channel (a predicate channel mismatch is still refused after the signature check); lock readers require one.
8
+ */
6
9
  export const EditionChannel = Object.freeze({ Develop: 'develop', Release: 'release' });
10
+
11
+ /**
12
+ * The channel of `--channel <channel>`, which must be all of `rest`; anything
13
+ * else is a usage error. Shared by every verb that operates on one channel.
14
+ */
15
+ export function parseChannelArgs(rest) {
16
+ if (rest.length !== 2 || rest[0] !== '--channel' || !Object.values(EditionChannel).includes(rest[1])) {
17
+ throw new Error(`--channel <${Object.values(EditionChannel).join('|')}> is required and nothing else is accepted (got ${rest.length === 0 ? 'nothing' : JSON.stringify(rest.join(' '))})`);
18
+ }
19
+ return { channel: rest[1] };
20
+ }
@@ -1,15 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  // `xema edition locks <id>`: one edition's validated locks, as JSON.
3
3
  //
4
- // usage: xema edition locks <id> --data-root <dir> --editions-root <dir> --kernel <dir>
4
+ // usage: xema edition locks <id> --channel <release|develop> --data-root <dir> --editions-root <dir> --kernel <dir>
5
5
  //
6
6
  // The locks are read and validated exactly as the evidence verifier reads
7
7
  // them: `images.lock.json` and `distribution.lock.json` under the kernel
8
8
  // schema, immutable coordinates only, and the image lock's kernel contract
9
- // authority equal to the data root's runtime image source index. A consumer
9
+ // authority equal to the data root's runtime image source index. The
10
+ // distribution lock is read for `--channel`: an unsealed lock on develop only,
11
+ // a sealed one on every other channel. A consumer
10
12
  // reads this output instead of parsing or validating a lock itself.
11
13
 
12
14
  import { assertCanonicalDistributionId } from './distribution-paths.mjs';
15
+ import { EditionChannel, parseChannelArgs } from './edition-channel.mjs';
13
16
  import {
14
17
  WorkspaceRoot,
15
18
  configureEditionWorkspaceFromArgv,
@@ -21,12 +24,12 @@ import { loadValidatedLocks } from './image-evidence.mjs';
21
24
  import { compareStrings, loadKernelDistributionSchemas, serializeJson } from './lib.mjs';
22
25
 
23
26
  const USAGE =
24
- 'usage: xema edition locks <id> --data-root <dir> --editions-root <dir> --kernel <dir>';
27
+ `usage: xema edition locks <id> --channel <${Object.values(EditionChannel).join('|')}> --data-root <dir> --editions-root <dir> --kernel <dir>`;
25
28
 
26
- export async function readEditionLocks({ distributionId, dataRoot: data, editionsRoot: editions, schemas }) {
29
+ export async function readEditionLocks({ distributionId, channel, dataRoot: data, editionsRoot: editions, schemas }) {
27
30
  assertCanonicalDistributionId(distributionId);
28
31
  const { distributionLock, imageLock, repositoryPins, runtimeSourceIndex } =
29
- await loadValidatedLocks(distributionId, data, schemas, { editionsRoot: editions });
32
+ await loadValidatedLocks(distributionId, data, schemas, { editionsRoot: editions, channel });
30
33
  return {
31
34
  schemaVersion: 1,
32
35
  distributionId,
@@ -45,14 +48,21 @@ async function main() {
45
48
  WorkspaceRoot.Editions,
46
49
  WorkspaceRoot.Kernel,
47
50
  ]);
48
- if (rest.length !== 1) {
51
+ if (rest.length === 0 || rest[0].startsWith('--')) {
49
52
  throw new Error(USAGE);
50
53
  }
54
+ let channel;
55
+ try {
56
+ ({ channel } = parseChannelArgs(rest.slice(1)));
57
+ } catch (error) {
58
+ throw new Error(`${error.message}\n${USAGE}`);
59
+ }
51
60
  const schemas = await loadKernelDistributionSchemas();
52
61
  process.stdout.write(
53
62
  serializeJson(
54
63
  await readEditionLocks({
55
64
  distributionId: rest[0],
65
+ channel,
56
66
  dataRoot: dataRoot(),
57
67
  editionsRoot: editionsRoot(),
58
68
  schemas,
@@ -2,7 +2,7 @@
2
2
  // it too.
3
3
  //
4
4
  // The kernel (`@xemahq/contracts/edition-protocol`) exports the identity of the
5
- // code this library executes as DATA: `{contentDigest, version}` in
5
+ // code this library executes as DATA: `{contentDigest, provisional, version}` in
6
6
  // `dist/edition-protocol/edition-protocol.json`, and the edition contract in
7
7
  // `dist/edition-protocol/edition-contract.schema.json` (the identity, the
8
8
  // hashing convention, the entry subpaths this library may load, and every
@@ -31,8 +31,8 @@ import { readBoundedNoFollowFile } from './no-follow-file.mjs';
31
31
  /** The edition protocols this library interprets: version -> content digest. */
32
32
  export const SUPPORTED_EDITION_PROTOCOLS = Object.freeze([
33
33
  Object.freeze({
34
- version: 1,
35
- contentDigest: 'd5b7ce85aa6596730527da64a7f6682684039398a88fd168429f6e4a3f0438bd',
34
+ version: 3,
35
+ contentDigest: '8d8ec9160f78ab46e50efe9da90633d9a906af7cdb0d7837ed3b1f9e885716a4',
36
36
  }),
37
37
  ]);
38
38
 
@@ -52,8 +52,10 @@ const SHA256_HEX = /^[a-f0-9]{64}$/;
52
52
  export const EditionProtocolMismatchReason = Object.freeze({
53
53
  /** The kernel publishes no edition protocol identity. */
54
54
  Undeclared: 'undeclared',
55
- /** The identity file is not exactly `{contentDigest, version}`. */
55
+ /** The identity file is not exactly `{contentDigest, provisional, version}`. */
56
56
  Malformed: 'malformed',
57
+ /** The identity states `provisional: true`: a branch-local working identity, never pinned. */
58
+ Provisional: 'provisional',
57
59
  /** This library supports no protocol of that version. */
58
60
  UnsupportedVersion: 'unsupported-version',
59
61
  /** The version is supported, but pinned to a different digest. */
@@ -77,7 +79,20 @@ const describe = ({ version, contentDigest }) => `edition protocol v${version} (
77
79
  const supportedList = () =>
78
80
  SUPPORTED_EDITION_PROTOCOLS.map((protocol) => `v${protocol.version}`).join(', ');
79
81
 
80
- /** Parse identity bytes strictly: exactly a positive integer version and a lowercase SHA-256 hex digest. */
82
+ /** Refuse an identity that states `provisional: true`; nothing provisional is ever read as a protocol. */
83
+ function assertFinalIdentity(identity, label) {
84
+ if (identity.provisional !== false) {
85
+ throw new EditionProtocolMismatchError(
86
+ EditionProtocolMismatchReason.Provisional,
87
+ `${label} declares ${describe(identity)} as provisional (a branch-local working identity); a provisional edition protocol is never pinned`,
88
+ );
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Parse identity bytes strictly: exactly `{contentDigest, provisional, version}`, a positive integer version, a
94
+ * lowercase SHA-256 hex digest and a boolean `provisional`. An identity stating `provisional: true` is refused.
95
+ */
81
96
  export function parseEditionProtocolIdentity(bytes, label) {
82
97
  let identity;
83
98
  try {
@@ -92,7 +107,8 @@ export function parseEditionProtocolIdentity(bytes, label) {
92
107
  identity === null ||
93
108
  typeof identity !== 'object' ||
94
109
  Array.isArray(identity) ||
95
- Object.keys(identity).sort().join() !== 'contentDigest,version' ||
110
+ Object.keys(identity).sort().join() !== 'contentDigest,provisional,version' ||
111
+ typeof identity.provisional !== 'boolean' ||
96
112
  !Number.isSafeInteger(identity.version) ||
97
113
  identity.version < 1 ||
98
114
  typeof identity.contentDigest !== 'string' ||
@@ -100,10 +116,15 @@ export function parseEditionProtocolIdentity(bytes, label) {
100
116
  ) {
101
117
  throw new EditionProtocolMismatchError(
102
118
  EditionProtocolMismatchReason.Malformed,
103
- `${label} must be exactly {"contentDigest":"<64 lowercase hex>","version":<positive integer>}`,
119
+ `${label} must be exactly {"contentDigest":"<64 lowercase hex>","provisional":<boolean>,"version":<positive integer>}`,
104
120
  );
105
121
  }
106
- return Object.freeze({ contentDigest: identity.contentDigest, version: identity.version });
122
+ assertFinalIdentity(identity, label);
123
+ return Object.freeze({
124
+ contentDigest: identity.contentDigest,
125
+ provisional: identity.provisional,
126
+ version: identity.version,
127
+ });
107
128
  }
108
129
 
109
130
  /** The `{version, contentDigest}` a contract file states, or undefined. */
@@ -216,6 +237,7 @@ export function assertKernelEditionProtocol(kernelPackageDir) {
216
237
  * it, so the kernel the workspace states must speak exactly that protocol.
217
238
  */
218
239
  export function assertKernelSpeaksBundledProtocol({ kernelPackageDir, bundled }) {
240
+ assertFinalIdentity(bundled, 'the edition protocol compiled into this verifier');
219
241
  const stated = readKernelEditionProtocol(kernelPackageDir);
220
242
  if (stated.version !== bundled.version || stated.contentDigest !== bundled.contentDigest) {
221
243
  throw new EditionProtocolMismatchError(
@@ -70,6 +70,14 @@ export function configureEditionWorkspace(roots) {
70
70
  return configured;
71
71
  }
72
72
 
73
+ /**
74
+ * State the Kernel root AFTER configuration, for a customer verb that takes its kernel from the catalogue
75
+ * (kernel-package.mjs) rather than from a flag. Every other configured root is kept; there is still no default.
76
+ */
77
+ export function stateKernelPackageDir(dir) {
78
+ configured = Object.freeze({ ...configured, [WorkspaceRoot.Kernel]: assertDirectory(WorkspaceRoot.Kernel, dir) });
79
+ }
80
+
73
81
  function root(name) {
74
82
  const value = configured[name];
75
83
  if (value === undefined) {
@@ -593,6 +593,13 @@ export function validateEntitlementStatementBinding({
593
593
  return statement;
594
594
  }
595
595
 
596
+ /** Throw the kernel's refusal code, verbatim, when a kernel predicate check returned one. */
597
+ function assertKernelPredicateAccepted(label, refusal) {
598
+ if (refusal !== undefined) {
599
+ throw new Error(`${label} is refused: ${refusal}`);
600
+ }
601
+ }
602
+
596
603
  function assertEvidenceChannel(channel) {
597
604
  if (typeof channel !== 'string' || channel === '') {
598
605
  throw new Error('evidence verification requires the channel of the edition being verified');
@@ -670,6 +677,7 @@ export function verifySigstoreEvidenceDescriptor({
670
677
  parseStatement,
671
678
  policyRoot = evidenceRoot,
672
679
  trustedCosign,
680
+ holdProducerWorkflow,
673
681
  }) {
674
682
  assertEvidenceChannel(channel);
675
683
  const statementFile = readExactDescriptorFile(
@@ -744,6 +752,7 @@ export function verifySigstoreEvidenceDescriptor({
744
752
  statement.predicate?.producerRun?.producerWorkflowSha;
745
753
  let verified = false;
746
754
  let verifiedIdentityPolicy;
755
+ let verifiedCandidateWorkflowSha;
747
756
  const verificationErrors = [];
748
757
  for (const candidate of candidates) {
749
758
  if (!verifiedWorkflowSha) {
@@ -806,6 +815,7 @@ export function verifySigstoreEvidenceDescriptor({
806
815
  });
807
816
  verified = true;
808
817
  verifiedIdentityPolicy = candidate;
818
+ verifiedCandidateWorkflowSha = verifiedWorkflowSha;
809
819
  break;
810
820
  } catch (error) {
811
821
  verificationErrors.push(
@@ -826,6 +836,16 @@ export function verifySigstoreEvidenceDescriptor({
826
836
  verificationErrors.join('; '),
827
837
  );
828
838
  }
839
+ // The certificate's workflow commit is held to the data root's operator-stated pin for that workflow's repository.
840
+ if (holdProducerWorkflow !== undefined) {
841
+ assertKernelPredicateAccepted(
842
+ `${predicateType} signer ${verifiedIdentityPolicy.githubWorkflow.repository}`,
843
+ holdProducerWorkflow({
844
+ repository: verifiedIdentityPolicy.githubWorkflow.repository,
845
+ workflowCommit: verifiedCandidateWorkflowSha,
846
+ }),
847
+ );
848
+ }
829
849
  return {
830
850
  statement,
831
851
  statementPath: statementFile.path,
@@ -874,10 +894,12 @@ async function verifyDistributionImageEvidenceSetWithCosign({
874
894
  distributionLock,
875
895
  imageLock,
876
896
  repositoryPins,
877
- runtimeSourceIndex: { contractAuthority: expectedContractAuthority },
897
+ runtimeSourceIndex,
878
898
  } = await loadValidatedLocks(distributionId, dataRoot, schemas, {
879
899
  editionsRoot,
900
+ channel,
880
901
  });
902
+ const expectedContractAuthority = runtimeSourceIndex.contractAuthority;
881
903
  if (evidenceSet.distributionId !== distributionId) {
882
904
  throw new Error(
883
905
  `evidence set "${evidenceSet.distributionId}" does not match requested distribution "${distributionId}"`,
@@ -922,6 +944,7 @@ async function verifyDistributionImageEvidenceSetWithCosign({
922
944
  parseStatement: schemas.parseImageProducerStatement,
923
945
  policyRoot,
924
946
  trustedCosign,
947
+ holdProducerWorkflow: (producer) => schemas.producerWorkflowPinRefusal(runtimeSourceIndex, producer),
925
948
  });
926
949
  validateProducerStatementBinding({
927
950
  statement,
@@ -943,7 +966,7 @@ async function verifyDistributionImageEvidenceSetWithCosign({
943
966
  }
944
967
  }
945
968
 
946
- const { statement: completenessStatement } = verifySigstoreEvidenceDescriptor(
969
+ const { statement: completenessStatement, identityPolicy: completenessPolicy } = verifySigstoreEvidenceDescriptor(
947
970
  {
948
971
  evidenceRoot,
949
972
  descriptor: evidenceSet.completeness,
@@ -956,6 +979,16 @@ async function verifyDistributionImageEvidenceSetWithCosign({
956
979
  trustedCosign,
957
980
  },
958
981
  );
982
+ // After the signature verified: the channel the predicate states is the verifying policy row's, and the
983
+ // predicate completes THIS lock. A kernel refusal is terminal and is surfaced by its code.
984
+ assertKernelPredicateAccepted(
985
+ `distribution completeness for ${imageLock.distributionId}`,
986
+ schemas.distributionImagesPredicateChannelRefusal(completenessStatement.predicate, completenessPolicy),
987
+ );
988
+ assertKernelPredicateAccepted(
989
+ `distribution completeness for ${imageLock.distributionId}`,
990
+ schemas.distributionImagesPredicateLockRefusal(completenessStatement.predicate, distributionLock),
991
+ );
959
992
  validateCompletenessStatementBinding({
960
993
  statement: completenessStatement,
961
994
  distributionLock,
@@ -1002,6 +1035,7 @@ async function verifyImageDeliveryEntitlementWithCosign({
1002
1035
  completenessStatementDigest,
1003
1036
  customerId,
1004
1037
  distributionId,
1038
+ distributionLock,
1005
1039
  channel,
1006
1040
  mode,
1007
1041
  targetRegistry,
@@ -1023,6 +1057,10 @@ async function verifyImageDeliveryEntitlementWithCosign({
1023
1057
  policyRoot,
1024
1058
  trustedCosign,
1025
1059
  });
1060
+ assertKernelPredicateAccepted(
1061
+ `delivery entitlement for ${customerId}`,
1062
+ schemas.imageDeliveryEntitlementRefusal(statement.predicate, distributionLock),
1063
+ );
1026
1064
  validateEntitlementStatementBinding({
1027
1065
  statement,
1028
1066
  completenessStatementDigest,
@@ -1124,20 +1162,57 @@ export function externalSourceRepositoryPins({
1124
1162
  * tolerant read-only posture `parseDistributionLockForReading` gives a
1125
1163
  * runtime, without depending on which kernel the bundle was built from.
1126
1164
  *
1127
- * `schemaVersion` must be 5, the one grammar there is (the kernel's
1128
- * `DISTRIBUTION_LOCK_SCHEMA_VERSIONS`): a pre-v5 lock is refused, with no
1129
- * compatibility path.
1165
+ * Two grammars exist, and which one is accepted is decided by `channel`, the
1166
+ * kernel evidence channel (`EVIDENCE_CHANNELS`) the caller operates on; it is
1167
+ * required and has no default. An UNSEALED lock (it carries `unsealedSchema`;
1168
+ * every edition lock on develop is one by design) is accepted ONLY on the
1169
+ * develop channel, read through the kernel's `parseUnsealedDistributionLock`.
1170
+ * Every other channel requires a SEALED lock: images sealed over an unsealed
1171
+ * lock is the crash state every sealed reader refuses, and a release statement
1172
+ * must never verify over it. A SEALED lock must have `schemaVersion` 5 (the
1173
+ * kernel's `DISTRIBUTION_LOCK_SCHEMA_VERSIONS`): a pre-v5 lock is refused, with
1174
+ * no compatibility path. Both carry `distributionId` and `inputHash`, and
1175
+ * sealing preserves them, so either way the binding is the same two fields.
1130
1176
  */
1131
- export function readDistributionLockBinding(raw) {
1177
+ /** The one evidence channel an unsealed distribution lock is read on. */
1178
+ const UNSEALED_LOCK_CHANNEL = 'develop';
1179
+
1180
+ /**
1181
+ * The channel a distribution lock is read for: one of the kernel's
1182
+ * `EVIDENCE_CHANNELS`, stated by the caller. Missing or unknown is refused.
1183
+ */
1184
+ function assertLockReadingChannel(channel, schemas) {
1185
+ if (!schemas) {
1186
+ throw new Error('trusted kernel distribution schemas are required');
1187
+ }
1188
+ if (!schemas.EVIDENCE_CHANNELS.includes(channel)) {
1189
+ throw new Error(
1190
+ `a distribution lock is read for one evidence channel (${schemas.EVIDENCE_CHANNELS.join(' | ')}); got ${JSON.stringify(channel)}`,
1191
+ );
1192
+ }
1193
+ }
1194
+
1195
+ export function readDistributionLockBinding(raw, schemas, channel) {
1196
+ assertLockReadingChannel(channel, schemas);
1132
1197
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
1133
1198
  throw new Error('distribution.lock.json is not a JSON object');
1134
1199
  }
1135
- const { schemaVersion, distributionId, inputHash } = raw;
1136
- if (schemaVersion !== 5) {
1200
+ let source = raw;
1201
+ if ('unsealedSchema' in raw) {
1202
+ if (channel !== UNSEALED_LOCK_CHANNEL) {
1203
+ throw new Error(
1204
+ `distribution.lock.json is unsealed; the ${channel} channel accepts only a sealed lock (schemaVersion 5) — ` +
1205
+ `an unsealed lock is read on the ${UNSEALED_LOCK_CHANNEL} channel only`,
1206
+ );
1207
+ }
1208
+ source = schemas.parseUnsealedDistributionLock(raw);
1209
+ } else if (raw.schemaVersion !== 5) {
1137
1210
  throw new Error(
1138
- `distribution.lock.json has schemaVersion ${String(schemaVersion)}; the only accepted grammar is 5 (regenerate the lock)`,
1211
+ `distribution.lock.json is neither an unsealed lock (carrying unsealedSchema) ` +
1212
+ `nor a sealed lock (schemaVersion 5); it has schemaVersion ${String(raw.schemaVersion)} (regenerate the lock)`,
1139
1213
  );
1140
1214
  }
1215
+ const { distributionId, inputHash } = source;
1141
1216
  // The id is format-checked only. Which edition it must equal is decided by
1142
1217
  // the bindings that USE it — the completeness predicate and the evidence set
1143
1218
  // compare it to the image lock and the signed statement — exactly as before
@@ -1149,21 +1224,63 @@ export function readDistributionLockBinding(raw) {
1149
1224
  'expected 64 lowercase sha256 hex characters',
1150
1225
  );
1151
1226
  }
1152
- return Object.freeze({ schemaVersion, distributionId, inputHash });
1227
+ return Object.freeze({ distributionId, inputHash });
1228
+ }
1229
+
1230
+ /**
1231
+ * The edition's sealed `distribution.lock.json`, parsed by the kernel's strict lock parser: the document the
1232
+ * predicates' `lockDigest` is computed over. Held to the binding rules of {@link readDistributionLockBinding} first.
1233
+ */
1234
+ export function readEditionDistributionLock(editionsRoot, distributionId, schemas, channel) {
1235
+ assertLockReadingChannel(channel, schemas);
1236
+ const raw = JSON.parse(
1237
+ readFileSync(join(editionsRoot, distributionId, 'distribution.lock.json'), 'utf8'),
1238
+ );
1239
+ readDistributionLockBinding(raw, schemas, channel);
1240
+ // The WHOLE lock in its own grammar, never the two-field binding: the
1241
+ // predicate and entitlement refusals recompute distributionLockDigest over
1242
+ // what this returns, so an unsealed develop lock is digested as itself.
1243
+ return 'unsealedSchema' in raw ? schemas.parseUnsealedDistributionLock(raw) : schemas.parseDistributionLock(raw);
1153
1244
  }
1154
1245
 
1155
1246
  /**
1156
1247
  * Read and validate an edition's two locks plus the runtime image source index
1157
- * they must agree with.
1248
+ * they must agree with. The distribution lock is read for `channel`
1249
+ * ({@link readDistributionLockBinding}); the rest is {@link loadValidatedImageLocks}.
1250
+ */
1251
+ export async function loadValidatedLocks(
1252
+ distributionId,
1253
+ dataRoot,
1254
+ schemas,
1255
+ { editionsRoot, channel },
1256
+ ) {
1257
+ if (!schemas) {
1258
+ throw new Error('trusted kernel distribution schemas are required');
1259
+ }
1260
+ const distributionLock = readEditionDistributionLock(
1261
+ editionsRoot,
1262
+ distributionId,
1263
+ schemas,
1264
+ channel,
1265
+ );
1266
+ return {
1267
+ distributionLock,
1268
+ ...(await loadValidatedImageLocks(distributionId, dataRoot, schemas, { editionsRoot })),
1269
+ };
1270
+ }
1271
+
1272
+ /**
1273
+ * Read and validate an edition's image lock plus the runtime image source index
1274
+ * it must agree with; the distribution lock is not read.
1158
1275
  *
1159
1276
  * `dataRoot` holds the runtime image source index, read from it always.
1160
- * `editionsRoot` is where `<id>/distribution.lock.json`,
1161
- * `<id>/images.lock.json` and `<id>/xema-distribution.json` are read from. The
1162
- * manifest extends the repository pins with the edition's own declared sources
1277
+ * `editionsRoot` is where `<id>/images.lock.json` and
1278
+ * `<id>/xema-distribution.json` are read from. The manifest extends the
1279
+ * repository pins with the edition's own declared sources
1163
1280
  * ({@link externalSourceRepositoryPins}); the verifier grades the editions
1164
- * checkout so all three files are HEAD-bound.
1281
+ * checkout so the files it reads are HEAD-bound.
1165
1282
  */
1166
- export async function loadValidatedLocks(
1283
+ export async function loadValidatedImageLocks(
1167
1284
  distributionId,
1168
1285
  dataRoot,
1169
1286
  schemas,
@@ -1172,14 +1289,6 @@ export async function loadValidatedLocks(
1172
1289
  if (!schemas) {
1173
1290
  throw new Error('trusted kernel distribution schemas are required');
1174
1291
  }
1175
- const distributionLock = readDistributionLockBinding(
1176
- JSON.parse(
1177
- readFileSync(
1178
- join(editionsRoot, distributionId, 'distribution.lock.json'),
1179
- 'utf8',
1180
- ),
1181
- ),
1182
- );
1183
1292
  const imageLock = validateImmutableImageLock(
1184
1293
  schemas.parseImageLock(
1185
1294
  JSON.parse(
@@ -1235,7 +1344,6 @@ export async function loadValidatedLocks(
1235
1344
  }
1236
1345
  return {
1237
1346
  schemas,
1238
- distributionLock,
1239
1347
  imageLock,
1240
1348
  runtimeSourceIndex,
1241
1349
  repositoryPins,