@xemahq/xema 0.58.0 → 0.59.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.
@@ -19,6 +19,7 @@ import { assertCanonicalDistributionId } from './distribution-paths.mjs';
19
19
  import { EDITION_CHART_SET_FILE, assertRosteredChartSetLock } from './edition-chart-set.mjs';
20
20
  import {
21
21
  CATALOGUE_PIN_KEY,
22
+ CustomerPinForm,
22
23
  DEFINITION_PIN_KEY,
23
24
  DIGEST_PIN_KEYS,
24
25
  EDITION_DESCRIPTOR_FILE,
@@ -31,11 +32,13 @@ import {
31
32
  EditionLockFile,
32
33
  SEALED_EDITION_CHANNEL,
33
34
  SEAL_OF_PATTERN,
35
+ VENDOR_MEMBER_PIN_PATTERN,
34
36
  admittedEvidenceTrustStates,
37
+ customerPinFormAdmitted,
35
38
  } from './edition-constants.mjs';
36
39
  import { dataRoot, editionsRoot } from './edition-workspace.mjs';
37
40
  import { serializeJson, sha256Hex } from './lib.mjs';
38
- import { sortedSnapshotPins } from './pin-source.mjs';
41
+ import { isSourcePinKey, sortedSnapshotPins } from './pin-source.mjs';
39
42
  import { readStrictJsonFile } from './strict-json.mjs';
40
43
 
41
44
  /** Which GitHub Actions run produced the artifact; recorded in its descriptor. */
@@ -119,6 +122,45 @@ const exactKeys = (value, keys, where) => {
119
122
  }
120
123
  };
121
124
 
125
+ /**
126
+ * The closed customer pin rule (edition-contract.json provenancePins.customerForms). A descriptor whose pins carry the
127
+ * definition pin is a customer edition, and it carries exactly one form: the CATALOGUE form (the catalogue pin, the
128
+ * definition pin and sources/<name> commits, nothing else), or the CATALOGUE-FREE form (no catalogue pin, at least one
129
+ * vendor member pin), each only on the kinds and channels the contract admits it on. A descriptor without the definition
130
+ * pin is not a customer edition and the rule does not apply to it.
131
+ */
132
+ export function assertCustomerPinForm(kind, channel, pins, distributionId) {
133
+ if (!(DEFINITION_PIN_KEY in pins)) {
134
+ // only a customer edition records a vendor side: every other edition carries neither form's marker
135
+ if (CATALOGUE_PIN_KEY in pins) {
136
+ throw new Error(`${EDITION_DESCRIPTOR_FILE}: the ${kind} ${channel} edition "${distributionId}" carries the catalogue pin without the definition pin: only a customer edition (the definition pin) records the catalogue it was resolved against`);
137
+ }
138
+ return;
139
+ }
140
+ const where = `${EDITION_DESCRIPTOR_FILE}: the customer ${kind} ${channel} edition "${distributionId}"`;
141
+ if (CATALOGUE_PIN_KEY in pins) {
142
+ if (!customerPinFormAdmitted(CustomerPinForm.Catalogue, kind, channel)) {
143
+ throw new Error(`${where} carries the catalogue pin, but the contract admits the catalogue form on no ${kind} ${channel} edition`);
144
+ }
145
+ const stray = Object.keys(pins).filter((key) => key !== CATALOGUE_PIN_KEY && key !== DEFINITION_PIN_KEY && !isSourcePinKey(key));
146
+ if (stray.length > 0) {
147
+ throw new Error(`${where} carries the catalogue pin and also ${stray.map((key) => JSON.stringify(key)).join(', ')}: the catalogue form pins only the catalogue, the definition and sources/<name>`);
148
+ }
149
+ return;
150
+ }
151
+ if (!customerPinFormAdmitted(CustomerPinForm.CatalogueFree, kind, channel)) {
152
+ throw new Error(`${where} carries no catalogue pin: only a ${catalogueFreeAdmissions()} customer edition may be resolved catalogue-free`);
153
+ }
154
+ if (!Object.keys(pins).some((key) => VENDOR_MEMBER_PIN_PATTERN.test(key))) {
155
+ throw new Error(`${where} carries neither the catalogue pin nor a vendor member pin: a catalogue-free edition records the vendor member pins it was resolved from`);
156
+ }
157
+ }
158
+
159
+ const catalogueFreeAdmissions = () =>
160
+ Object.values(EditionKind)
161
+ .flatMap((kind) => Object.values(EditionChannel).filter((channel) => customerPinFormAdmitted(CustomerPinForm.CatalogueFree, kind, channel)).map((channel) => `${kind} ${channel}`))
162
+ .join(' or ');
163
+
122
164
  /**
123
165
  * The strict parse of a descriptor: exact keys, the closed kind enum (no default, never inferred), `sealOf` exactly on a
124
166
  * sealed edition, closed vocabularies, the evidence trust state the contract admits for the kind on the channel, an engine
@@ -164,6 +206,7 @@ export function parseEditionDescriptor(value) {
164
206
  );
165
207
  }
166
208
  }
209
+ assertCustomerPinForm(value.kind, value.provenance.channel, pins, value.distributionId);
167
210
  exactKeys(workflow, ['repository', 'runId'], `${EDITION_DESCRIPTOR_FILE} provenance.workflow`);
168
211
  if (!workflow.repository || !workflow.runId || typeof workflow.repository !== 'string' || typeof workflow.runId !== 'string') {
169
212
  throw new Error(`${EDITION_DESCRIPTOR_FILE}: provenance.workflow is {repository, runId}, both non-empty strings`);
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { EditionChannel } from './edition-channel.mjs';
8
8
  import { EDITION_CHART_SET_FILE } from './edition-chart-set.mjs';
9
- import { parseEditionContract } from './edition-contract-parse.mjs';
9
+ import { CustomerPinForm, parseEditionContract } from './edition-contract-parse.mjs';
10
10
  import editionContract from './edition-contract.json' with { type: 'json' };
11
11
  import { EditionKind, EvidenceTrustState } from './edition-kind.mjs';
12
12
 
@@ -40,7 +40,7 @@ export const EDITION_REPOSITORY_SEGMENTS = Object.freeze(
40
40
  Object.fromEntries(Object.values(EditionChannel).map((channel) => [channel, EDITION_CONTRACT.repositories[channel].namespaceSegment])),
41
41
  );
42
42
 
43
- export { EditionChannel, EditionKind, EvidenceTrustState };
43
+ export { CustomerPinForm, EditionChannel, EditionKind, EvidenceTrustState };
44
44
 
45
45
  /**
46
46
  * The evidence trust states an edition of `kind` on `channel` may record (contract `evidenceTrust.statesByKind`):
@@ -72,9 +72,29 @@ if (!(DEFINITION_PIN_KEY in EDITION_CONTRACT.provenancePins)) {
72
72
  throw new Error('edition-contract.json provenancePins does not declare the definition pin');
73
73
  }
74
74
 
75
+ /**
76
+ * The commit of the customer definition repository a catalogue-free customer edition's definition was read at: a pin of
77
+ * the catalogue-free form only (provenancePins.customerForms["catalogue-free"].pins names it), a 40-hex commit.
78
+ */
79
+ export const DEFINITION_REPOSITORY_PIN_KEY = 'definition-repository';
80
+ if (!EDITION_CONTRACT.provenancePins.customerForms['catalogue-free'].pins.includes(`"${DEFINITION_REPOSITORY_PIN_KEY}"`)) {
81
+ throw new Error(`edition-contract.json provenancePins.customerForms["catalogue-free"].pins does not name the "${DEFINITION_REPOSITORY_PIN_KEY}" pin`);
82
+ }
83
+
75
84
  /** The provenance pins that are sha256 digests, not commits. */
76
85
  export const DIGEST_PIN_KEYS = Object.freeze([CATALOGUE_PIN_KEY, DEFINITION_PIN_KEY]);
77
86
 
87
+ /**
88
+ * The key of a vendor member (develop graph repository) pin, `repos/<x>` or `submodules/<x>`: the one statement is the
89
+ * contract's (provenancePins.customerForms.vendorMemberPinPattern); the snapshot pin namespaces read it from here.
90
+ */
91
+ export const VENDOR_MEMBER_PIN_PATTERN = new RegExp(EDITION_CONTRACT.provenancePins.customerForms.vendorMemberPinPattern);
92
+
93
+ /** Whether the contract admits customer pin form `form` (CustomerPinForm) on an edition of `kind` and `channel`. */
94
+ export function customerPinFormAdmitted(form, kind, channel) {
95
+ return EDITION_CONTRACT.provenancePins.customerForms[form].kinds[kind]?.includes(channel) === true;
96
+ }
97
+
78
98
  /** The lock files an edition can carry. */
79
99
  export const EditionLockFile = Object.freeze({
80
100
  Distribution: 'distribution.lock.json',
@@ -91,6 +91,47 @@ function assertDescriptorVocabulary(contract) {
91
91
  }
92
92
  }
93
93
 
94
+ /** The customer pin forms (provenancePins.customerForms), one key per form. */
95
+ export const CustomerPinForm = Object.freeze({ Catalogue: 'catalogue', CatalogueFree: 'catalogue-free' });
96
+
97
+ /**
98
+ * `provenancePins`: exactly the catalogue and definition pin statements and `customerForms`, the closed statement of how a
99
+ * customer edition records its vendor side. The catalogue form is admitted on every kind and channel a kind has (a customer
100
+ * edition that is not catalogue-free still requires the catalogue pin), the catalogue-free form on a subset of them; the
101
+ * vendor member pin pattern is an anchored regular expression.
102
+ */
103
+ function assertProvenancePins(contract) {
104
+ const pins = contract.provenancePins;
105
+ const texts = ['catalogue', 'definition'];
106
+ if (!isObject(pins) || !sameSet(Object.keys(pins), [...texts, 'customerForms']) || !texts.every((key) => typeof pins[key] === 'string' && pins[key] !== '')) {
107
+ throw new Error('edition-contract.json: "provenancePins" must be exactly {catalogue, definition, customerForms}');
108
+ }
109
+ const forms = pins.customerForms;
110
+ const formNames = Object.values(CustomerPinForm);
111
+ if (!isObject(forms) || !sameSet(Object.keys(forms), ['names', 'vendorMemberPinPattern', ...formNames]) || typeof forms.names !== 'string' || forms.names === '') {
112
+ throw new Error(`edition-contract.json: provenancePins.customerForms must be exactly {names, vendorMemberPinPattern, ${formNames.join(', ')}}`);
113
+ }
114
+ assertAnchoredPattern(forms.vendorMemberPinPattern, 'provenancePins.customerForms.vendorMemberPinPattern');
115
+ const channelsOf = { [EditionKind.Resolved]: Object.values(EditionChannel), [EditionKind.Sealed]: [contract.sealOf.channel] };
116
+ for (const name of formNames) {
117
+ const form = forms[name];
118
+ if (!isObject(form) || !sameSet(Object.keys(form), ['pins', 'kinds']) || typeof form.pins !== 'string' || form.pins === '' || !isObject(form.kinds)) {
119
+ throw new Error(`edition-contract.json: provenancePins.customerForms.${name} must be exactly {pins, kinds}`);
120
+ }
121
+ for (const [kind, channels] of Object.entries(form.kinds)) {
122
+ const own = channelsOf[kind];
123
+ if (own === undefined || !Array.isArray(channels) || channels.length === 0 || new Set(channels).size !== channels.length || !channels.every((channel) => own.includes(channel))) {
124
+ throw new Error(`edition-contract.json: provenancePins.customerForms.${name}.kinds.${kind} must be a non-empty set of the channels of a ${kind} edition`);
125
+ }
126
+ }
127
+ }
128
+ for (const [kind, own] of Object.entries(channelsOf)) {
129
+ if (!sameSet(forms[CustomerPinForm.Catalogue].kinds[kind], own)) {
130
+ throw new Error(`edition-contract.json: provenancePins.customerForms.catalogue.kinds.${kind} must name every channel of a ${kind} edition (${own.join(' | ')}): the catalogue form is the default form`);
131
+ }
132
+ }
133
+ }
134
+
94
135
  const SEGMENT = /^[a-z0-9][a-z0-9.-]*$/;
95
136
 
96
137
  /** The strict parse of the contract; returns it unchanged. */
@@ -133,5 +174,6 @@ export function parseEditionContract(contract) {
133
174
  throw new Error('edition-contract.json: the channels\' namespace segments must differ');
134
175
  }
135
176
  assertDescriptorVocabulary(contract);
177
+ assertProvenancePins(contract);
136
178
  return contract;
137
179
  }
@@ -35,7 +35,24 @@
35
35
  },
36
36
  "provenancePins": {
37
37
  "catalogue": "the sha256 digest (sha256:<64 hex>) of the offering artifact a customer edition was resolved against",
38
- "definition": "the sha256 digest (sha256:<64 hex>) of the canonical JSON of the customer definition a customer edition was resolved from (vendor selection, image registry mirror, Store publisher anchors, sources); every other pin is a 40-hex commit"
38
+ "definition": "the sha256 digest (sha256:<64 hex>) of the canonical JSON of the customer definition a customer edition was resolved from (vendor selection, image registry mirror, Store publisher anchors, sources); every other pin is a 40-hex commit",
39
+ "customerForms": {
40
+ "names": "a customer edition is one whose pins carry the definition pin. It records its vendor side in exactly one form, closed per kind and channel: the catalogue form, or the catalogue-free form. Every other edition carries neither form's marker: no catalogue pin. The kinds and channels each form admits are enforced by the writers and this CLI's readers (the descriptor parse); the catalogue-free form being develop only also rests on only the develop resolve identity being able to sign one. The deploy root's admission does not enforce the forms: it re-vendors this contract and reads neither provenancePins nor the forms",
41
+ "catalogue": {
42
+ "pins": "the catalogue pin, the definition pin, and a sources/<name> commit per source the definition declares; nothing else. The distribution lock names the catalogue artifact by catalogueDigest, never by catalogueContentDigest",
43
+ "kinds": {
44
+ "resolved": ["develop", "release"],
45
+ "sealed": ["release"]
46
+ }
47
+ },
48
+ "catalogue-free": {
49
+ "pins": "no catalogue pin: the vendor side was resolved from the vendor members in the same call, so the edition records the vendor member pins (keys matching vendorMemberPinPattern, at least one) as 40-hex commits, beside the definition pin, the \"definition-repository\" commit (the commit of the customer definition repository the definition was read at), the sources/<name> commits and the other 40-hex pins of the resolve. The catalogue the vendor step wrote is named in the distribution lock only, by catalogueContentDigest (sha256 of its exact catalogue.json bytes, kernel edition protocol v37), never by catalogueDigest: no catalogue artifact exists",
50
+ "kinds": {
51
+ "resolved": ["develop"]
52
+ }
53
+ },
54
+ "vendorMemberPinPattern": "^(?:repos|submodules)/[a-z0-9][a-z0-9-]*$"
55
+ }
39
56
  },
40
57
  "imageNamespaceSegment": "dev",
41
58
  "evidenceTrust": {
@@ -1,4 +1,5 @@
1
1
  import { execFileSync } from 'node:child_process';
2
+ import { X509Certificate, createPublicKey } from 'node:crypto';
2
3
  import {
3
4
  mkdtempSync,
4
5
  readFileSync,
@@ -126,6 +127,76 @@ export function validatePinnedTrustedRoot(policyRoot, policy) {
126
127
  return { path: trustedRoot, bytes: rootBytes };
127
128
  }
128
129
 
130
+ /** The one transparency log a CLASSIC (`.sig` / `.att`) cosign signature is entered in: Rekor v1 of the public good instance. */
131
+ const CLASSIC_REKOR_BASE_URL = 'https://rekor.sigstore.dev';
132
+ const STRICT_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u;
133
+
134
+ const pemOf = (label, der) => `-----BEGIN ${label}-----\n${der.toString('base64').match(/.{1,64}/gu).join('\n')}\n-----END ${label}-----\n`;
135
+ const isOpen = (entry) => entry?.validFor?.end === undefined;
136
+
137
+ function derOf(rawBytes, what) {
138
+ if (typeof rawBytes !== 'string' || rawBytes.length === 0 || !STRICT_BASE64.test(rawBytes)) {
139
+ throw new Error(`Sigstore trusted root: ${what} rawBytes is not base64 DER`);
140
+ }
141
+ return Buffer.from(rawBytes, 'base64');
142
+ }
143
+
144
+ function publicKeyPem(entry, what) {
145
+ const der = derOf(entry?.publicKey?.rawBytes, what);
146
+ try {
147
+ createPublicKey({ key: der, format: 'der', type: 'spki' });
148
+ } catch {
149
+ throw new Error(`Sigstore trusted root: ${what} is not a DER SubjectPublicKeyInfo`);
150
+ }
151
+ return pemOf('PUBLIC KEY', der);
152
+ }
153
+
154
+ /**
155
+ * The classic cosign trust material of the pinned Sigstore trusted root, derived deterministically from its bytes (the
156
+ * output of `validatePinnedTrustedRoot`): what cosign's classic verification reads from `SIGSTORE_ROOT_FILE`,
157
+ * `SIGSTORE_REKOR_PUBLIC_KEY` and `SIGSTORE_CT_LOG_PUBLIC_KEY_FILE` when it cannot take `--trusted-root` (cosign 2.6
158
+ * `verify-attestation` accepts `--trusted-root` only with `--new-bundle-format`, which classic `.att` attestations are
159
+ * not). Only material still open is kept (no `validFor.end`): the Fulcio certificate chains of every open certificate
160
+ * authority (at least one), exactly one open Rekor v1 log (`https://rekor.sigstore.dev`; later logs carry new-format
161
+ * entries only) and exactly one open CT log. Any other count is refused.
162
+ *
163
+ * @returns {{fulcioPem: string, rekorPem: string, ctLogPem: string}}
164
+ */
165
+ export function classicCosignTrustMaterial(rootBytes) {
166
+ const root = JSON.parse(decodeExactUtf8(rootBytes, 'Sigstore trusted root'));
167
+ const listOf = (key) => (Array.isArray(root?.[key]) ? root[key] : []);
168
+ const authorities = listOf('certificateAuthorities').filter(isOpen);
169
+ if (authorities.length === 0) {
170
+ throw new Error('Sigstore trusted root: no open Fulcio certificate authority (certificateAuthorities without validFor.end)');
171
+ }
172
+ const fulcioPem = authorities
173
+ .flatMap((authority, index) => {
174
+ const certificates = authority?.certChain?.certificates;
175
+ if (!Array.isArray(certificates) || certificates.length === 0) {
176
+ throw new Error(`Sigstore trusted root: open certificate authority ${index} has no certificate chain`);
177
+ }
178
+ return certificates.map((certificate) => {
179
+ const der = derOf(certificate?.rawBytes, `certificate authority ${index} certificate`);
180
+ try {
181
+ new X509Certificate(der);
182
+ } catch {
183
+ throw new Error(`Sigstore trusted root: certificate authority ${index} holds a certificate that is not DER X.509`);
184
+ }
185
+ return pemOf('CERTIFICATE', der);
186
+ });
187
+ })
188
+ .join('');
189
+ const rekor = listOf('tlogs').filter((log) => log?.baseUrl === CLASSIC_REKOR_BASE_URL && isOpen(log?.publicKey));
190
+ if (rekor.length !== 1) {
191
+ throw new Error(`Sigstore trusted root: ${rekor.length} open Rekor v1 logs (${CLASSIC_REKOR_BASE_URL}); exactly one is required`);
192
+ }
193
+ const ctLogs = listOf('ctlogs').filter((log) => isOpen(log?.publicKey));
194
+ if (ctLogs.length !== 1) {
195
+ throw new Error(`Sigstore trusted root: ${ctLogs.length} open CT logs (ctlogs without validFor.end); exactly one is required`);
196
+ }
197
+ return { fulcioPem, rekorPem: publicKeyPem(rekor[0], 'the Rekor v1 log key'), ctLogPem: publicKeyPem(ctLogs[0], 'the CT log key') };
198
+ }
199
+
129
200
  function assertTimeWindow({ issuedAt, expiresAt, now, maxTtlSeconds, label }) {
130
201
  const issued = Date.parse(issuedAt);
131
202
  const expires = Date.parse(expiresAt);
@@ -23,6 +23,7 @@
23
23
 
24
24
  import { developGraphMembers } from './develop-graph.mjs';
25
25
  import { workspaceSection } from './edition-config.mjs';
26
+ import { VENDOR_MEMBER_PIN_PATTERN } from './edition-constants.mjs';
26
27
  import { compareStrings } from './lib.mjs';
27
28
  import { PinRole } from './workspace-sections.mjs';
28
29
 
@@ -55,7 +56,8 @@ export const dataPinKey = () => pinKeyOf(PinRole.Data);
55
56
  export const kernelPinKey = () => pinKeyOf(PinRole.Kernel);
56
57
  export const deployPinKey = () => pinKeyOf(PinRole.Deploy);
57
58
  const SOURCE_PIN_PREFIX = 'sources/';
58
- const GRAPH_PIN_PATTERN = /^(?:repos|submodules)\/[a-z0-9][a-z0-9-]*$/;
59
+ // A graph pin key is a vendor member pin key: the pattern is the edition contract's (customerForms.vendorMemberPinPattern).
60
+ const GRAPH_PIN_PATTERN = VENDOR_MEMBER_PIN_PATTERN;
59
61
 
60
62
  /** The snapshot key of one declared `sources[]` entry. */
61
63
  export function sourcePinKey(name) {
@@ -3,8 +3,9 @@
3
3
  // selection can both subclass it. A registry, cosign or git that could not answer is a plain Error instead.
4
4
 
5
5
  export class RecordRefused extends Error {
6
- constructor(repo, message) {
7
- super(`select-records: ${repo}: ${message}`);
6
+ /** `context` labels the verb the refusal surfaces in (select-records by default, `follow` for its sources). */
7
+ constructor(repo, message, context = 'select-records') {
8
+ super(`${context}: ${repo}: ${message}`);
8
9
  this.name = 'RecordRefused';
9
10
  this.repo = repo;
10
11
  }
@@ -20,6 +20,7 @@ import { isDeepStrictEqual } from 'node:util';
20
20
 
21
21
  import { adapterHostRow, assertAdapterBundlesMatchHost, fetchRegistryManifest } from './adapter-bundled-versions.mjs';
22
22
  import { AUTHORIZATION_LOCK_FILENAME, writeAuthorizationLock } from './authorization-base.mjs';
23
+ import { catalogueLockFieldOf } from './catalogue-lock-field.mjs';
23
24
  import {
24
25
  assertCustomerLockWithinCatalogueCeiling,
25
26
  CatalogueCeilingError,
@@ -213,8 +214,29 @@ export function assertVendorRowsAreCatalogueRows({ catalogue, imageLock, lock, m
213
214
  }
214
215
  }
215
216
 
216
- /** The customer's image lock: vendor rows copied (and mirrored), own rows resolved (and sealed by `sealOwn`, which takes and returns subjects), the merged lock parsed once. */
217
- export async function resolveCustomerImages({ id, catalogue, lock, composed, schemas, inputs, sealOwn }) {
217
+ /**
218
+ * The customer's OWN lineage of a previous images lock: `previousLock` (the customer edition's own last images lock, its
219
+ * id already held to this edition's) narrowed to the composed-process rows every owner of which is one of this lock's
220
+ * own (customer-sourced) biomes. The vendor rows of a customer lock are the catalogue's (assertVendorRowsAreCatalogueRows)
221
+ * and carry the vendor's generations, never the customer's, so they never enter the customer's own carry-forward.
222
+ * Null (a first seal) stays null.
223
+ */
224
+ export function ownComposedPreviousLock(previousLock, lock, enums) {
225
+ if (previousLock === null) {return null;}
226
+ const ownBiomes = new Set(lock.biomes.filter((biome) => biome.source !== undefined).map((biome) => biome.id));
227
+ const own = (subject) =>
228
+ subject.kind === enums.RuntimeImageSubjectKind.ComposedProcess &&
229
+ subject.owners.length > 0 &&
230
+ subject.owners.every((owner) => owner.kind === enums.RuntimeImageOwnerKind.Biome && ownBiomes.has(owner.biomeId));
231
+ return { ...previousLock, subjects: previousLock.subjects.filter(own) };
232
+ }
233
+
234
+ /**
235
+ * The customer's image lock: vendor rows copied (and mirrored), own rows resolved (and sealed by `sealOwn`, which takes
236
+ * and returns subjects), the merged lock parsed once. `previousLock` (the customer's own previous seal, null for a first
237
+ * seal) carries the own composed artifacts' generations forward (ownComposedPreviousLock).
238
+ */
239
+ export async function resolveCustomerImages({ id, catalogue, lock, composed, schemas, inputs, sealOwn, previousLock = null }) {
218
240
  const vendor = projectVendorImages(catalogue, lock, schemas);
219
241
  const vendorSubjects = mirrorVendorSubjects(vendor.subjects, composed.mirror);
220
242
  const ownBiomes = lock.biomes.filter((biome) => biome.source !== undefined);
@@ -232,7 +254,7 @@ export async function resolveCustomerImages({ id, catalogue, lock, composed, sch
232
254
  buildStepContextDirectories: [],
233
255
  };
234
256
  // The own rows are ROWS, not a lock: the one parse is of the merged lock below.
235
- own = await resolveImageRows(id, { inputs: { ...inputs, runtimeImageSourceIndex, composed }, lock: ownLock });
257
+ own = await resolveImageRows(id, { inputs: { ...inputs, runtimeImageSourceIndex, composed }, lock: ownLock, previousLock: ownComposedPreviousLock(previousLock, lock, schemas) });
236
258
  // A source that builds no image (a web biome) has no row to seal.
237
259
  if (sealOwn !== undefined && own.subjects.length > 0) {own = { ...own, subjects: await sealOwn({ distributionId: id, subjects: own.subjects }) };}
238
260
  }
@@ -258,12 +280,14 @@ export async function resolveCustomerImages({ id, catalogue, lock, composed, sch
258
280
 
259
281
  /**
260
282
  * The distribution and authorization locks of the customer edition `rawDefinition`
261
- * against `catalogue`. `catalogueDigest` is the digest of `catalogue`, written into the lock. `adoptKernel` (the catalogue's kernel from npm, proved its contract authority; returns the kernel schemas) and `materialize` are injectable for the test suites.
283
+ * against `catalogue`. Exactly one of `catalogueDigest` (a published catalogue artifact) and `catalogueContentDigest` (the
284
+ * catalogue bytes of a catalogue-free resolve) names `catalogue`; it is written into the lock under its own key. `adoptKernel` (the catalogue's kernel from npm, proved its contract authority; returns the kernel schemas) and `materialize` are injectable for the test suites.
262
285
  */
263
286
  export async function resolveCustomerDistribution({
264
287
  rawDefinition,
265
288
  catalogue,
266
289
  catalogueDigest,
290
+ catalogueContentDigest,
267
291
  sourceImages,
268
292
  pins,
269
293
  materialize,
@@ -271,7 +295,7 @@ export async function resolveCustomerDistribution({
271
295
  fetchManifest = fetchRegistryManifest,
272
296
  developGitBundles,
273
297
  }) {
274
- if (catalogueDigest === undefined) {throw new CatalogueError('a customer resolve names the digest of its catalogue');}
298
+ const catalogueField = catalogueLockFieldOf({ catalogueDigest, catalogueContentDigest }, { what: 'a customer resolve', ErrorType: CatalogueError });
275
299
  // Only a develop edition carries a git-sourced OCI bundle (develop-git-bundles.mjs); a release never verifies one.
276
300
  if (developGitBundles !== undefined && catalogue.channel !== EditionChannel.Develop) {
277
301
  throw new Error(`a git-sourced bundle verifier is a develop resolve's; the catalogue "${catalogue.offeringId}" is ${catalogue.channel}`);
@@ -317,14 +341,19 @@ export async function resolveCustomerDistribution({
317
341
  await assertAdapterBundlesMatchHost({ lock, hostRow: adapterHostRow(catalogue.biomes), fetchManifest });
318
342
  const authorizationLock = await composeAuthorizationBase(id, lock, index);
319
343
  // A customer lock names the catalogue it was resolved against; seal and verify refuse any other.
320
- return { id, schemas, composed, inputs, lock: { ...lock, catalogueDigest }, authorizationLock, storePublisherAnchorKeys };
344
+ return { id, schemas, composed, inputs, lock: { ...lock, [catalogueField.key]: catalogueField.digest }, authorizationLock, storePublisherAnchorKeys };
321
345
  }
322
346
 
323
- /** Resolve the customer edition of `channel` against `catalogue`; returns the four locks. `sealOwn` seals the customer's OWN image rows only (a develop edition); vendor rows arrive sealed. */
347
+ /**
348
+ * Resolve the customer edition of `channel` against `catalogue`; returns the four locks. `sealOwn` seals the customer's OWN
349
+ * image rows only (a develop edition); vendor rows arrive sealed. `previousSeal` answers the customer's own previous seal
350
+ * (resolvePreviousSeal under this edition's id: null for a first seal), read only after the catalogue's kernel is adopted
351
+ * (it parses with that kernel); its own composed artifacts carry their generation forward. Absent: a first seal.
352
+ */
324
353
  export async function resolveCustomerEdition(request) {
325
354
  const { id, schemas, composed, inputs, lock, authorizationLock, storePublisherAnchorKeys } = await resolveCustomerDistribution(request);
326
355
  const { catalogue } = request;
327
- const imageLock = await resolveCustomerImages({ id, catalogue, lock, composed, schemas, inputs, sealOwn: request.sealOwn });
356
+ const imageLock = await resolveCustomerImages({ id, catalogue, lock, composed, schemas, inputs, sealOwn: request.sealOwn, previousLock: request.previousSeal === undefined ? null : await request.previousSeal(id) });
328
357
  // The chart-set is copied whole, so it carries the coordinates of the channel it was published on:
329
358
  // the edition being written states its channel, and a chart-set of the other one is refused here.
330
359
  const chartSetLock = assertRosteredChartSetLock(catalogue.chartSet, `the catalogue's chart-set of "${catalogue.offeringId}"`);
@@ -158,6 +158,9 @@ export function parseCustomerResolveArgs(args) {
158
158
  if (!Object.values(EditionChannel).includes(values.get(CHANNEL_FLAG))) {
159
159
  throw new Error(`${CHANNEL_FLAG} must be one of ${Object.values(EditionChannel).join(' | ')}`);
160
160
  }
161
+ if (values.get(CHANNEL_FLAG) === EditionChannel.Develop) {
162
+ throw new Error(`${DEFINITION_FLAG} with ${CHANNEL_FLAG} develop is refused here: this verb takes no previous seal, so the customer's own composed rows would restart at generation 1; use xema edition develop resolve ${DEFINITION_FLAG} <file> --previous-lock <file> --previous-lock-sha256 <hex> | --first-seal`);
163
+ }
161
164
  return { values, sourceImages: parseSourceImages(sourceImages) };
162
165
  }
163
166
 
@@ -2126,12 +2126,21 @@ export async function resolvePreviousSeal({ distributionId, previousLockPath, pr
2126
2126
  : `--previous-lock and --previous-lock-sha256 go together (one was given); give ${PREVIOUS_SEAL_USAGE}`,
2127
2127
  );
2128
2128
  }
2129
- const bytes = readFileSync(previousLockPath);
2129
+ const bytes = readPreviousLockBytes(previousLockPath, previousLockSha256);
2130
+ return parsePreviousLock(distributionId, parseStrictJson(bytes.toString('utf8'), previousLockPath), '--previous-lock');
2131
+ }
2132
+
2133
+ /**
2134
+ * The bytes of a previous lock file, held to the sha256 (hex) they were given with: the one check of the pair
2135
+ * (`flags` names the pair in the refusal). The parse, with the kernel and against the edition's id, is the caller's.
2136
+ */
2137
+ export function readPreviousLockBytes(path, sha256, flags = { lock: '--previous-lock', sha256: '--previous-lock-sha256' }) {
2138
+ const bytes = readFileSync(path);
2130
2139
  const actual = createHash('sha256').update(bytes).digest('hex');
2131
- if (actual !== previousLockSha256) {
2132
- throw new Error(`--previous-lock ${previousLockPath} has sha256 ${actual}, not the given --previous-lock-sha256 ${previousLockSha256}`);
2140
+ if (actual !== sha256) {
2141
+ throw new Error(`${flags.lock} ${path} has sha256 ${actual}, not the given ${flags.sha256} ${sha256}`);
2133
2142
  }
2134
- return parsePreviousLock(distributionId, readStrictJsonFile(previousLockPath), '--previous-lock');
2143
+ return bytes;
2135
2144
  }
2136
2145
 
2137
2146
  /** The in-repo lane's previous lock: the committed file at `outPath`, parsed before it is overwritten; null (first seal) when there is none. */
@@ -3,9 +3,12 @@
3
3
  // verified by cosign (`third-party-source-evidence.mjs`) before it is committed.
4
4
 
5
5
  import { execFileSync } from 'node:child_process';
6
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
6
9
 
7
10
  import { certificateConstraintArgs, certificateIdentityArgs } from './cosign-certificate-constraints.mjs';
8
- import { COSIGN_ENV, decodeExactUtf8, sha256Digest, validatePinnedTrustedRoot } from './image-evidence.mjs';
11
+ import { COSIGN_ENV, classicCosignTrustMaterial, decodeExactUtf8, sha256Digest, validatePinnedTrustedRoot } from './image-evidence.mjs';
9
12
  import { splitImageRef } from './registry-credentials.mjs';
10
13
  import { BUNDLE_MEDIA_TYPE } from './sigstore-bundle-media-type.mjs';
11
14
  import { SLSA_PROVENANCE_V1, decodeJsonStatement, digestOfOciRef } from './third-party-source-evidence.mjs';
@@ -137,7 +140,13 @@ export async function bundleDigestRef({ clientFor, ociRef }) {
137
140
 
138
141
  const tail = (error) => String(error?.stderr ?? error?.message ?? error).trim().split('\n').slice(-4).join(' | ').slice(0, 1024);
139
142
 
140
- /** The cosign run of a registry verification: the trusted binary, the operator's registry credential (HOME / DOCKER_CONFIG). */
143
+ /**
144
+ * The cosign run of a registry verification: the trusted binary under a CLEAN environment (`COSIGN_ENV` and the
145
+ * operator's registry credential, HOME / DOCKER_CONFIG; no `SIGSTORE_*`, `COSIGN_*` or `TUF_*` of the operator is ever
146
+ * inherited). `run` takes `--offline --trusted-root <pinned>`; `runClassic` instead pins cosign's classic trust inputs
147
+ * to the keys derived from the same pinned root (`classicCosignTrustMaterial`), in a private directory removed after the
148
+ * run, with an empty private TUF_ROOT so no live TUF material is ever read.
149
+ */
141
150
  function registryCosign({ trustedCosign, policy, policyRoot, row, ociRef, env }) {
142
151
  const root = validatePinnedTrustedRoot(policyRoot, policy);
143
152
  const environment = {
@@ -145,18 +154,41 @@ function registryCosign({ trustedCosign, policy, policyRoot, row, ociRef, env })
145
154
  ...(env.HOME === undefined ? {} : { HOME: env.HOME }),
146
155
  ...(env.DOCKER_CONFIG === undefined ? {} : { DOCKER_CONFIG: env.DOCKER_CONFIG }),
147
156
  };
157
+ const exec = (command, args, runEnv) => {
158
+ try {
159
+ return execFileSync(assertPreparedExecutable(trustedCosign, 'cosign'), [command, ...args], {
160
+ env: runEnv,
161
+ encoding: 'utf8',
162
+ stdio: ['ignore', 'pipe', 'pipe'],
163
+ timeout: REGISTRY_COSIGN_TIMEOUT_MS,
164
+ });
165
+ } catch (error) {
166
+ throw new Error(`cosign ${command} refused ${ociRef} under row "${row.policyId}": ${tail(error)}`);
167
+ }
168
+ };
148
169
  return {
149
170
  trustArgs: ['--offline', '--trusted-root', root.path],
150
- run(command, args) {
171
+ run: (command, args) => exec(command, args, environment),
172
+ runClassic(command, args) {
173
+ const material = classicCosignTrustMaterial(root.bytes);
174
+ const dir = mkdtempSync(join(tmpdir(), 'xema-cosign-trust-'));
151
175
  try {
152
- return execFileSync(assertPreparedExecutable(trustedCosign, 'cosign'), [command, ...args], {
153
- env: environment,
154
- encoding: 'utf8',
155
- stdio: ['ignore', 'pipe', 'pipe'],
156
- timeout: REGISTRY_COSIGN_TIMEOUT_MS,
176
+ const file = (name, content) => {
177
+ const path = join(dir, name);
178
+ writeFileSync(path, content, { mode: 0o600, flag: 'wx' });
179
+ return path;
180
+ };
181
+ const tufRoot = join(dir, 'tuf');
182
+ mkdirSync(tufRoot, { mode: 0o700 });
183
+ return exec(command, ['--offline', ...args], {
184
+ ...environment,
185
+ SIGSTORE_ROOT_FILE: file('fulcio.pem', material.fulcioPem),
186
+ SIGSTORE_REKOR_PUBLIC_KEY: file('rekor.pub', material.rekorPem),
187
+ SIGSTORE_CT_LOG_PUBLIC_KEY_FILE: file('ctlog.pub', material.ctLogPem),
188
+ TUF_ROOT: tufRoot,
157
189
  });
158
- } catch (error) {
159
- throw new Error(`cosign ${command} refused ${ociRef} under row "${row.policyId}": ${tail(error)}`);
190
+ } finally {
191
+ rmSync(dir, { recursive: true, force: true });
160
192
  }
161
193
  },
162
194
  };
@@ -165,16 +197,21 @@ function registryCosign({ trustedCosign, policy, policyRoot, row, ociRef, env })
165
197
  /**
166
198
  * (a) against the registry: keyless `cosign verify` and `cosign verify-attestation --type slsaprovenance1` of the bundle
167
199
  * artifact under EXACTLY the signer row's identity and issuer, the row's workflow repository, ref and the stated
168
- * commit, the pinned trusted root and the trusted cosign binary. `--offline` verifies the tlog inclusion proof carried
169
- * with the signature; the tlog is never ignored. The registry credential is the operator's (HOME / DOCKER_CONFIG).
200
+ * commit, and the trusted cosign binary, both `--offline`: the tlog entry carried with the signature is verified, the
201
+ * tlog is never ignored. The registry credential is the operator's (HOME / DOCKER_CONFIG).
202
+ *
203
+ * Publishers write the CLASSIC cosign format (`sha256-<d>.sig` / `.att` tags; `cosign sign` / `cosign attest`), not
204
+ * referrer Sigstore bundles. `verify` takes the pinned trusted root as is (`--trusted-root`, classic or bundle form).
205
+ * cosign 2.6 `verify-attestation` takes `--trusted-root` only with `--new-bundle-format`, which finds no classic
206
+ * attestation, so it runs without either, against the Fulcio, Rekor v1 and CT keys derived from the SAME pinned root
207
+ * and passed in cosign's classic trust variables (`registryCosign().runClassic`). A publisher switching to referrer
208
+ * bundles is refused loudly (cosign then requires a trusted root), never verified against anything unpinned.
170
209
  */
171
210
  export function verifyBundleOnRegistry({ trustedCosign, policy, policyRoot, row, workflowSha, ociRef, env = process.env }) {
172
211
  const cosign = registryCosign({ trustedCosign, policy, policyRoot, row, ociRef, env });
173
- const common = [...cosign.trustArgs, ...certificateConstraintArgs(row, workflowSha)];
174
- cosign.run('verify', [...common, ociRef]);
175
- // cosign refuses `verify-attestation --trusted-root` without `--new-bundle-format`; plain `verify` takes it as is.
176
- const [offline, trustedRoot, rootPath, ...claims] = common;
177
- cosign.run('verify-attestation', [offline, '--new-bundle-format', trustedRoot, rootPath, '--type', COSIGN_SLSA_PROVENANCE_ALIAS, ...claims, ociRef]);
212
+ const claims = certificateConstraintArgs(row, workflowSha);
213
+ cosign.run('verify', [...cosign.trustArgs, ...claims, ociRef]);
214
+ cosign.runClassic('verify-attestation', ['--type', COSIGN_SLSA_PROVENANCE_ALIAS, ...claims, ociRef]);
178
215
  }
179
216
 
180
217
  const WORKFLOW_SHA = /^[0-9a-f]{40}$/u;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xemahq/xema",
3
- "version": "0.58.0",
3
+ "version": "0.59.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/xema-dev/xema-cli.git",
@@ -100,7 +100,7 @@
100
100
  "typecheck": "tsc --noEmit"
101
101
  },
102
102
  "xemaRelease": {
103
- "sourceDigest": "sha256:90788d0401718a3e8bfab84c8818ff21bde3b1218931ab71aedd032894bc346a",
104
- "sha": "931d0b103540e14e34d6c726a5167640aa7a1c6e"
103
+ "sourceDigest": "sha256:bdd50b327d2da70bdc17c4cdcfce8ed38500fbd68aea8f23029c53d45ddbcb86",
104
+ "sha": "9e648fd62243cb0fc9df74680ce6c2a110a901ad"
105
105
  }
106
106
  }