@xemahq/xema 0.55.0 → 0.57.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/edition/verify-image-evidence.bundle.mjs +7 -4
- package/dist/edition/verify-image-evidence.bundle.provenance.json +61 -61
- package/edition/README.md +7 -1
- package/edition/biome-content.mjs +4 -4
- package/edition/candidate-edition.mjs +8 -3
- package/edition/customer-edition-verbs.mjs +6 -4
- package/edition/develop-edition-resolve.mjs +14 -7
- package/edition/develop-edition.mjs +18 -2
- package/edition/develop-git-bundles.mjs +201 -0
- package/edition/external-source-walk.mjs +37 -15
- package/edition/locked-bundle-signer.mjs +52 -8
- package/edition/previous-seal-registry.mjs +149 -0
- package/edition/resolve-distribution.mjs +84 -21
- package/edition/resolve-images.mjs +25 -12
- package/edition/third-party-bundle-registry.mjs +73 -17
- package/edition/third-party-source-evidence.mjs +39 -14
- package/edition/third-party-source-writer.mjs +2 -18
- package/edition/trust-policy-arming.mjs +17 -6
- package/package.json +3 -2
|
@@ -130,7 +130,7 @@ import { runImageRecord } from './develop-image-record.mjs';
|
|
|
130
130
|
import { runSelectRecords } from './develop-select-records.mjs';
|
|
131
131
|
import { readSelectionPins } from './develop-selection-pins.mjs';
|
|
132
132
|
import { runVerifyImageRecord } from './develop-verify-image-record.mjs';
|
|
133
|
-
import { LOCAL_WORKFLOW, assertExistingArtifactMatches, developEditionTarget, workflowFromEnv, writeEditionArtifact } from './edition-artifact.mjs';
|
|
133
|
+
import { LOCAL_WORKFLOW, assertExistingArtifactMatches, developEditionTarget, editionRepository, workflowFromEnv, writeEditionArtifact } from './edition-artifact.mjs';
|
|
134
134
|
import { EditionChannel, EditionKind } from './edition-constants.mjs';
|
|
135
135
|
import { WorkspaceRoot, configureEditionWorkspaceFromProcess, isEntryModule, kernelRepoDir } from './edition-workspace.mjs';
|
|
136
136
|
import { descriptorEvidenceTrust } from './evidence-trust-policy.mjs';
|
|
@@ -138,6 +138,7 @@ import { createSatelliteVerifier, imageBuildRecordDigest, readImageBuildRecord }
|
|
|
138
138
|
import { assertKernelCommitSupported, chooseKernelCommit } from './kernel-protocol-choice.mjs';
|
|
139
139
|
import { loadKernelDistributionSchemas } from './lib.mjs';
|
|
140
140
|
import { snapshotPinSource } from './pin-source.mjs';
|
|
141
|
+
import { PREVIOUS_SEAL_SIGNER_FLAG, previousSealFromRegistry } from './previous-seal-registry.mjs';
|
|
141
142
|
import { composeEdition } from './resolve-distribution.mjs';
|
|
142
143
|
import { resolvePreviousSeal } from './resolve-images.mjs';
|
|
143
144
|
import { readStrictJsonFile } from './strict-json.mjs';
|
|
@@ -206,7 +207,22 @@ async function run(argv, env) {
|
|
|
206
207
|
env,
|
|
207
208
|
engine: { repository: options['--engine-repository'], signer: options['--engine-signer'] },
|
|
208
209
|
cosignBinary: options['--cosign'],
|
|
209
|
-
previousLock: await resolvePreviousSeal({
|
|
210
|
+
previousLock: await resolvePreviousSeal({
|
|
211
|
+
distributionId: id,
|
|
212
|
+
previousLockPath: options[PREVIOUS_LOCK_FLAG],
|
|
213
|
+
previousLockSha256: options[PREVIOUS_LOCK_SHA256_FLAG],
|
|
214
|
+
firstSeal: Boolean(options[FIRST_SEAL_FLAG]),
|
|
215
|
+
...(options[PREVIOUS_SEAL_SIGNER_FLAG] === undefined ? {} : {
|
|
216
|
+
registry: {
|
|
217
|
+
find: () => previousSealFromRegistry({
|
|
218
|
+
repository: editionRepository(id, options['--registry'], EditionChannel.Develop),
|
|
219
|
+
signerIdentity: options[PREVIOUS_SEAL_SIGNER_FLAG],
|
|
220
|
+
cosignBinary: options['--cosign'],
|
|
221
|
+
env,
|
|
222
|
+
}),
|
|
223
|
+
},
|
|
224
|
+
}),
|
|
225
|
+
}),
|
|
210
226
|
...(options[IMAGES_FROM_FLAG] === undefined ? {} : {
|
|
211
227
|
imagesFrom: await readImageBuildRecord(options[IMAGES_FROM_FLAG]),
|
|
212
228
|
verifySatellites: createSatelliteVerifier({ registry: options['--registry'], cosignBinary: options['--cosign'], env }),
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// develop-git-bundles — the OCI bundle of a git-sourced biome, in a VENDOR DEVELOP edition only (XEMA-2456).
|
|
2
|
+
//
|
|
3
|
+
// A release and a customer edition never carry an OCI bundle on a git-sourced row: the manifest's `bundleSource` names
|
|
4
|
+
// an artifact the release lane builds, not one built from the pinned commit (resolve-distribution.mjs strips it, the
|
|
5
|
+
// release refuses it). A develop edition freezes a git source at its develop head F, and the source's own develop lane
|
|
6
|
+
// publishes the bundle the manifest names, signed keyless by its develop workflow. This module proves that bundle is
|
|
7
|
+
// the one F describes, and only then does the resolve keep it on the row, by DIGEST, with its develop bundle signer:
|
|
8
|
+
//
|
|
9
|
+
// 1. the bundle: the manifest's own `bundleSource.ociRef` at F (`<repository>:<version>`; the kernel holds the tag to
|
|
10
|
+
// the manifest version) resolved to its digest at the registry. The lock records `<repository>@sha256:<hex>`, never
|
|
11
|
+
// the tag, so the edition names immutable bytes;
|
|
12
|
+
// 2. the signer: the ONE `develop` row of the source's repository in the data root's trust policy
|
|
13
|
+
// (`bundleSignerRowFor`). cosign verifies the digest's signature under that row's identity, issuer, workflow
|
|
14
|
+
// repository and ref, and states the ONE commit P its certificate was issued at (`verifiedBundleSignerCommit`);
|
|
15
|
+
// `verifyBundleOnRegistry` then holds the signature AND the SLSA provenance to exactly the row and P. P is never
|
|
16
|
+
// read from bytes cosign has not verified;
|
|
17
|
+
// 3. the content binding of P to F. A version that is already published is never re-attested by the publisher
|
|
18
|
+
// (`xema biome publish` "already current"), so P is the commit that last published the version, and F may be a
|
|
19
|
+
// later commit of the same version. Two checks, refused on either, each naming P, F and the mismatching pair:
|
|
20
|
+
// (a) the kernel's `computeBiomeSourceManifestHash` of the bundle's own `xema-biome.json` equals the
|
|
21
|
+
// `sourceManifestHash` of the biome at F (the hash the seal and verify hold to the git source at F);
|
|
22
|
+
// (b) the git tree id of the biome directory at P equals its tree id at F (the catalogue's `sourceTreeIds`
|
|
23
|
+
// notion), so the bundle was built from the same biome source F holds.
|
|
24
|
+
// THE GAP, develop only: build inputs outside the biome directory (shared packages, the lockfile, the publisher's
|
|
25
|
+
// toolchain) are not bound. A change there between P and F reaches no check here. A release never relies on this:
|
|
26
|
+
// it carries a third-party bundle only through an `oci` source with a third-party pin, whose evidence is bound to
|
|
27
|
+
// the pinned commit itself.
|
|
28
|
+
//
|
|
29
|
+
// Every failure is a `DevelopGitBundleError` naming the biome; all subjects are verified before one is refused.
|
|
30
|
+
|
|
31
|
+
import { canonicalPublicHttpsRepositoryUrl, repositoryOwnerName } from './canonical-repository-url.mjs';
|
|
32
|
+
import { RefusalError } from './collected-refusals.mjs';
|
|
33
|
+
import { EditionChannel } from './edition-channel.mjs';
|
|
34
|
+
import { editionWorkspaceConfig } from './edition-config.mjs';
|
|
35
|
+
import { loadPinnedDeclaredTrustPolicy } from './evidence-trust-pin.mjs';
|
|
36
|
+
import { sourceTreeIdAt } from './external-source-walk.mjs';
|
|
37
|
+
import { prepareTrustedCosignBinary } from './image-evidence.mjs';
|
|
38
|
+
import { BundleSignerRefusedError, DevelopGitBundleError } from './locked-bundle-signer.mjs';
|
|
39
|
+
import { createRegistryClientResolver } from './oci-image-source-evidence.mjs';
|
|
40
|
+
import { bundleDigestRef, fetchBundleManifest, verifiedBundleSignerCommit, verifyBundleOnRegistry } from './third-party-bundle-registry.mjs';
|
|
41
|
+
import { bundleSignerRowFor } from './third-party-source-evidence.mjs';
|
|
42
|
+
import { OCI_BUNDLE_SOURCE_KIND } from './third-party-subjects.mjs';
|
|
43
|
+
|
|
44
|
+
/** The key a verified bundle is looked up by: the source name and the biome id of its index entry. */
|
|
45
|
+
export const developGitBundleKey = (sourceName, biomeId) => `${sourceName}\u0000${biomeId}`;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The rows a develop resolve must verify a bundle for: each git-sourced biome of `lock` (a row with a `source`) whose
|
|
49
|
+
* index entry declares a biome-level OCI `bundleSource`.
|
|
50
|
+
*
|
|
51
|
+
* @returns {{biomeId: string, source: {name: string, url: string, commit: string}, entry: object}[]}
|
|
52
|
+
*/
|
|
53
|
+
export function developGitBundleSubjects({ lock, index }) {
|
|
54
|
+
const entries = new Map(index.filter((entry) => entry.source !== undefined).map((entry) => [developGitBundleKey(entry.source, entry.id), entry]));
|
|
55
|
+
return lock.biomes.flatMap((biome) => {
|
|
56
|
+
if (biome.source === undefined) {return [];}
|
|
57
|
+
const entry = entries.get(developGitBundleKey(biome.source.name, biome.id));
|
|
58
|
+
return entry?.bundleSource?.kind === OCI_BUNDLE_SOURCE_KIND ? [{ biomeId: biome.id, source: biome.source, entry }] : [];
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const subjectLabel = ({ biomeId, source }) => `biome "${biomeId}" (git source "${source.name}" at ${source.commit})`;
|
|
63
|
+
|
|
64
|
+
/** The biome directory relative to its source repository: the index path is `<source name>/<directory>`. */
|
|
65
|
+
function repositoryPathOf({ source, entry }) {
|
|
66
|
+
const prefix = `${source.name}/`;
|
|
67
|
+
if (!entry.path.startsWith(prefix)) {
|
|
68
|
+
throw new Error(`${subjectLabel({ biomeId: entry.id, source })}: index path ${JSON.stringify(entry.path)} is not under the source "${source.name}"`);
|
|
69
|
+
}
|
|
70
|
+
return entry.path.slice(prefix.length);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Verify every subject, then refuse them all at once: one refusal is rethrown unchanged, several are one error. */
|
|
74
|
+
async function verifyEach(subjects, verifyOne) {
|
|
75
|
+
const verified = new Map();
|
|
76
|
+
const refused = [];
|
|
77
|
+
for (const subject of subjects) {
|
|
78
|
+
try {
|
|
79
|
+
verified.set(developGitBundleKey(subject.source.name, subject.biomeId), await verifyOne(subject));
|
|
80
|
+
} catch (error) {
|
|
81
|
+
// A refusal, or a plain Error of the registry, cosign or git: this subject is refused. A defect (TypeError, ...) is not.
|
|
82
|
+
const refusal = error instanceof RefusalError || error?.constructor === Error;
|
|
83
|
+
if (!refusal) {throw error;}
|
|
84
|
+
refused.push(error instanceof DevelopGitBundleError ? error : new DevelopGitBundleError(`${subjectLabel(subject)}: ${error.message}`));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (refused.length === 1) {throw refused[0];}
|
|
88
|
+
if (refused.length > 1) {throw new BundleSignerRefusedError(refused);}
|
|
89
|
+
return verified;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* One subject, verified (steps 1 to 3 of this file's header). `context` is `{policy, policyRoot, schemas, trustedCosign}`.
|
|
94
|
+
*
|
|
95
|
+
* @returns {Promise<{bundleSource: object}>} the bundle source the lock row carries: the declared one, by digest
|
|
96
|
+
*/
|
|
97
|
+
export async function verifyDevelopGitBundle({
|
|
98
|
+
subject,
|
|
99
|
+
checkout,
|
|
100
|
+
context,
|
|
101
|
+
clientFor,
|
|
102
|
+
signerCommit = verifiedBundleSignerCommit,
|
|
103
|
+
verifyOnRegistry = verifyBundleOnRegistry,
|
|
104
|
+
treeIdAt = sourceTreeIdAt,
|
|
105
|
+
env = process.env,
|
|
106
|
+
}) {
|
|
107
|
+
const { biomeId, source, entry } = subject;
|
|
108
|
+
const label = subjectLabel(subject);
|
|
109
|
+
if (checkout === undefined) {
|
|
110
|
+
throw new Error(`${label}: the source walk holds no checkout of "${source.name}"`);
|
|
111
|
+
}
|
|
112
|
+
const repository = repositoryOwnerName(canonicalPublicHttpsRepositoryUrl(source.url, `${label} source url`), `${label} source url`);
|
|
113
|
+
const row = bundleSignerRowFor({ policy: context.policy, repository, channel: EditionChannel.Develop, schemas: context.schemas, label: `${label} develop bundle signer` });
|
|
114
|
+
|
|
115
|
+
// 1 + 2: the digest the manifest's tag serves; the commit P cosign verified the develop signature at; signature and
|
|
116
|
+
// provenance held to the row at P.
|
|
117
|
+
const ociRef = await bundleDigestRef({ clientFor, ociRef: entry.bundleSource.ociRef });
|
|
118
|
+
const cosign = { trustedCosign: context.trustedCosign, policy: context.policy, policyRoot: context.policyRoot, row, ociRef, env };
|
|
119
|
+
const attestedCommit = signerCommit(cosign);
|
|
120
|
+
verifyOnRegistry({ ...cosign, workflowSha: attestedCommit });
|
|
121
|
+
|
|
122
|
+
// 3: the content binding of P to F (see the header for the gap it leaves: build inputs outside the biome directory).
|
|
123
|
+
const binding = `the bundle ${ociRef} attested at ${attestedCommit} (P) against the source at ${source.commit} (F)`;
|
|
124
|
+
const { manifest } = await fetchBundleManifest({ clientFor, ociRef });
|
|
125
|
+
if (manifest?.xema?.id !== biomeId) {
|
|
126
|
+
throw new Error(`${label}: ${ociRef} declares xema.id ${JSON.stringify(manifest?.xema?.id)}, not "${biomeId}"`);
|
|
127
|
+
}
|
|
128
|
+
const bundleManifestHash = await context.schemas.computeBiomeSourceManifestHash(manifest);
|
|
129
|
+
if (bundleManifestHash !== entry.sourceManifestHash) {
|
|
130
|
+
throw new DevelopGitBundleError(
|
|
131
|
+
`${label}: ${binding}: the bundle's xema-biome.json hashes to sourceManifestHash ${bundleManifestHash}, but the manifest at F hashes to ${entry.sourceManifestHash}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
const path = repositoryPathOf(subject);
|
|
135
|
+
const treeAtAttested = treeIdAt({ checkout, commit: attestedCommit, path, env });
|
|
136
|
+
const treeAtFrozen = treeIdAt({ checkout, commit: source.commit, path, env });
|
|
137
|
+
if (treeAtAttested !== treeAtFrozen) {
|
|
138
|
+
throw new DevelopGitBundleError(
|
|
139
|
+
`${label}: ${binding}: the biome directory ${path} is tree ${treeAtAttested} at P but tree ${treeAtFrozen} at F; publish a new version of the biome from F`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return { bundleSource: { ...entry.bundleSource, ociRef } };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The verifier a vendor develop resolve hands `prepareDistributionWithIndex` (`inputs.developGitBundles`). Nothing is
|
|
147
|
+
* read (no trust policy, no cosign) unless a subject exists.
|
|
148
|
+
*
|
|
149
|
+
* @param {object} options
|
|
150
|
+
* @param {string|undefined} options.cosignBinary the cosign the trust policy pins by digest (`--cosign`)
|
|
151
|
+
* @param {string} options.dataRoot
|
|
152
|
+
*/
|
|
153
|
+
export function createDevelopGitBundleVerifier({ cosignBinary, dataRoot, env = process.env, clientFor, treeIdAt }) {
|
|
154
|
+
return Object.freeze({
|
|
155
|
+
async verify({ subjects, checkouts, schemas }) {
|
|
156
|
+
if (subjects.length === 0) {return new Map();}
|
|
157
|
+
if (cosignBinary === undefined) {
|
|
158
|
+
throw new DevelopGitBundleError(
|
|
159
|
+
`${subjects.map(subjectLabel).join(', ')} declare an OCI bundle; verifying it needs a cosign binary (--cosign), and none was given`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
const policy = loadPinnedDeclaredTrustPolicy({ dataRoot, declaration: editionWorkspaceConfig().evidenceTrustPolicy, schemas });
|
|
163
|
+
const trustedCosign = prepareTrustedCosignBinary({ cosignBinary, policy });
|
|
164
|
+
const context = { policy, policyRoot: dataRoot, schemas, trustedCosign };
|
|
165
|
+
const registry = clientFor ?? createRegistryClientResolver({ dataRoot });
|
|
166
|
+
try {
|
|
167
|
+
return await verifyEach(subjects, (subject) =>
|
|
168
|
+
verifyDevelopGitBundle({ subject, checkout: checkouts.get(subject.source.name), context, clientFor: registry, env, ...(treeIdAt === undefined ? {} : { treeIdAt }) }),
|
|
169
|
+
);
|
|
170
|
+
} finally {
|
|
171
|
+
trustedCosign.dispose();
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The OFFLINE verifier of a candidate re-seal: the bundle the pinned develop edition already verified for the SAME
|
|
179
|
+
* source commit, carried forward unchanged, with the signer it was verified under (`pinnedBundleSigner`): the resolve
|
|
180
|
+
* refuses the row when the current trust policy selects any other signer (a rotated develop row). A subject at another commit, or one the pinned edition carried no bundle
|
|
181
|
+
* for, is refused: verifying a new bundle reads the registry, which an offline re-seal does not.
|
|
182
|
+
*
|
|
183
|
+
* @param {object} pinnedLock the pinned edition's parsed distribution lock
|
|
184
|
+
*/
|
|
185
|
+
export function carriedDevelopGitBundles(pinnedLock) {
|
|
186
|
+
return Object.freeze({
|
|
187
|
+
async verify({ subjects }) {
|
|
188
|
+
return verifyEach(subjects, async (subject) => {
|
|
189
|
+
const row = pinnedLock.biomes.find(
|
|
190
|
+
(biome) => biome.id === subject.biomeId && biome.source?.name === subject.source.name && biome.source.commit === subject.source.commit,
|
|
191
|
+
);
|
|
192
|
+
if (row?.bundleSource?.kind !== OCI_BUNDLE_SOURCE_KIND || row.bundleSigner === undefined) {
|
|
193
|
+
throw new DevelopGitBundleError(
|
|
194
|
+
`${subjectLabel(subject)}: the pinned edition carries no verified bundle at this commit, and an offline re-seal cannot verify one; resolve the develop edition instead`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
return { bundleSource: row.bundleSource, pinnedBundleSigner: row.bundleSigner };
|
|
198
|
+
});
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
}
|
|
@@ -180,6 +180,24 @@ function resolveRemoteCommit(source, gitEnv, exec = execFileSync) {
|
|
|
180
180
|
return commit;
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
/** Fetch `commit` by id, depth 1, into the checkout at `directory` unless it already holds it. */
|
|
184
|
+
function fetchCommitUnlessHeld(directory, commit, gitEnv, exec) {
|
|
185
|
+
const have = (() => {
|
|
186
|
+
try {
|
|
187
|
+
return git(directory, ['cat-file', '-e', `${commit}^{commit}`], gitEnv, exec) === '';
|
|
188
|
+
} catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
})();
|
|
192
|
+
if (!have) {
|
|
193
|
+
exec(
|
|
194
|
+
'git',
|
|
195
|
+
['-C', directory, 'fetch', '--quiet', '--depth=1', 'origin', commit],
|
|
196
|
+
{ env: gitEnv, stdio: ['ignore', 'pipe', 'pipe'] },
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
183
201
|
/**
|
|
184
202
|
* Fetch a declared git source and return the directory + the EXACT commit its
|
|
185
203
|
* manifests will be read at.
|
|
@@ -209,20 +227,7 @@ export function materializeGitSource({
|
|
|
209
227
|
// reuses it: fetch from where the declaration says the source lives NOW.
|
|
210
228
|
git(directory, ['remote', 'set-url', 'origin', source.url], gitEnv, exec);
|
|
211
229
|
}
|
|
212
|
-
|
|
213
|
-
try {
|
|
214
|
-
return git(directory, ['cat-file', '-e', `${commit}^{commit}`], gitEnv, exec) === '';
|
|
215
|
-
} catch {
|
|
216
|
-
return false;
|
|
217
|
-
}
|
|
218
|
-
})();
|
|
219
|
-
if (!have) {
|
|
220
|
-
exec(
|
|
221
|
-
'git',
|
|
222
|
-
['-C', directory, 'fetch', '--quiet', '--depth=1', 'origin', commit],
|
|
223
|
-
{ env: gitEnv, stdio: ['ignore', 'pipe', 'pipe'] },
|
|
224
|
-
);
|
|
225
|
-
}
|
|
230
|
+
fetchCommitUnlessHeld(directory, commit, gitEnv, exec);
|
|
226
231
|
git(directory, ['checkout', '--quiet', '--detach', commit], gitEnv, exec);
|
|
227
232
|
return { directory, commit: pinExternalSourceCommit({ name: source.name, directory, commit }) };
|
|
228
233
|
});
|
|
@@ -405,6 +410,7 @@ export async function walkDeclaredSources({
|
|
|
405
410
|
} = {}) {
|
|
406
411
|
const entries = [];
|
|
407
412
|
const sourceRecords = new Map();
|
|
413
|
+
const checkouts = new Map();
|
|
408
414
|
// Every source is held (kind, then credential and URL) before the first is materialised.
|
|
409
415
|
const heldWorkspace = sources.length === 0 ? undefined : (workspace ?? editionWorkspaceConfig());
|
|
410
416
|
for (const source of sources) {
|
|
@@ -446,6 +452,7 @@ export async function walkDeclaredSources({
|
|
|
446
452
|
entries.push(stampEntry(assertSourceWalkedOrigin(await manifestToIndexEntry(manifest), source.name), source.name));
|
|
447
453
|
}
|
|
448
454
|
sourceRecords.set(source.name, lockedSourceRecord(source, commit));
|
|
455
|
+
checkouts.set(source.name, { source, directory });
|
|
449
456
|
}
|
|
450
457
|
// The last point at which an unstamped entry can be caught before it reaches
|
|
451
458
|
// the shared index, where absent-is-the-identity makes it admissible
|
|
@@ -459,5 +466,20 @@ export async function walkDeclaredSources({
|
|
|
459
466
|
'open-source edition. Refusing.',
|
|
460
467
|
);
|
|
461
468
|
}
|
|
462
|
-
return { entries, sourceRecords };
|
|
469
|
+
return { entries, sourceRecords, checkouts };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* The git tree id of `path` (repository-relative, `.` for the root) of a declared git source at `commit`, read in the
|
|
474
|
+
* walk's checkout of that source (`checkouts` of `walkDeclaredSources`). A commit the checkout does not hold is fetched
|
|
475
|
+
* by id, depth 1, from the declared URL under the source's own credential hold: no history is needed.
|
|
476
|
+
*/
|
|
477
|
+
export function sourceTreeIdAt({ checkout, commit, path, env = process.env, workspace = editionWorkspaceConfig(), exec = execFileSync }) {
|
|
478
|
+
if (!EXACT_COMMIT.test(String(commit))) {
|
|
479
|
+
throw new Error(`source "${checkout.source.name}": tree of ${JSON.stringify(commit)} asked, which is not an exact 40-hex commit`);
|
|
480
|
+
}
|
|
481
|
+
return withSourceGitEnv(checkout.source, { workspace, env }, (gitEnv) => {
|
|
482
|
+
fetchCommitUnlessHeld(checkout.directory, commit, gitEnv, exec);
|
|
483
|
+
return git(checkout.directory, ['rev-parse', '--verify', path === '.' ? `${commit}^{tree}` : `${commit}:${path}`], gitEnv, exec);
|
|
484
|
+
});
|
|
463
485
|
}
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
// Every `bundleSource: {kind: 'oci'}` row of a lock (a biome's, or one of its host modules') states WHO signs its bundle:
|
|
4
4
|
// `bundleSigner: {policyId, signer}`, the kernel's `lockedBundleSignerOf` projection of ONE row of the data root's evidence
|
|
5
5
|
// trust policy. The row is selected, never defaulted:
|
|
6
|
-
// - a git-sourced row (one with a lock `source`) carries no OCI bundle
|
|
6
|
+
// - a git-sourced row (one with a lock `source`) carries no OCI bundle, and a lock stating one is refused, EXCEPT in a
|
|
7
|
+
// selection made for a vendor develop edition (`GitSourcedBundle.DevelopVerified`, XEMA-2456): there a git-sourced
|
|
8
|
+
// biome row may carry the bundle the develop resolve verified (`develop-git-bundles.mjs`), signed by the ONE
|
|
9
|
+
// `develop` row of the source's repository;
|
|
7
10
|
// - a third-party row (no `source`, `isThirdPartyOciRow`) by its pin (`third-party-source-pins.json`), whose `bundle.signerPolicyId` names an
|
|
8
11
|
// `oidc-certificate` row held to the checks `thirdPartySignerRow` makes;
|
|
9
12
|
// - a first-party row by the data root's `firstPartyBundleSigner.policyId`, a `public-key` row whose key is bound to
|
|
@@ -15,13 +18,15 @@
|
|
|
15
18
|
|
|
16
19
|
import { createRequire } from 'node:module';
|
|
17
20
|
|
|
21
|
+
import { canonicalPublicHttpsRepositoryUrl, repositoryOwnerName } from './canonical-repository-url.mjs';
|
|
18
22
|
import { KernelRefusalName, RefusalError, collectRefusals, isRefusal, joinRefusalMessages } from './collected-refusals.mjs';
|
|
23
|
+
import { EditionChannel } from './edition-channel.mjs';
|
|
19
24
|
import { editionWorkspaceConfig } from './edition-config.mjs';
|
|
20
25
|
import { assertKernelEditionProtocol } from './edition-protocol.mjs';
|
|
21
26
|
import { dataRoot, kernelDistPath, kernelPackageDir } from './edition-workspace.mjs';
|
|
22
27
|
import { loadPinnedDeclaredTrustPolicy } from './evidence-trust-pin.mjs';
|
|
23
28
|
import { loadTrustedThirdPartySourcePins } from './third-party-pins-trusted.mjs';
|
|
24
|
-
import { thirdPartySignerRow } from './third-party-source-evidence.mjs';
|
|
29
|
+
import { bundleSignerRowFor, thirdPartySignerRow } from './third-party-source-evidence.mjs';
|
|
25
30
|
import { OCI_BUNDLE_SOURCE_KIND, isThirdPartyOciRow } from './third-party-subjects.mjs';
|
|
26
31
|
|
|
27
32
|
const requireFromHere = createRequire(import.meta.url);
|
|
@@ -31,6 +36,13 @@ const PUBLIC_KEY_KIND = 'public-key';
|
|
|
31
36
|
|
|
32
37
|
const KERNEL_EXPORTS = Object.freeze(['lockedBundleSignerOf', 'assertLockedBundleSignerMatches', 'LockedBundleSignerUnknownPolicyError', 'LockedBundleSignerAmbiguousPolicyError']);
|
|
33
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Whether a selection admits an OCI bundle on a git-sourced row. `Refused` is every release, customer and dist-check
|
|
41
|
+
* selection, unchanged; `DevelopVerified` only a vendor develop edition's resolve, seal and verify, whose git bundles
|
|
42
|
+
* the develop resolve verified against the source commit (`develop-git-bundles.mjs`).
|
|
43
|
+
*/
|
|
44
|
+
export const GitSourcedBundle = Object.freeze({ Refused: 'refused', DevelopVerified: 'develop-verified' });
|
|
45
|
+
|
|
34
46
|
/** A named refusal of the bundle signer plane. */
|
|
35
47
|
export class BundleSignerError extends RefusalError {
|
|
36
48
|
constructor(message) {
|
|
@@ -56,6 +68,8 @@ export class BundleSignerMissingError extends BundleSignerError {}
|
|
|
56
68
|
export class BundleSourceOnGitRowError extends BundleSignerError {}
|
|
57
69
|
/** A resolve found an OCI host-module bundle on a git-sourced biome: such a bundle reaches an edition only through an `oci` source. */
|
|
58
70
|
export class GitSourcedHostModuleBundleError extends BundleSignerError {}
|
|
71
|
+
/** A develop resolve could not verify the OCI bundle a git-sourced biome declares (no develop row, cosign, or the content binding refused it). */
|
|
72
|
+
export class DevelopGitBundleError extends BundleSignerError {}
|
|
59
73
|
/** The loaded kernel does not export what the bundle signer plane needs. */
|
|
60
74
|
export class BundleSignerKernelUnsupportedError extends BundleSignerError {}
|
|
61
75
|
/** Several OCI rows were refused in one pass; `.errors` holds each row's own refusal. */
|
|
@@ -98,6 +112,13 @@ function assertNotGitSourced(subject) {
|
|
|
98
112
|
}
|
|
99
113
|
}
|
|
100
114
|
|
|
115
|
+
/** Refuses a git-sourced subject unless the selection admits develop git bundles and the subject is a biome row. */
|
|
116
|
+
function assertGitSourceAdmitted(subject, gitSourcedBundle) {
|
|
117
|
+
if (subject.source === undefined) {return;}
|
|
118
|
+
if (gitSourcedBundle === GitSourcedBundle.DevelopVerified && subject.componentKey === undefined) {return;}
|
|
119
|
+
assertNotGitSourced(subject);
|
|
120
|
+
}
|
|
121
|
+
|
|
101
122
|
const rowsOf = (policy, policyId) => policy.policies.filter((row) => row.policyId === policyId);
|
|
102
123
|
|
|
103
124
|
/**
|
|
@@ -105,7 +126,7 @@ const rowsOf = (policy, policyId) => policy.policies.filter((row) => row.policyI
|
|
|
105
126
|
* firstPartyPolicyId: string|undefined, schemas: object, kernel?: object}} options
|
|
106
127
|
* @returns {{policyIdFor: (subject: object) => string, signerFor: (subject: object) => object, assertSealed: (subject: object, stated: object|undefined) => void}}
|
|
107
128
|
*/
|
|
108
|
-
export function createBundleSignerSelection({ policy, thirdPartyPins, firstPartyPolicyId, schemas, kernel }) {
|
|
129
|
+
export function createBundleSignerSelection({ policy, thirdPartyPins, firstPartyPolicyId, schemas, kernel, gitSourcedBundle = GitSourcedBundle.Refused }) {
|
|
109
130
|
let loaded = kernel;
|
|
110
131
|
const kernelExports = () => (loaded ??= loadBundleSignerKernel());
|
|
111
132
|
|
|
@@ -155,6 +176,24 @@ export function createBundleSignerSelection({ policy, thirdPartyPins, firstParty
|
|
|
155
176
|
return row;
|
|
156
177
|
}
|
|
157
178
|
|
|
179
|
+
// A git-sourced biome's develop bundle: the ONE develop row of the source's repository (a host module never reaches here:
|
|
180
|
+
// the resolve refuses an OCI host module on a git-sourced biome in every channel).
|
|
181
|
+
function developGitRow(subject) {
|
|
182
|
+
const label = `${describeSubject(subject)} develop bundle signer`;
|
|
183
|
+
const repository = repositoryOwnerName(canonicalPublicHttpsRepositoryUrl(subject.source.url, `${label} source url`), `${label} source url`);
|
|
184
|
+
let row;
|
|
185
|
+
try {
|
|
186
|
+
row = bundleSignerRowFor({ policy, repository, channel: EditionChannel.Develop, schemas, label });
|
|
187
|
+
} catch (error) {
|
|
188
|
+
throw new BundleSignerPolicyAbsentError(error.message);
|
|
189
|
+
}
|
|
190
|
+
requireKind(subject, row, OIDC_CERTIFICATE_KIND);
|
|
191
|
+
return onlyRow(row.policyId);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Refuses a git-sourced subject this selection does not admit; any other subject passes. */
|
|
195
|
+
const assertAdmitted = (subject) => assertGitSourceAdmitted(subject, gitSourcedBundle);
|
|
196
|
+
|
|
158
197
|
function requirePolicy(subject) {
|
|
159
198
|
if (policy === null) {
|
|
160
199
|
throw new BundleSignerPolicyAbsentError(
|
|
@@ -164,8 +203,9 @@ export function createBundleSignerSelection({ policy, thirdPartyPins, firstParty
|
|
|
164
203
|
}
|
|
165
204
|
|
|
166
205
|
function selectedRow(subject) {
|
|
167
|
-
|
|
206
|
+
assertAdmitted(subject);
|
|
168
207
|
requirePolicy(subject);
|
|
208
|
+
if (subject.source !== undefined) {return developGitRow(subject);}
|
|
169
209
|
return isThirdPartyOciRow(subject) ? thirdPartyRow(subject) : firstPartyRow(subject);
|
|
170
210
|
}
|
|
171
211
|
|
|
@@ -178,6 +218,7 @@ export function createBundleSignerSelection({ policy, thirdPartyPins, firstParty
|
|
|
178
218
|
};
|
|
179
219
|
|
|
180
220
|
return {
|
|
221
|
+
assertAdmitted,
|
|
181
222
|
policyIdFor: (subject) => selectedRow(subject).policyId,
|
|
182
223
|
signerFor,
|
|
183
224
|
assertSealed(subject, stated) {
|
|
@@ -200,7 +241,7 @@ export function createBundleSignerSelection({ policy, thirdPartyPins, firstParty
|
|
|
200
241
|
* The data root's selection, LAZY: nothing is read until the first OCI subject asks, so a lock with no OCI row never
|
|
201
242
|
* needs a policy, a pin or a kernel that speaks v33.
|
|
202
243
|
*/
|
|
203
|
-
export function loadBundleSignerSelection({ dataRoot, schemas }) {
|
|
244
|
+
export function loadBundleSignerSelection({ dataRoot, schemas, gitSourcedBundle = GitSourcedBundle.Refused }) {
|
|
204
245
|
let selection;
|
|
205
246
|
const resolved = () => {
|
|
206
247
|
if (selection === undefined) {
|
|
@@ -211,11 +252,14 @@ export function loadBundleSignerSelection({ dataRoot, schemas }) {
|
|
|
211
252
|
thirdPartyPins: loadTrustedThirdPartySourcePins(dataRoot),
|
|
212
253
|
firstPartyPolicyId: config.firstPartyBundleSigner?.policyId,
|
|
213
254
|
schemas,
|
|
255
|
+
gitSourcedBundle,
|
|
214
256
|
});
|
|
215
257
|
}
|
|
216
258
|
return selection;
|
|
217
259
|
};
|
|
218
260
|
return Object.freeze({
|
|
261
|
+
// Refusing an unadmitted git-sourced row needs no data root, so it reads none.
|
|
262
|
+
assertAdmitted: (subject) => assertGitSourceAdmitted(subject, gitSourcedBundle),
|
|
219
263
|
policyIdFor: (subject) => resolved().policyIdFor(subject),
|
|
220
264
|
signerFor: (subject) => resolved().signerFor(subject),
|
|
221
265
|
assertSealed: (subject, stated) => resolved().assertSealed(subject, stated),
|
|
@@ -242,12 +286,12 @@ export function lockBundleSubjects(lock) {
|
|
|
242
286
|
* row is never checked and never resolves a data root: `selection` is used when given, else the data root's is loaded
|
|
243
287
|
* on the first OCI subject.
|
|
244
288
|
*/
|
|
245
|
-
export function assertLockBundleSigners({ lock, selection, schemas }) {
|
|
289
|
+
export function assertLockBundleSigners({ lock, selection, schemas, gitSourcedBundle = GitSourcedBundle.Refused }) {
|
|
246
290
|
const subjects = lockBundleSubjects(lock);
|
|
247
291
|
if (subjects.length === 0) {return;}
|
|
248
|
-
const chosen = selection ?? loadBundleSignerSelection({ dataRoot: dataRoot(), schemas });
|
|
292
|
+
const chosen = selection ?? loadBundleSignerSelection({ dataRoot: dataRoot(), schemas, gitSourcedBundle });
|
|
249
293
|
collectRefusals(subjects, ({ row, subject }) => {
|
|
250
|
-
|
|
294
|
+
chosen.assertAdmitted(subject);
|
|
251
295
|
chosen.assertSealed(subject, row.bundleSigner);
|
|
252
296
|
}, isRefusal, BundleSignerRefusedError);
|
|
253
297
|
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// previous-seal-registry — the previous seal of a snapshot edition, read from the edition's own registry repository.
|
|
2
|
+
//
|
|
3
|
+
// A write of an images lock carries its composed artifacts' generations forward from the previous seal (resolve-images.mjs
|
|
4
|
+
// resolvePreviousSeal). In CI that is the images.lock layer of the LATEST SIGNED edition already published to the
|
|
5
|
+
// edition repository `<registry root>/<channel segment>/xema-edition-<id>`. `--previous-seal-signer <identity>` finds it:
|
|
6
|
+
//
|
|
7
|
+
// - every snapshot tag (64 hex) is read by tag, deduplicated by digest; each must be an edition artifact with an
|
|
8
|
+
// `org.opencontainers.image.created` annotation (oras writes it on push; it is inside the signed manifest);
|
|
9
|
+
// - the newest by that annotation is the candidate. A candidate with no cosign signature tag (`sha256-<hex>.sig`) was never
|
|
10
|
+
// published (a failed sign) and is passed over, named; a candidate WITH a signature must verify against exactly the given
|
|
11
|
+
// certificate identity and the GitHub OIDC issuer, offline, through the pinned cosign. A failure there is a refusal,
|
|
12
|
+
// never a step back to an older edition (that would regress or fork the generation);
|
|
13
|
+
// - its images.lock layer is fetched by digest (the bytes hash to the layer digest the signed manifest names).
|
|
14
|
+
//
|
|
15
|
+
// FIRST SEAL is never inferred here. A registry that answers 404 for the repository's tag list may be one that hides a
|
|
16
|
+
// private repository from a credential that cannot read it, and a repository that exists but holds no signed edition may
|
|
17
|
+
// have lost its editions to retention: neither proves that no edition was ever published. Both are refused, naming the
|
|
18
|
+
// operator's explicit `--first-seal` (once, for a distribution that has never had an edition).
|
|
19
|
+
//
|
|
20
|
+
// Concurrency is the caller's: the per-edition concurrency group serializes the runs that push to one repository, so the
|
|
21
|
+
// edition read here is the one the previous run of that group pushed and signed.
|
|
22
|
+
|
|
23
|
+
import { RefusalError } from './collected-refusals.mjs';
|
|
24
|
+
import { EDITION_ARTIFACT_TYPE, EditionLockFile } from './edition-constants.mjs';
|
|
25
|
+
import { cosignEnvironment, withPinnedCosign } from './pinned-cosign.mjs';
|
|
26
|
+
import { splitImageRef } from './registry-credentials.mjs';
|
|
27
|
+
import { isNotPublishedYet } from './seal-image-digests.mjs';
|
|
28
|
+
import { GITHUB_ACTIONS_OIDC_ISSUER } from './third-party-source-evidence.mjs';
|
|
29
|
+
|
|
30
|
+
export const PREVIOUS_SEAL_SIGNER_FLAG = '--previous-seal-signer';
|
|
31
|
+
|
|
32
|
+
const SNAPSHOT_TAG_PATTERN = /^[0-9a-f]{64}$/;
|
|
33
|
+
const SIGNATURE_TAG_PATTERN = /^sha256-([0-9a-f]{64})\.sig$/;
|
|
34
|
+
const CREATED_ANNOTATION = 'org.opencontainers.image.created';
|
|
35
|
+
const TITLE_ANNOTATION = 'org.opencontainers.image.title';
|
|
36
|
+
const RFC3339_UTC_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
|
|
37
|
+
const IDENTITY_PATTERN = /^https:\/\/github\.com\/[^/@\s]+\/[^/@\s]+\/\.github\/workflows\/[^/@\s]+@refs\/[^\s]+$/;
|
|
38
|
+
const MAX_IMAGES_LOCK_BYTES = 64 * 1024 * 1024;
|
|
39
|
+
const MANIFEST_READ_CONCURRENCY = 4;
|
|
40
|
+
const STDERR_TAIL_BYTES = 800;
|
|
41
|
+
|
|
42
|
+
const tail = (text) => String(text).trim().slice(-STDERR_TAIL_BYTES);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The signer `--previous-seal-signer` names: the EXACT certificate identity of the workflow that signs this repository's
|
|
46
|
+
* editions (`https://github.com/<owner>/<name>/.github/workflows/<file>@refs/...`) and the GitHub OIDC issuer. Only the
|
|
47
|
+
* identity is held, not the workflow-repository extension: a reusable workflow called from another repository signs
|
|
48
|
+
* with its own identity but the CALLER's repository extension.
|
|
49
|
+
*/
|
|
50
|
+
export function previousSealSignerOf(identity) {
|
|
51
|
+
if (typeof identity !== 'string' || !IDENTITY_PATTERN.test(identity)) {
|
|
52
|
+
throw new RefusalError(
|
|
53
|
+
`${PREVIOUS_SEAL_SIGNER_FLAG} must be the exact certificate identity https://github.com/<owner>/<name>/.github/workflows/<file>@refs/<...>; got ${JSON.stringify(identity)}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return { certificateIdentity: identity, oidcIssuer: GITHUB_ACTIONS_OIDC_ISSUER };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function mapBounded(items, limit, fn) {
|
|
60
|
+
const results = new Array(items.length);
|
|
61
|
+
let next = 0;
|
|
62
|
+
const worker = async () => {
|
|
63
|
+
while (next < items.length) {
|
|
64
|
+
const index = next;
|
|
65
|
+
next += 1;
|
|
66
|
+
results[index] = await fn(items[index]);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
70
|
+
return results;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Find the latest signed edition of `repository` (`<authority>/<path>`) and return its images.lock bytes.
|
|
75
|
+
* @returns {Promise<{edition: string, created: string, imagesLockSha256: string, bytes: Buffer}>}
|
|
76
|
+
*/
|
|
77
|
+
export async function findPreviousSealOnRegistry({ repository, signer, clientFor, runCosign, cosignEnvFor, trustedRootPath, log = (line) => console.error(line) }) {
|
|
78
|
+
const { authority, repository: path } = splitImageRef(repository);
|
|
79
|
+
const client = clientFor(authority);
|
|
80
|
+
let tags;
|
|
81
|
+
try {
|
|
82
|
+
tags = await client.listTags(path);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (isNotPublishedYet(error)) {
|
|
85
|
+
throw new RefusalError(
|
|
86
|
+
`[previous-seal] the registry answered 404 for the tags of ${repository}: either no edition was ever published there or this credential cannot see it, ` +
|
|
87
|
+
'and a 404 does not tell which. If this distribution has never had an edition, resolve once with --first-seal; otherwise fix the credential.',
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
throw new Error(`[previous-seal] the registry did not answer for the tags of ${repository}; refusing to guess the previous seal: ${error.message}`);
|
|
91
|
+
}
|
|
92
|
+
const signed = new Set(tags.map((tag) => SIGNATURE_TAG_PATTERN.exec(tag)?.[1]).filter((hex) => hex !== undefined).map((hex) => `sha256:${hex}`));
|
|
93
|
+
const snapshotTags = tags.filter((tag) => SNAPSHOT_TAG_PATTERN.test(tag)).sort();
|
|
94
|
+
const read = await mapBounded(snapshotTags, MANIFEST_READ_CONCURRENCY, async (tag) => ({ tag, manifest: await client.getManifestByTag(path, tag) }));
|
|
95
|
+
const byDigest = new Map();
|
|
96
|
+
for (const { tag, manifest } of read) {
|
|
97
|
+
const ref = `${repository}:${tag}`;
|
|
98
|
+
if (manifest.isIndex || manifest.document?.artifactType !== EDITION_ARTIFACT_TYPE) {
|
|
99
|
+
throw new RefusalError(`[previous-seal] ${ref} is not an edition artifact (artifactType ${JSON.stringify(manifest.document?.artifactType)}); refusing to choose a previous seal past it`);
|
|
100
|
+
}
|
|
101
|
+
const created = manifest.document.annotations?.[CREATED_ANNOTATION];
|
|
102
|
+
if (typeof created !== 'string' || !RFC3339_UTC_PATTERN.test(created) || Number.isNaN(Date.parse(created))) {
|
|
103
|
+
throw new RefusalError(`[previous-seal] ${ref} (${manifest.digest}) has no ${CREATED_ANNOTATION} annotation in RFC 3339 UTC; the latest edition cannot be told`);
|
|
104
|
+
}
|
|
105
|
+
if (!byDigest.has(manifest.digest)) {byDigest.set(manifest.digest, { digest: manifest.digest, tag, created, time: Date.parse(created), document: manifest.document });}
|
|
106
|
+
}
|
|
107
|
+
const editions = [...byDigest.values()].sort((a, b) => b.time - a.time);
|
|
108
|
+
for (let i = 0; i < editions.length; i += 1) {
|
|
109
|
+
const edition = editions[i];
|
|
110
|
+
const ref = `${repository}@${edition.digest}`;
|
|
111
|
+
if (i + 1 < editions.length && editions[i + 1].time === edition.time) {
|
|
112
|
+
throw new RefusalError(`[previous-seal] ${ref} and ${repository}@${editions[i + 1].digest} were both created at ${edition.created}; the latest edition cannot be told`);
|
|
113
|
+
}
|
|
114
|
+
if (!signed.has(edition.digest)) {
|
|
115
|
+
log(`[previous-seal] ${ref} (${edition.created}) carries no signature: it was never published; passing over it`);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const result = await runCosign(
|
|
119
|
+
['verify', '--offline', '--trusted-root', trustedRootPath, '--certificate-identity', signer.certificateIdentity, '--certificate-oidc-issuer', signer.oidcIssuer, ref],
|
|
120
|
+
cosignEnvironment(cosignEnvFor, authority),
|
|
121
|
+
);
|
|
122
|
+
if (result.code !== 0) {
|
|
123
|
+
throw new RefusalError(
|
|
124
|
+
`[previous-seal] the latest edition ${ref} (${edition.created}) carries a signature that does not verify for ${signer.certificateIdentity}; ` +
|
|
125
|
+
`refusing it and refusing to fall back to an older edition (cosign exit ${result.code}): ${tail(result.stderr)}`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
const layers = (edition.document.layers ?? []).filter((layer) => layer.annotations?.[TITLE_ANNOTATION] === EditionLockFile.Images);
|
|
129
|
+
if (layers.length !== 1) {
|
|
130
|
+
throw new RefusalError(`[previous-seal] ${ref} carries ${layers.length} layers titled ${EditionLockFile.Images}; an edition carries exactly one`);
|
|
131
|
+
}
|
|
132
|
+
const [layer] = layers;
|
|
133
|
+
const bytes = await client.getBlob(path, layer.digest, MAX_IMAGES_LOCK_BYTES);
|
|
134
|
+
log(`[previous-seal] previous seal: ${ref} (${edition.created}), ${EditionLockFile.Images} ${layer.digest}`);
|
|
135
|
+
return { edition: ref, created: edition.created, imagesLockSha256: layer.digest.slice('sha256:'.length), bytes };
|
|
136
|
+
}
|
|
137
|
+
throw new RefusalError(
|
|
138
|
+
`[previous-seal] ${repository} exists but holds no signed edition (${editions.length} unsigned); that no edition was ever published cannot be proven. ` +
|
|
139
|
+
'If none ever was, resolve once with --first-seal.',
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** `findPreviousSealOnRegistry` under the data root's pinned cosign and declared registry credentials. */
|
|
144
|
+
export async function previousSealFromRegistry({ repository, signerIdentity, cosignBinary, env, pinned = {}, log }) {
|
|
145
|
+
const signer = previousSealSignerOf(signerIdentity);
|
|
146
|
+
return withPinnedCosign({ cosignBinary, env, ...pinned }, ({ runCosign, cosignEnvFor, clientFor, trustedRootPath }) =>
|
|
147
|
+
findPreviousSealOnRegistry({ repository, signer, clientFor, runCosign, cosignEnvFor, trustedRootPath, ...(log === undefined ? {} : { log }) }),
|
|
148
|
+
);
|
|
149
|
+
}
|