@xemahq/xema 0.48.0 → 0.49.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 (98) hide show
  1. package/dist/assets/appliance-base/distribution.lock.json +3936 -1515
  2. package/dist/assets/appliance-base/images.lock.json +878 -1161
  3. package/dist/assets/appliance-lean/distribution.lock.json +3663 -916
  4. package/dist/assets/appliance-lean/images.lock.json +825 -924
  5. package/dist/assets/boot-graph.json +398 -93
  6. package/dist/assets/distribution-assets.manifest.json +10 -10
  7. package/dist/commands/edition.d.ts.map +1 -1
  8. package/dist/commands/edition.js +5 -3
  9. package/dist/commands/edition.js.map +1 -1
  10. package/dist/dev/env-defaults.d.ts.map +1 -1
  11. package/dist/dev/env-defaults.js +0 -1
  12. package/dist/dev/env-defaults.js.map +1 -1
  13. package/dist/edition/verify-image-evidence.bundle.mjs +4386 -2503
  14. package/dist/edition/verify-image-evidence.bundle.provenance.json +590 -488
  15. package/dist/runtime/auth/cli-application.d.ts +18 -0
  16. package/dist/runtime/auth/cli-application.d.ts.map +1 -0
  17. package/dist/runtime/auth/cli-application.js +39 -0
  18. package/dist/runtime/auth/cli-application.js.map +1 -0
  19. package/dist/runtime/edition/verb-map.d.ts +3 -1
  20. package/dist/runtime/edition/verb-map.d.ts.map +1 -1
  21. package/dist/runtime/edition/verb-map.js +5 -1
  22. package/dist/runtime/edition/verb-map.js.map +1 -1
  23. package/dist/runtime/env-seeder.d.ts.map +1 -1
  24. package/dist/runtime/env-seeder.js +21 -0
  25. package/dist/runtime/env-seeder.js.map +1 -1
  26. package/dist/runtime/launch.d.ts.map +1 -1
  27. package/dist/runtime/launch.js +0 -18
  28. package/dist/runtime/launch.js.map +1 -1
  29. package/edition/README.md +20 -3
  30. package/edition/biome-content.mjs +5 -2
  31. package/edition/build-runtime-image-source-index.mjs +79 -5
  32. package/edition/canonical-evidence-json.mjs +82 -0
  33. package/edition/catalogue-evidence.mjs +8 -5
  34. package/edition/customer-edition-verbs.mjs +9 -3
  35. package/edition/develop-coverage-run.mjs +2 -1
  36. package/edition/develop-coverage.mjs +12 -1
  37. package/edition/develop-edition-resolve.mjs +3 -2
  38. package/edition/develop-edition.mjs +15 -3
  39. package/edition/develop-record-verify.mjs +216 -0
  40. package/edition/develop-select-records.mjs +24 -225
  41. package/edition/develop-verify-image-record.mjs +90 -0
  42. package/edition/dist-check.mjs +22 -2
  43. package/edition/distribution-evidence-set.mjs +30 -2
  44. package/edition/edition-artifact.mjs +110 -22
  45. package/edition/edition-ceiling.mjs +180 -0
  46. package/edition/edition-config.mjs +91 -0
  47. package/edition/edition-constants.mjs +21 -3
  48. package/edition/edition-contract-parse.mjs +88 -3
  49. package/edition/edition-contract.json +47 -4
  50. package/edition/edition-image-seal.mjs +9 -2
  51. package/edition/edition-kind.mjs +10 -0
  52. package/edition/edition-protocol.mjs +7 -1
  53. package/edition/edition-publisher.mjs +21 -0
  54. package/edition/edition-seal.mjs +257 -0
  55. package/edition/evidence-grammars.mjs +2 -0
  56. package/edition/evidence-trust-pin.mjs +66 -10
  57. package/edition/evidence-trust-policy.mjs +39 -29
  58. package/edition/external-source-walk.mjs +16 -3
  59. package/edition/fleet-coordinate-finality.mjs +18 -5
  60. package/edition/fulcio-certificate-extensions.mjs +231 -0
  61. package/edition/image-evidence.mjs +34 -79
  62. package/edition/locked-bundle-signer.mjs +224 -0
  63. package/edition/oci-evidence-verbs.mjs +7 -1
  64. package/edition/pinned-cosign.mjs +70 -0
  65. package/edition/plan-image-evidence-refresh.mjs +3 -1
  66. package/edition/producer-evidence-bundle.mjs +3 -1
  67. package/edition/producer-evidence-index-file.mjs +4 -0
  68. package/edition/release-edition-artifact.mjs +3 -3
  69. package/edition/repin-evidence-trust.mjs +6 -1
  70. package/edition/resolve-distribution.mjs +47 -6
  71. package/edition/resolve-edition.mjs +1 -1
  72. package/edition/resolve-images.mjs +53 -7
  73. package/edition/seal-image-digests.mjs +19 -8
  74. package/edition/sealed-distribution-lock.mjs +2 -2
  75. package/edition/third-party-admission.mjs +91 -0
  76. package/edition/third-party-bundle-registry.mjs +147 -0
  77. package/edition/third-party-certificate-extensions.mjs +21 -0
  78. package/edition/third-party-image-pins.mjs +41 -0
  79. package/edition/third-party-pins-trusted.mjs +24 -0
  80. package/edition/third-party-producer-evidence.mjs +76 -0
  81. package/edition/third-party-source-check.mjs +28 -0
  82. package/edition/third-party-source-context.mjs +41 -0
  83. package/edition/third-party-source-evidence.mjs +255 -0
  84. package/edition/third-party-source-flags.mjs +134 -0
  85. package/edition/third-party-source-pins.mjs +259 -0
  86. package/edition/third-party-source-uniqueness.mjs +54 -0
  87. package/edition/third-party-source-walk.mjs +115 -0
  88. package/edition/third-party-source-writer.mjs +229 -0
  89. package/edition/third-party-subjects.mjs +127 -0
  90. package/edition/trust-policy-image-ownership.mjs +85 -0
  91. package/edition/verify-catalogue-evidence.mjs +2 -0
  92. package/edition/verify-image-evidence.mjs +2 -0
  93. package/edition/verify-image-platforms.mjs +33 -9
  94. package/package.json +5 -5
  95. package/dist/runtime/auth/client-bootstrap.d.ts +0 -11
  96. package/dist/runtime/auth/client-bootstrap.d.ts.map +0 -1
  97. package/dist/runtime/auth/client-bootstrap.js +0 -60
  98. package/dist/runtime/auth/client-bootstrap.js.map +0 -1
@@ -30,6 +30,8 @@ import {
30
30
  } from './image-evidence.mjs';
31
31
  import { readBoundedNoFollowFile } from './no-follow-file.mjs';
32
32
  import { parseStrictJson } from './strict-json.mjs';
33
+ import { THIRD_PARTY_SOURCE_PINS_FILE, thirdPartySourcePinsDigest } from './third-party-source-pins.mjs';
34
+ import { assertTrustPolicyImageOwnership, firstPartyOwnersFromIndex } from './trust-policy-image-ownership.mjs';
33
35
 
34
36
  export const EVIDENCE_TRUST_PIN_SCHEMA_VERSION = 2;
35
37
  export const RUNTIME_IMAGE_SOURCE_INDEX_FILE = 'runtime-image-source-index.json';
@@ -59,11 +61,17 @@ const PIN_KEYS = Object.freeze([
59
61
  'trustedOrasBinaryDigests',
60
62
  'verifier',
61
63
  ]);
64
+ /**
65
+ * Present iff the data root holds `third-party-source-pins.json`. Optional so a pin written before third-party
66
+ * sources existed, for a data root without that file, keeps loading unchanged (the schemaVersion stays 2).
67
+ */
68
+ const THIRD_PARTY_SOURCE_PINS_DIGEST_KEY = 'thirdPartySourcePinsDigest';
62
69
  const VERIFIER_KEYS = Object.freeze(['bundleDigest', 'provenanceDigest']);
63
70
  const AUTHORITY_PIN_KEYS = Object.freeze(['digest', 'sourceCommit']);
64
71
  const PIN_SHAPE =
65
72
  '{schemaVersion, policyDigest, verifier: {bundleDigest, provenanceDigest}, ' +
66
- 'kernelContractAuthority: {sourceCommit, digest} | null, trustedOrasBinaryDigests: [sha256:…, …]}';
73
+ 'kernelContractAuthority: {sourceCommit, digest} | null, trustedOrasBinaryDigests: [sha256:…, …], ' +
74
+ `[${THIRD_PARTY_SOURCE_PINS_DIGEST_KEY}: sha256:… (only when the data root holds ${THIRD_PARTY_SOURCE_PINS_FILE})]}`;
67
75
 
68
76
  const exactKeys = (value, keys) =>
69
77
  value !== null &&
@@ -103,6 +111,10 @@ export function loadDeclaredTrustPolicy({ dataRoot, declaration, schemas }) {
103
111
  ),
104
112
  );
105
113
  validatePinnedTrustedRoot(dataRoot, policy);
114
+ assertTrustPolicyImageOwnership({
115
+ policy,
116
+ firstPartyOwners: hasRuntimeImageSourceIndex(dataRoot) ? firstPartyOwnersFromIndex(readRuntimeImageSourceIndexDocument(dataRoot)) : null,
117
+ });
106
118
  return Object.freeze({ policy, policyDigest: trustPolicyDigest(policy) });
107
119
  }
108
120
 
@@ -116,8 +128,9 @@ export function readEvidenceTrustPin({ dataRoot, declaration }) {
116
128
  MAX_PIN_BYTES,
117
129
  );
118
130
  const pin = parseStrictJson(bytes.toString('utf8'), declaration.pin);
131
+ const hasSourcePinsDigest = pin !== null && typeof pin === 'object' && THIRD_PARTY_SOURCE_PINS_DIGEST_KEY in pin;
119
132
  if (
120
- !exactKeys(pin, PIN_KEYS) ||
133
+ !exactKeys(pin, hasSourcePinsDigest ? [...PIN_KEYS, THIRD_PARTY_SOURCE_PINS_DIGEST_KEY].sort() : PIN_KEYS) ||
121
134
  !exactKeys(pin.verifier, VERIFIER_KEYS) ||
122
135
  !(pin.kernelContractAuthority === null || exactKeys(pin.kernelContractAuthority, AUTHORITY_PIN_KEYS)) ||
123
136
  !Array.isArray(pin.trustedOrasBinaryDigests)
@@ -147,9 +160,41 @@ export function readEvidenceTrustPin({ dataRoot, declaration }) {
147
160
  digest: requireDigest(pin.kernelContractAuthority.digest, 'kernelContractAuthority.digest'),
148
161
  }),
149
162
  trustedOrasBinaryDigests: requireOrasDigests(pin.trustedOrasBinaryDigests),
163
+ thirdPartySourcePinsDigest: hasSourcePinsDigest
164
+ ? requireDigest(pin[THIRD_PARTY_SOURCE_PINS_DIGEST_KEY], THIRD_PARTY_SOURCE_PINS_DIGEST_KEY)
165
+ : null,
150
166
  });
151
167
  }
152
168
 
169
+ /**
170
+ * Hold the data root's `third-party-source-pins.json` to the pin: when the file exists the pin must record its sha256
171
+ * (and it must be the file's); when the file does not exist the pin must record none.
172
+ */
173
+ export function assertPinnedThirdPartySourcePins({ pin, dataRoot }) {
174
+ const actual = thirdPartySourcePinsDigest(dataRoot);
175
+ if (actual === null) {
176
+ if (pin.thirdPartySourcePinsDigest !== null) {
177
+ throw new Error(
178
+ `the pin records ${THIRD_PARTY_SOURCE_PINS_FILE} at ${pin.thirdPartySourcePinsDigest} but the data root holds no such file; ` +
179
+ `review the removal, then re-pin with \`${REPIN_COMMAND}\``,
180
+ );
181
+ }
182
+ return;
183
+ }
184
+ if (pin.thirdPartySourcePinsDigest === null) {
185
+ throw new Error(
186
+ `the data root holds ${THIRD_PARTY_SOURCE_PINS_FILE} (${actual}) but the pin records none; ` +
187
+ `review the third-party source pins, then re-pin with \`${REPIN_COMMAND}\``,
188
+ );
189
+ }
190
+ if (pin.thirdPartySourcePinsDigest !== actual) {
191
+ throw new Error(
192
+ `${THIRD_PARTY_SOURCE_PINS_FILE} hashes to ${actual} but the pin records ${pin.thirdPartySourcePinsDigest}; ` +
193
+ `review the third-party source pin change, then re-pin with \`${REPIN_COMMAND}\``,
194
+ );
195
+ }
196
+ }
197
+
153
198
  function requireCommit(value) {
154
199
  if (typeof value !== 'string' || !COMMIT_PATTERN.test(value)) {
155
200
  throw new Error('evidence trust pin: kernelContractAuthority.sourceCommit must be an exact 40-hex commit');
@@ -188,18 +233,23 @@ export function hasRuntimeImageSourceIndex(dataRoot) {
188
233
  return existsSync(join(dataRoot, RUNTIME_IMAGE_SOURCE_INDEX_FILE));
189
234
  }
190
235
 
191
- /** The kernel contract authority the data root's runtime image source index STATES; nothing is trusted yet. */
192
- export function readStatedKernelContractAuthority({ dataRoot, schemas }) {
193
- if (!schemas?.parseRuntimeImageSourceIndex) {
194
- throw new Error('trusted kernel distribution schemas are required');
195
- }
236
+ /** The data root's runtime image source index as parsed JSON (not schema-validated here). */
237
+ function readRuntimeImageSourceIndexDocument(dataRoot) {
196
238
  const { bytes } = readBoundedNoFollowFile(
197
239
  dataRoot,
198
240
  RUNTIME_IMAGE_SOURCE_INDEX_FILE,
199
241
  'runtime image source index',
200
242
  MAX_INDEX_BYTES,
201
243
  );
202
- return schemas.parseRuntimeImageSourceIndex(JSON.parse(bytes.toString('utf8'))).contractAuthority;
244
+ return JSON.parse(bytes.toString('utf8'));
245
+ }
246
+
247
+ /** The kernel contract authority the data root's runtime image source index STATES; nothing is trusted yet. */
248
+ export function readStatedKernelContractAuthority({ dataRoot, schemas }) {
249
+ if (!schemas?.parseRuntimeImageSourceIndex) {
250
+ throw new Error('trusted kernel distribution schemas are required');
251
+ }
252
+ return schemas.parseRuntimeImageSourceIndex(readRuntimeImageSourceIndexDocument(dataRoot)).contractAuthority;
203
253
  }
204
254
 
205
255
  /** Refuse an authority that is not the one the pin records. */
@@ -270,7 +320,9 @@ export function loadPinnedDeclaredTrustPolicy({ dataRoot, declaration, schemas }
270
320
  );
271
321
  }
272
322
  const { policy, policyDigest } = loadDeclaredTrustPolicy({ dataRoot, declaration, schemas });
273
- assertPinnedPolicy({ pin: readEvidenceTrustPin({ dataRoot, declaration }), policyDigest });
323
+ const pin = readEvidenceTrustPin({ dataRoot, declaration });
324
+ assertPinnedPolicy({ pin, policyDigest });
325
+ assertPinnedThirdPartySourcePins({ pin, dataRoot });
274
326
  return policy;
275
327
  }
276
328
 
@@ -287,7 +339,7 @@ export function assertPinnedVerifier({ pin, actual }) {
287
339
  }
288
340
  }
289
341
 
290
- /** Write the pin for the declared policy, the verifier digests, the kernel contract authority and the oras digests. */
342
+ /** Write the pin for the declared policy, the verifier digests, the kernel contract authority, the oras digests and (when the data root holds the file) the third-party source pins' digest. */
291
343
  export function writeEvidenceTrustPin({
292
344
  dataRoot,
293
345
  declaration,
@@ -295,6 +347,7 @@ export function writeEvidenceTrustPin({
295
347
  verifier,
296
348
  kernelContractAuthority,
297
349
  trustedOrasBinaryDigests,
350
+ thirdPartySourcePinsDigest: sourcePinsDigest = null,
298
351
  }) {
299
352
  const pin = {
300
353
  schemaVersion: EVIDENCE_TRUST_PIN_SCHEMA_VERSION,
@@ -305,6 +358,9 @@ export function writeEvidenceTrustPin({
305
358
  },
306
359
  kernelContractAuthority: kernelContractAuthority === null ? null : { ...kernelContractAuthorityPin(kernelContractAuthority) },
307
360
  trustedOrasBinaryDigests: [...requireOrasDigests(trustedOrasBinaryDigests)].sort(),
361
+ ...(sourcePinsDigest === null
362
+ ? {}
363
+ : { [THIRD_PARTY_SOURCE_PINS_DIGEST_KEY]: requireDigest(sourcePinsDigest, THIRD_PARTY_SOURCE_PINS_DIGEST_KEY) }),
308
364
  };
309
365
  const text = `${JSON.stringify(pin, null, 2)}\n`;
310
366
  const descriptor = openSync(
@@ -5,11 +5,13 @@
5
5
  // pin}` in `edition-workspace.json`) or states that it declares none (`null`).
6
6
  // No switch admits an ungated release:
7
7
  //
8
- // * a RELEASE edition is written only against a policy ARMED on the release
9
- // channel. No policy, UNARMED or PARTIALLY ARMED there -> refused
10
- // (EvidenceTrustRequiredError, code EVIDENCE_TRUST_REQUIRED_UNARMED);
11
- // * a DEVELOP edition admits not-armed and says so in one line; a policy
12
- // PARTIALLY ARMED on the develop channel is refused;
8
+ // * a SEALED edition (either channel) and a RESOLVED RELEASE edition are
9
+ // written only against a policy ARMED on the edition's channel. No policy,
10
+ // UNARMED or PARTIALLY ARMED there -> refused (EvidenceTrustRequiredError,
11
+ // code EVIDENCE_TRUST_REQUIRED_UNARMED); edition-contract.json
12
+ // evidenceTrust.statesByKind is the one statement of this rule;
13
+ // * a RESOLVED DEVELOP edition admits not-armed and says so in one line; a
14
+ // policy PARTIALLY ARMED on the develop channel is refused;
13
15
  // * `xema edition check` FAILS when the editions tree holds a release
14
16
  // edition (an edition directory holding a release lock) and the policy is
15
17
  // not ARMED on the release channel (none declared, UNARMED or PARTIAL):
@@ -29,10 +31,11 @@ import { join } from 'node:path';
29
31
 
30
32
  import { locateBuiltVerifier, readBuiltVerifierDigests } from './built-verifier.mjs';
31
33
  import { EDITION_WORKSPACE_CONFIG_FILE, editionWorkspaceConfig } from './edition-config.mjs';
32
- import { EDITION_CONTRACT, EditionChannel, EvidenceTrustState } from './edition-constants.mjs';
34
+ import { EDITION_CONTRACT, EditionChannel, EditionKind, EvidenceTrustState, admittedEvidenceTrustStates } from './edition-constants.mjs';
33
35
  import { dataRoot as configuredDataRoot, editionsRoot as configuredEditionsRoot } from './edition-workspace.mjs';
34
36
  import {
35
37
  assertPinnedPolicy,
38
+ assertPinnedThirdPartySourcePins,
36
39
  assertPinnedVerifier,
37
40
  loadDeclaredTrustPolicy,
38
41
  readEvidenceTrustPin,
@@ -109,11 +112,14 @@ function notArmedOnReleaseVerdict(what, repair) {
109
112
  * refused with this error; a release edition never records `not-armed`.
110
113
  */
111
114
  export class EvidenceTrustRequiredError extends Error {
112
- /** @param {string} dataRoot @param {string} reason what is not armed, in words */
113
- constructor(dataRoot, reason) {
115
+ /**
116
+ * @param {string} dataRoot @param {string} reason what is not armed, in words
117
+ * @param {{ edition: string, channel: string }} subject the edition that requires it (e.g. "release edition", "sealed release edition") and its channel
118
+ */
119
+ constructor(dataRoot, reason, { edition, channel }) {
114
120
  super(
115
- `a release edition requires an armed evidence trust policy, but ${join(dataRoot, EDITION_WORKSPACE_CONFIG_FILE)} ${reason}: ` +
116
- 'declare a policy ({"policy": ..., "pin": ...}) and arm it on the release channel (`xema edition check` says what is unmet).',
121
+ `a ${edition} requires an armed evidence trust policy, but ${join(dataRoot, EDITION_WORKSPACE_CONFIG_FILE)} ${reason}: ` +
122
+ `declare a policy ({"policy": ..., "pin": ...}) and arm it on the ${channel} channel (\`xema edition check\` says what is unmet).`,
117
123
  );
118
124
  this.name = 'EvidenceTrustRequiredError';
119
125
  this.code = EVIDENCE_TRUST_REQUIRED_UNARMED;
@@ -129,6 +135,7 @@ function loadPinnedPolicy({ dataRoot, declaration, schemas, locateVerifier }) {
129
135
  const { policy, policyDigest } = loadDeclaredTrustPolicy({ dataRoot, declaration, schemas });
130
136
  const pin = readEvidenceTrustPin({ dataRoot, declaration });
131
137
  assertPinnedPolicy({ pin, policyDigest });
138
+ assertPinnedThirdPartySourcePins({ pin, dataRoot });
132
139
  assertPinnedVerifier({ pin, actual: readBuiltVerifierDigests(locateVerifier) });
133
140
  return { policy, policyDigest };
134
141
  }
@@ -151,7 +158,9 @@ export async function loadDeclaredPinnedPolicy() {
151
158
  }
152
159
  const schemas = await loadKernelDistributionSchemas();
153
160
  const { policy, policyDigest } = loadDeclaredTrustPolicy({ dataRoot: root, declaration, schemas });
154
- assertPinnedPolicy({ pin: readEvidenceTrustPin({ dataRoot: root, declaration }), policyDigest });
161
+ const pin = readEvidenceTrustPin({ dataRoot: root, declaration });
162
+ assertPinnedPolicy({ pin, policyDigest });
163
+ assertPinnedThirdPartySourcePins({ pin, dataRoot: root });
155
164
  return { policy, dataRoot: root };
156
165
  }
157
166
 
@@ -198,21 +207,22 @@ export class CatalogueEvidenceUnverifiableError extends Error {
198
207
  }
199
208
 
200
209
  /**
201
- * The evidence state an edition of `channel` records in its descriptor. Arming
210
+ * The evidence state an edition of `kind` on `channel` records in its descriptor. Arming
202
211
  * is judged for that ONE channel (`assessTrustPolicyArming`): the signers of
203
- * the other channel count for nothing.
212
+ * the other channel count for nothing. Which states the kind may record on the
213
+ * channel is the contract's (`evidenceTrust.statesByKind`, admittedEvidenceTrustStates):
204
214
  *
205
215
  * * `armed` - the data root declares a policy, the policy is pinned (the
206
216
  * policy and this CLI's verifier match the data-root pin) and
207
217
  * ARMED on `channel`;
208
- * * `not-armed` - a DEVELOP edition only: its channel is not armed (no
218
+ * * `not-armed` - a RESOLVED DEVELOP edition only: its channel is not armed (no
209
219
  * policy, or UNARMED there), and it says so in one stderr line.
210
220
  *
211
- * Every other state is refused. A RELEASE edition that is not ARMED on the
212
- * release channel (no policy, UNARMED or PARTIAL there) throws
213
- * EvidenceTrustRequiredError: this function never returns `not-armed` for it.
214
- * A policy edited after pinning, a drifted verifier, and a policy PARTIALLY
215
- * ARMED on `channel` are refused on either channel.
221
+ * Every other state is refused. A SEALED edition (either channel) and a RESOLVED
222
+ * RELEASE edition that is not ARMED on its channel (no policy, UNARMED or PARTIAL
223
+ * there) throw EvidenceTrustRequiredError: this function never returns `not-armed`
224
+ * for them. A policy edited after pinning, a drifted verifier, and a policy
225
+ * PARTIALLY ARMED on `channel` are refused for every edition.
216
226
  *
217
227
  * `consumesCatalogue` (a customer edition consumes a catalogue): on RELEASE it adds the edition-catalogue (E4) row to the
218
228
  * required ones, and the catalogue's E4 evidence is verified before it is parsed. A DEVELOP catalogue carries no E4
@@ -220,22 +230,23 @@ export class CatalogueEvidenceUnverifiableError extends Error {
220
230
  * row is required and nothing can verify the catalogue: a customer develop edition is never written `armed`. A policy
221
231
  * ARMED on develop throws CatalogueEvidenceUnverifiableError instead of recording a claim nothing checked.
222
232
  *
223
- * @param {{ channel: string, locateVerifier?: () => {bundlePath: string, provenancePath: string}, consumesCatalogue?: boolean }} options
233
+ * @param {{ kind: string, channel: string, locateVerifier?: () => {bundlePath: string, provenancePath: string}, consumesCatalogue?: boolean }} options
224
234
  */
225
- export async function descriptorEvidenceTrust({ channel, locateVerifier = locateBuiltVerifier, consumesCatalogue = false }) {
235
+ export async function descriptorEvidenceTrust({ kind, channel, locateVerifier = locateBuiltVerifier, consumesCatalogue = false }) {
226
236
  const declaration = editionWorkspaceConfig().evidenceTrustPolicy;
227
237
  const root = configuredDataRoot();
228
238
  const schemas = await loadKernelDistributionSchemas();
229
239
  assertArmingChannel(schemas, channel);
230
240
  const release = channel === EditionChannel.Release;
231
- const developNotArmed = () => {
241
+ const notArmedAdmitted = admittedEvidenceTrustStates(kind, channel).includes(EvidenceTrustState.NotArmed);
242
+ // A resolved edition is named by its channel ("a release edition"); a seal by its kind and channel.
243
+ const subject = { edition: kind === EditionKind.Sealed ? `${kind} ${channel} edition` : `${channel} edition`, channel };
244
+ const notArmed = (reason) => {
245
+ if (!notArmedAdmitted) {throw new EvidenceTrustRequiredError(root, reason, subject);}
232
246
  console.error(DEVELOP_NOT_ARMED_NOTICE);
233
247
  return EvidenceTrustState.NotArmed;
234
248
  };
235
- if (declaration.policy === null) {
236
- if (release) {throw new EvidenceTrustRequiredError(root, 'declares no policy');}
237
- return developNotArmed();
238
- }
249
+ if (declaration.policy === null) {return notArmed('declares no policy');}
239
250
  const { policy } = loadPinnedPolicy({ dataRoot: root, declaration, schemas, locateVerifier });
240
251
  const { state, unmet } = assessTrustPolicyArming(policy, schemas, channel, { consumesCatalogue: consumesCatalogue && release });
241
252
  if (state === TrustPolicyArmingState.ARMED) {
@@ -243,11 +254,10 @@ export async function descriptorEvidenceTrust({ channel, locateVerifier = locate
243
254
  return EvidenceTrustState.Armed;
244
255
  }
245
256
  if (state === TrustPolicyArmingState.PARTIAL) {
246
- if (release) {throw new EvidenceTrustRequiredError(root, `declares a policy that is PARTIALLY ARMED on the ${channel} channel (${unmet.join('; ')})`);}
257
+ if (!notArmedAdmitted) {throw new EvidenceTrustRequiredError(root, `declares a policy that is PARTIALLY ARMED on the ${channel} channel (${unmet.join('; ')})`, subject);}
247
258
  throw new Error(`the declared evidence trust policy is PARTIALLY ARMED on the ${channel} channel (${unmet.join('; ')}); an edition cannot be written against it`);
248
259
  }
249
- if (release) {throw new EvidenceTrustRequiredError(root, `declares a policy that is UNARMED on the ${channel} channel`);}
250
- return developNotArmed();
260
+ return notArmed(`declares a policy that is UNARMED on the ${channel} channel`);
251
261
  }
252
262
 
253
263
  export const UNDECLARED_TRUST_DIRECTORY = 'trust';
@@ -92,12 +92,16 @@ import {
92
92
  readGitJsonAtCommit,
93
93
  readGitTextAtCommitOrNull,
94
94
  } from './lib.mjs';
95
+ import { walkOciSources } from './third-party-source-walk.mjs';
95
96
 
96
97
  /** Where fetched sources are cached: a directory under the configured cache root. Never an input to a lock. */
97
98
  export const externalSourceCacheDir = () => join(cacheDir(), 'external-sources');
98
99
 
99
100
  const EXACT_COMMIT = /^[a-f0-9]{40}$/;
100
101
 
102
+ /** The declared source kind that names a third-party pin instead of a git repository. */
103
+ const OCI_SOURCE_KIND = 'oci';
104
+
101
105
  /**
102
106
  * Directory names never walked for biome manifests in an EXTERNAL source.
103
107
  *
@@ -397,24 +401,33 @@ export async function walkDeclaredSources({
397
401
  materialize = materializeGitSource,
398
402
  env = process.env,
399
403
  workspace = undefined,
404
+ thirdParty = undefined,
400
405
  } = {}) {
401
406
  const entries = [];
402
407
  const sourceRecords = new Map();
403
408
  // Every source is held (kind, then credential and URL) before the first is materialised.
404
409
  const heldWorkspace = sources.length === 0 ? undefined : (workspace ?? editionWorkspaceConfig());
405
410
  for (const source of sources) {
411
+ if (source.kind === OCI_SOURCE_KIND) {continue;}
406
412
  if (source.kind !== 'git') {
407
413
  throw new Error(
408
414
  `source "${source.name}" declares kind "${source.kind}", which this walk does not ` +
409
415
  'resolve. The kernel\'s LockedBiomeSourceSchema can only record a GIT source ' +
410
- '(kind is z.literal(Git)), so an oci or npm source has nowhere to be pinned in the ' +
416
+ '(kind is z.literal(Git)), so an npm source has nowhere to be pinned in the ' +
411
417
  'lock — resolving it would produce an edition whose members cannot be traced to a ' +
412
- 'revision. Refusing rather than resolving.',
418
+ 'revision. Refusing rather than resolving. (An oci source is a third-party pin: it ' +
419
+ 'is held to the data root\'s third-party-source-pins.json and records no lock source.)',
413
420
  );
414
421
  }
415
422
  holdSource(source, heldWorkspace, env);
416
423
  }
417
- for (const source of sources) {
424
+ const ociSources = sources.filter((source) => source.kind === OCI_SOURCE_KIND);
425
+ if (ociSources.length > 0) {
426
+ for (const { source, entry } of await walkOciSources(ociSources, { env, ...(thirdParty === undefined ? {} : { thirdParty }) })) {
427
+ entries.push(stampEntry(assertSourceWalkedOrigin(entry, source.name), source.name));
428
+ }
429
+ }
430
+ for (const source of sources.filter((candidate) => candidate.kind !== OCI_SOURCE_KIND)) {
418
431
  const { directory, commit } = materialize({ source, env, workspace: heldWorkspace });
419
432
  if (!EXACT_COMMIT.test(String(commit))) {
420
433
  throw new Error(
@@ -99,7 +99,7 @@ export function coordinateOf(subject) {
99
99
  * cannot name who is still waiting sends the reader to look for the reason
100
100
  * themselves, and the reason is in another edition's declaration.
101
101
  */
102
- export function collectFleetPlatformRequirements({ editions }) {
102
+ export function collectFleetPlatformRequirements({ editions, isThirdPartySubject = () => false }) {
103
103
  if (!Array.isArray(editions) || editions.length === 0) {
104
104
  throw new Error(
105
105
  'fleet-coordinate-finality: no edition was supplied; a fleet requirement map ' +
@@ -133,6 +133,9 @@ export function collectFleetPlatformRequirements({ editions }) {
133
133
  'and silently weaken the refusal for every edition sharing its coordinate.',
134
134
  );
135
135
  }
136
+ // A third-party subject is read by its pinned digest, not at `<imageRef>:<contentHash>` (its contentHash is
137
+ // publisher-attested, not a tag), and no fleet lane supplements it: it contributes no first-party coordinate.
138
+ if (isThirdPartySubject(subject)) {continue;}
136
139
  const coordinate = coordinateOf(subject);
137
140
  let platforms = byCoordinate.get(coordinate);
138
141
  if (!platforms) {
@@ -242,20 +245,30 @@ export function readFleetImageLocks(editionsRoot) {
242
245
  * reconciler permanently red with no action available to clear it, and a gate
243
246
  * that cannot be cleared is one people learn to bypass.
244
247
  *
248
+ * THIRD-PARTY SUBJECTS ARE EXCLUDED, NAMED. `thirdPartyKeys` (`kind:id`) are the pinned third-party subjects of this
249
+ * edition: each is read by its pinned manifest digest (an immutable coordinate no fleet lane supplements), so it has no
250
+ * first-party fleet coordinate to grade. They are returned as `thirdParty` so the caller prints them.
251
+ *
245
252
  * Throws with `error.notFinal === true` — a PROPERTY, never a message match, so
246
253
  * the reconciler's skip cannot silently widen the day this wording changes. Same
247
254
  * discipline as `error.status` on the not-published-yet path.
248
255
  */
249
256
  export function assertCoordinatesFinal({
250
257
  distributionId,
251
- writes,
258
+ writes: plannedWrites,
252
259
  verified,
253
260
  fleetRequirements,
261
+ thirdPartyKeys = new Set(),
254
262
  }) {
255
263
  const graded = [];
256
264
  const pendingElsewhere = [];
257
- if (!Array.isArray(writes) || writes.length === 0) {
258
- return { distributionId, graded, coordinateCount: 0, pendingElsewhere };
265
+ if (!Array.isArray(plannedWrites)) {
266
+ return { distributionId, graded, coordinateCount: 0, pendingElsewhere, thirdParty: [] };
267
+ }
268
+ const thirdParty = plannedWrites.filter((write) => thirdPartyKeys.has(write.key)).map((write) => write.key);
269
+ const writes = plannedWrites.filter((write) => !thirdPartyKeys.has(write.key));
270
+ if (writes.length === 0) {
271
+ return { distributionId, graded, coordinateCount: 0, pendingElsewhere, thirdParty };
259
272
  }
260
273
  const byKey = new Map(
261
274
  verified.map((entry) => [`${entry.subjectKind}:${entry.subjectId}`, entry]),
@@ -337,7 +350,7 @@ export function assertCoordinatesFinal({
337
350
  error.blockers = blockers;
338
351
  throw error;
339
352
  }
340
- return { distributionId, graded, coordinateCount: graded.length, pendingElsewhere };
353
+ return { distributionId, graded, coordinateCount: graded.length, pendingElsewhere, thirdParty };
341
354
  }
342
355
 
343
356
  /**
@@ -0,0 +1,231 @@
1
+ // The Fulcio v2 certificate extensions cosign has no verification flag for, read from the
2
+ // leaf certificate of a sigstore bundle with a small DER walker (Node's X509Certificate does
3
+ // not expose arbitrary extensions).
4
+ //
5
+ // 1.3.6.1.4.1.57264.1.11 Runner Environment
6
+ // 1.3.6.1.4.1.57264.1.15 Source Repository Identifier
7
+ // 1.3.6.1.4.1.57264.1.17 Source Repository Owner Identifier
8
+ //
9
+ // Each is the v2 form: the extension's OCTET STRING contains a DER UTF8String. The legacy raw
10
+ // forms (.1 to .6, which put the bare bytes in the OCTET STRING) are never read. An absent
11
+ // extension, a repeated one or one that is not a UTF8String is refused naming its OID.
12
+ //
13
+ // The certificate is the one cosign verified: the bytes come from the bundle that was verified
14
+ // (`verificationMaterial.certificate.rawBytes`, else the first
15
+ // `verificationMaterial.x509CertificateChain.certificates[].rawBytes`), never from a second fetch.
16
+
17
+ const TAG_SEQUENCE = 0x30;
18
+ const TAG_OID = 0x06;
19
+ const TAG_BOOLEAN = 0x01;
20
+ const TAG_OCTET_STRING = 0x04;
21
+ const TAG_UTF8_STRING = 0x0c;
22
+ const TAG_CONTEXT_0 = 0xa0;
23
+ const TAG_CONTEXT_1 = 0xa1;
24
+ const TAG_CONTEXT_2 = 0xa2;
25
+ const TAG_CONTEXT_3 = 0xa3;
26
+
27
+ export const FulcioExtensionOid = Object.freeze({
28
+ RunnerEnvironment: '1.3.6.1.4.1.57264.1.11',
29
+ SourceRepositoryIdentifier: '1.3.6.1.4.1.57264.1.15',
30
+ SourceRepositoryOwnerIdentifier: '1.3.6.1.4.1.57264.1.17',
31
+ });
32
+
33
+ /** The Runner Environment value of a GitHub-hosted runner. */
34
+ export const GITHUB_HOSTED_RUNNER_ENVIRONMENT = 'github-hosted';
35
+
36
+ const OID_FIELDS = Object.freeze([
37
+ ['runnerEnvironment', FulcioExtensionOid.RunnerEnvironment],
38
+ ['sourceRepositoryIdentifier', FulcioExtensionOid.SourceRepositoryIdentifier],
39
+ ['sourceRepositoryOwnerIdentifier', FulcioExtensionOid.SourceRepositoryOwnerIdentifier],
40
+ ]);
41
+
42
+ /** One DER element at `offset`: its tag, content bounds and the offset after it. Indefinite lengths are refused (DER). */
43
+ function readElement(bytes, offset, end, what) {
44
+ if (offset + 2 > end) {
45
+ throw new Error(`certificate DER is truncated at ${what}`);
46
+ }
47
+ const tag = bytes[offset];
48
+ if ((tag & 0x1f) === 0x1f) {
49
+ throw new Error(`certificate DER has a multi-byte tag at ${what}`);
50
+ }
51
+ let length = bytes[offset + 1];
52
+ let contentStart = offset + 2;
53
+ if (length === 0x80) {
54
+ throw new Error(`certificate DER has an indefinite length at ${what}`);
55
+ }
56
+ if (length > 0x80) {
57
+ const count = length & 0x7f;
58
+ if (count > 4 || contentStart + count > end) {
59
+ throw new Error(`certificate DER has an invalid length at ${what}`);
60
+ }
61
+ length = 0;
62
+ for (let index = 0; index < count; index += 1) {
63
+ length = length * 256 + bytes[contentStart + index];
64
+ }
65
+ contentStart += count;
66
+ }
67
+ const contentEnd = contentStart + length;
68
+ if (contentEnd > end) {
69
+ throw new Error(`certificate DER overruns its container at ${what}`);
70
+ }
71
+ return { tag, contentStart, contentEnd };
72
+ }
73
+
74
+ function expectElement(bytes, offset, end, tag, what) {
75
+ const element = readElement(bytes, offset, end, what);
76
+ if (element.tag !== tag) {
77
+ throw new Error(`certificate DER: ${what} has tag 0x${element.tag.toString(16)}, expected 0x${tag.toString(16)}`);
78
+ }
79
+ return element;
80
+ }
81
+
82
+ function decodeOid(bytes, start, end) {
83
+ if (end <= start) {
84
+ throw new Error('certificate DER has an empty OBJECT IDENTIFIER');
85
+ }
86
+ const arcs = [];
87
+ let value = 0;
88
+ for (let index = start; index < end; index += 1) {
89
+ value = value * 128 + (bytes[index] & 0x7f);
90
+ if ((bytes[index] & 0x80) === 0) {
91
+ if (arcs.length === 0) {
92
+ const first = Math.min(Math.floor(value / 40), 2);
93
+ arcs.push(first, value - first * 40);
94
+ } else {
95
+ arcs.push(value);
96
+ }
97
+ value = 0;
98
+ }
99
+ }
100
+ return arcs.join('.');
101
+ }
102
+
103
+ /** The extensions of the certificate: `Map<oid, {critical, value: Buffer}>` where `value` is the OCTET STRING content. */
104
+ function certificateExtensions(der) {
105
+ const certificate = expectElement(der, 0, der.length, TAG_SEQUENCE, 'Certificate');
106
+ if (certificate.contentEnd !== der.length) {
107
+ throw new Error('certificate DER has trailing bytes after the Certificate');
108
+ }
109
+ const tbs = expectElement(der, certificate.contentStart, certificate.contentEnd, TAG_SEQUENCE, 'TBSCertificate');
110
+ let offset = tbs.contentStart;
111
+ let element = readElement(der, offset, tbs.contentEnd, 'TBSCertificate field');
112
+ if (element.tag === TAG_CONTEXT_0) {
113
+ offset = element.contentEnd;
114
+ }
115
+ // serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo
116
+ for (let field = 0; field < 6; field += 1) {
117
+ element = readElement(der, offset, tbs.contentEnd, 'TBSCertificate field');
118
+ offset = element.contentEnd;
119
+ }
120
+ let extensionsBlock = null;
121
+ while (offset < tbs.contentEnd) {
122
+ element = readElement(der, offset, tbs.contentEnd, 'TBSCertificate trailing field');
123
+ offset = element.contentEnd;
124
+ if (element.tag === TAG_CONTEXT_3) {
125
+ extensionsBlock = element;
126
+ } else if (element.tag !== TAG_CONTEXT_1 && element.tag !== TAG_CONTEXT_2) {
127
+ throw new Error(`certificate DER: unexpected TBSCertificate field tag 0x${element.tag.toString(16)}`);
128
+ }
129
+ }
130
+ if (extensionsBlock === null) {
131
+ throw new Error('certificate has no extensions block');
132
+ }
133
+ const list = expectElement(der, extensionsBlock.contentStart, extensionsBlock.contentEnd, TAG_SEQUENCE, 'Extensions');
134
+ const found = new Map();
135
+ let cursor = list.contentStart;
136
+ while (cursor < list.contentEnd) {
137
+ const extension = expectElement(der, cursor, list.contentEnd, TAG_SEQUENCE, 'Extension');
138
+ cursor = extension.contentEnd;
139
+ const oidElement = expectElement(der, extension.contentStart, extension.contentEnd, TAG_OID, 'Extension OID');
140
+ const oid = decodeOid(der, oidElement.contentStart, oidElement.contentEnd);
141
+ let next = oidElement.contentEnd;
142
+ let critical = false;
143
+ let part = readElement(der, next, extension.contentEnd, 'Extension field');
144
+ if (part.tag === TAG_BOOLEAN) {
145
+ critical = der[part.contentStart] !== 0;
146
+ next = part.contentEnd;
147
+ part = readElement(der, next, extension.contentEnd, 'Extension value');
148
+ }
149
+ if (part.tag !== TAG_OCTET_STRING || part.contentEnd !== extension.contentEnd) {
150
+ throw new Error(`certificate DER: extension ${oid} value is not a lone OCTET STRING`);
151
+ }
152
+ if (found.has(oid)) {
153
+ throw new Error(`certificate carries extension ${oid} more than once`);
154
+ }
155
+ found.set(oid, { critical, value: der.subarray(part.contentStart, part.contentEnd) });
156
+ }
157
+ return found;
158
+ }
159
+
160
+ /** The extension's string: the OCTET STRING content must be exactly one DER UTF8String. */
161
+ function utf8ExtensionValue(extensions, oid) {
162
+ const extension = extensions.get(oid);
163
+ if (extension === undefined) {
164
+ throw new Error(`certificate does not carry Fulcio extension ${oid}`);
165
+ }
166
+ const { value } = extension;
167
+ let element;
168
+ try {
169
+ element = readElement(value, 0, value.length, `extension ${oid}`);
170
+ } catch (error) {
171
+ throw new Error(`Fulcio extension ${oid} is not a DER UTF8String: ${error.message}`);
172
+ }
173
+ if (element.tag !== TAG_UTF8_STRING || element.contentEnd !== value.length) {
174
+ throw new Error(`Fulcio extension ${oid} is not a DER UTF8String`);
175
+ }
176
+ const text = value.subarray(element.contentStart, element.contentEnd);
177
+ if (text.length === 0) {
178
+ throw new Error(`Fulcio extension ${oid} is an empty UTF8String`);
179
+ }
180
+ try {
181
+ return new TextDecoder('utf-8', { fatal: true }).decode(text);
182
+ } catch {
183
+ throw new Error(`Fulcio extension ${oid} is not valid UTF-8`);
184
+ }
185
+ }
186
+
187
+ function decodeBase64Der(rawBytes, what) {
188
+ if (typeof rawBytes !== 'string' || rawBytes.length === 0) {
189
+ throw new Error(`${what} has no rawBytes`);
190
+ }
191
+ return Buffer.from(rawBytes, 'base64');
192
+ }
193
+
194
+ /**
195
+ * The DER bytes of the leaf certificate of a parsed sigstore bundle:
196
+ * `verificationMaterial.certificate.rawBytes`, else the first
197
+ * `verificationMaterial.x509CertificateChain.certificates[].rawBytes`.
198
+ */
199
+ export function leafCertificateDerFromBundle(bundle) {
200
+ const material = bundle?.verificationMaterial;
201
+ if (material === null || typeof material !== 'object') {
202
+ throw new Error('sigstore bundle has no verificationMaterial');
203
+ }
204
+ if (material.certificate !== undefined) {
205
+ return decodeBase64Der(material.certificate?.rawBytes, 'verificationMaterial.certificate');
206
+ }
207
+ const first = material.x509CertificateChain?.certificates?.[0];
208
+ if (first === undefined) {
209
+ throw new Error('sigstore bundle verificationMaterial carries no certificate and no x509CertificateChain');
210
+ }
211
+ return decodeBase64Der(first.rawBytes, 'verificationMaterial.x509CertificateChain.certificates[0]');
212
+ }
213
+
214
+ /**
215
+ * The runner environment, source repository identifier and source repository owner identifier of a
216
+ * DER certificate. Throws naming the OID when one is missing or is not a UTF8String.
217
+ *
218
+ * @param {Buffer} der
219
+ * @returns {{runnerEnvironment: string, sourceRepositoryIdentifier: string, sourceRepositoryOwnerIdentifier: string}}
220
+ */
221
+ export function readFulcioCertificateExtensions(der) {
222
+ const extensions = certificateExtensions(der);
223
+ return Object.freeze(
224
+ Object.fromEntries(OID_FIELDS.map(([field, oid]) => [field, utf8ExtensionValue(extensions, oid)])),
225
+ );
226
+ }
227
+
228
+ /** The three extensions of the leaf certificate of a parsed sigstore bundle (the bytes cosign verified). */
229
+ export function readFulcioExtensionsFromBundle(bundle) {
230
+ return readFulcioCertificateExtensions(leafCertificateDerFromBundle(bundle));
231
+ }