@xemahq/xema 0.25.1 → 0.27.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/README.md +70 -37
- package/dist/assets/appliance-base/images.lock.json +126 -126
- package/dist/assets/appliance-lean/images.lock.json +123 -123
- package/dist/assets/cloud/images.lock.json +126 -126
- package/dist/assets/core/images.lock.json +120 -120
- package/dist/assets/distribution-assets.manifest.json +15 -16
- package/dist/assets/minimal/images.lock.json +108 -108
- package/dist/assets/oss/images.lock.json +126 -126
- package/dist/assets/starter/images.lock.json +123 -123
- package/dist/cli.js +2 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/dev.js +1 -0
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/down.d.ts.map +1 -1
- package/dist/commands/down.js +7 -1
- package/dist/commands/down.js.map +1 -1
- package/dist/commands/edition.d.ts +3 -0
- package/dist/commands/edition.d.ts.map +1 -0
- package/dist/commands/edition.js +46 -0
- package/dist/commands/edition.js.map +1 -0
- package/dist/commands/init.js +2 -2
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/serve.js +1 -0
- package/dist/commands/serve.js.map +1 -1
- package/dist/commands/up.js +1 -0
- package/dist/commands/up.js.map +1 -1
- package/dist/dev/env-defaults.d.ts.map +1 -1
- package/dist/dev/env-defaults.js +6 -1
- package/dist/dev/env-defaults.js.map +1 -1
- package/dist/dev/infra-preflight.d.ts +3 -2
- package/dist/dev/infra-preflight.d.ts.map +1 -1
- package/dist/dev/infra-preflight.js +18 -8
- package/dist/dev/infra-preflight.js.map +1 -1
- package/dist/edition/verify-image-evidence.bundle.mjs +54179 -0
- package/dist/edition/verify-image-evidence.bundle.provenance.json +2871 -0
- package/dist/runtime/backing-registration.d.ts.map +1 -1
- package/dist/runtime/backing-registration.js +7 -2
- package/dist/runtime/backing-registration.js.map +1 -1
- package/dist/runtime/config/schema.d.ts +1 -0
- package/dist/runtime/config/schema.d.ts.map +1 -1
- package/dist/runtime/custody/local-custody.d.ts +12 -0
- package/dist/runtime/custody/local-custody.d.ts.map +1 -0
- package/dist/runtime/custody/local-custody.js +22 -0
- package/dist/runtime/custody/local-custody.js.map +1 -0
- package/dist/runtime/custody/openbao-custody-bootstrap.d.ts +18 -0
- package/dist/runtime/custody/openbao-custody-bootstrap.d.ts.map +1 -0
- package/dist/runtime/custody/openbao-custody-bootstrap.js +50 -0
- package/dist/runtime/custody/openbao-custody-bootstrap.js.map +1 -0
- package/dist/runtime/custody/openbao-custody-store.d.ts +18 -0
- package/dist/runtime/custody/openbao-custody-store.d.ts.map +1 -0
- package/dist/runtime/custody/openbao-custody-store.js +55 -0
- package/dist/runtime/custody/openbao-custody-store.js.map +1 -0
- package/dist/runtime/custody/openbao-http-client.d.ts +34 -0
- package/dist/runtime/custody/openbao-http-client.d.ts.map +1 -0
- package/dist/runtime/custody/openbao-http-client.js +154 -0
- package/dist/runtime/custody/openbao-http-client.js.map +1 -0
- package/dist/runtime/custody/openbao-local-constants.d.ts +22 -0
- package/dist/runtime/custody/openbao-local-constants.d.ts.map +1 -0
- package/dist/runtime/custody/openbao-local-constants.js +48 -0
- package/dist/runtime/custody/openbao-local-constants.js.map +1 -0
- package/dist/runtime/custody/openbao-local-runner.d.ts +16 -0
- package/dist/runtime/custody/openbao-local-runner.d.ts.map +1 -0
- package/dist/runtime/custody/openbao-local-runner.js +86 -0
- package/dist/runtime/custody/openbao-local-runner.js.map +1 -0
- package/dist/runtime/edition/run-entry.d.ts +9 -0
- package/dist/runtime/edition/run-entry.d.ts.map +1 -0
- package/dist/runtime/edition/run-entry.js +36 -0
- package/dist/runtime/edition/run-entry.js.map +1 -0
- package/dist/runtime/edition/verb-map.d.ts +60 -0
- package/dist/runtime/edition/verb-map.d.ts.map +1 -0
- package/dist/runtime/edition/verb-map.js +146 -0
- package/dist/runtime/edition/verb-map.js.map +1 -0
- package/dist/runtime/env-seeder.d.ts +3 -0
- package/dist/runtime/env-seeder.d.ts.map +1 -1
- package/dist/runtime/env-seeder.js +20 -3
- package/dist/runtime/env-seeder.js.map +1 -1
- package/dist/runtime/launch.d.ts +3 -1
- package/dist/runtime/launch.d.ts.map +1 -1
- package/dist/runtime/launch.js +37 -8
- package/dist/runtime/launch.js.map +1 -1
- package/dist/runtime/paths.d.ts +1 -2
- package/dist/runtime/paths.d.ts.map +1 -1
- package/dist/runtime/paths.js +0 -10
- package/dist/runtime/paths.js.map +1 -1
- package/dist/runtime/runner/chart-local-provenance.d.ts +20 -0
- package/dist/runtime/runner/chart-local-provenance.d.ts.map +1 -0
- package/dist/runtime/runner/chart-local-provenance.js +114 -0
- package/dist/runtime/runner/chart-local-provenance.js.map +1 -0
- package/dist/runtime/runner/chart-preflight.d.ts +8 -8
- package/dist/runtime/runner/chart-preflight.d.ts.map +1 -1
- package/dist/runtime/runner/chart-preflight.js +30 -69
- package/dist/runtime/runner/chart-preflight.js.map +1 -1
- package/dist/runtime/runner/chart-pull.d.ts +14 -0
- package/dist/runtime/runner/chart-pull.d.ts.map +1 -0
- package/dist/runtime/runner/chart-pull.js +96 -0
- package/dist/runtime/runner/chart-pull.js.map +1 -0
- package/dist/runtime/runner/chart-resolver.d.ts +4 -12
- package/dist/runtime/runner/chart-resolver.d.ts.map +1 -1
- package/dist/runtime/runner/chart-resolver.js +16 -62
- package/dist/runtime/runner/chart-resolver.js.map +1 -1
- package/dist/runtime/runner/chart-set-lock.d.ts +6 -20
- package/dist/runtime/runner/chart-set-lock.d.ts.map +1 -1
- package/dist/runtime/runner/chart-set-lock.js +55 -99
- package/dist/runtime/runner/chart-set-lock.js.map +1 -1
- package/dist/runtime/runner/helm-cli.d.ts +3 -1
- package/dist/runtime/runner/helm-cli.d.ts.map +1 -1
- package/dist/runtime/runner/helm-cli.js +9 -7
- package/dist/runtime/runner/helm-cli.js.map +1 -1
- package/dist/runtime/runner/helm-runner.d.ts +4 -4
- package/dist/runtime/runner/helm-runner.d.ts.map +1 -1
- package/dist/runtime/runner/helm-runner.js +18 -16
- package/dist/runtime/runner/helm-runner.js.map +1 -1
- package/dist/runtime/sources/kinds/git.d.ts +1 -2
- package/dist/runtime/sources/kinds/git.d.ts.map +1 -1
- package/dist/runtime/sources/kinds/git.js +8 -5
- package/dist/runtime/sources/kinds/git.js.map +1 -1
- package/dist/runtime/sources/manifest-walk.d.ts +3 -1
- package/dist/runtime/sources/manifest-walk.d.ts.map +1 -1
- package/dist/runtime/sources/manifest-walk.js +8 -3
- package/dist/runtime/sources/manifest-walk.js.map +1 -1
- package/dist/runtime/sources/schema.d.ts +3 -0
- package/dist/runtime/sources/schema.d.ts.map +1 -1
- package/dist/runtime/sources/schema.js +6 -1
- package/dist/runtime/sources/schema.js.map +1 -1
- package/dist/runtime/supervisor.d.ts +3 -0
- package/dist/runtime/supervisor.d.ts.map +1 -1
- package/dist/runtime/supervisor.js +2 -0
- package/dist/runtime/supervisor.js.map +1 -1
- package/edition/README.md +315 -0
- package/edition/assemble-producer-input.mjs +1251 -0
- package/edition/authorization-base.mjs +347 -0
- package/edition/biome-content.mjs +268 -0
- package/edition/build-biome-index.mjs +175 -0
- package/edition/build-platform-service-index.mjs +288 -0
- package/edition/build-runtime-image-source-index.mjs +821 -0
- package/edition/built-verifier.mjs +55 -0
- package/edition/catalogue-edition.mjs +49 -0
- package/edition/catalogue-fetch.mjs +64 -0
- package/edition/catalogue.mjs +135 -0
- package/edition/chart-set-source-commit.mjs +92 -0
- package/edition/check-committed-image-evidence.mjs +383 -0
- package/edition/checkout-indexed-source-repositories.mjs +105 -0
- package/edition/comment-blanker.mjs +75 -0
- package/edition/connector-source-authority.mjs +60 -0
- package/edition/customer-definition.mjs +183 -0
- package/edition/customer-flags.mjs +7 -0
- package/edition/develop-customer-edition.mjs +99 -0
- package/edition/develop-edition-resolve.mjs +65 -0
- package/edition/develop-edition.mjs +274 -0
- package/edition/develop-freeze.mjs +173 -0
- package/edition/develop-image-coordinate.mjs +54 -0
- package/edition/develop-seal.mjs +77 -0
- package/edition/develop-snapshot-inputs.mjs +104 -0
- package/edition/dist-check.mjs +986 -0
- package/edition/distribution-evidence-set.mjs +987 -0
- package/edition/distribution-paths.mjs +191 -0
- package/edition/edition-artifact.mjs +270 -0
- package/edition/edition-biome-manifests.mjs +85 -0
- package/edition/edition-channel.mjs +6 -0
- package/edition/edition-chart-set.mjs +93 -0
- package/edition/edition-config.mjs +207 -0
- package/edition/edition-constants.mjs +57 -0
- package/edition/edition-contract.json +23 -0
- package/edition/edition-list.mjs +35 -0
- package/edition/edition-locks.mjs +69 -0
- package/edition/edition-protocol.mjs +228 -0
- package/edition/edition-workspace.mjs +176 -0
- package/edition/evidence-checkout.mjs +418 -0
- package/edition/evidence-grammars.mjs +16 -0
- package/edition/evidence-trust-pin.mjs +304 -0
- package/edition/evidence-trust-policy.mjs +256 -0
- package/edition/external-source-walk.mjs +463 -0
- package/edition/first-party-source-ids.mjs +59 -0
- package/edition/fleet-coordinate-finality.mjs +350 -0
- package/edition/git-source-auth.mjs +52 -0
- package/edition/image-evidence-replay-state.mjs +242 -0
- package/edition/image-evidence.mjs +1225 -0
- package/edition/kernel-contract-authority.mjs +443 -0
- package/edition/lib.mjs +3018 -0
- package/edition/no-follow-file.mjs +94 -0
- package/edition/oci-evidence-verbs.mjs +392 -0
- package/edition/oci-image-source-evidence.mjs +239 -0
- package/edition/oras.mjs +648 -0
- package/edition/pin-source.mjs +227 -0
- package/edition/plan-image-evidence-refresh.mjs +408 -0
- package/edition/platform-lock-readability.mjs +892 -0
- package/edition/platform-service-class.mjs +18 -0
- package/edition/platform-service-source-authority.mjs +105 -0
- package/edition/positive-closure.mjs +162 -0
- package/edition/produce-distribution-completeness-evidence.mjs +311 -0
- package/edition/produce-image-delivery-entitlement-evidence.mjs +294 -0
- package/edition/produce-image-source-evidence.mjs +765 -0
- package/edition/producer-evidence-bundle.mjs +157 -0
- package/edition/registry-credentials.mjs +193 -0
- package/edition/release-catalogue.mjs +39 -0
- package/edition/release-channel-guard.mjs +99 -0
- package/edition/release-edition-artifact.mjs +158 -0
- package/edition/remedy-command.mjs +89 -0
- package/edition/repin-evidence-trust.mjs +101 -0
- package/edition/required-resource-provider.mjs +58 -0
- package/edition/resolve-chart-set.mjs +415 -0
- package/edition/resolve-customer-edition.mjs +288 -0
- package/edition/resolve-distribution.mjs +2318 -0
- package/edition/resolve-edition.mjs +186 -0
- package/edition/resolve-images.mjs +1606 -0
- package/edition/seal-image-digests.mjs +855 -0
- package/edition/sealed-evidence-accept.mjs +82 -0
- package/edition/source-authority.mjs +725 -0
- package/edition/source-closure.mjs +116 -0
- package/edition/strict-json.mjs +121 -0
- package/edition/string-order.mjs +5 -0
- package/edition/trust-policy-arming.mjs +120 -0
- package/edition/trusted-executable.mjs +166 -0
- package/edition/verifier-binding.mjs +45 -0
- package/edition/verifier-files.mjs +8 -0
- package/edition/verifier-kernel-schemas.mjs +22 -0
- package/edition/verifier-provenance.mjs +64 -0
- package/edition/verify-image-evidence.mjs +430 -0
- package/edition/verify-image-platforms.mjs +495 -0
- package/edition/why.mjs +56 -0
- package/edition/workspace-sections.mjs +195 -0
- package/examples/06-biome-sources.yaml +1 -0
- package/package.json +18 -7
- package/dist/commands/dist.d.ts +0 -16
- package/dist/commands/dist.d.ts.map +0 -1
- package/dist/commands/dist.js +0 -182
- package/dist/commands/dist.js.map +0 -1
- package/dist/runtime/distribution/available-biome.d.ts +0 -10
- package/dist/runtime/distribution/available-biome.d.ts.map +0 -1
- package/dist/runtime/distribution/available-biome.js +0 -154
- package/dist/runtime/distribution/available-biome.js.map +0 -1
- package/dist/runtime/distribution/compose.d.ts +0 -23
- package/dist/runtime/distribution/compose.d.ts.map +0 -1
- package/dist/runtime/distribution/compose.js +0 -48
- package/dist/runtime/distribution/compose.js.map +0 -1
- package/dist/runtime/runner/chart-trust.d.ts +0 -57
- package/dist/runtime/runner/chart-trust.d.ts.map +0 -1
- package/dist/runtime/runner/chart-trust.js +0 -270
- package/dist/runtime/runner/chart-trust.js.map +0 -1
|
@@ -0,0 +1,987 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// THE DISTRIBUTION-LEVEL EVIDENCE SET — ONE OWNER FOR ITS SHAPE
|
|
3
|
+
//
|
|
4
|
+
// `produce-image-source-evidence.mjs` signs ONE statement per image, in the
|
|
5
|
+
// repository that built it. This module owns everything that happens ABOVE
|
|
6
|
+
// that: gathering those per-image bundles for one edition, stating that the
|
|
7
|
+
// set is COMPLETE, entitling a customer to a delivery of it, and assembling
|
|
8
|
+
// the `evidence-set.json` the offline verifier reads.
|
|
9
|
+
//
|
|
10
|
+
// It exists as its own module because three entrypoints need exactly the
|
|
11
|
+
// same shapes and would otherwise each grow their own copy:
|
|
12
|
+
//
|
|
13
|
+
// * produce-distribution-completeness-evidence.mjs (signs completeness)
|
|
14
|
+
// * produce-image-delivery-entitlement-evidence.mjs (signs entitlement)
|
|
15
|
+
// * distribution-evidence-producer-contract-tests.mjs (proves both agree
|
|
16
|
+
// with the VERIFIER's own binding validators)
|
|
17
|
+
//
|
|
18
|
+
// Every shape here is parsed through the kernel schema before it is returned,
|
|
19
|
+
// so a statement this module builds and a statement the verifier accepts are
|
|
20
|
+
// the same object by construction, not by review.
|
|
21
|
+
//
|
|
22
|
+
// WHAT IS DELIBERATELY *NOT* HERE
|
|
23
|
+
//
|
|
24
|
+
// Signature verification. Staging reads committed bundles and proves they are
|
|
25
|
+
// internally consistent (descriptor digests, DSSE payload equality, kernel
|
|
26
|
+
// schema, exact source coordinate) — it does NOT verify Sigstore signatures.
|
|
27
|
+
// That is `image-evidence.mjs verifyDistributionImageEvidenceSet`, offline,
|
|
28
|
+
// against the armed trust policy, at delivery. Re-implementing it here would
|
|
29
|
+
// be a second trust decision in a place with no trust anchor.
|
|
30
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
31
|
+
|
|
32
|
+
import { createHash } from 'node:crypto';
|
|
33
|
+
import {
|
|
34
|
+
mkdirSync,
|
|
35
|
+
readFileSync,
|
|
36
|
+
readdirSync,
|
|
37
|
+
writeFileSync,
|
|
38
|
+
} from 'node:fs';
|
|
39
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
40
|
+
|
|
41
|
+
import { assertCanonicalDistributionId } from './distribution-paths.mjs';
|
|
42
|
+
import { editionWorkspaceConfig } from './edition-config.mjs';
|
|
43
|
+
import { COMMIT_PATTERN, SHA256_DIGEST_PATTERN } from './evidence-grammars.mjs';
|
|
44
|
+
import {
|
|
45
|
+
loadPinnedDeclaredTrustPolicy,
|
|
46
|
+
} from './evidence-trust-pin.mjs';
|
|
47
|
+
import {
|
|
48
|
+
EVIDENCE_TREE_ROOT,
|
|
49
|
+
canonicalJson,
|
|
50
|
+
canonicalJsonBytes,
|
|
51
|
+
compareCompletenessImages,
|
|
52
|
+
decodeExactUtf8,
|
|
53
|
+
evidenceDirectoryForDigest,
|
|
54
|
+
loadValidatedLocks,
|
|
55
|
+
normalizedImageLockDigest,
|
|
56
|
+
sha256Digest,
|
|
57
|
+
sourceIdentity,
|
|
58
|
+
} from './image-evidence.mjs';
|
|
59
|
+
import { assertKernelContractAuthorityEqual } from './kernel-contract-authority.mjs';
|
|
60
|
+
import { compareStrings } from './lib.mjs';
|
|
61
|
+
import { readBoundedNoFollowFile } from './no-follow-file.mjs';
|
|
62
|
+
import {
|
|
63
|
+
createRegistryClientResolver,
|
|
64
|
+
resolveImageSourceEvidenceReferrer,
|
|
65
|
+
} from './oci-image-source-evidence.mjs';
|
|
66
|
+
import { BUNDLE_MEDIA_TYPE, canonicalSignedBundleBytes, cosignVerifyAttestationArgs, validateProducedBundle } from './produce-image-source-evidence.mjs';
|
|
67
|
+
import { splitImageRef } from './registry-credentials.mjs';
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The ONE Sigstore bundle media type this plane stores — re-exported, never
|
|
71
|
+
* re-declared. The kernel's `SigstoreEvidenceDescriptorSchema` accepts this
|
|
72
|
+
* literal and nothing else, and `produce-image-source-evidence.mjs` owns it.
|
|
73
|
+
*/
|
|
74
|
+
export { BUNDLE_MEDIA_TYPE };
|
|
75
|
+
|
|
76
|
+
/** Same bound `check-committed-image-evidence.mjs` reads committed files at. */
|
|
77
|
+
const MAX_EVIDENCE_FILE_BYTES = 4 * 1024 * 1024;
|
|
78
|
+
|
|
79
|
+
/** `evidence/sha256-<64 hex>` — the shape `evidenceDirectoryForDigest` emits. */
|
|
80
|
+
const EVIDENCE_DIRECTORY_PATTERN = /^sha256-[a-f0-9]{64}$/;
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
function assertAbsolutePath(value, label) {
|
|
84
|
+
if (typeof value !== 'string' || !isAbsolute(value)) {
|
|
85
|
+
throw new Error(`${label} must be an absolute path`);
|
|
86
|
+
}
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The issuance window a statement may carry.
|
|
92
|
+
*
|
|
93
|
+
* `image-evidence.mjs assertTimeWindow` is the VERIFIER's copy of this rule and
|
|
94
|
+
* additionally compares against `now`. A producer cannot use it: it signs at
|
|
95
|
+
* issuance, so "not yet valid" and "expired" are not decidable statements about
|
|
96
|
+
* its own output — but the MAXIMUM TTL is, and refusing an over-long TTL here
|
|
97
|
+
* is what stops a statement that can never verify from being signed at all.
|
|
98
|
+
*/
|
|
99
|
+
function assertIssuanceWindow({ issuedAt, expiresAt, maxTtlSeconds, label }) {
|
|
100
|
+
const issued = Date.parse(issuedAt);
|
|
101
|
+
const expires = Date.parse(expiresAt);
|
|
102
|
+
if (!Number.isFinite(issued) || !Number.isFinite(expires)) {
|
|
103
|
+
throw new Error(`${label} has an invalid issuedAt/expiresAt`);
|
|
104
|
+
}
|
|
105
|
+
if (!Number.isSafeInteger(maxTtlSeconds) || maxTtlSeconds <= 0) {
|
|
106
|
+
throw new Error(`${label} requires a positive maximum TTL from the policy`);
|
|
107
|
+
}
|
|
108
|
+
if (expires <= issued) {
|
|
109
|
+
throw new Error(`${label} expiresAt must be after issuedAt`);
|
|
110
|
+
}
|
|
111
|
+
if (expires - issued > maxTtlSeconds * 1000) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`${label} TTL ${Math.floor((expires - issued) / 1000)}s exceeds the ` +
|
|
114
|
+
`policy maximum of ${maxTtlSeconds}s`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The canonical order of an evidence subject list — the VERIFIER's own
|
|
121
|
+
* comparator, re-exported. `validateCompletenessStatementBinding` refuses a
|
|
122
|
+
* statement whose `images[]` is not in this order, so a second copy here would
|
|
123
|
+
* be a comparator a signature is bound to, drifting from the one that judges it.
|
|
124
|
+
*/
|
|
125
|
+
export { compareCompletenessImages as compareEvidenceSubjects };
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Deterministic per-subject artifact stem inside a staged evidence root.
|
|
129
|
+
* Keyed by the same `kind\0id` identity `image-evidence.mjs` uses everywhere
|
|
130
|
+
* else, so two subjects can never collide and one subject can never move.
|
|
131
|
+
*/
|
|
132
|
+
function subjectStem(subject) {
|
|
133
|
+
return createHash('sha256')
|
|
134
|
+
.update(sourceIdentity(subject))
|
|
135
|
+
.digest('hex');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Nonces are DERIVED from run identity, never random.
|
|
140
|
+
*
|
|
141
|
+
* Same construction as `evidenceNonce` in `produce-image-source-evidence.mjs`,
|
|
142
|
+
* with its own domain-separation prefix so a completeness nonce can never
|
|
143
|
+
* collide with a producer or entitlement nonce — the verifier keeps ONE
|
|
144
|
+
* consumed-nonce set across all three and rejects a reuse
|
|
145
|
+
* (`validateCompletenessStatementBinding` refuses a statement that replays a
|
|
146
|
+
* producer nonce).
|
|
147
|
+
*
|
|
148
|
+
* Derivation, not randomness, is what makes a re-run of the SAME release
|
|
149
|
+
* byte-identical: a random nonce would make every retry a distinct statement
|
|
150
|
+
* and the replay state could never tell a retry from a rollback attempt.
|
|
151
|
+
*/
|
|
152
|
+
function derivedNonce(domain, parts) {
|
|
153
|
+
const hash = createHash('sha256').update(`${domain}\0`);
|
|
154
|
+
for (const part of parts) {
|
|
155
|
+
hash.update(String(part)).update('\0');
|
|
156
|
+
}
|
|
157
|
+
return hash.digest('base64url');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
161
|
+
// Reading the committed producer evidence of every producing repository
|
|
162
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
function readCommittedArtifact(evidenceDirectory, relativePath, label) {
|
|
165
|
+
return readBoundedNoFollowFile(
|
|
166
|
+
evidenceDirectory,
|
|
167
|
+
relativePath,
|
|
168
|
+
label,
|
|
169
|
+
MAX_EVIDENCE_FILE_BYTES,
|
|
170
|
+
).bytes;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Index every committed producer bundle reachable from the given producing
|
|
175
|
+
* repository checkouts, keyed by the EXACT source coordinate its statement
|
|
176
|
+
* attests (`canonicalJson(predicate.source)`).
|
|
177
|
+
*
|
|
178
|
+
* `onDefect` is REQUIRED and has no default: the two callers make opposite —
|
|
179
|
+
* and both correct — decisions about a malformed committed bundle. Staging must
|
|
180
|
+
* abort (it is about to build a release around this evidence); the refresh
|
|
181
|
+
* planner must classify and continue (its entire job is reporting what is
|
|
182
|
+
* broken). A default here would silently give one of them the other's posture.
|
|
183
|
+
*
|
|
184
|
+
* @returns {Map<string, Array<object>>}
|
|
185
|
+
*/
|
|
186
|
+
export function indexCommittedProducerEvidence({
|
|
187
|
+
producerRepositoryRoots,
|
|
188
|
+
schemas,
|
|
189
|
+
onDefect,
|
|
190
|
+
}) {
|
|
191
|
+
if (
|
|
192
|
+
!Array.isArray(producerRepositoryRoots) ||
|
|
193
|
+
producerRepositoryRoots.length === 0
|
|
194
|
+
) {
|
|
195
|
+
throw new Error('at least one producing repository root is required');
|
|
196
|
+
}
|
|
197
|
+
if (typeof onDefect !== 'function') {
|
|
198
|
+
throw new Error(
|
|
199
|
+
'a defect handler is required — staging and planning must not share a default posture',
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (!schemas?.parseImageProducerStatement) {
|
|
203
|
+
throw new Error('trusted kernel distribution schemas are required');
|
|
204
|
+
}
|
|
205
|
+
const seenRoots = new Set();
|
|
206
|
+
const index = new Map();
|
|
207
|
+
for (const root of producerRepositoryRoots) {
|
|
208
|
+
assertAbsolutePath(root, 'producing repository root');
|
|
209
|
+
if (seenRoots.has(root)) {
|
|
210
|
+
throw new Error(`producing repository root ${root} was given twice`);
|
|
211
|
+
}
|
|
212
|
+
seenRoots.add(root);
|
|
213
|
+
const evidenceRoot = join(root, EVIDENCE_TREE_ROOT);
|
|
214
|
+
let entries;
|
|
215
|
+
try {
|
|
216
|
+
entries = readdirSync(evidenceRoot, { withFileTypes: true });
|
|
217
|
+
} catch {
|
|
218
|
+
// A producing repository that has never produced evidence is not a
|
|
219
|
+
// defect — it is exactly the ABSENT verdict the planner must report and
|
|
220
|
+
// the "no matching bundle" failure staging must raise, per subject.
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
for (const entry of [...entries].sort((left, right) =>
|
|
224
|
+
compareStrings(left.name, right.name),
|
|
225
|
+
)) {
|
|
226
|
+
const directory = join(evidenceRoot, entry.name);
|
|
227
|
+
if (entry.isSymbolicLink()) {
|
|
228
|
+
onDefect({
|
|
229
|
+
root,
|
|
230
|
+
directory,
|
|
231
|
+
subjectKey: null,
|
|
232
|
+
reason: `${EVIDENCE_TREE_ROOT}/${entry.name} is a symlink`,
|
|
233
|
+
});
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (!entry.isDirectory()) {continue;}
|
|
237
|
+
if (!EVIDENCE_DIRECTORY_PATTERN.test(entry.name)) {
|
|
238
|
+
onDefect({
|
|
239
|
+
root,
|
|
240
|
+
directory,
|
|
241
|
+
subjectKey: null,
|
|
242
|
+
reason: `${EVIDENCE_TREE_ROOT}/${entry.name} is not a sha256-<digest> evidence directory`,
|
|
243
|
+
});
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
indexOneEvidenceDirectory({
|
|
247
|
+
root,
|
|
248
|
+
directory,
|
|
249
|
+
directoryName: entry.name,
|
|
250
|
+
schemas,
|
|
251
|
+
onDefect,
|
|
252
|
+
index,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return index;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function indexOneEvidenceDirectory({
|
|
260
|
+
root,
|
|
261
|
+
directory,
|
|
262
|
+
directoryName,
|
|
263
|
+
schemas,
|
|
264
|
+
onDefect,
|
|
265
|
+
index,
|
|
266
|
+
}) {
|
|
267
|
+
let indexDocument;
|
|
268
|
+
try {
|
|
269
|
+
indexDocument = JSON.parse(
|
|
270
|
+
decodeExactUtf8(
|
|
271
|
+
readCommittedArtifact(directory, 'producer-evidence.json', 'producer index'),
|
|
272
|
+
'producer index',
|
|
273
|
+
),
|
|
274
|
+
);
|
|
275
|
+
} catch (error) {
|
|
276
|
+
onDefect({
|
|
277
|
+
root,
|
|
278
|
+
directory,
|
|
279
|
+
subjectKey: null,
|
|
280
|
+
reason: `producer-evidence.json is missing or unreadable: ${error.message}`,
|
|
281
|
+
});
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (
|
|
285
|
+
indexDocument?.schemaVersion !== 1 ||
|
|
286
|
+
!Array.isArray(indexDocument.producerEvidence) ||
|
|
287
|
+
indexDocument.producerEvidence.length === 0
|
|
288
|
+
) {
|
|
289
|
+
onDefect({
|
|
290
|
+
root,
|
|
291
|
+
directory,
|
|
292
|
+
subjectKey: null,
|
|
293
|
+
reason: 'producer-evidence.json is not a non-empty schemaVersion 1 index',
|
|
294
|
+
});
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
for (const entry of indexDocument.producerEvidence) {
|
|
298
|
+
if (
|
|
299
|
+
entry === null ||
|
|
300
|
+
typeof entry !== 'object' ||
|
|
301
|
+
typeof entry.subjectKind !== 'string' ||
|
|
302
|
+
typeof entry.subjectId !== 'string' ||
|
|
303
|
+
entry.evidence === null ||
|
|
304
|
+
typeof entry.evidence !== 'object'
|
|
305
|
+
) {
|
|
306
|
+
onDefect({
|
|
307
|
+
root,
|
|
308
|
+
directory,
|
|
309
|
+
subjectKey: null,
|
|
310
|
+
reason: 'producer-evidence.json carries a malformed entry',
|
|
311
|
+
});
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
const subjectKey = sourceIdentity({
|
|
315
|
+
kind: entry.subjectKind,
|
|
316
|
+
id: entry.subjectId,
|
|
317
|
+
});
|
|
318
|
+
const descriptor = entry.evidence;
|
|
319
|
+
let statementBytes;
|
|
320
|
+
let bundleBytes;
|
|
321
|
+
let statement;
|
|
322
|
+
try {
|
|
323
|
+
statementBytes = readCommittedArtifact(
|
|
324
|
+
directory,
|
|
325
|
+
descriptor.statementPath,
|
|
326
|
+
`${entry.subjectKind}:${entry.subjectId} statement`,
|
|
327
|
+
);
|
|
328
|
+
bundleBytes = readCommittedArtifact(
|
|
329
|
+
directory,
|
|
330
|
+
descriptor.bundlePath,
|
|
331
|
+
`${entry.subjectKind}:${entry.subjectId} bundle`,
|
|
332
|
+
);
|
|
333
|
+
if (
|
|
334
|
+
sha256Digest(statementBytes) !== descriptor.statementDigest ||
|
|
335
|
+
sha256Digest(bundleBytes) !== descriptor.bundleDigest ||
|
|
336
|
+
descriptor.bundleMediaType !== BUNDLE_MEDIA_TYPE
|
|
337
|
+
) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
'committed artifacts do not match the digests and media type producer-evidence.json records',
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
// Proves the bundle carries exactly ONE DSSE in-toto signature whose
|
|
343
|
+
// payload is byte-identical to the statement. Reused from the producer so
|
|
344
|
+
// the staged bytes are validated by the very code that wrote them.
|
|
345
|
+
validateProducedBundle(statementBytes, bundleBytes);
|
|
346
|
+
statement = schemas.parseImageProducerStatement(
|
|
347
|
+
JSON.parse(decodeExactUtf8(statementBytes, 'committed statement')),
|
|
348
|
+
);
|
|
349
|
+
if (
|
|
350
|
+
evidenceDirectoryForDigest(statement.predicate.artifact.manifestDigest) !==
|
|
351
|
+
`${EVIDENCE_TREE_ROOT}/${directoryName}`
|
|
352
|
+
) {
|
|
353
|
+
throw new Error(
|
|
354
|
+
`statement attests ${statement.predicate.artifact.manifestDigest}, which is not the digest this directory is named after`,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
if (
|
|
358
|
+
statement.predicate.source.kind !== entry.subjectKind ||
|
|
359
|
+
statement.predicate.source.id !== entry.subjectId
|
|
360
|
+
) {
|
|
361
|
+
throw new Error(
|
|
362
|
+
`statement attests ${statement.predicate.source.kind}:${statement.predicate.source.id}, which producer-evidence.json indexes as ${entry.subjectKind}:${entry.subjectId}`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
} catch (error) {
|
|
366
|
+
onDefect({ root, directory, subjectKey, reason: error.message });
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
const coordinate = canonicalJson(statement.predicate.source);
|
|
370
|
+
const record = {
|
|
371
|
+
root,
|
|
372
|
+
directory,
|
|
373
|
+
subjectKind: statement.predicate.source.kind,
|
|
374
|
+
subjectId: statement.predicate.source.id,
|
|
375
|
+
statement,
|
|
376
|
+
statementBytes,
|
|
377
|
+
bundleBytes,
|
|
378
|
+
descriptor,
|
|
379
|
+
};
|
|
380
|
+
const incumbent = index.get(coordinate);
|
|
381
|
+
if (incumbent === undefined) {
|
|
382
|
+
index.set(coordinate, [record]);
|
|
383
|
+
} else {
|
|
384
|
+
incumbent.push(record);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The exact source coordinate a producer statement must attest for one locked
|
|
391
|
+
* subject: the lock's own fields joined with the repository pin the runtime
|
|
392
|
+
* index committed. Value-identical to `expectedSource` in
|
|
393
|
+
* `image-evidence.mjs validateProducerStatementBinding` — that function is the
|
|
394
|
+
* verifier's authority and the contract tests prove agreement by round-tripping
|
|
395
|
+
* a staged statement through it.
|
|
396
|
+
*/
|
|
397
|
+
export function expectedProducerSource({
|
|
398
|
+
subject,
|
|
399
|
+
repositoryPin,
|
|
400
|
+
sourceHashAlgorithm,
|
|
401
|
+
}) {
|
|
402
|
+
if (
|
|
403
|
+
repositoryPin?.repo !== subject.repo ||
|
|
404
|
+
repositoryPin.sourceCommit !== subject.sourceCommit
|
|
405
|
+
) {
|
|
406
|
+
throw new Error(
|
|
407
|
+
`runtime subject "${subject.kind}:${subject.id}" does not match the committed repository pin for ${subject.repo}`,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
return {
|
|
411
|
+
kind: subject.kind,
|
|
412
|
+
id: subject.id,
|
|
413
|
+
repositoryUrl: repositoryPin.repositoryUrl,
|
|
414
|
+
repo: subject.repo,
|
|
415
|
+
path: subject.path,
|
|
416
|
+
sourceCommit: subject.sourceCommit,
|
|
417
|
+
sourceManifestHash: subject.sourceManifestHash,
|
|
418
|
+
sourceHashAlgorithm,
|
|
419
|
+
sourceTreeHash: subject.contentHash,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Resolve the committed producer bundle of every subject in one edition's
|
|
425
|
+
* `images.lock.json` and COPY the pairs into `outputRoot`.
|
|
426
|
+
*
|
|
427
|
+
* Copying (rather than referencing the producing checkouts in place) is what
|
|
428
|
+
* makes the evidence root a self-contained artifact: `evidence-set.json`
|
|
429
|
+
* descriptor paths are relative to it, and the offline verifier is handed that
|
|
430
|
+
* one directory with no access to any producing repository.
|
|
431
|
+
*
|
|
432
|
+
* @returns {Promise<{distributionLock, imageLock, producers: Array<object>,
|
|
433
|
+
* producerEvidence: Array<object>, images: Array<object>}>}
|
|
434
|
+
*/
|
|
435
|
+
export async function stageProducerEvidence({ outputRoot, ...resolution }) {
|
|
436
|
+
assertAbsolutePath(outputRoot, 'staging output root');
|
|
437
|
+
const resolved = await resolveProducerEvidence(resolution);
|
|
438
|
+
for (const producer of resolved.producers) {
|
|
439
|
+
mkdirSync(join(outputRoot, 'producers', producer.stem), { recursive: true, mode: 0o700 });
|
|
440
|
+
for (const [path, bytes] of stagedProducerFiles(producer)) {
|
|
441
|
+
writeFileSync(join(outputRoot, path), bytes, { flag: 'wx', mode: 0o644 });
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return resolved;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** The staged path and the exact bytes of both files of one producer's evidence. */
|
|
448
|
+
export function stagedProducerFiles(producer) {
|
|
449
|
+
return [
|
|
450
|
+
[producer.evidence.statementPath, producer.statementBytes],
|
|
451
|
+
[producer.evidence.bundlePath, producer.bundleBytes],
|
|
452
|
+
];
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Resolve the published producer evidence of every subject in one edition's
|
|
457
|
+
* `images.lock.json`; writes nothing. The accept re-derives it with this and
|
|
458
|
+
* compares it with the staging, so what it seals is what the registry serves.
|
|
459
|
+
*/
|
|
460
|
+
export async function resolveProducerEvidence({
|
|
461
|
+
distributionId,
|
|
462
|
+
dataRoot,
|
|
463
|
+
editionsRoot,
|
|
464
|
+
schemas,
|
|
465
|
+
expectedContractAuthority,
|
|
466
|
+
registryClientFactory,
|
|
467
|
+
}) {
|
|
468
|
+
assertCanonicalDistributionId(distributionId);
|
|
469
|
+
assertAbsolutePath(dataRoot, 'data root');
|
|
470
|
+
assertAbsolutePath(editionsRoot, 'editions root');
|
|
471
|
+
if (!expectedContractAuthority) {
|
|
472
|
+
throw new Error('the expected kernel contract authority is required');
|
|
473
|
+
}
|
|
474
|
+
const { distributionLock, imageLock, repositoryPins, runtimeSourceIndex } =
|
|
475
|
+
await loadValidatedLocks(distributionId, dataRoot, schemas, { editionsRoot });
|
|
476
|
+
assertKernelContractAuthorityEqual(
|
|
477
|
+
runtimeSourceIndex.contractAuthority,
|
|
478
|
+
expectedContractAuthority,
|
|
479
|
+
'runtime-image-source-index.json',
|
|
480
|
+
);
|
|
481
|
+
const clientFor = createRegistryClientResolver({
|
|
482
|
+
dataRoot,
|
|
483
|
+
factory: registryClientFactory,
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
const producers = [];
|
|
487
|
+
for (const subject of imageLock.subjects) {
|
|
488
|
+
const expectedSource = expectedProducerSource({
|
|
489
|
+
subject,
|
|
490
|
+
repositoryPin: repositoryPins.get(subject.repo),
|
|
491
|
+
sourceHashAlgorithm: imageLock.sourceHashAlgorithm,
|
|
492
|
+
});
|
|
493
|
+
const { authority, repository } = splitImageRef(subject.imageRef);
|
|
494
|
+
const client = clientFor(authority);
|
|
495
|
+
const image = await client.getManifestByTag(repository, subject.contentHash);
|
|
496
|
+
const ociEvidence = await resolveImageSourceEvidenceReferrer({
|
|
497
|
+
client,
|
|
498
|
+
imageRepository: subject.imageRef,
|
|
499
|
+
subjectDigest: image.digest,
|
|
500
|
+
schemas,
|
|
501
|
+
});
|
|
502
|
+
if (ociEvidence === null) {
|
|
503
|
+
throw new Error(
|
|
504
|
+
`no OCI image-source evidence referrer exists for "${subject.kind}:${subject.id}" ` +
|
|
505
|
+
`at ${subject.imageRef}@${image.digest}`,
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
const matches = ociEvidence.records
|
|
509
|
+
.filter(
|
|
510
|
+
(record) =>
|
|
511
|
+
canonicalJson(record.statement.predicate.source) ===
|
|
512
|
+
canonicalJson(expectedSource),
|
|
513
|
+
)
|
|
514
|
+
.map((record) => ({
|
|
515
|
+
...record,
|
|
516
|
+
root: subject.imageRef,
|
|
517
|
+
directory: `oci://${subject.imageRef}@${ociEvidence.artifactDigest}`,
|
|
518
|
+
}));
|
|
519
|
+
if (matches.length === 0) {
|
|
520
|
+
throw new Error(
|
|
521
|
+
`no published producer evidence attests "${subject.kind}:${subject.id}" at ` +
|
|
522
|
+
`${subject.repo}@${subject.sourceCommit} (source tree ${subject.contentHash})`,
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
const [match] = matches;
|
|
526
|
+
const { artifact } = match.statement.predicate;
|
|
527
|
+
if (
|
|
528
|
+
artifact.imageRepository !== subject.imageRef ||
|
|
529
|
+
match.statement.subject[0].name !== subject.imageRef ||
|
|
530
|
+
`sha256:${match.statement.subject[0].digest.sha256}` !==
|
|
531
|
+
artifact.manifestDigest
|
|
532
|
+
) {
|
|
533
|
+
throw new Error(
|
|
534
|
+
`committed producer evidence for "${subject.kind}:${subject.id}" attests image ` +
|
|
535
|
+
`"${artifact.imageRepository}", not the locked "${subject.imageRef}"`,
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
const stem = subjectStem(subject);
|
|
539
|
+
producers.push({
|
|
540
|
+
stem,
|
|
541
|
+
subjectKind: subject.kind,
|
|
542
|
+
subjectId: subject.id,
|
|
543
|
+
imageRepository: subject.imageRef,
|
|
544
|
+
manifestDigest: artifact.manifestDigest,
|
|
545
|
+
statement: match.statement,
|
|
546
|
+
sourceDirectory: match.directory,
|
|
547
|
+
statementBytes: match.statementBytes,
|
|
548
|
+
bundleBytes: match.bundleBytes,
|
|
549
|
+
evidence: {
|
|
550
|
+
statementPath: `producers/${stem}/statement.json`,
|
|
551
|
+
statementDigest: sha256Digest(match.statementBytes),
|
|
552
|
+
bundlePath: `producers/${stem}/bundle.sigstore.json`,
|
|
553
|
+
bundleDigest: sha256Digest(match.bundleBytes),
|
|
554
|
+
bundleMediaType: BUNDLE_MEDIA_TYPE,
|
|
555
|
+
},
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
producers.sort(compareCompletenessImages);
|
|
559
|
+
|
|
560
|
+
return {
|
|
561
|
+
distributionLock,
|
|
562
|
+
imageLock,
|
|
563
|
+
producers,
|
|
564
|
+
producerEvidence: producers.map(({ subjectKind, subjectId, evidence }) => ({
|
|
565
|
+
subjectKind,
|
|
566
|
+
subjectId,
|
|
567
|
+
evidence,
|
|
568
|
+
})),
|
|
569
|
+
images: producers.map((producer) => ({
|
|
570
|
+
subjectKind: producer.subjectKind,
|
|
571
|
+
subjectId: producer.subjectId,
|
|
572
|
+
imageRepository: producer.imageRepository,
|
|
573
|
+
manifestDigest: producer.manifestDigest,
|
|
574
|
+
producerStatementDigest: producer.evidence.statementDigest,
|
|
575
|
+
producerBundleDigest: producer.evidence.bundleDigest,
|
|
576
|
+
})),
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
581
|
+
// Statement shapes
|
|
582
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* The distribution-completeness statement: "these EXACT images, produced by
|
|
586
|
+
* these EXACT bundles, are the complete image set of this edition at this
|
|
587
|
+
* release commit."
|
|
588
|
+
*/
|
|
589
|
+
export function buildDistributionImagesStatement({
|
|
590
|
+
distributionLock,
|
|
591
|
+
imageLock,
|
|
592
|
+
images,
|
|
593
|
+
releaseSequence,
|
|
594
|
+
releaseSourceCommit,
|
|
595
|
+
runId,
|
|
596
|
+
runAttempt,
|
|
597
|
+
issuedAt,
|
|
598
|
+
expiresAt,
|
|
599
|
+
maxTtlSeconds,
|
|
600
|
+
schemas,
|
|
601
|
+
}) {
|
|
602
|
+
if (!schemas?.parseDistributionImagesStatement) {
|
|
603
|
+
throw new Error('trusted kernel distribution schemas are required');
|
|
604
|
+
}
|
|
605
|
+
if (
|
|
606
|
+
distributionLock?.distributionId !== imageLock?.distributionId ||
|
|
607
|
+
typeof distributionLock.inputHash !== 'string'
|
|
608
|
+
) {
|
|
609
|
+
throw new Error(
|
|
610
|
+
'distribution completeness requires the distribution and image locks of ONE edition',
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
const label = `distribution completeness for ${imageLock.distributionId}`;
|
|
614
|
+
if (!COMMIT_PATTERN.test(releaseSourceCommit ?? '')) {
|
|
615
|
+
throw new Error(`${label} requires an exact 40-hex release source commit`);
|
|
616
|
+
}
|
|
617
|
+
if (!Number.isSafeInteger(releaseSequence) || releaseSequence < 0) {
|
|
618
|
+
throw new Error(`${label} requires a non-negative release sequence`);
|
|
619
|
+
}
|
|
620
|
+
if (!Number.isSafeInteger(runAttempt) || runAttempt <= 0) {
|
|
621
|
+
throw new Error(`${label} requires a positive run attempt`);
|
|
622
|
+
}
|
|
623
|
+
assertIssuanceWindow({ issuedAt, expiresAt, maxTtlSeconds, label });
|
|
624
|
+
|
|
625
|
+
if (!Array.isArray(images) || images.length === 0) {
|
|
626
|
+
throw new Error(`${label} has an empty image set`);
|
|
627
|
+
}
|
|
628
|
+
// The COMPLETE set, exactly: the verifier rebuilds this list from the lock
|
|
629
|
+
// and refuses anything that is missing, extra, or out of canonical order.
|
|
630
|
+
// Refusing here as well means an incomplete set can never be SIGNED, rather
|
|
631
|
+
// than being signed and rejected days later at a customer install.
|
|
632
|
+
const expectedIdentities = imageLock.subjects
|
|
633
|
+
.map((subject) => sourceIdentity(subject))
|
|
634
|
+
.sort(compareStrings);
|
|
635
|
+
const actualIdentities = images
|
|
636
|
+
.map((image) =>
|
|
637
|
+
sourceIdentity({ kind: image.subjectKind, id: image.subjectId }),
|
|
638
|
+
)
|
|
639
|
+
.sort(compareStrings);
|
|
640
|
+
if (
|
|
641
|
+
new Set(actualIdentities).size !== actualIdentities.length ||
|
|
642
|
+
canonicalJson(expectedIdentities) !== canonicalJson(actualIdentities)
|
|
643
|
+
) {
|
|
644
|
+
throw new Error(
|
|
645
|
+
`${label} image set is not the exact complete subject set of images.lock.json`,
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
const ordered = [...images].sort(compareCompletenessImages);
|
|
649
|
+
if (canonicalJson(images) !== canonicalJson(ordered)) {
|
|
650
|
+
throw new Error(`${label} image set is not canonically ordered`);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// ONE owner for "what digest names this lock": the verifier compares against
|
|
654
|
+
// `normalizedImageLockDigest(imageLock)`, so a second inline computation here
|
|
655
|
+
// would be the exact drift this binding exists to catch.
|
|
656
|
+
const normalizedLockDigest = normalizedImageLockDigest(imageLock);
|
|
657
|
+
const statement = {
|
|
658
|
+
_type: 'https://in-toto.io/Statement/v1',
|
|
659
|
+
subject: [
|
|
660
|
+
{
|
|
661
|
+
name: `xema-distribution/${imageLock.distributionId}`,
|
|
662
|
+
digest: { sha256: normalizedLockDigest.slice('sha256:'.length) },
|
|
663
|
+
},
|
|
664
|
+
],
|
|
665
|
+
predicateType: schemas.DISTRIBUTION_IMAGES_PREDICATE_TYPE,
|
|
666
|
+
predicate: {
|
|
667
|
+
schemaVersion: 1,
|
|
668
|
+
distributionId: imageLock.distributionId,
|
|
669
|
+
distributionInputHash: distributionLock.inputHash,
|
|
670
|
+
normalizedImageLockDigest: normalizedLockDigest,
|
|
671
|
+
releaseSequence,
|
|
672
|
+
releaseSourceCommit,
|
|
673
|
+
runId,
|
|
674
|
+
runAttempt,
|
|
675
|
+
nonce: derivedNonce('xema.distribution-completeness-evidence.nonce.v1', [
|
|
676
|
+
runId,
|
|
677
|
+
runAttempt,
|
|
678
|
+
imageLock.distributionId,
|
|
679
|
+
releaseSourceCommit,
|
|
680
|
+
releaseSequence,
|
|
681
|
+
normalizedLockDigest,
|
|
682
|
+
]),
|
|
683
|
+
issuedAt,
|
|
684
|
+
expiresAt,
|
|
685
|
+
images: ordered.map((image) => ({
|
|
686
|
+
subjectKind: image.subjectKind,
|
|
687
|
+
subjectId: image.subjectId,
|
|
688
|
+
imageRepository: image.imageRepository,
|
|
689
|
+
manifestDigest: image.manifestDigest,
|
|
690
|
+
producerStatementDigest: image.producerStatementDigest,
|
|
691
|
+
producerBundleDigest: image.producerBundleDigest,
|
|
692
|
+
})),
|
|
693
|
+
},
|
|
694
|
+
};
|
|
695
|
+
return schemas.parseDistributionImagesStatement(statement);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* The delivery-entitlement statement: "this customer may take THIS release,
|
|
700
|
+
* in THIS mode, to THIS target, once, before it expires."
|
|
701
|
+
*
|
|
702
|
+
* The mode/targetRegistry pairing is enforced by the kernel schema's own
|
|
703
|
+
* `superRefine` (mirror REQUIRES a target registry; shared and airgap FORBID
|
|
704
|
+
* one) at the `parse` below — which happens here, in the builder, BEFORE any
|
|
705
|
+
* signing. Restating the rule in this file would be a second copy of a closed
|
|
706
|
+
* decision that already has an owner.
|
|
707
|
+
*/
|
|
708
|
+
export function buildImageDeliveryEntitlementStatement({
|
|
709
|
+
distributionId,
|
|
710
|
+
completenessStatementDigest,
|
|
711
|
+
customerId,
|
|
712
|
+
mode,
|
|
713
|
+
targetRegistry,
|
|
714
|
+
idempotencyKey,
|
|
715
|
+
runId,
|
|
716
|
+
runAttempt,
|
|
717
|
+
issuedAt,
|
|
718
|
+
expiresAt,
|
|
719
|
+
maxTtlSeconds,
|
|
720
|
+
schemas,
|
|
721
|
+
}) {
|
|
722
|
+
if (!schemas?.parseImageDeliveryEntitlementStatement) {
|
|
723
|
+
throw new Error('trusted kernel distribution schemas are required');
|
|
724
|
+
}
|
|
725
|
+
assertCanonicalDistributionId(distributionId);
|
|
726
|
+
const label = `delivery entitlement for ${String(customerId)}`;
|
|
727
|
+
if (!SHA256_DIGEST_PATTERN.test(completenessStatementDigest ?? '')) {
|
|
728
|
+
throw new Error(
|
|
729
|
+
`${label} requires the exact sha256 digest of the completeness statement`,
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
if (targetRegistry !== null && typeof targetRegistry !== 'string') {
|
|
733
|
+
throw new Error(
|
|
734
|
+
`${label} requires an explicit target registry or an explicit null`,
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
if (!Number.isSafeInteger(runAttempt) || runAttempt <= 0) {
|
|
738
|
+
throw new Error(`${label} requires a positive run attempt`);
|
|
739
|
+
}
|
|
740
|
+
assertIssuanceWindow({ issuedAt, expiresAt, maxTtlSeconds, label });
|
|
741
|
+
|
|
742
|
+
const statement = {
|
|
743
|
+
_type: 'https://in-toto.io/Statement/v1',
|
|
744
|
+
subject: [
|
|
745
|
+
{
|
|
746
|
+
name: `xema-distribution/${distributionId}`,
|
|
747
|
+
digest: {
|
|
748
|
+
sha256: completenessStatementDigest.slice('sha256:'.length),
|
|
749
|
+
},
|
|
750
|
+
},
|
|
751
|
+
],
|
|
752
|
+
predicateType: schemas.IMAGE_DELIVERY_ENTITLEMENT_PREDICATE_TYPE,
|
|
753
|
+
predicate: {
|
|
754
|
+
schemaVersion: 1,
|
|
755
|
+
customerId,
|
|
756
|
+
distributionId,
|
|
757
|
+
distributionStatementDigest: completenessStatementDigest,
|
|
758
|
+
mode,
|
|
759
|
+
targetRegistry,
|
|
760
|
+
nonce: derivedNonce('xema.image-delivery-entitlement.nonce.v1', [
|
|
761
|
+
runId,
|
|
762
|
+
runAttempt,
|
|
763
|
+
customerId,
|
|
764
|
+
distributionId,
|
|
765
|
+
completenessStatementDigest,
|
|
766
|
+
idempotencyKey,
|
|
767
|
+
]),
|
|
768
|
+
idempotencyKey,
|
|
769
|
+
issuedAt,
|
|
770
|
+
expiresAt,
|
|
771
|
+
},
|
|
772
|
+
};
|
|
773
|
+
return schemas.parseImageDeliveryEntitlementStatement(statement);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* `evidence-set.json` — the one document the offline verifier is handed.
|
|
778
|
+
*
|
|
779
|
+
* It can only be assembled once completeness EXISTS: the kernel schema requires
|
|
780
|
+
* the `completeness` descriptor, so there is no such thing as a half-assembled
|
|
781
|
+
* set on disk. That ordering is the design, not an accident of this function.
|
|
782
|
+
*/
|
|
783
|
+
export function buildDistributionImageEvidenceSet({
|
|
784
|
+
distributionId,
|
|
785
|
+
producerEvidence,
|
|
786
|
+
completeness,
|
|
787
|
+
schemas,
|
|
788
|
+
}) {
|
|
789
|
+
if (!schemas?.parseDistributionImageEvidenceSet) {
|
|
790
|
+
throw new Error('trusted kernel distribution schemas are required');
|
|
791
|
+
}
|
|
792
|
+
assertCanonicalDistributionId(distributionId);
|
|
793
|
+
return schemas.parseDistributionImageEvidenceSet({
|
|
794
|
+
schemaVersion: 1,
|
|
795
|
+
distributionId,
|
|
796
|
+
producerEvidence,
|
|
797
|
+
completeness,
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
802
|
+
// The signing path shared by the completeness and entitlement producers
|
|
803
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* The pinned cosign version is read from the COMMITTED trust policy, never from
|
|
807
|
+
* a literal in this file: the policy is what the offline verifier enforces, so
|
|
808
|
+
* a producer signing with a different cosign than the verifier trusts must fail
|
|
809
|
+
* at signing time, not at delivery.
|
|
810
|
+
*/
|
|
811
|
+
export function assertPinnedCosignVersion(output, expectedVersion) {
|
|
812
|
+
if (!/^v\d+\.\d+\.\d+$/.test(expectedVersion ?? '')) {
|
|
813
|
+
throw new Error('the trust policy declares no exact cosign version');
|
|
814
|
+
}
|
|
815
|
+
const match = /\bGitVersion:\s*(v\d+\.\d+\.\d+)\b/.exec(String(output));
|
|
816
|
+
if (!match || match[1] !== expectedVersion) {
|
|
817
|
+
throw new Error(`cosign version does not match pinned ${expectedVersion}`);
|
|
818
|
+
}
|
|
819
|
+
return match[1];
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* The trust policy the data root declares, held to the data root's pin of it.
|
|
824
|
+
* Producers take their cosign version and TTL bounds from this policy, the
|
|
825
|
+
* same one the verifier verifies under.
|
|
826
|
+
*/
|
|
827
|
+
export function loadPinnedTrustPolicy({ dataRoot, schemas }) {
|
|
828
|
+
return loadPinnedDeclaredTrustPolicy({
|
|
829
|
+
dataRoot,
|
|
830
|
+
declaration: editionWorkspaceConfig().evidenceTrustPolicy,
|
|
831
|
+
schemas,
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function assertSignableStatement({ statement, root, certificateWorkflowSha, predicateType }) {
|
|
836
|
+
assertAbsolutePath(root, 'signing root');
|
|
837
|
+
if (!COMMIT_PATTERN.test(certificateWorkflowSha ?? '')) {
|
|
838
|
+
throw new Error(
|
|
839
|
+
'signing requires the exact 40-hex commit of the signing workflow',
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
if (statement.predicateType !== predicateType) {
|
|
843
|
+
throw new Error(
|
|
844
|
+
`refusing to stage a ${statement.predicateType} statement as ${predicateType}`,
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Write the canonical bytes of ONE statement for signing and return its
|
|
851
|
+
* signing plan entry. This library never signs: the caller runs
|
|
852
|
+
* `cosign attest-blob` and hands the bundle to {@link acceptSignedEvidenceStatement}.
|
|
853
|
+
*/
|
|
854
|
+
export function stageEvidenceStatement({
|
|
855
|
+
statement,
|
|
856
|
+
root,
|
|
857
|
+
statementPath,
|
|
858
|
+
bundlePath,
|
|
859
|
+
signer,
|
|
860
|
+
certificateWorkflowSha,
|
|
861
|
+
predicateType,
|
|
862
|
+
}) {
|
|
863
|
+
assertSignableStatement({ statement, root, certificateWorkflowSha, predicateType });
|
|
864
|
+
const absoluteStatement = join(root, statementPath);
|
|
865
|
+
mkdirSync(dirname(absoluteStatement), { recursive: true, mode: 0o700 });
|
|
866
|
+
mkdirSync(dirname(join(root, bundlePath)), { recursive: true, mode: 0o700 });
|
|
867
|
+
writeFileSync(absoluteStatement, canonicalJsonBytes(statement), { flag: 'wx', mode: 0o600 });
|
|
868
|
+
return {
|
|
869
|
+
schemaVersion: 1,
|
|
870
|
+
root,
|
|
871
|
+
predicateType,
|
|
872
|
+
signer,
|
|
873
|
+
certificateWorkflowSha,
|
|
874
|
+
statements: [{ statementPath, bundlePath }],
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* Accept the signed bundle of a staged statement into `sealingRoot`. `statement`
|
|
880
|
+
* is re-derived by the caller; the staged bytes must be exactly its canonical
|
|
881
|
+
* bytes and the bundle must carry exactly them. The canonical statement and
|
|
882
|
+
* bundle are WRITTEN to `sealingRoot` (the staging is only read) and cosign
|
|
883
|
+
* verifies the bundle there, against the declared signer at the exact workflow
|
|
884
|
+
* commit.
|
|
885
|
+
*/
|
|
886
|
+
export function acceptSignedEvidenceStatement({
|
|
887
|
+
statement,
|
|
888
|
+
root,
|
|
889
|
+
sealingRoot,
|
|
890
|
+
statementPath,
|
|
891
|
+
bundlePath,
|
|
892
|
+
signer,
|
|
893
|
+
certificateWorkflowSha,
|
|
894
|
+
predicateType,
|
|
895
|
+
runCosign,
|
|
896
|
+
}) {
|
|
897
|
+
assertSignableStatement({ statement, root, certificateWorkflowSha, predicateType });
|
|
898
|
+
assertAbsolutePath(sealingRoot, 'sealing root');
|
|
899
|
+
if (typeof runCosign !== 'function') {
|
|
900
|
+
throw new Error('a cosign runner is required');
|
|
901
|
+
}
|
|
902
|
+
const statementBytes = canonicalJsonBytes(statement);
|
|
903
|
+
if (!readFileSync(join(root, statementPath)).equals(statementBytes)) {
|
|
904
|
+
throw new Error(
|
|
905
|
+
`staged statement ${statementPath} is not the statement these inputs build`,
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
const bundleBytes = canonicalSignedBundleBytes({ statementBytes, bundlePath: join(root, bundlePath) });
|
|
909
|
+
mkdirSync(dirname(join(sealingRoot, statementPath)), { recursive: true, mode: 0o755 });
|
|
910
|
+
mkdirSync(dirname(join(sealingRoot, bundlePath)), { recursive: true, mode: 0o755 });
|
|
911
|
+
writeFileSync(join(sealingRoot, statementPath), statementBytes, { flag: 'wx', mode: 0o644 });
|
|
912
|
+
writeFileSync(join(sealingRoot, bundlePath), bundleBytes, { flag: 'wx', mode: 0o644 });
|
|
913
|
+
// Verified where it is sealed: the bytes cosign accepts are the bytes kept.
|
|
914
|
+
runCosign(
|
|
915
|
+
cosignVerifyAttestationArgs({
|
|
916
|
+
bundlePath: join(sealingRoot, bundlePath),
|
|
917
|
+
signer,
|
|
918
|
+
certificateWorkflowSha,
|
|
919
|
+
predicateType,
|
|
920
|
+
}),
|
|
921
|
+
);
|
|
922
|
+
return {
|
|
923
|
+
statementPath,
|
|
924
|
+
statementDigest: sha256Digest(statementBytes),
|
|
925
|
+
bundlePath,
|
|
926
|
+
bundleDigest: sha256Digest(bundleBytes),
|
|
927
|
+
bundleMediaType: BUNDLE_MEDIA_TYPE,
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
932
|
+
// The CLI contract shared by this family's three entrypoints
|
|
933
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Strict `--flag value` parsing.
|
|
937
|
+
*
|
|
938
|
+
* One owner for the three entrypoints in this family so they cannot drift in
|
|
939
|
+
* how they refuse an unknown flag, a repeated single-valued flag, or a missing
|
|
940
|
+
* value. A typo'd flag that parses as "not provided" is exactly how a producer
|
|
941
|
+
* silently signs with a default nobody chose.
|
|
942
|
+
*/
|
|
943
|
+
export function parseEvidenceCliFlags(argv, { single = [], repeated = [] }) {
|
|
944
|
+
const singles = new Set(single);
|
|
945
|
+
const repeats = new Set(repeated);
|
|
946
|
+
const flags = new Map();
|
|
947
|
+
for (let index = 0; index < argv.length; index += 2) {
|
|
948
|
+
const flag = argv[index];
|
|
949
|
+
if (typeof flag !== 'string' || !flag.startsWith('--')) {
|
|
950
|
+
throw new Error(`unexpected argument "${String(flag)}"`);
|
|
951
|
+
}
|
|
952
|
+
const name = flag.slice(2);
|
|
953
|
+
const value = argv[index + 1];
|
|
954
|
+
if (value === undefined || value.startsWith('--')) {
|
|
955
|
+
throw new Error(`--${name} requires a value`);
|
|
956
|
+
}
|
|
957
|
+
if (repeats.has(name)) {
|
|
958
|
+
flags.set(name, [...(flags.get(name) ?? []), value]);
|
|
959
|
+
continue;
|
|
960
|
+
}
|
|
961
|
+
if (!singles.has(name)) {
|
|
962
|
+
throw new Error(`unknown option "--${name}"`);
|
|
963
|
+
}
|
|
964
|
+
if (flags.has(name)) {
|
|
965
|
+
throw new Error(`--${name} was given twice`);
|
|
966
|
+
}
|
|
967
|
+
flags.set(name, value);
|
|
968
|
+
}
|
|
969
|
+
return {
|
|
970
|
+
optional: (name) => flags.get(name),
|
|
971
|
+
many: (name) => flags.get(name) ?? [],
|
|
972
|
+
require(name) {
|
|
973
|
+
const value = flags.get(name);
|
|
974
|
+
if (value === undefined) {
|
|
975
|
+
throw new Error(`--${name} is required`);
|
|
976
|
+
}
|
|
977
|
+
return value;
|
|
978
|
+
},
|
|
979
|
+
requireMany(name) {
|
|
980
|
+
const values = flags.get(name);
|
|
981
|
+
if (!Array.isArray(values) || values.length === 0) {
|
|
982
|
+
throw new Error(`--${name} is required at least once`);
|
|
983
|
+
}
|
|
984
|
+
return values;
|
|
985
|
+
},
|
|
986
|
+
};
|
|
987
|
+
}
|