@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.
- package/dist/commands/edition.d.ts.map +1 -1
- package/dist/commands/edition.js +1 -0
- package/dist/commands/edition.js.map +1 -1
- package/dist/edition/verify-image-evidence.bundle.mjs +281 -9
- package/dist/edition/verify-image-evidence.bundle.provenance.json +66 -48
- package/dist/runtime/edition/verb-map.d.ts +3 -1
- package/dist/runtime/edition/verb-map.d.ts.map +1 -1
- package/dist/runtime/edition/verb-map.js +4 -0
- package/dist/runtime/edition/verb-map.js.map +1 -1
- package/edition/README.md +6 -0
- package/edition/build-biome-index.mjs +9 -4
- package/edition/build-runtime-image-source-index.mjs +165 -37
- package/edition/catalogue-evidence.mjs +225 -0
- package/edition/catalogue-fetch.mjs +40 -7
- package/edition/customer-edition-verbs.mjs +477 -0
- package/edition/customer-flags.mjs +2 -0
- package/edition/develop-coverage-readers.mjs +212 -0
- package/edition/develop-coverage-run.mjs +48 -0
- package/edition/develop-coverage-verdict.mjs +153 -0
- package/edition/develop-coverage.mjs +120 -0
- package/edition/develop-edition-resolve.mjs +18 -7
- package/edition/develop-edition.mjs +47 -220
- package/edition/develop-freeze.mjs +50 -13
- package/edition/develop-image-probe.mjs +7 -7
- package/edition/edition-artifact.mjs +31 -14
- package/edition/edition-constants.mjs +9 -0
- package/edition/edition-contract.json +2 -1
- package/edition/{develop-seal.mjs → edition-image-seal.mjs} +36 -17
- package/edition/edition-workspace.mjs +4 -3
- package/edition/evidence-trust-pin.mjs +28 -8
- package/edition/evidence-trust-policy.mjs +51 -7
- package/edition/image-evidence.mjs +2 -2
- package/edition/indexes-at-pins.mjs +5 -21
- package/edition/lib.mjs +7 -0
- package/edition/oci-image-source-evidence.mjs +3 -0
- package/edition/oras-process.mjs +3 -3
- package/edition/oras.mjs +47 -5
- package/edition/produce-edition-catalogue-evidence.mjs +252 -0
- package/edition/produce-image-source-evidence.mjs +11 -2
- package/edition/release-edition.mjs +54 -0
- package/edition/repin-evidence-trust.mjs +5 -3
- package/edition/sigstore-bundle-media-type.mjs +4 -0
- package/edition/source-authority.mjs +2 -1
- package/edition/trust-policy-arming.mjs +14 -1
- package/edition/verify-catalogue-evidence.mjs +245 -0
- package/edition/verify-image-evidence.mjs +15 -1
- package/package.json +2 -2
- 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
|
|
902
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
930
|
-
|
|
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
|
-
|
|
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(
|
|
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 ${
|
|
943
|
-
|
|
944
|
-
? '
|
|
945
|
-
:
|
|
946
|
-
exactCommit === undefined
|
|
947
|
-
|
|
948
|
-
|
|
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
|
-
/**
|
|
59
|
-
export
|
|
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
|
|
73
|
-
if (
|
|
74
|
-
|
|
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,
|
|
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}; ` +
|