@xemahq/xema 0.40.2 → 0.41.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.
Files changed (48) hide show
  1. package/dist/commands/edition.d.ts.map +1 -1
  2. package/dist/commands/edition.js +1 -0
  3. package/dist/commands/edition.js.map +1 -1
  4. package/dist/edition/verify-image-evidence.bundle.mjs +281 -9
  5. package/dist/edition/verify-image-evidence.bundle.provenance.json +66 -48
  6. package/dist/runtime/edition/verb-map.d.ts +3 -1
  7. package/dist/runtime/edition/verb-map.d.ts.map +1 -1
  8. package/dist/runtime/edition/verb-map.js +4 -0
  9. package/dist/runtime/edition/verb-map.js.map +1 -1
  10. package/edition/README.md +6 -0
  11. package/edition/build-biome-index.mjs +9 -4
  12. package/edition/build-runtime-image-source-index.mjs +165 -37
  13. package/edition/catalogue-evidence.mjs +225 -0
  14. package/edition/catalogue-fetch.mjs +40 -7
  15. package/edition/customer-edition-verbs.mjs +477 -0
  16. package/edition/customer-flags.mjs +2 -0
  17. package/edition/develop-coverage-readers.mjs +212 -0
  18. package/edition/develop-coverage-run.mjs +48 -0
  19. package/edition/develop-coverage-verdict.mjs +153 -0
  20. package/edition/develop-coverage.mjs +120 -0
  21. package/edition/develop-edition-resolve.mjs +18 -7
  22. package/edition/develop-edition.mjs +47 -220
  23. package/edition/develop-freeze.mjs +50 -13
  24. package/edition/develop-image-probe.mjs +7 -7
  25. package/edition/edition-artifact.mjs +31 -14
  26. package/edition/edition-constants.mjs +9 -0
  27. package/edition/edition-contract.json +2 -1
  28. package/edition/{develop-seal.mjs → edition-image-seal.mjs} +36 -17
  29. package/edition/edition-workspace.mjs +4 -3
  30. package/edition/evidence-trust-pin.mjs +28 -8
  31. package/edition/evidence-trust-policy.mjs +51 -7
  32. package/edition/image-evidence.mjs +2 -2
  33. package/edition/indexes-at-pins.mjs +5 -21
  34. package/edition/lib.mjs +7 -0
  35. package/edition/oci-image-source-evidence.mjs +3 -0
  36. package/edition/oras-process.mjs +3 -3
  37. package/edition/oras.mjs +47 -5
  38. package/edition/produce-edition-catalogue-evidence.mjs +252 -0
  39. package/edition/produce-image-source-evidence.mjs +11 -2
  40. package/edition/release-edition.mjs +54 -0
  41. package/edition/repin-evidence-trust.mjs +5 -3
  42. package/edition/sigstore-bundle-media-type.mjs +4 -0
  43. package/edition/source-authority.mjs +2 -1
  44. package/edition/trust-policy-arming.mjs +14 -1
  45. package/edition/verify-catalogue-evidence.mjs +245 -0
  46. package/edition/verify-image-evidence.mjs +15 -1
  47. package/package.json +2 -2
  48. package/edition/develop-customer-edition.mjs +0 -149
@@ -4,9 +4,10 @@ import { execFileSync } from 'node:child_process';
4
4
  import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
5
5
  import { isAbsolute, join } from 'node:path';
6
6
 
7
+ import { buildValidatedPlatformServiceIndex } from './build-platform-service-index.mjs';
7
8
  import { connectorCrossRepoBuildInputs } from './connector-source-authority.mjs';
8
9
  import { editionWorkspaceConfig, workspaceSection } from './edition-config.mjs';
9
- import { biomeIndexPath, dataRoot, runtimeImageSourceIndexPath, WorkspaceRoot, configureEditionWorkspaceFromProcess, isEntryModule, sourcePath } from './edition-workspace.mjs';
10
+ import { biomeIndexPath, dataRoot, platformServiceIndexPath, runtimeImageSourceIndexPath, WorkspaceRoot, configureEditionWorkspaceFromProcess, isEntryModule, sourcePath } from './edition-workspace.mjs';
10
11
  import { COMMIT_PATTERN } from './evidence-grammars.mjs';
11
12
  import {
12
13
  buildTrustedKernelContractAuthority,
@@ -15,8 +16,10 @@ import {
15
16
  validateKernelContractAuthorityFromGit,
16
17
  } from './kernel-contract-authority.mjs';
17
18
  import {
19
+ buildBiomeIndex,
18
20
  canonicalPublicHttpsRepositoryUrl,
19
21
  compareStrings,
22
+ distributionBiomeSourceRepositories,
20
23
  loadKernelDistributionSchemas,
21
24
  readGitJsonAtCommit,
22
25
  serializeJson,
@@ -27,6 +30,7 @@ import {
27
30
  } from './platform-service-source-authority.mjs';
28
31
  import {
29
32
  createExactCommitSourceAuthority,
33
+ createPinnedSourceAuthority,
30
34
  createSourceAuthority,
31
35
  parseSourceModeArgs,
32
36
  } from './source-authority.mjs';
@@ -140,7 +144,7 @@ function declaredSourceRepositories() {
140
144
  * ONE authority that answers both questions about a component, never two
141
145
  * competing ones that can disagree about which commit a component is at.
142
146
  */
143
- function repositoryIdentity(repo, sourceAuthority) {
147
+ export function repositoryIdentity(repo, sourceAuthority) {
144
148
  const repoDirectory = sourcePath(repo);
145
149
  const sourceCommit = sourceAuthority.commitForRepository(repo);
146
150
  const vendoredSourceCommit =
@@ -834,6 +838,107 @@ function parseKernelCommit(argv) {
834
838
  };
835
839
  }
836
840
 
841
+ /**
842
+ * @param {{ schemas: object, repositoryPins: object[], kernelRoot?: string, producerWorkflowPins?: object[] }} request
843
+ * `kernelRoot` is the kernel checkout the committed contract authority is re-verified against
844
+ * (the default checkout when omitted); `producerWorkflowPins` are the operator-stated ones a
845
+ * trusted write merges over the committed ones.
846
+ * @returns {{ platformServiceIndex, biomeIndex, runtimeImageSourceIndex }}
847
+ */
848
+ export async function buildIndexesAtPins({ schemas, repositoryPins, kernelRoot, producerWorkflowPins = [] }) {
849
+ const sourceAuthority = createPinnedSourceAuthority({ pins: repositoryPins });
850
+ const platformServiceIndex = buildValidatedPlatformServiceIndex(schemas, sourceAuthority);
851
+ const biomeIndex = await buildBiomeIndex(platformServiceIndex, distributionBiomeSourceRepositories(), sourceAuthority);
852
+ const runtimeImageSourceIndex = schemas.parseRuntimeImageSourceIndex(
853
+ await buildTrustedIndex(schemas, sourceAuthority, kernelRoot, undefined, {
854
+ biomeIndex,
855
+ platformServiceIndex,
856
+ producerWorkflowPins,
857
+ }),
858
+ );
859
+ await validateRuntimeImageSourceIndex(runtimeImageSourceIndex, schemas, { biomeIndex, platformServiceIndex, kernelRoot });
860
+ return { platformServiceIndex, biomeIndex, runtimeImageSourceIndex };
861
+ }
862
+
863
+ /**
864
+ * A repository JOINING the index (`--repo <repository>` with no committed pin): all three indexes,
865
+ * from ONE resolution of the joining repository, with every committed pin kept.
866
+ *
867
+ * WHY HERE AND NOT A `--repo` ON THE BIOME BUILDER. The biome builder's `--repo` was removed
868
+ * because it resolved the scoped repository LIVE a second time, so a build landing between the two
869
+ * commands split the two artifacts on exactly that repository (see `build-biome-index.mjs`). A
870
+ * joining repository has the same shape: the runtime index cannot take it until the biome index
871
+ * names its biomes, and the biome index cannot take it until a pin exists. So the joining
872
+ * repository is resolved ONCE here (`sourceAuthority`, live or `--at`), appended to the committed
873
+ * pins, and every index is derived from that one pin set by the same builders a develop snapshot
874
+ * uses; nothing else is resolved live.
875
+ *
876
+ * Every committed pin that survives must come back field for field; one that differs is refused by
877
+ * name. A committed pin whose repository is no longer a source repository is DROPPED, and the caller
878
+ * names it (`describeRepositoryPinChanges`). A second declared repository with no pin is refused by
879
+ * the pinned authority: one joining repository per write.
880
+ */
881
+ export async function buildIndexesWithJoiningRepository({
882
+ schemas,
883
+ sourceAuthority,
884
+ repoScope,
885
+ committedPins,
886
+ kernelRoot,
887
+ producerWorkflowPins = [],
888
+ }) {
889
+ if (committedPins.some((pin) => pin.repo === repoScope)) {
890
+ throw new Error(`--repo "${repoScope}" already has a committed pin: it is a refresh, not a join`);
891
+ }
892
+ const kept = committedPins.map(({ repo, repositoryUrl, sourceCommit, vendoredSourceCommit }) => ({
893
+ repo,
894
+ repositoryUrl,
895
+ sourceCommit,
896
+ vendoredSourceCommit,
897
+ }));
898
+ const repositoryPins = [...kept, repositoryIdentity(repoScope, sourceAuthority)];
899
+ const built = await buildIndexesAtPins({ schemas, repositoryPins, kernelRoot, producerWorkflowPins });
900
+ const written = new Map(built.runtimeImageSourceIndex.repositoryPins.map((pin) => [pin.repo, pin]));
901
+ const moved = kept.filter((pin) => written.has(pin.repo) && serializeJson(written.get(pin.repo)) !== serializeJson(pin));
902
+ if (moved.length > 0) {
903
+ throw new Error(
904
+ `a join must keep every committed pin, and ${moved.map((pin) => pin.repo).join(', ')} came back different ` +
905
+ '(its repositoryUrl no longer matches its origin remote?)',
906
+ );
907
+ }
908
+ if (!written.has(repoScope)) {
909
+ throw new Error(
910
+ `--repo "${repoScope}" contributes no biome, platform service or supplemental subject at ` +
911
+ `${repositoryPins.at(-1).sourceCommit}: there is nothing for it to join`,
912
+ );
913
+ }
914
+ return built;
915
+ }
916
+
917
+ /**
918
+ * What a write did to the committed `repositoryPins`, by NAME: the repositories added, the ones
919
+ * dropped (a committed pin whose repository no longer sources any subject), the ones moved, and
920
+ * how many were kept unchanged. A count alone hid a dropped pin inside "N other pin(s)
921
+ * preserved", which is the one change a reviewer must see.
922
+ */
923
+ export function describeRepositoryPinChanges(committedPins, writtenPins) {
924
+ const committed = new Map((committedPins ?? []).map((pin) => [pin.repo, pin]));
925
+ const written = new Map(writtenPins.map((pin) => [pin.repo, pin]));
926
+ const added = [...written.keys()].filter((repo) => !committed.has(repo)).sort(compareStrings);
927
+ const dropped = [...committed.keys()].filter((repo) => !written.has(repo)).sort(compareStrings);
928
+ const moved = [...written.keys()]
929
+ .filter((repo) => committed.has(repo) && serializeJson(committed.get(repo)) !== serializeJson(written.get(repo)))
930
+ .sort(compareStrings);
931
+ const kept = written.size - added.length - moved.length;
932
+ const named = (label, repos) => `${label} ${repos.length}${repos.length > 0 ? ` (${repos.join(', ')})` : ''}`;
933
+ return {
934
+ added,
935
+ dropped,
936
+ moved,
937
+ kept,
938
+ summary: `${named('added', added)}; ${named('moved', moved)}; ${named('dropped', dropped)}; kept ${kept} unchanged`,
939
+ };
940
+ }
941
+
837
942
  const REPIN_FLAG = '--repin-contract-authority';
838
943
  const USAGE =
839
944
  'usage: build-runtime-image-source-index.mjs (--check | --check-sources | --trusted-write | ' +
@@ -898,55 +1003,78 @@ async function main() {
898
1003
  );
899
1004
  return;
900
1005
  }
901
- const candidate = schemas.parseRuntimeImageSourceIndex(
902
- await buildTrustedIndex(
1006
+ const committedPins = existsSync(runtimeImageSourceIndexPath())
1007
+ ? readJson(runtimeImageSourceIndexPath()).repositoryPins ?? []
1008
+ : [];
1009
+ const liveAuthority = () => createSourceAuthority({ mode, knownRepositories: declaredSourceRepositories() });
1010
+ const sourceAuthority =
1011
+ exactCommit === undefined
1012
+ ? liveAuthority()
1013
+ : createExactCommitSourceAuthority({ delegate: liveAuthority(), repo: repoScope, commit: exactCommit });
1014
+ // A `--repo` with no committed pin JOINS the index, and the biome and platform-service indexes
1015
+ // must take it in the same write: see `buildIndexesWithJoiningRepository`.
1016
+ const joining = repoScope !== undefined && !committedPins.some((pin) => pin.repo === repoScope);
1017
+ let candidate;
1018
+ let joined;
1019
+ if (joining) {
1020
+ if (committedPins.length === 0) {
1021
+ throw new Error(
1022
+ `--repo requires ${runtimeImageSourceIndexPath()} with committed repositoryPins: there are none to keep. ` +
1023
+ 'Bootstrap with --trusted-write and no --repo.',
1024
+ );
1025
+ }
1026
+ if (kernelCommit !== undefined) {
1027
+ throw new Error(`${KERNEL_COMMIT_FLAG} chooses a new contract authority; a join keeps the committed one`);
1028
+ }
1029
+ joined = await buildIndexesWithJoiningRepository({
903
1030
  schemas,
904
- exactCommit === undefined
905
- ? createSourceAuthority({
906
- mode,
907
- knownRepositories: declaredSourceRepositories(),
908
- })
909
- : createExactCommitSourceAuthority({
910
- delegate: createSourceAuthority({
911
- mode,
912
- knownRepositories: declaredSourceRepositories(),
913
- }),
914
- repo: repoScope,
915
- commit: exactCommit,
916
- }),
917
- kernelRoot,
1031
+ sourceAuthority,
918
1032
  repoScope,
919
- {
1033
+ committedPins,
1034
+ kernelRoot,
1035
+ producerWorkflowPins,
1036
+ });
1037
+ candidate = joined.runtimeImageSourceIndex;
1038
+ } else {
1039
+ candidate = schemas.parseRuntimeImageSourceIndex(
1040
+ await buildTrustedIndex(schemas, sourceAuthority, kernelRoot, repoScope, {
920
1041
  biomeIndex: readJson(biomeIndexPath()),
921
1042
  platformServiceIndex: readJson(join(dataRoot(), 'platform-service-index.json')),
922
1043
  kernelCommit,
923
1044
  producerWorkflowPins,
924
- },
925
- ),
926
- );
927
- await validateRuntimeImageSourceIndex(candidate, schemas, { kernelRoot });
1045
+ }),
1046
+ );
1047
+ await validateRuntimeImageSourceIndex(candidate, schemas, { kernelRoot });
1048
+ }
1049
+ const outputs = [
1050
+ [runtimeImageSourceIndexPath(), serializeJson(candidate)],
1051
+ ...(joined === undefined
1052
+ ? []
1053
+ : [
1054
+ [biomeIndexPath(), serializeJson(joined.biomeIndex)],
1055
+ [platformServiceIndexPath(), serializeJson(joined.platformServiceIndex)],
1056
+ ]),
1057
+ ];
928
1058
  if (flag === '--check-sources') {
929
- if (
930
- readFileSync(runtimeImageSourceIndexPath(), 'utf8') !==
931
- serializeJson(candidate)
932
- ) {
1059
+ const stale = outputs.filter(([path, text]) => !existsSync(path) || readFileSync(path, 'utf8') !== text);
1060
+ if (stale.length > 0) {
933
1061
  throw new Error(
934
- 'runtime-image-source-index.json does not match exact trusted sources',
1062
+ `${stale.map(([path]) => path).join(', ')} does not match exact trusted sources`,
935
1063
  );
936
1064
  }
937
1065
  console.log('[runtime-image-source-index] trusted source check passed');
938
1066
  return;
939
1067
  }
940
- writeFileSync(runtimeImageSourceIndexPath(), serializeJson(candidate));
1068
+ for (const [path, text] of outputs) {writeFileSync(path, text);}
1069
+ const changes = describeRepositoryPinChanges(committedPins, candidate.repositoryPins);
941
1070
  console.log(
942
- `[runtime-image-source-index] wrote ${runtimeImageSourceIndexPath()}` +
943
- (repoScope === undefined
944
- ? ' (all repository pins re-resolved)'
945
- : ` (${
946
- exactCommit === undefined
947
- ? `re-resolved ${repoScope}`
948
- : `pinned ${repoScope} at the exact commit ${exactCommit}`
949
- }; ${candidate.repositoryPins.length - 1} other pin(s) preserved)`),
1071
+ `[runtime-image-source-index] wrote ${outputs.map(([path]) => path).join(', ')} (${
1072
+ repoScope === undefined
1073
+ ? 'all repository pins re-resolved'
1074
+ : `${joining ? 'joined' : 're-resolved'} ${repoScope}${
1075
+ exactCommit === undefined ? '' : ` at the exact commit ${exactCommit}`
1076
+ }`
1077
+ }; ${changes.summary})`,
950
1078
  );
951
1079
  }
952
1080
 
@@ -0,0 +1,225 @@
1
+ // catalogue-evidence — the catalogue's signed E4 evidence, verified BEFORE the catalogue is read.
2
+ //
3
+ // A release catalogue is the ceiling of everything resolved against it, so its bytes are not parsed, and not even
4
+ // fetched, until the E4 statement over its digest, catalogue layer and lock layer has been verified against the evidence
5
+ // trust policy of the CALLER'S OWN data root (never the vendor's). The statement is an `oras attach` referrer of the
6
+ // edition artifact digest (`EDITION_CATALOGUE_EVIDENCE_ARTIFACT_TYPE`): two layers, `statement.json` (in-toto) and
7
+ // `bundle.sigstore.json` (the Sigstore bundle), and the exact commit of the signing workflow as an annotation.
8
+ //
9
+ // The verification is ONE seam, `verifyCatalogueEvidenceReferrer`, and it is done by the data root's PINNED built
10
+ // verifier (`catalogue-evidence verify`, verify-catalogue-evidence.mjs): there is no second verifier. The only
11
+ // kernel-dependent parts of the judgement are the policy schema (parseSigstoreOfflineTrustPolicy) and the
12
+ // predicate-type/channel constants; before the catalogue's kernel is adopted (kernel-package.mjs) there is no kernel
13
+ // package to load them from, so they come from the pinned bundle's own embedded kernel. The library never judges the
14
+ // statement itself: it checks the referrer's layout, hands the two blobs to the bundle (only after the bundle's digests
15
+ // equal the data-root pin; otherwise CatalogueVerifierNotPinnedError, before anything is spawned) and reads its verdict.
16
+
17
+ import { spawnSync } from 'node:child_process';
18
+ import { createHash } from 'node:crypto';
19
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
20
+ import { tmpdir } from 'node:os';
21
+ import { join } from 'node:path';
22
+
23
+ import { locateBuiltVerifier, readBuiltVerifierDigests } from './built-verifier.mjs';
24
+ import { editionWorkspaceConfig } from './edition-config.mjs';
25
+ import { dataRoot as configuredDataRoot } from './edition-workspace.mjs';
26
+ import { assertPinnedVerifier, readEvidenceTrustPin } from './evidence-trust-pin.mjs';
27
+ import { EvidenceTrustRequiredError } from './evidence-trust-policy.mjs';
28
+ import { EDITION_CATALOGUE_EVIDENCE_ARTIFACT_TYPE } from './oci-image-source-evidence.mjs';
29
+ import { BUNDLE_MEDIA_TYPE } from './sigstore-bundle-media-type.mjs';
30
+
31
+ const TITLE = 'org.opencontainers.image.title';
32
+ /** The titles of the two layers of an E4 referrer, the in-toto statement's media type and the annotation naming the signing workflow commit. */
33
+ export const CATALOGUE_EVIDENCE_STATEMENT_TITLE = 'statement.json';
34
+ export const CATALOGUE_EVIDENCE_BUNDLE_TITLE = 'bundle.sigstore.json';
35
+ export const CATALOGUE_EVIDENCE_STATEMENT_MEDIA_TYPE = 'application/vnd.in-toto+json';
36
+ export const CATALOGUE_EVIDENCE_WORKFLOW_SHA_ANNOTATION = 'dev.xema.evidence.signer-workflow-sha';
37
+ /** The variables the pinned verifier bundle is launched with (it refuses NODE_OPTIONS, NODE_PATH, DYLD_*, LD_*, GIT_*). */
38
+ export const VERIFIER_LAUNCH_ENV_KEYS = Object.freeze(['PATH', 'HOME', 'TMPDIR', 'LANG', 'LC_ALL', 'TZ']);
39
+ const STATEMENT_MAX_BYTES = 2 * 1024 * 1024;
40
+ const BUNDLE_MAX_BYTES = 16 * 1024 * 1024;
41
+ const SPAWN_TIMEOUT_MS = 120_000;
42
+ const COMMIT = /^[a-f0-9]{40}$/;
43
+ const sha256Of = (bytes) => `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
44
+
45
+ /** No E4 referrer is attached to the artifact: the catalogue has no signed evidence at all. */
46
+ export class CatalogueEvidenceMissingError extends Error {
47
+ constructor(ref) {
48
+ super(
49
+ `${ref} carries no ${EDITION_CATALOGUE_EVIDENCE_ARTIFACT_TYPE} referrer: its catalogue has no signed edition evidence (E4), ` +
50
+ 'so it is refused before it is read',
51
+ );
52
+ this.name = 'CatalogueEvidenceMissingError';
53
+ }
54
+ }
55
+
56
+ /** An E4 referrer exists but does not verify against the caller's evidence trust policy. */
57
+ export class CatalogueEvidenceNotVerifiedError extends Error {
58
+ constructor(ref, reason) {
59
+ super(`${ref}: the catalogue's edition evidence (E4) is not verified: ${reason}; the catalogue is refused before it is read`);
60
+ this.name = 'CatalogueEvidenceNotVerifiedError';
61
+ }
62
+ }
63
+
64
+ /** The built verifier is not the one the data root pins (or is missing): nothing is spawned. */
65
+ export class CatalogueVerifierNotPinnedError extends Error {
66
+ constructor(reason) {
67
+ super(`the catalogue's edition evidence (E4) cannot be verified: this CLI's built evidence verifier is not the one the data root pins: ${reason}`);
68
+ this.name = 'CatalogueVerifierNotPinnedError';
69
+ }
70
+ }
71
+
72
+ /** The pinned built verifier's bundle path, after the data root's pin of it held; refuses before anything is spawned. */
73
+ function pinnedVerifierBundle(locateVerifier) {
74
+ const declaration = editionWorkspaceConfig().evidenceTrustPolicy;
75
+ const root = configuredDataRoot();
76
+ if (declaration.policy === null) {throw new EvidenceTrustRequiredError(root, 'declares no policy');}
77
+ try {
78
+ const pin = readEvidenceTrustPin({ dataRoot: root, declaration });
79
+ assertPinnedVerifier({ pin, actual: readBuiltVerifierDigests(locateVerifier) });
80
+ return { bundlePath: locateVerifier().bundlePath, root };
81
+ } catch (error) {
82
+ throw new CatalogueVerifierNotPinnedError(error.message);
83
+ }
84
+ }
85
+
86
+ function launchEnvironment(source = process.env) {
87
+ const reduced = {};
88
+ for (const key of VERIFIER_LAUNCH_ENV_KEYS) {
89
+ if (source[key] !== undefined) {reduced[key] = source[key];}
90
+ }
91
+ return reduced;
92
+ }
93
+
94
+ function runVerifier({ spawn, bundlePath, args }) {
95
+ return spawn(process.execPath, [bundlePath, 'catalogue-evidence', ...args], {
96
+ encoding: 'utf8',
97
+ env: launchEnvironment(),
98
+ timeout: SPAWN_TIMEOUT_MS,
99
+ maxBuffer: 1024 * 1024,
100
+ });
101
+ }
102
+
103
+ const stderrOf = (result) => String(result.stderr ?? '').trim() || (result.error ? result.error.message : `exit ${String(result.status)}`);
104
+
105
+ /**
106
+ * Before any registry call: the caller's data root declares a policy, the built verifier is the one the pin records, and
107
+ * the policy is ARMED on `channel` with the E4 row required (the one arming rule, judged by the pinned bundle with its own
108
+ * kernel). Throws EvidenceTrustRequiredError for no policy / not armed, CatalogueVerifierNotPinnedError for a verifier the
109
+ * pin does not record.
110
+ *
111
+ * @param {{ channel: string, locateVerifier?: Function, spawn?: Function }} options
112
+ */
113
+ export function assertCatalogueConsumerArmed({ channel, locateVerifier = locateBuiltVerifier, spawn = spawnSync }) {
114
+ const { bundlePath, root } = pinnedVerifierBundle(locateVerifier);
115
+ const result = runVerifier({ spawn, bundlePath, args: ['arming', '--channel', channel, '--data-root', root] });
116
+ if (result.status === 0) {return;}
117
+ const stderr = stderrOf(result);
118
+ if (stderr.includes('EVIDENCE_TRUST_REQUIRED_UNARMED')) {
119
+ throw new EvidenceTrustRequiredError(root, `declares a policy that is not armed on the ${channel} channel for a catalogue consumer (${stderr})`);
120
+ }
121
+ throw new CatalogueEvidenceNotVerifiedError(`the ${channel} catalogue`, `the arming check failed: ${stderr}`);
122
+ }
123
+
124
+ function layerOf(layers, title, mediaType, ref) {
125
+ const found = layers.filter((layer) => layer?.annotations?.[TITLE] === title);
126
+ if (found.length !== 1 || found[0].mediaType !== mediaType || typeof found[0].digest !== 'string') {
127
+ throw new CatalogueEvidenceNotVerifiedError(ref, `the referrer must carry exactly one ${title} layer of media type ${mediaType}`);
128
+ }
129
+ return found[0];
130
+ }
131
+
132
+ async function readReferrerLayers({ client, repository, referrer, manifestDigest, ref }) {
133
+ const manifest = await client.getManifestByDigest(repository, referrer.digest);
134
+ const document = manifest.document ?? {};
135
+ if (document.artifactType !== EDITION_CATALOGUE_EVIDENCE_ARTIFACT_TYPE) {
136
+ throw new CatalogueEvidenceNotVerifiedError(ref, `referrer ${referrer.digest} is not a ${EDITION_CATALOGUE_EVIDENCE_ARTIFACT_TYPE} artifact`);
137
+ }
138
+ if (document.subject?.digest !== manifestDigest) {
139
+ throw new CatalogueEvidenceNotVerifiedError(ref, `referrer ${referrer.digest} is a referrer of ${String(document.subject?.digest)}, not of ${manifestDigest}`);
140
+ }
141
+ const layers = Array.isArray(document.layers) ? document.layers : [];
142
+ if (layers.length !== 2) {
143
+ throw new CatalogueEvidenceNotVerifiedError(ref, `referrer ${referrer.digest} must carry exactly two layers (${CATALOGUE_EVIDENCE_STATEMENT_TITLE}, ${CATALOGUE_EVIDENCE_BUNDLE_TITLE}), it carries ${layers.length}`);
144
+ }
145
+ const statement = layerOf(layers, CATALOGUE_EVIDENCE_STATEMENT_TITLE, CATALOGUE_EVIDENCE_STATEMENT_MEDIA_TYPE, ref);
146
+ const bundle = layerOf(layers, CATALOGUE_EVIDENCE_BUNDLE_TITLE, BUNDLE_MEDIA_TYPE, ref);
147
+ const workflowSha = document.annotations?.[CATALOGUE_EVIDENCE_WORKFLOW_SHA_ANNOTATION];
148
+ if (typeof workflowSha !== 'string' || !COMMIT.test(workflowSha)) {
149
+ throw new CatalogueEvidenceNotVerifiedError(ref, `referrer ${referrer.digest} states no 40-hex ${CATALOGUE_EVIDENCE_WORKFLOW_SHA_ANNOTATION} annotation`);
150
+ }
151
+ return { statement, bundle, workflowSha };
152
+ }
153
+
154
+ /**
155
+ * The seam. Hold one E4 referrer to the caller's policy and to the exact catalogue and lock layers: read its two layers
156
+ * (bounded) into a private temporary directory and have the PINNED built verifier judge them. Throws when it does not
157
+ * verify, returns when it does. The verifier's pin is re-checked here, before anything is spawned.
158
+ *
159
+ * @param {{ ref: string, client: object, repository: string, referrer: object, channel: string, offeringId: string, manifestDigest: string, catalogueLayerDigest: string, lockLayerDigest: string, cosign: string, locateVerifier?: Function, spawn?: Function }} subject
160
+ */
161
+ export async function verifyCatalogueEvidenceReferrer({
162
+ ref,
163
+ client,
164
+ repository,
165
+ referrer,
166
+ channel,
167
+ offeringId,
168
+ manifestDigest,
169
+ catalogueLayerDigest,
170
+ lockLayerDigest,
171
+ cosign,
172
+ locateVerifier = locateBuiltVerifier,
173
+ spawn = spawnSync,
174
+ }) {
175
+ if (typeof cosign !== 'string' || !cosign.startsWith('/')) {
176
+ throw new CatalogueEvidenceNotVerifiedError(ref, 'a release catalogue is verified with a cosign binary: pass --cosign <absolute path>');
177
+ }
178
+ const layers = await readReferrerLayers({ client, repository, referrer, manifestDigest, ref });
179
+ const statementBytes = await client.getBlob(repository, layers.statement.digest, STATEMENT_MAX_BYTES);
180
+ const bundleBytes = await client.getBlob(repository, layers.bundle.digest, BUNDLE_MAX_BYTES);
181
+ if (sha256Of(statementBytes) !== layers.statement.digest || sha256Of(bundleBytes) !== layers.bundle.digest) {
182
+ throw new CatalogueEvidenceNotVerifiedError(ref, 'a referrer layer does not hash to its digest');
183
+ }
184
+ const { bundlePath, root } = pinnedVerifierBundle(locateVerifier);
185
+ const evidenceRoot = mkdtempSync(join(tmpdir(), 'xema-catalogue-evidence-'));
186
+ try {
187
+ writeFileSync(join(evidenceRoot, CATALOGUE_EVIDENCE_STATEMENT_TITLE), statementBytes, { flag: 'wx', mode: 0o600 });
188
+ writeFileSync(join(evidenceRoot, CATALOGUE_EVIDENCE_BUNDLE_TITLE), bundleBytes, { flag: 'wx', mode: 0o600 });
189
+ const result = runVerifier({
190
+ spawn,
191
+ bundlePath,
192
+ args: [
193
+ 'verify',
194
+ '--channel', channel,
195
+ '--data-root', root,
196
+ '--evidence-root', evidenceRoot,
197
+ '--statement-digest', layers.statement.digest,
198
+ '--bundle-digest', layers.bundle.digest,
199
+ '--workflow-sha', layers.workflowSha,
200
+ '--offering-id', offeringId,
201
+ '--subject-digest', manifestDigest,
202
+ '--catalogue-layer-digest', catalogueLayerDigest,
203
+ '--lock-layer-digest', lockLayerDigest,
204
+ '--cosign', cosign,
205
+ ],
206
+ });
207
+ if (result.status !== 0) {throw new CatalogueEvidenceNotVerifiedError(ref, stderrOf(result));}
208
+ } finally {
209
+ rmSync(evidenceRoot, { recursive: true, force: true });
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Verify the catalogue's E4 evidence from the registry, before any catalogue byte is fetched or parsed.
215
+ *
216
+ * @param {{ client: object, ref: string, repository: string, manifestDigest: string, channel: string, offeringId: string, catalogueLayerDigest: string, lockLayerDigest: string, cosign?: string, locateVerifier?: Function, verifyReferrer?: Function }} options
217
+ */
218
+ export async function verifyCatalogueEvidence({ client, ref, repository, manifestDigest, channel, offeringId, catalogueLayerDigest, lockLayerDigest, cosign, locateVerifier, verifyReferrer = verifyCatalogueEvidenceReferrer }) {
219
+ const listed = await client.listReferrers(repository, manifestDigest, EDITION_CATALOGUE_EVIDENCE_ARTIFACT_TYPE);
220
+ const referrers = listed.filter((descriptor) => descriptor?.artifactType === EDITION_CATALOGUE_EVIDENCE_ARTIFACT_TYPE);
221
+ if (referrers.length === 0) {throw new CatalogueEvidenceMissingError(ref);}
222
+ for (const referrer of referrers) {
223
+ await verifyReferrer({ ref, client, repository, referrer, channel, offeringId, manifestDigest, catalogueLayerDigest, lockLayerDigest, cosign, ...(locateVerifier === undefined ? {} : { locateVerifier }) });
224
+ }
225
+ }
@@ -13,9 +13,10 @@
13
13
 
14
14
  import { Buffer } from 'node:buffer';
15
15
 
16
+ import { verifyCatalogueEvidence } from './catalogue-evidence.mjs';
16
17
  import { CATALOGUE_FILE, CatalogueError, parseCatalogue } from './catalogue.mjs';
17
18
  import { parseEditionRepository } from './edition-artifact.mjs';
18
- import { EDITION_ARTIFACT_TYPE } from './edition-constants.mjs';
19
+ import { EDITION_ARTIFACT_TYPE, EditionChannel, EditionLockFile } from './edition-constants.mjs';
19
20
  import { OrasRegistry } from './oras.mjs';
20
21
  import { splitImageRef } from './registry-credentials.mjs';
21
22
 
@@ -55,8 +56,23 @@ export function catalogueCredential({ authority, username, tokenEnv, env = proce
55
56
  return { authority, access: 'authenticated', basicAuth: Buffer.from(`${username}:${token}`, 'utf8').toString('base64') };
56
57
  }
57
58
 
58
- /** Fetch and strictly parse the catalogue of the artifact at `ref`, resolving for `channel`. `client` is injectable for tests. */
59
- export async function fetchCatalogue({ ref, channel, username, tokenEnv, env = process.env, client }) {
59
+ /** The one layer of `manifest` titled `title`; an offering artifact carries exactly one of each. */
60
+ export function layerTitled(manifest, title, ref) {
61
+ const layers = (manifest.document.layers ?? []).filter((layer) => layer.annotations?.[TITLE] === title);
62
+ if (layers.length !== 1) {
63
+ throw new CatalogueError(`${ref} carries ${layers.length} layers titled ${title}; an offering artifact carries exactly one`);
64
+ }
65
+ return layers[0];
66
+ }
67
+
68
+ /**
69
+ * Fetch and strictly parse the catalogue of the artifact at `ref`, resolving for `channel`. A RELEASE catalogue is read
70
+ * only after its E4 evidence verified against the caller's own evidence trust policy (catalogue-evidence.mjs): the
71
+ * verification precedes the blob fetch and the parse, so an unverified catalogue is never read. It is done by the data
72
+ * root's pinned built verifier, with the absolute `cosign` the caller names. `client` and `verifyEvidence` are injectable
73
+ * for tests.
74
+ */
75
+ export async function fetchCatalogue({ ref, channel, username, tokenEnv, env = process.env, client, cosign, locateVerifier, verifyEvidence = verifyCatalogueEvidence }) {
60
76
  const { authority, repository, digest, edition } = parseCatalogueRef(ref);
61
77
  if (edition.channel !== channel) {
62
78
  throw new CatalogueError(
@@ -69,11 +85,22 @@ export async function fetchCatalogue({ ref, channel, username, tokenEnv, env = p
69
85
  if (manifest.isIndex || manifest.document.artifactType !== EDITION_ARTIFACT_TYPE) {
70
86
  throw new CatalogueError(`${ref} is not an edition artifact (artifactType ${JSON.stringify(manifest.document.artifactType)}, expected ${EDITION_ARTIFACT_TYPE})`);
71
87
  }
72
- const layers = (manifest.document.layers ?? []).filter((layer) => layer.annotations?.[TITLE] === CATALOGUE_FILE);
73
- if (layers.length !== 1) {
74
- throw new CatalogueError(`${ref} carries ${layers.length} layers titled ${CATALOGUE_FILE}; an offering artifact carries exactly one`);
88
+ const catalogueLayer = layerTitled(manifest, CATALOGUE_FILE, ref);
89
+ if (channel === EditionChannel.Release) {
90
+ await verifyEvidence({
91
+ client: registry,
92
+ ref,
93
+ repository,
94
+ manifestDigest: digest,
95
+ channel,
96
+ offeringId: edition.distributionId,
97
+ cosign,
98
+ locateVerifier,
99
+ catalogueLayerDigest: catalogueLayer.digest,
100
+ lockLayerDigest: layerTitled(manifest, EditionLockFile.Distribution, ref).digest,
101
+ });
75
102
  }
76
- const bytes = await registry.getBlob(repository, layers[0].digest, CATALOGUE_MAX_BYTES);
103
+ const bytes = await registry.getBlob(repository, catalogueLayer.digest, CATALOGUE_MAX_BYTES);
77
104
  let document;
78
105
  try {
79
106
  document = JSON.parse(bytes.toString('utf8'));
@@ -81,6 +108,12 @@ export async function fetchCatalogue({ ref, channel, username, tokenEnv, env = p
81
108
  throw new CatalogueError(`${ref}: ${CATALOGUE_FILE} is not JSON: ${error.message}`);
82
109
  }
83
110
  const catalogue = parseCatalogue(document);
111
+ if (catalogue.offeringId !== edition.distributionId) {
112
+ throw new CatalogueError(
113
+ `${ref}: ${CATALOGUE_FILE} is the catalogue of offering ${catalogue.offeringId}, but it was fetched from the repository of offering ${edition.distributionId}; ` +
114
+ 'an artifact whose stated offering differs from its repository is refused',
115
+ );
116
+ }
84
117
  if (catalogue.channel !== edition.channel) {
85
118
  throw new CatalogueError(
86
119
  `${ref}: ${CATALOGUE_FILE} states channel ${catalogue.channel}, but it was fetched from the ${edition.channel} repository ${authority}/${repository}; ` +