@xemahq/xema 0.47.1 → 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.
- package/dist/assets/appliance-base/distribution.lock.json +3936 -1515
- package/dist/assets/appliance-base/images.lock.json +878 -1161
- package/dist/assets/appliance-lean/distribution.lock.json +3663 -916
- package/dist/assets/appliance-lean/images.lock.json +825 -924
- package/dist/assets/boot-graph.json +398 -93
- package/dist/assets/distribution-assets.manifest.json +10 -10
- package/dist/commands/edition.d.ts.map +1 -1
- package/dist/commands/edition.js +5 -3
- package/dist/commands/edition.js.map +1 -1
- package/dist/dev/env-defaults.d.ts.map +1 -1
- package/dist/dev/env-defaults.js +0 -1
- package/dist/dev/env-defaults.js.map +1 -1
- package/dist/edition/verify-image-evidence.bundle.mjs +4649 -2742
- package/dist/edition/verify-image-evidence.bundle.provenance.json +597 -495
- package/dist/runtime/auth/cli-application.d.ts +18 -0
- package/dist/runtime/auth/cli-application.d.ts.map +1 -0
- package/dist/runtime/auth/cli-application.js +39 -0
- package/dist/runtime/auth/cli-application.js.map +1 -0
- package/dist/runtime/edition/verb-map.d.ts +3 -1
- package/dist/runtime/edition/verb-map.d.ts.map +1 -1
- package/dist/runtime/edition/verb-map.js +5 -1
- package/dist/runtime/edition/verb-map.js.map +1 -1
- package/dist/runtime/env-seeder.d.ts.map +1 -1
- package/dist/runtime/env-seeder.js +21 -0
- package/dist/runtime/env-seeder.js.map +1 -1
- package/dist/runtime/launch.d.ts.map +1 -1
- package/dist/runtime/launch.js +0 -18
- package/dist/runtime/launch.js.map +1 -1
- package/edition/README.md +20 -3
- package/edition/biome-content.mjs +5 -2
- package/edition/build-runtime-image-source-index.mjs +79 -5
- package/edition/canonical-evidence-json.mjs +82 -0
- package/edition/catalogue-evidence.mjs +8 -5
- package/edition/customer-edition-verbs.mjs +9 -3
- package/edition/develop-coverage-run.mjs +2 -1
- package/edition/develop-coverage.mjs +12 -1
- package/edition/develop-edition-resolve.mjs +3 -2
- package/edition/develop-edition.mjs +15 -3
- package/edition/develop-record-verify.mjs +216 -0
- package/edition/develop-select-records.mjs +24 -225
- package/edition/develop-verify-image-record.mjs +90 -0
- package/edition/dist-check.mjs +22 -2
- package/edition/distribution-evidence-set.mjs +30 -2
- package/edition/edition-artifact.mjs +110 -22
- package/edition/edition-ceiling.mjs +180 -0
- package/edition/edition-config.mjs +91 -0
- package/edition/edition-constants.mjs +21 -3
- package/edition/edition-contract-parse.mjs +88 -3
- package/edition/edition-contract.json +47 -4
- package/edition/edition-image-seal.mjs +9 -2
- package/edition/edition-kind.mjs +10 -0
- package/edition/edition-protocol.mjs +8 -1
- package/edition/edition-publisher.mjs +21 -0
- package/edition/edition-seal.mjs +257 -0
- package/edition/evidence-grammars.mjs +2 -0
- package/edition/evidence-trust-pin.mjs +66 -10
- package/edition/evidence-trust-policy.mjs +39 -29
- package/edition/external-source-walk.mjs +16 -3
- package/edition/fleet-coordinate-finality.mjs +18 -5
- package/edition/fulcio-certificate-extensions.mjs +231 -0
- package/edition/image-evidence.mjs +34 -79
- package/edition/locked-bundle-signer.mjs +224 -0
- package/edition/oci-evidence-verbs.mjs +7 -1
- package/edition/oci-image-source-evidence.mjs +8 -7
- package/edition/pinned-cosign.mjs +70 -0
- package/edition/plan-image-evidence-refresh.mjs +3 -1
- package/edition/producer-evidence-bundle.mjs +3 -1
- package/edition/producer-evidence-index-file.mjs +4 -0
- package/edition/release-edition-artifact.mjs +3 -3
- package/edition/repin-evidence-trust.mjs +6 -1
- package/edition/resolve-distribution.mjs +47 -6
- package/edition/resolve-edition.mjs +1 -1
- package/edition/resolve-images.mjs +53 -7
- package/edition/seal-image-digests.mjs +19 -8
- package/edition/sealed-distribution-lock.mjs +2 -2
- package/edition/third-party-admission.mjs +91 -0
- package/edition/third-party-bundle-registry.mjs +147 -0
- package/edition/third-party-certificate-extensions.mjs +21 -0
- package/edition/third-party-image-pins.mjs +41 -0
- package/edition/third-party-pins-trusted.mjs +24 -0
- package/edition/third-party-producer-evidence.mjs +76 -0
- package/edition/third-party-source-check.mjs +28 -0
- package/edition/third-party-source-context.mjs +41 -0
- package/edition/third-party-source-evidence.mjs +255 -0
- package/edition/third-party-source-flags.mjs +134 -0
- package/edition/third-party-source-pins.mjs +259 -0
- package/edition/third-party-source-uniqueness.mjs +54 -0
- package/edition/third-party-source-walk.mjs +115 -0
- package/edition/third-party-source-writer.mjs +229 -0
- package/edition/third-party-subjects.mjs +127 -0
- package/edition/trust-policy-image-ownership.mjs +85 -0
- package/edition/verify-catalogue-evidence.mjs +2 -0
- package/edition/verify-image-evidence.mjs +2 -0
- package/edition/verify-image-platforms.mjs +33 -9
- package/package.json +5 -5
- package/dist/runtime/auth/client-bootstrap.d.ts +0 -11
- package/dist/runtime/auth/client-bootstrap.d.ts.map +0 -1
- package/dist/runtime/auth/client-bootstrap.js +0 -60
- 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
|
|
192
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
9
|
-
//
|
|
10
|
-
// (EvidenceTrustRequiredError,
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
-
/**
|
|
113
|
-
|
|
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
|
|
116
|
-
|
|
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
|
-
|
|
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
|
|
212
|
-
*
|
|
213
|
-
* EvidenceTrustRequiredError: this function never returns `not-armed`
|
|
214
|
-
* A policy edited after pinning, a drifted verifier, and a policy
|
|
215
|
-
* ARMED on `channel` are refused
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|