@xemahq/xema 0.51.0 → 0.52.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 +21 -3
- package/dist/edition/verify-image-evidence.bundle.provenance.json +62 -62
- package/dist/runtime/edition/verb-map.d.ts +1 -0
- package/dist/runtime/edition/verb-map.d.ts.map +1 -1
- package/dist/runtime/edition/verb-map.js +2 -0
- package/dist/runtime/edition/verb-map.js.map +1 -1
- package/edition/README.md +3 -0
- package/edition/assemble-producer-input.mjs +244 -83
- package/edition/assemble-third-party-input.mjs +336 -0
- package/edition/evidence-input-mode.mjs +154 -0
- package/edition/image-evidence.mjs +19 -1
- package/edition/kernel-contract-authority.mjs +10 -2
- package/edition/kernel-package.mjs +13 -0
- package/edition/lib.mjs +51 -37
- package/edition/oci-evidence-verbs.mjs +21 -6
- package/edition/oras.mjs +5 -4
- package/edition/produce-image-source-evidence.mjs +33 -17
- package/edition/registry-credentials.mjs +24 -2
- package/edition/third-party-source-evidence.mjs +8 -1
- package/edition/third-party-source-flags.mjs +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// assemble-third-party-input — the producer input of ONE image of a THIRD-PARTY biome, assembled in the publisher's own
|
|
3
|
+
// GitHub-hosted workflow (XEMA-1611 design section 5).
|
|
4
|
+
//
|
|
5
|
+
// xema edition evidence assemble-third-party-input \
|
|
6
|
+
// --bundle <oci repository>@sha256:<64-hex> \
|
|
7
|
+
// --contract-authority <file> \
|
|
8
|
+
// --image <image repository>@sha256:<64-hex> \
|
|
9
|
+
// --subject-id <component image name> \
|
|
10
|
+
// --provenance <absolute exported BuildKit SLSA v1 predicate> \
|
|
11
|
+
// --registry-credentials <file> \
|
|
12
|
+
// --output <path> \
|
|
13
|
+
// [--ttl-seconds <n>]
|
|
14
|
+
//
|
|
15
|
+
// It runs on PUBLIC inputs only: this CLI and the `@xemahq/contracts` it ships (both from public npm), the publisher's
|
|
16
|
+
// own checkout at GITHUB_WORKSPACE, its own registry credential declaration and the GITHUB_* context of the run. It
|
|
17
|
+
// takes no data root, no kernel checkout and nothing of the vendor's. The checks that need the vendor's trust policy
|
|
18
|
+
// (the producer TTL ceiling, the signer rows, image ownership) run at the vendor's admission, by the trusted writer
|
|
19
|
+
// (`index runtime-image-source --trusted-write --third-party`), never here.
|
|
20
|
+
//
|
|
21
|
+
// Emits the EXACT producer input `produce-image-source-evidence.mjs` accepts, through the first-party assembler's own
|
|
22
|
+
// document builder and gates (`assemble-producer-input.mjs`). Field derivation:
|
|
23
|
+
// * contractAuthority — the `--contract-authority` file (the kernel contract authority the vendor's evidence trust
|
|
24
|
+
// pin names), proved to be the kernel this CLI ships before anything else is read.
|
|
25
|
+
// * subject kind and id — the bundle's `xema-biome.json` (`--bundle`, read BY DIGEST): the one service or worker
|
|
26
|
+
// component with an `oci-image` artifact whose image name is `--subject-id` (a service by
|
|
27
|
+
// `protocol.serviceName`, a worker by its key), kind `runtime-component` for a
|
|
28
|
+
// `scheduler: runtime` component and `service` otherwise, as the index builder names it.
|
|
29
|
+
// * repo, repositoryUrl, sourceCommit, producerWorkflowSha, signer, builderId, runId, runAttempt
|
|
30
|
+
// — GITHUB_REPOSITORY, GITHUB_SHA, GITHUB_WORKFLOW_REF, GITHUB_RUN_ID, GITHUB_RUN_ATTEMPT.
|
|
31
|
+
// * sourceManifestHash — the kernel hash of the bundle's `xema-biome.json`.
|
|
32
|
+
// * path — `<repo>/<biome dir>`: the directory of the ONE `xema-biome.json` of the checkout at
|
|
33
|
+
// GITHUB_SHA whose hash is the bundle's (none, or more than one, is refused by name).
|
|
34
|
+
// * sourceTreeHash — `hashImageBuildInputs` over the publisher's checkout at GITHUB_SHA, the same algorithm
|
|
35
|
+
// first-party images are hashed with, with no cross-repo inputs. It is publisher-attested:
|
|
36
|
+
// the vendor never recomputes it.
|
|
37
|
+
// * builder digests — the exported BuildKit SLSA v1 predicate (`--provenance`), whose VCS revision must be
|
|
38
|
+
// GITHUB_SHA.
|
|
39
|
+
// * manifest media type, platforms, sbomDigest, provenanceDigest
|
|
40
|
+
// — the registry, read BY DIGEST at `--image` (no tag is read), with the publisher's credential.
|
|
41
|
+
// * issuedAt/expiresAt — now, plus `--ttl-seconds` or DEFAULT_PRODUCER_TTL_SECONDS (72h). The ceiling is the vendor
|
|
42
|
+
// trust policy's maxProducerTtlSeconds, enforced by the vendor's third-party pin writer
|
|
43
|
+
// (`index runtime-image-source --trusted-write --third-party`) and `verify-evidence`; this CLI holds
|
|
44
|
+
// no second one. A value that is not a positive integer, or whose expiresAt RFC 3339 cannot
|
|
45
|
+
// name, is refused by name.
|
|
46
|
+
//
|
|
47
|
+
// It refuses to run unless RUNNER_ENVIRONMENT is `github-hosted` and GITHUB_REF is `refs/heads/main`. Those variables are
|
|
48
|
+
// self-asserted by the run, so this is only a fail-fast mirror: the authority is the vendor's admission, which checks
|
|
49
|
+
// the Fulcio certificate's Runner Environment and Source Repository Ref extensions (a third-party source is admitted
|
|
50
|
+
// only from the release channel of a GitHub-hosted workflow).
|
|
51
|
+
|
|
52
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
53
|
+
|
|
54
|
+
import {
|
|
55
|
+
ProducerTtlCeilingSource,
|
|
56
|
+
assertAssembledProducerInput,
|
|
57
|
+
assertCanonicalSubjectId,
|
|
58
|
+
assertWorkflowContext,
|
|
59
|
+
deriveBuilder,
|
|
60
|
+
producerInputDocument,
|
|
61
|
+
producerTtlSecondsFlag,
|
|
62
|
+
readAssemblerFlags,
|
|
63
|
+
resolveImageArtifactEvidenceByDigest,
|
|
64
|
+
resolveProducerTtlSeconds,
|
|
65
|
+
writeProducerInputFile,
|
|
66
|
+
} from './assemble-producer-input.mjs';
|
|
67
|
+
import { configureEditionWorkspaceFromArgv, isEntryModule, kernelPackageDir, stateKernelPackageDir } from './edition-workspace.mjs';
|
|
68
|
+
import { readPublicContractAuthority } from './evidence-input-mode.mjs';
|
|
69
|
+
import { shippedKernelPackageDir } from './kernel-package.mjs';
|
|
70
|
+
import {
|
|
71
|
+
assertGitCommitExists,
|
|
72
|
+
biomeImageComponents,
|
|
73
|
+
claimBiomeImageName,
|
|
74
|
+
hashImageBuildInputs,
|
|
75
|
+
isRuntimeImageComponent,
|
|
76
|
+
imageInputSourceHashAlgorithm,
|
|
77
|
+
readGitJsonAtCommit,
|
|
78
|
+
} from './lib.mjs';
|
|
79
|
+
import { OrasRegistry } from './oras.mjs';
|
|
80
|
+
import { sandboxedGit } from './produce-image-source-evidence.mjs';
|
|
81
|
+
import { loadRegistryCredentialDeclarationsFile, resolveRegistryCredential, splitImageRef } from './registry-credentials.mjs';
|
|
82
|
+
import { subjectServicePath } from './resolve-images.mjs';
|
|
83
|
+
import { fetchBundleManifest } from './third-party-bundle-registry.mjs';
|
|
84
|
+
import { OCI_REPOSITORY_AT_DIGEST } from './third-party-source-flags.mjs';
|
|
85
|
+
import { PUBLISHER_ATTESTED_LABEL } from './third-party-subjects.mjs';
|
|
86
|
+
|
|
87
|
+
/** The runner a third-party producer input is assembled on. */
|
|
88
|
+
export const THIRD_PARTY_RUNNER_ENVIRONMENT = 'github-hosted';
|
|
89
|
+
/** The only ref a third-party producer input is assembled from (the release channel). */
|
|
90
|
+
export const THIRD_PARTY_PRODUCER_REF = 'refs/heads/main';
|
|
91
|
+
|
|
92
|
+
const BIOME_MANIFEST_FILE = 'xema-biome.json';
|
|
93
|
+
const OCI_IMAGE_ARTIFACT = 'oci-image';
|
|
94
|
+
|
|
95
|
+
/** The flags of the verb; every one but `--ttl-seconds` is required. */
|
|
96
|
+
export const THIRD_PARTY_INPUT_FLAGS = Object.freeze({
|
|
97
|
+
bundle: 'bundle',
|
|
98
|
+
contractAuthority: 'contract-authority',
|
|
99
|
+
image: 'image',
|
|
100
|
+
subjectId: 'subject-id',
|
|
101
|
+
provenance: 'provenance',
|
|
102
|
+
registryCredentials: 'registry-credentials',
|
|
103
|
+
output: 'output',
|
|
104
|
+
ttlSeconds: 'ttl-seconds',
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
export const THIRD_PARTY_INPUT_USAGE =
|
|
108
|
+
'usage: xema edition evidence assemble-third-party-input --bundle <oci repository>@sha256:<64-hex> ' +
|
|
109
|
+
'--contract-authority <file> --image <image repository>@sha256:<64-hex> --subject-id <component image name> ' +
|
|
110
|
+
'--provenance <absolute exported SLSA v1 predicate> --registry-credentials <file> --output <path> [--ttl-seconds <n>]';
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Refuse a run that is not a GitHub-hosted run of refs/heads/main, before anything else is read. RUNNER_ENVIRONMENT
|
|
114
|
+
* and GITHUB_REF are self-asserted, so this is a fail-fast mirror only; the authority is the vendor admission's check
|
|
115
|
+
* of the Fulcio certificate's Runner Environment and Source Repository Ref extensions.
|
|
116
|
+
*/
|
|
117
|
+
export function assertThirdPartyRunner(env) {
|
|
118
|
+
if (env.RUNNER_ENVIRONMENT !== THIRD_PARTY_RUNNER_ENVIRONMENT) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`a third-party producer input is assembled only on a ${THIRD_PARTY_RUNNER_ENVIRONMENT} runner ` +
|
|
121
|
+
`(RUNNER_ENVIRONMENT is ${JSON.stringify(env.RUNNER_ENVIRONMENT ?? null)}): the vendor admits only certificates whose ` +
|
|
122
|
+
'Fulcio Runner Environment extension is github-hosted',
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (env.GITHUB_REF !== THIRD_PARTY_PRODUCER_REF) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`a third-party producer input is assembled only from ${THIRD_PARTY_PRODUCER_REF} ` +
|
|
128
|
+
`(GITHUB_REF is ${JSON.stringify(env.GITHUB_REF ?? null)}): the vendor admits only certificates whose Fulcio ` +
|
|
129
|
+
`Source Repository Ref extension is ${THIRD_PARTY_PRODUCER_REF}, the release channel`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function ociReference(flag, value) {
|
|
135
|
+
if (!OCI_REPOSITORY_AT_DIGEST.test(value ?? '')) {
|
|
136
|
+
throw new Error(`--${flag} must be <repository>@sha256:<64-hex>; got ${JSON.stringify(value)}`);
|
|
137
|
+
}
|
|
138
|
+
const at = value.indexOf('@');
|
|
139
|
+
return { repository: value.slice(0, at), digest: value.slice(at + 1) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The bundle component `subjectId` names, and its runtime image subject kind, by the index builder's own rule
|
|
144
|
+
* (`biomeImageComponents`, `claimBiomeImageName`, `isRuntimeImageComponent` in lib.mjs): every image component is
|
|
145
|
+
* named, so a component with no image name, a web biome with an image and a duplicate image name are refused as the
|
|
146
|
+
* index builder refuses them; the subject is the one with its own `oci-image` artifact named `subjectId`.
|
|
147
|
+
*/
|
|
148
|
+
export function bundleImageSubject({ manifest, subjectId, enums, bundleRef }) {
|
|
149
|
+
const xema = manifest?.xema;
|
|
150
|
+
const components = Array.isArray(xema?.components) ? xema.components : [];
|
|
151
|
+
const rel = `bundle ${bundleRef} ${BIOME_MANIFEST_FILE}`;
|
|
152
|
+
const taken = new Set();
|
|
153
|
+
const images = biomeImageComponents(components)
|
|
154
|
+
.map((component) => ({ component, name: claimBiomeImageName(component, { rel, id: xema?.id, target: xema?.target, taken }) }))
|
|
155
|
+
.filter(({ component }) => component.artifact?.kind === OCI_IMAGE_ARTIFACT);
|
|
156
|
+
const match = images.find(({ name }) => name === subjectId);
|
|
157
|
+
if (match === undefined) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`bundle ${bundleRef} has no ${OCI_IMAGE_ARTIFACT} service or worker component named ${JSON.stringify(subjectId)} ` +
|
|
160
|
+
`(its images: ${images.map(({ name }) => name).join(', ') || 'none'}); --subject-id names exactly one`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
const kind = isRuntimeImageComponent(match.component)
|
|
164
|
+
? enums.RuntimeImageSubjectKind.RuntimeComponent
|
|
165
|
+
: enums.RuntimeImageSubjectKind.Service;
|
|
166
|
+
return { kind, id: subjectId };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The biome directory of the publisher's checkout at `commit`: the directory of the ONE `xema-biome.json` whose kernel
|
|
171
|
+
* source manifest hash is `sourceManifestHash` (`''` for the repository root). None, or more than one, is refused.
|
|
172
|
+
*/
|
|
173
|
+
export async function biomeDirectoryAtCommit({ checkout, commit, sourceManifestHash, schemas, bundleRef }) {
|
|
174
|
+
const paths = sandboxedGit(checkout, ['ls-tree', '-r', '-z', '--name-only', commit])
|
|
175
|
+
.split('\0')
|
|
176
|
+
.filter((path) => path === BIOME_MANIFEST_FILE || path.endsWith(`/${BIOME_MANIFEST_FILE}`));
|
|
177
|
+
const matching = [];
|
|
178
|
+
for (const path of paths) {
|
|
179
|
+
const hash = await schemas.computeBiomeSourceManifestHash(readGitJsonAtCommit(checkout, path, commit));
|
|
180
|
+
if (hash === sourceManifestHash) {matching.push(path);}
|
|
181
|
+
}
|
|
182
|
+
if (matching.length !== 1) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`the checkout at ${commit} has ${matching.length} ${BIOME_MANIFEST_FILE} files whose source manifest hash is ` +
|
|
185
|
+
`${sourceManifestHash}, the hash of bundle ${bundleRef} ` +
|
|
186
|
+
`(${matching.length === 0 ? `its ${BIOME_MANIFEST_FILE} files: ${paths.join(', ') || 'none'}` : matching.join(', ')}); exactly one is required: ` +
|
|
187
|
+
'the bundle must be published from this commit',
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
const [path] = matching;
|
|
191
|
+
return path === BIOME_MANIFEST_FILE ? '' : path.slice(0, -`/${BIOME_MANIFEST_FILE}`.length);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Assemble the producer input of one third-party image and prove it satisfies the producer's exact contract.
|
|
196
|
+
*
|
|
197
|
+
* @param {object} request
|
|
198
|
+
* @param {string} request.bundle `<oci repository>@sha256:<hex>` of the published biome bundle
|
|
199
|
+
* @param {string} request.contractAuthorityPath the `--contract-authority` file
|
|
200
|
+
* @param {string} request.image `<image repository>@sha256:<hex>` of the published image
|
|
201
|
+
* @param {string} request.subjectId the bundle component's image name
|
|
202
|
+
* @param {string} request.provenancePath the exported BuildKit SLSA v1 predicate (absolute)
|
|
203
|
+
* @param {string} request.registryCredentialsPath the publisher's registry credential declaration file
|
|
204
|
+
* @param {number} [request.ttlSeconds]
|
|
205
|
+
* @param {string} [request.kernelDir] the kernel package (default: the configured Kernel root)
|
|
206
|
+
* @param {object} [request.env]
|
|
207
|
+
* @param {Function} [request.registryFor] `({authority, credential}) => client`, for tests
|
|
208
|
+
* @param {number} [request.now]
|
|
209
|
+
*/
|
|
210
|
+
export async function assembleThirdPartyInput({
|
|
211
|
+
bundle,
|
|
212
|
+
contractAuthorityPath,
|
|
213
|
+
image,
|
|
214
|
+
subjectId,
|
|
215
|
+
provenancePath,
|
|
216
|
+
registryCredentialsPath,
|
|
217
|
+
ttlSeconds,
|
|
218
|
+
kernelDir = kernelPackageDir(),
|
|
219
|
+
env = process.env,
|
|
220
|
+
fetchImpl = globalThis.fetch,
|
|
221
|
+
registryFor = null,
|
|
222
|
+
now = Date.now(),
|
|
223
|
+
}) {
|
|
224
|
+
assertThirdPartyRunner(env);
|
|
225
|
+
const context = assertWorkflowContext(env);
|
|
226
|
+
ociReference(THIRD_PARTY_INPUT_FLAGS.bundle, bundle);
|
|
227
|
+
const imageRef = ociReference(THIRD_PARTY_INPUT_FLAGS.image, image);
|
|
228
|
+
assertCanonicalSubjectId(subjectId);
|
|
229
|
+
const effectiveTtlSeconds = resolveProducerTtlSeconds({ ttlSeconds, now, ceiling: { source: ProducerTtlCeilingSource.VendorAdmission } });
|
|
230
|
+
const checkout = env.GITHUB_WORKSPACE;
|
|
231
|
+
if (typeof checkout !== 'string' || !isAbsolute(checkout)) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
`GITHUB_WORKSPACE (${JSON.stringify(checkout ?? null)}) is not an absolute path: the publisher's checkout at GITHUB_SHA is hashed there`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
assertGitCommitExists(checkout, context.sha);
|
|
238
|
+
} catch (error) {
|
|
239
|
+
throw new Error(`the publisher's checkout at GITHUB_WORKSPACE ${checkout} does not hold GITHUB_SHA ${context.sha}: ${error.message}`);
|
|
240
|
+
}
|
|
241
|
+
const { contractAuthority, schemas } = await readPublicContractAuthority({ path: contractAuthorityPath, kernelDir });
|
|
242
|
+
const builder = deriveBuilder({ provenancePath, sourceCommit: context.sha });
|
|
243
|
+
|
|
244
|
+
const declarations = loadRegistryCredentialDeclarationsFile(registryCredentialsPath);
|
|
245
|
+
const clients = new Map();
|
|
246
|
+
const clientFor = (authority) => {
|
|
247
|
+
if (!clients.has(authority)) {
|
|
248
|
+
const credential = resolveRegistryCredential({ authority, declarations, env });
|
|
249
|
+
// No vendor data root: the publisher's oras is held to its version, not to a vendor pin. Every byte read is
|
|
250
|
+
// content-addressed here and re-verified by the vendor's admission.
|
|
251
|
+
clients.set(authority, registryFor ? registryFor({ authority, credential }) : new OrasRegistry({ credential, trust: () => null, fetchImpl }));
|
|
252
|
+
}
|
|
253
|
+
return clients.get(authority);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const { manifest } = await fetchBundleManifest({ clientFor, ociRef: bundle });
|
|
257
|
+
const sourceManifestHash = await schemas.computeBiomeSourceManifestHash(manifest);
|
|
258
|
+
const subject = bundleImageSubject({ manifest, subjectId, enums: schemas, bundleRef: bundle });
|
|
259
|
+
const biomeDirectory = await biomeDirectoryAtCommit({ checkout, commit: context.sha, sourceManifestHash, schemas, bundleRef: bundle });
|
|
260
|
+
const servicePath = subjectServicePath(
|
|
261
|
+
{ kind: subject.kind, id: subject.id, repo: context.repository, path: biomeDirectory === '' ? '.' : biomeDirectory },
|
|
262
|
+
context.sha,
|
|
263
|
+
() => ({ dir: checkout, base: checkout }),
|
|
264
|
+
);
|
|
265
|
+
const sourceTreeHash = hashImageBuildInputs(
|
|
266
|
+
{ repoDir: checkout, service: subject.id, servicePath, commit: context.sha, crossRepoInputs: [], buildStepDirectories: [] },
|
|
267
|
+
imageInputSourceHashAlgorithm(),
|
|
268
|
+
);
|
|
269
|
+
const artifact = await resolveImageArtifactEvidenceByDigest({
|
|
270
|
+
client: clientFor(splitImageRef(imageRef.repository).authority),
|
|
271
|
+
imageRepository: imageRef.repository,
|
|
272
|
+
manifestDigest: imageRef.digest,
|
|
273
|
+
referenceProvenance: builder.predicate,
|
|
274
|
+
});
|
|
275
|
+
const input = producerInputDocument({
|
|
276
|
+
contractAuthority,
|
|
277
|
+
context,
|
|
278
|
+
builder,
|
|
279
|
+
now,
|
|
280
|
+
ttlSeconds: effectiveTtlSeconds,
|
|
281
|
+
subject: {
|
|
282
|
+
kind: subject.kind,
|
|
283
|
+
id: subject.id,
|
|
284
|
+
repositoryUrl: `https://github.com/${context.repository}`,
|
|
285
|
+
repo: context.repository,
|
|
286
|
+
path: biomeDirectory === '' ? context.repository : `${context.repository}/${biomeDirectory}`,
|
|
287
|
+
sourceCommit: context.sha,
|
|
288
|
+
sourceManifestHash,
|
|
289
|
+
sourceTreeHash,
|
|
290
|
+
imageRepository: imageRef.repository,
|
|
291
|
+
...artifact,
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
assertAssembledProducerInput({ input, schemas, contractAuthority, context });
|
|
295
|
+
return input;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function requireFlag(flags, name) {
|
|
299
|
+
const value = flags.get(name);
|
|
300
|
+
if (value === undefined) {
|
|
301
|
+
throw new Error(`--${name} is required\n${THIRD_PARTY_INPUT_USAGE}`);
|
|
302
|
+
}
|
|
303
|
+
return value;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function main() {
|
|
307
|
+
// No workspace root: the verb runs on public inputs, with the kernel this CLI ships.
|
|
308
|
+
const args = configureEditionWorkspaceFromArgv(process.argv.slice(2), [], { refuseOthers: true });
|
|
309
|
+
stateKernelPackageDir(shippedKernelPackageDir());
|
|
310
|
+
const flags = readAssemblerFlags(args, new Set(Object.values(THIRD_PARTY_INPUT_FLAGS)));
|
|
311
|
+
const output = resolve(requireFlag(flags, THIRD_PARTY_INPUT_FLAGS.output));
|
|
312
|
+
const ttl = flags.get(THIRD_PARTY_INPUT_FLAGS.ttlSeconds);
|
|
313
|
+
const input = await assembleThirdPartyInput({
|
|
314
|
+
bundle: requireFlag(flags, THIRD_PARTY_INPUT_FLAGS.bundle),
|
|
315
|
+
contractAuthorityPath: resolve(requireFlag(flags, THIRD_PARTY_INPUT_FLAGS.contractAuthority)),
|
|
316
|
+
image: requireFlag(flags, THIRD_PARTY_INPUT_FLAGS.image),
|
|
317
|
+
subjectId: requireFlag(flags, THIRD_PARTY_INPUT_FLAGS.subjectId),
|
|
318
|
+
provenancePath: requireFlag(flags, THIRD_PARTY_INPUT_FLAGS.provenance),
|
|
319
|
+
registryCredentialsPath: resolve(requireFlag(flags, THIRD_PARTY_INPUT_FLAGS.registryCredentials)),
|
|
320
|
+
ttlSeconds: producerTtlSecondsFlag(ttl),
|
|
321
|
+
});
|
|
322
|
+
writeProducerInputFile(output, input);
|
|
323
|
+
const [subject] = input.subjects;
|
|
324
|
+
console.log(
|
|
325
|
+
`[assemble-third-party-input] wrote ${output} for subject ${subject.kind}:${subject.id} ` +
|
|
326
|
+
`(${subject.imageRepository}@${subject.manifestDigest}, ${subject.repo}@${subject.sourceCommit}, ` +
|
|
327
|
+
`sourceTreeHash (${PUBLISHER_ATTESTED_LABEL}) ${subject.sourceTreeHash})`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (isEntryModule(import.meta.url)) {
|
|
332
|
+
main().catch((error) => {
|
|
333
|
+
console.error(`[assemble-third-party-input] ${error.message}`);
|
|
334
|
+
process.exitCode = 1;
|
|
335
|
+
});
|
|
336
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// evidence-input-mode — where a producer-side evidence verb takes its kernel contract authority and schemas from.
|
|
2
|
+
//
|
|
3
|
+
// Vendor mode (`--data-root <dir> --kernel <dir>`): the authority the data root's evidence-trust pin records, and the
|
|
4
|
+
// schemas of a kernel checkout at that commit. Public-input mode (`--contract-authority <file>`): a third-party
|
|
5
|
+
// publisher's run, on public inputs only. The authority is the file, and the schemas are the `@xemahq/contracts` this
|
|
6
|
+
// CLI bundles, proved to be that authority. There is no data root, no kernel checkout and no vendor trust policy; what
|
|
7
|
+
// needs the vendor's trust policy is checked at the vendor's admission.
|
|
8
|
+
//
|
|
9
|
+
// `readPublicContractAuthority` is the one resolution of a `--contract-authority` file, shared by
|
|
10
|
+
// `evidence assemble-third-party-input`, `statement producer <stage|accept>` and `oci <attach-plan|validate>`.
|
|
11
|
+
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { join, resolve } from 'node:path';
|
|
14
|
+
import { pathToFileURL } from 'node:url';
|
|
15
|
+
|
|
16
|
+
import { readKernelEditionProtocol } from './edition-protocol.mjs';
|
|
17
|
+
import {
|
|
18
|
+
WorkspaceRoot,
|
|
19
|
+
configureEditionWorkspaceFromArgv,
|
|
20
|
+
kernelPackageDir,
|
|
21
|
+
stateKernelPackageDir,
|
|
22
|
+
} from './edition-workspace.mjs';
|
|
23
|
+
import { KERNEL_PACKAGE_NAME, assertKernelPackageIsCatalogueAuthority, shippedKernelPackageDir } from './kernel-package.mjs';
|
|
24
|
+
import { loadKernelDistributionSchemas } from './lib.mjs';
|
|
25
|
+
|
|
26
|
+
/** The two closed ways a producer-side evidence verb is given its kernel contract authority. */
|
|
27
|
+
export const EvidenceInputMode = Object.freeze({
|
|
28
|
+
/** `--data-root` and `--kernel`: the vendor's pinned authority and kernel checkout. */
|
|
29
|
+
Vendor: 'vendor',
|
|
30
|
+
/** `--contract-authority`: a publisher's run on public inputs, with the kernel this CLI bundles. */
|
|
31
|
+
PublicInput: 'public-input',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const CONTRACT_AUTHORITY_FLAG = '--contract-authority';
|
|
35
|
+
const VENDOR_FLAGS = ['--data-root', '--kernel'];
|
|
36
|
+
|
|
37
|
+
const flagOf = (arg) => {
|
|
38
|
+
const eq = typeof arg === 'string' ? arg.indexOf('=') : -1;
|
|
39
|
+
return eq > 0 ? arg.slice(0, eq) : arg;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const readVersion = (packageJsonPath) => {
|
|
43
|
+
const { version } = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
44
|
+
if (typeof version !== 'string' || version.length === 0) {throw new Error(`${packageJsonPath} has no "version"`);}
|
|
45
|
+
return version;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The bundled kernel's own strict `KernelContractAuthoritySchema` (not re-exported by its distribution index), read
|
|
50
|
+
* from the package this CLI ships.
|
|
51
|
+
*/
|
|
52
|
+
async function loadKernelContractAuthoritySchema(kernelDir) {
|
|
53
|
+
const module = join(kernelDir, 'dist', 'distribution', 'lib', 'kernel-contract-authority.js');
|
|
54
|
+
let loaded;
|
|
55
|
+
try {
|
|
56
|
+
loaded = await import(pathToFileURL(module).href);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
throw new Error(`${KERNEL_PACKAGE_NAME} at ${kernelDir} has no loadable kernel contract authority schema (${module}): ${error.message}`);
|
|
59
|
+
}
|
|
60
|
+
const schema = loaded.KernelContractAuthoritySchema ?? loaded.default?.KernelContractAuthoritySchema;
|
|
61
|
+
if (typeof schema?.safeParse !== 'function') {
|
|
62
|
+
throw new Error(`${KERNEL_PACKAGE_NAME} at ${kernelDir} exports no KernelContractAuthoritySchema from ${module}`);
|
|
63
|
+
}
|
|
64
|
+
return schema;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The contract authority of a `--contract-authority` file, proved to be the kernel this CLI bundles (`kernelDir`),
|
|
69
|
+
* and that kernel's distribution schemas. An authority whose contracts digest is not the bundled one is refused
|
|
70
|
+
* first, naming both digests: the publisher installs the xema CLI whose bundled `@xemahq/contracts` is that authority.
|
|
71
|
+
* The file is then strict-parsed by the kernel's `KernelContractAuthoritySchema` (an unknown or missing field is
|
|
72
|
+
* refused) and returned in the schema's form, so its key order is never what a later comparison sees.
|
|
73
|
+
*
|
|
74
|
+
* `sourceCommit` is held to the schema's shape only: it is provenance, which the bundled package cannot prove. The
|
|
75
|
+
* vendor holds it, comparing the statement's whole authority with its pinned one at the third-party pin writer
|
|
76
|
+
* (`assertImageStatementBindsPin`, third-party-source-evidence.mjs).
|
|
77
|
+
*/
|
|
78
|
+
export async function readPublicContractAuthority({ path, kernelDir = kernelPackageDir() }) {
|
|
79
|
+
let contractAuthority;
|
|
80
|
+
try {
|
|
81
|
+
contractAuthority = JSON.parse(readFileSync(path, 'utf8'));
|
|
82
|
+
} catch (error) {
|
|
83
|
+
throw new Error(`${CONTRACT_AUTHORITY_FLAG} ${path} is not a JSON kernel contract authority: ${error.message}`);
|
|
84
|
+
}
|
|
85
|
+
if (typeof contractAuthority !== 'object' || contractAuthority === null || Array.isArray(contractAuthority)) {
|
|
86
|
+
throw new Error(`${CONTRACT_AUTHORITY_FLAG} ${path} does not hold one kernel contract authority object`);
|
|
87
|
+
}
|
|
88
|
+
const bundledDigest = readKernelEditionProtocol(kernelDir).contentDigest;
|
|
89
|
+
if (contractAuthority.contentDigest !== bundledDigest) {
|
|
90
|
+
const cli = readVersion(new URL('../package.json', import.meta.url));
|
|
91
|
+
const contracts = readVersion(join(kernelDir, 'package.json'));
|
|
92
|
+
throw new Error(
|
|
93
|
+
`${CONTRACT_AUTHORITY_FLAG} ${path} is kernel contracts digest ${JSON.stringify(contractAuthority.contentDigest)}, ` +
|
|
94
|
+
`but this CLI (@xemahq/xema ${cli}) bundles ${KERNEL_PACKAGE_NAME} ${contracts}, digest ${bundledDigest}; ` +
|
|
95
|
+
`install the @xemahq/xema version whose bundled ${KERNEL_PACKAGE_NAME} has digest ${JSON.stringify(contractAuthority.contentDigest)}`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
const parsed = (await loadKernelContractAuthoritySchema(kernelDir)).safeParse(contractAuthority);
|
|
99
|
+
if (!parsed.success) {
|
|
100
|
+
const issues = parsed.error.issues.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`).join('; ');
|
|
101
|
+
throw new Error(`${CONTRACT_AUTHORITY_FLAG} ${path} is not a kernel contract authority: ${issues}`);
|
|
102
|
+
}
|
|
103
|
+
contractAuthority = parsed.data;
|
|
104
|
+
try {
|
|
105
|
+
const schemas = await assertKernelPackageIsCatalogueAuthority({
|
|
106
|
+
catalogue: { contractAuthority },
|
|
107
|
+
kernelPackageDir: kernelDir,
|
|
108
|
+
loadSchemas: loadKernelDistributionSchemas,
|
|
109
|
+
});
|
|
110
|
+
return { contractAuthority, schemas };
|
|
111
|
+
} catch (error) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`${CONTRACT_AUTHORITY_FLAG} ${path} is not the kernel this CLI ships (${kernelDir}): ${error.message}; ` +
|
|
114
|
+
`install the xema CLI version whose ${KERNEL_PACKAGE_NAME} is that authority`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Configure a producer-side evidence verb from its argv in exactly one mode. `--contract-authority <file>` together
|
|
121
|
+
* with `--data-root`/`--kernel`, or neither, is refused by name.
|
|
122
|
+
*
|
|
123
|
+
* Vendor mode configures the Data and Kernel roots and returns `{mode, rest}`; the verb reads its pinned authority.
|
|
124
|
+
* Public-input mode configures no root, states the bundled kernel as the Kernel root and returns
|
|
125
|
+
* `{mode, rest, contractAuthority, schemas}` from {@link readPublicContractAuthority}.
|
|
126
|
+
*/
|
|
127
|
+
export async function configureEvidenceInputMode(argv, { verb }) {
|
|
128
|
+
const authorityAt = argv.findIndex((arg) => flagOf(arg) === CONTRACT_AUTHORITY_FLAG);
|
|
129
|
+
const vendorFlags = VENDOR_FLAGS.filter((flag) => argv.some((arg) => flagOf(arg) === flag));
|
|
130
|
+
const modes = `${CONTRACT_AUTHORITY_FLAG} <file> (public-input mode) or --data-root <dir> --kernel <dir> (vendor mode)`;
|
|
131
|
+
if (authorityAt !== -1 && vendorFlags.length > 0) {
|
|
132
|
+
throw new Error(`${verb}: ${CONTRACT_AUTHORITY_FLAG} and ${vendorFlags.join('/')} are two modes; pass one: ${modes}`);
|
|
133
|
+
}
|
|
134
|
+
if (authorityAt === -1 && vendorFlags.length === 0) {
|
|
135
|
+
throw new Error(`${verb} needs ${modes}`);
|
|
136
|
+
}
|
|
137
|
+
if (authorityAt === -1) {
|
|
138
|
+
return { mode: EvidenceInputMode.Vendor, rest: configureEditionWorkspaceFromArgv(argv, [WorkspaceRoot.Data, WorkspaceRoot.Kernel]) };
|
|
139
|
+
}
|
|
140
|
+
const arg = argv[authorityAt];
|
|
141
|
+
const inline = arg.length > CONTRACT_AUTHORITY_FLAG.length;
|
|
142
|
+
const path = inline ? arg.slice(CONTRACT_AUTHORITY_FLAG.length + 1) : argv[authorityAt + 1];
|
|
143
|
+
if (typeof path !== 'string' || path.length === 0 || path.startsWith('--')) {
|
|
144
|
+
throw new Error(`${CONTRACT_AUTHORITY_FLAG} requires a file path`);
|
|
145
|
+
}
|
|
146
|
+
if (argv.some((other, index) => index !== authorityAt && flagOf(other) === CONTRACT_AUTHORITY_FLAG)) {
|
|
147
|
+
throw new Error(`${CONTRACT_AUTHORITY_FLAG} is given more than once`);
|
|
148
|
+
}
|
|
149
|
+
const remaining = argv.filter((_, index) => index !== authorityAt && (inline || index !== authorityAt + 1));
|
|
150
|
+
const rest = configureEditionWorkspaceFromArgv(remaining, [], { refuseOthers: true });
|
|
151
|
+
stateKernelPackageDir(shippedKernelPackageDir());
|
|
152
|
+
const { contractAuthority, schemas } = await readPublicContractAuthority({ path: resolve(path) });
|
|
153
|
+
return { mode: EvidenceInputMode.PublicInput, rest, contractAuthority, schemas };
|
|
154
|
+
}
|
|
@@ -138,11 +138,29 @@ function assertTimeWindow({ issuedAt, expiresAt, now, maxTtlSeconds, label }) {
|
|
|
138
138
|
if (expires <= instant) {
|
|
139
139
|
throw new Error(`${label} is expired`);
|
|
140
140
|
}
|
|
141
|
+
assertProducerTtlCeiling({ issuedAt, expiresAt, maxTtlSeconds, label });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The declared lifetime (`expiresAt - issuedAt`) is at most the trust policy's ceiling `maxTtlSeconds`. Held by
|
|
146
|
+
* `verify-evidence` (through the time window above) and by the third-party pin writer, which has no `now` to hold.
|
|
147
|
+
*/
|
|
148
|
+
export function assertProducerTtlCeiling({ issuedAt, expiresAt, maxTtlSeconds, label }) {
|
|
149
|
+
if (!Number.isSafeInteger(maxTtlSeconds) || maxTtlSeconds < 1) {
|
|
150
|
+
throw new Error(`${label}: the trust policy's TTL ceiling ${String(maxTtlSeconds)} is not a positive integer`);
|
|
151
|
+
}
|
|
152
|
+
const issued = Date.parse(issuedAt);
|
|
153
|
+
const expires = Date.parse(expiresAt);
|
|
154
|
+
if (![issued, expires].every(Number.isFinite)) {
|
|
155
|
+
throw new Error(`${label} has an invalid time`);
|
|
156
|
+
}
|
|
141
157
|
if (expires <= issued) {
|
|
142
158
|
throw new Error(`${label} expiresAt must be after issuedAt`);
|
|
143
159
|
}
|
|
144
160
|
if (expires - issued > maxTtlSeconds * 1000) {
|
|
145
|
-
throw new Error(
|
|
161
|
+
throw new Error(
|
|
162
|
+
`${label} exceeds the trusted maximum TTL (${(expires - issued) / 1000}s declared, maxProducerTtlSeconds ${maxTtlSeconds})`,
|
|
163
|
+
);
|
|
146
164
|
}
|
|
147
165
|
}
|
|
148
166
|
|
|
@@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
2
2
|
import { lstatSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
|
|
5
|
+
import { canonicalJson } from './canonical-evidence-json.mjs';
|
|
5
6
|
import { kernelRepoDir } from './edition-workspace.mjs';
|
|
6
7
|
import { COMMIT_PATTERN } from './evidence-grammars.mjs';
|
|
7
8
|
import {
|
|
@@ -198,12 +199,19 @@ export function assertSourcePathsMatchCommit(repositoryDirectory, sourceCommit,
|
|
|
198
199
|
assertSourcePathsEqualSet(sourcePaths, sourcePathSetAtCommit(repositoryDirectory, sourceCommit), sourceCommit);
|
|
199
200
|
}
|
|
200
201
|
|
|
202
|
+
/** The canonical JSON of an authority (sorted keys), or null for a value canonical evidence JSON cannot hold. */
|
|
201
203
|
function exactAuthorityJson(value) {
|
|
202
|
-
|
|
204
|
+
try {
|
|
205
|
+
return canonicalJson(value);
|
|
206
|
+
} catch {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
203
209
|
}
|
|
204
210
|
|
|
211
|
+
/** `actual` is exactly `expected`, field for field and value for value; key order is not part of an authority. */
|
|
205
212
|
export function assertKernelContractAuthorityEqual(actual, expected, label) {
|
|
206
|
-
|
|
213
|
+
const canonical = exactAuthorityJson(actual);
|
|
214
|
+
if (canonical === null || canonical !== exactAuthorityJson(expected)) {
|
|
207
215
|
throw new Error(
|
|
208
216
|
`${label} does not exactly match kernel contract authority`,
|
|
209
217
|
);
|
|
@@ -87,6 +87,19 @@ function shippedPackageDir(name) {
|
|
|
87
87
|
return undefined;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* The `@xemahq/contracts` package this CLI itself ships (installed from public npm with the CLI): the kernel of a
|
|
92
|
+
* verb that runs with no kernel checkout and no catalogue, such as a third-party publisher's
|
|
93
|
+
* `evidence assemble-third-party-input`. Refused when the CLI's installation has none.
|
|
94
|
+
*/
|
|
95
|
+
export function shippedKernelPackageDir() {
|
|
96
|
+
const dir = shippedPackageDir(KERNEL_PACKAGE_NAME);
|
|
97
|
+
if (dir === undefined) {
|
|
98
|
+
throw new KernelPackageError(`this CLI's installation ships no ${KERNEL_PACKAGE_NAME}; reinstall the CLI from npm`);
|
|
99
|
+
}
|
|
100
|
+
return dir;
|
|
101
|
+
}
|
|
102
|
+
|
|
90
103
|
function linkRuntimeDependencies(packageDir) {
|
|
91
104
|
const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8'));
|
|
92
105
|
const nodeModules = dirname(manifest.name.startsWith('@') ? dirname(packageDir) : packageDir);
|