@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.
- package/dist/commands/edition.js +1 -1
- package/dist/commands/edition.js.map +1 -1
- package/dist/edition/verify-image-evidence.bundle.mjs +90 -1
- package/dist/edition/verify-image-evidence.bundle.provenance.json +66 -66
- package/edition/README.md +7 -1
- package/edition/biome-content.mjs +25 -16
- package/edition/catalogue-lock-field.mjs +41 -0
- package/edition/customer-edition-verbs.mjs +93 -12
- package/edition/customer-flags.mjs +7 -0
- package/edition/develop-catalogue-free-resolve.mjs +280 -0
- package/edition/develop-edition.mjs +43 -1
- package/edition/develop-follow.mjs +491 -0
- package/edition/develop-record-ancestry.mjs +21 -16
- package/edition/develop-selection-pins.mjs +11 -5
- package/edition/distribution-evidence-set.mjs +2 -0
- package/edition/edition-artifact.mjs +44 -1
- package/edition/edition-constants.mjs +22 -2
- package/edition/edition-contract-parse.mjs +42 -0
- package/edition/edition-contract.json +18 -1
- package/edition/image-evidence.mjs +71 -0
- package/edition/pin-source.mjs +3 -1
- package/edition/record-refused.mjs +3 -2
- package/edition/resolve-customer-edition.mjs +37 -8
- package/edition/resolve-edition.mjs +3 -0
- package/edition/resolve-images.mjs +13 -4
- package/edition/third-party-bundle-registry.mjs +54 -17
- package/package.json +3 -3
|
@@ -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);
|
package/edition/pin-source.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
7
|
-
|
|
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
|
-
/**
|
|
217
|
-
|
|
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`.
|
|
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
|
-
|
|
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,
|
|
344
|
+
return { id, schemas, composed, inputs, lock: { ...lock, [catalogueField.key]: catalogueField.digest }, authorizationLock, storePublisherAnchorKeys };
|
|
321
345
|
}
|
|
322
346
|
|
|
323
|
-
/**
|
|
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 =
|
|
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 !==
|
|
2132
|
-
throw new Error(
|
|
2140
|
+
if (actual !== sha256) {
|
|
2141
|
+
throw new Error(`${flags.lock} ${path} has sha256 ${actual}, not the given ${flags.sha256} ${sha256}`);
|
|
2133
2142
|
}
|
|
2134
|
-
return
|
|
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
|
-
/**
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
}
|
|
159
|
-
|
|
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,
|
|
169
|
-
*
|
|
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
|
|
174
|
-
cosign.run('verify', [...
|
|
175
|
-
|
|
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.
|
|
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:
|
|
104
|
-
"sha": "
|
|
103
|
+
"sourceDigest": "sha256:bdd50b327d2da70bdc17c4cdcfce8ed38500fbd68aea8f23029c53d45ddbcb86",
|
|
104
|
+
"sha": "9e648fd62243cb0fc9df74680ce6c2a110a901ad"
|
|
105
105
|
}
|
|
106
106
|
}
|