@xemahq/xema 0.26.0 → 0.28.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/distribution-assets.manifest.json +1 -2
- 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 +5 -2
- 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 +54283 -0
- package/dist/edition/verify-image-evidence.bundle.provenance.json +2877 -0
- 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 +2 -0
- package/dist/runtime/env-seeder.d.ts.map +1 -1
- package/dist/runtime/env-seeder.js +18 -3
- package/dist/runtime/env-seeder.js.map +1 -1
- package/dist/runtime/launch.d.ts +2 -1
- package/dist/runtime/launch.d.ts.map +1 -1
- package/dist/runtime/launch.js +31 -7
- 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 +2 -0
- package/dist/runtime/supervisor.d.ts.map +1 -1
- package/dist/runtime/supervisor.js +1 -0
- package/dist/runtime/supervisor.js.map +1 -1
- package/edition/README.md +313 -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 +284 -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 +205 -0
- package/edition/edition-constants.mjs +57 -0
- package/edition/edition-contract.json +30 -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 +187 -0
- package/edition/evidence-checkout.mjs +439 -0
- package/edition/evidence-grammars.mjs +16 -0
- package/edition/evidence-trust-pin.mjs +304 -0
- package/edition/evidence-trust-policy.mjs +294 -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-delivery-entitlement-descriptor.mjs +106 -0
- package/edition/image-evidence-replay-state.mjs +242 -0
- package/edition/image-evidence.mjs +1233 -0
- package/edition/kernel-contract-authority.mjs +443 -0
- package/edition/lib.mjs +3021 -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 +109 -0
- package/edition/release-edition-artifact.mjs +157 -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 +458 -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 +14 -6
- 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 -156
- 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,1233 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import {
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
realpathSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import {
|
|
12
|
+
isAbsolute,
|
|
13
|
+
join,
|
|
14
|
+
relative,
|
|
15
|
+
resolve,
|
|
16
|
+
sep,
|
|
17
|
+
} from 'node:path';
|
|
18
|
+
import { TextDecoder } from 'node:util';
|
|
19
|
+
|
|
20
|
+
import { assertCanonicalDistributionId } from './distribution-paths.mjs';
|
|
21
|
+
import { SHA256_DIGEST_PATTERN } from './evidence-grammars.mjs';
|
|
22
|
+
import { assertKernelContractAuthorityEqual } from './kernel-contract-authority.mjs';
|
|
23
|
+
import {
|
|
24
|
+
compareStrings,
|
|
25
|
+
validateImmutableImageLock,
|
|
26
|
+
} from './lib.mjs';
|
|
27
|
+
import { readBoundedNoFollowFile } from './no-follow-file.mjs';
|
|
28
|
+
import {
|
|
29
|
+
assertPreparedExecutable,
|
|
30
|
+
exactExecutableOutput,
|
|
31
|
+
prepareDigestPinnedExecutable,
|
|
32
|
+
} from './trusted-executable.mjs';
|
|
33
|
+
|
|
34
|
+
const STRICT_UTF8 = new TextDecoder('utf-8', {
|
|
35
|
+
fatal: true,
|
|
36
|
+
ignoreBOM: true,
|
|
37
|
+
});
|
|
38
|
+
const MAX_CANONICAL_JSON_BYTES = 2 * 1024 * 1024;
|
|
39
|
+
const MAX_STATEMENT_BYTES = 2 * 1024 * 1024;
|
|
40
|
+
const MAX_SIGSTORE_BUNDLE_BYTES = 16 * 1024 * 1024;
|
|
41
|
+
const MAX_TRUSTED_ROOT_BYTES = 8 * 1024 * 1024;
|
|
42
|
+
const COSIGN_ENV = Object.freeze({
|
|
43
|
+
LANG: 'C',
|
|
44
|
+
LC_ALL: 'C',
|
|
45
|
+
PATH: '/usr/bin:/bin',
|
|
46
|
+
TZ: 'UTC',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export function canonicalJson(value) {
|
|
50
|
+
if (
|
|
51
|
+
value === null ||
|
|
52
|
+
typeof value === 'boolean' ||
|
|
53
|
+
typeof value === 'string'
|
|
54
|
+
) {
|
|
55
|
+
return JSON.stringify(value);
|
|
56
|
+
}
|
|
57
|
+
if (typeof value === 'number') {
|
|
58
|
+
if (!Number.isSafeInteger(value)) {
|
|
59
|
+
throw new Error('canonical evidence JSON permits only safe integers');
|
|
60
|
+
}
|
|
61
|
+
return JSON.stringify(value);
|
|
62
|
+
}
|
|
63
|
+
if (Array.isArray(value)) {
|
|
64
|
+
return `[${value.map(canonicalJson).join(',')}]`;
|
|
65
|
+
}
|
|
66
|
+
if (typeof value === 'object') {
|
|
67
|
+
const entries = Object.entries(value).sort(([left], [right]) =>
|
|
68
|
+
compareStrings(left, right),
|
|
69
|
+
);
|
|
70
|
+
return `{${entries
|
|
71
|
+
.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`)
|
|
72
|
+
.join(',')}}`;
|
|
73
|
+
}
|
|
74
|
+
throw new Error(`unsupported canonical evidence JSON value ${typeof value}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function canonicalJsonBytes(value) {
|
|
78
|
+
return Buffer.from(`${canonicalJson(value)}\n`, 'utf8');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function sha256Digest(bytes) {
|
|
82
|
+
return `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Exact UTF-8 decode. Exported because the producers need byte-for-byte the
|
|
87
|
+
* same strictness the verifier applies — a producer that accepts bytes the
|
|
88
|
+
* verifier will reject signs evidence that can never verify.
|
|
89
|
+
*/
|
|
90
|
+
export function decodeExactUtf8(bytes, label) {
|
|
91
|
+
let text;
|
|
92
|
+
try {
|
|
93
|
+
text = STRICT_UTF8.decode(bytes);
|
|
94
|
+
} catch {
|
|
95
|
+
throw new Error(`${label} contains invalid UTF-8`);
|
|
96
|
+
}
|
|
97
|
+
if (!Buffer.from(text, 'utf8').equals(bytes)) {
|
|
98
|
+
throw new Error(`${label} is not exact UTF-8`);
|
|
99
|
+
}
|
|
100
|
+
return text;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function parseCanonicalJsonBytes(bytes, label) {
|
|
104
|
+
const text = decodeExactUtf8(bytes, label);
|
|
105
|
+
let value;
|
|
106
|
+
try {
|
|
107
|
+
value = JSON.parse(text);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
throw new Error(`${label} is not valid JSON: ${error.message}`);
|
|
110
|
+
}
|
|
111
|
+
const canonical = canonicalJsonBytes(value);
|
|
112
|
+
if (!bytes.equals(canonical)) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`${label} is not canonical JSON (duplicate keys, non-canonical ordering, or whitespace are refused)`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function resolveContainedFile(root, candidate, label) {
|
|
121
|
+
if (
|
|
122
|
+
typeof candidate !== 'string' ||
|
|
123
|
+
candidate.length === 0 ||
|
|
124
|
+
isAbsolute(candidate)
|
|
125
|
+
) {
|
|
126
|
+
throw new Error(`${label} must be a repository-relative path`);
|
|
127
|
+
}
|
|
128
|
+
const rootReal = realpathSync(root);
|
|
129
|
+
const path = resolve(rootReal, candidate);
|
|
130
|
+
const pathReal = realpathSync(path);
|
|
131
|
+
const rel = relative(rootReal, pathReal);
|
|
132
|
+
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
133
|
+
throw new Error(`${label} escapes its evidence root`);
|
|
134
|
+
}
|
|
135
|
+
return pathReal;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function readExactDescriptorFile(root, path, expectedDigest, label, maxBytes) {
|
|
139
|
+
if (!SHA256_DIGEST_PATTERN.test(expectedDigest)) {
|
|
140
|
+
throw new Error(`${label} has an invalid expected sha256 digest`);
|
|
141
|
+
}
|
|
142
|
+
const { path: resolved, bytes } = readBoundedNoFollowFile(
|
|
143
|
+
root,
|
|
144
|
+
path,
|
|
145
|
+
`${label} path`,
|
|
146
|
+
maxBytes,
|
|
147
|
+
);
|
|
148
|
+
const actual = sha256Digest(bytes);
|
|
149
|
+
if (actual !== expectedDigest) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`${label} digest mismatch: expected ${expectedDigest}, got ${actual}`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return { path: resolved, bytes };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function readCanonicalJsonFromRoot(
|
|
158
|
+
root,
|
|
159
|
+
path,
|
|
160
|
+
label,
|
|
161
|
+
maxBytes = MAX_CANONICAL_JSON_BYTES,
|
|
162
|
+
) {
|
|
163
|
+
return parseCanonicalJsonBytes(
|
|
164
|
+
readBoundedNoFollowFile(root, path, label, maxBytes).bytes,
|
|
165
|
+
label,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function validatePinnedTrustedRoot(policyRoot, policy) {
|
|
170
|
+
const trustedRootFile = readBoundedNoFollowFile(
|
|
171
|
+
policyRoot,
|
|
172
|
+
policy.trustedRootPath,
|
|
173
|
+
'trusted root',
|
|
174
|
+
MAX_TRUSTED_ROOT_BYTES,
|
|
175
|
+
);
|
|
176
|
+
const { path: trustedRoot, bytes: rootBytes } = trustedRootFile;
|
|
177
|
+
if (sha256Digest(rootBytes) !== policy.trustedRootDigest) {
|
|
178
|
+
throw new Error('pinned Sigstore trusted-root digest mismatch');
|
|
179
|
+
}
|
|
180
|
+
const rootDocument = JSON.parse(
|
|
181
|
+
decodeExactUtf8(rootBytes, 'Sigstore trusted root'),
|
|
182
|
+
);
|
|
183
|
+
if (
|
|
184
|
+
rootDocument?.mediaType !==
|
|
185
|
+
'application/vnd.dev.sigstore.trustedroot+json;version=0.1'
|
|
186
|
+
) {
|
|
187
|
+
throw new Error('Sigstore trusted root has an unsupported mediaType');
|
|
188
|
+
}
|
|
189
|
+
return { path: trustedRoot, bytes: rootBytes };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function assertTimeWindow({ issuedAt, expiresAt, now, maxTtlSeconds, label }) {
|
|
193
|
+
const issued = Date.parse(issuedAt);
|
|
194
|
+
const expires = Date.parse(expiresAt);
|
|
195
|
+
const instant = now instanceof Date ? now.getTime() : Date.parse(now);
|
|
196
|
+
if (![issued, expires, instant].every(Number.isFinite)) {
|
|
197
|
+
throw new Error(`${label} has an invalid time`);
|
|
198
|
+
}
|
|
199
|
+
if (issued > instant) {
|
|
200
|
+
throw new Error(`${label} is not yet valid`);
|
|
201
|
+
}
|
|
202
|
+
if (expires <= instant) {
|
|
203
|
+
throw new Error(`${label} is expired`);
|
|
204
|
+
}
|
|
205
|
+
if (expires <= issued) {
|
|
206
|
+
throw new Error(`${label} expiresAt must be after issuedAt`);
|
|
207
|
+
}
|
|
208
|
+
if (expires - issued > maxTtlSeconds * 1000) {
|
|
209
|
+
throw new Error(`${label} exceeds the trusted maximum TTL`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function sourceIdentity(subject) {
|
|
214
|
+
return `${subject.kind}\0${subject.id}`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Root of the committed evidence tree, in every producing repository. */
|
|
218
|
+
export const EVIDENCE_TREE_ROOT = 'evidence';
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The repository-relative directory a producer commits ONE subject digest's
|
|
222
|
+
* evidence bundle to. ONE owner: the producing workflows, the reuse check and
|
|
223
|
+
* (later) the delivery-side loader all read the name from here rather than
|
|
224
|
+
* interpolating a digest into a path themselves.
|
|
225
|
+
*
|
|
226
|
+
* `sha256-<hex>`, NOT `sha256:<hex>`. `:` is illegal in an NTFS path component,
|
|
227
|
+
* so a `sha256:`-named directory cannot be checked out on Windows at all — the
|
|
228
|
+
* clone fails, which would make the evidence plane silently undeliverable to
|
|
229
|
+
* any Windows operator. `sha256-<hex>` is cosign's own on-registry convention
|
|
230
|
+
* for exactly this reason (`sha256-<digest>.att`), so the plane gains no new
|
|
231
|
+
* vocabulary.
|
|
232
|
+
*/
|
|
233
|
+
export function evidenceDirectoryForDigest(manifestDigest) {
|
|
234
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(manifestDigest ?? '')) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`evidence directory requires an exact sha256:<64-hex> manifest digest, got "${String(manifestDigest)}"`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
return `${EVIDENCE_TREE_ROOT}/${manifestDigest.replace(':', '-')}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function comparePlatform(left, right) {
|
|
243
|
+
return compareStrings(
|
|
244
|
+
`${left.os}/${left.architecture}/${left.variant ?? ''}`,
|
|
245
|
+
`${right.os}/${right.architecture}/${right.variant ?? ''}`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function platformIdentity(platform) {
|
|
250
|
+
return `${platform.os}/${platform.architecture}/${platform.variant ?? ''}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function assertPlatforms(platforms, requiredPlatforms, label) {
|
|
254
|
+
if (platforms.length === 0) {
|
|
255
|
+
throw new Error(`${label} omits its complete platform set`);
|
|
256
|
+
}
|
|
257
|
+
const sorted = [...platforms].sort(comparePlatform);
|
|
258
|
+
if (canonicalJson(platforms) !== canonicalJson(sorted)) {
|
|
259
|
+
throw new Error(`${label} platforms are not canonically ordered`);
|
|
260
|
+
}
|
|
261
|
+
const identities = new Set();
|
|
262
|
+
const digests = new Set();
|
|
263
|
+
for (const platform of platforms) {
|
|
264
|
+
const identity = platformIdentity(platform);
|
|
265
|
+
if (identities.has(identity)) {
|
|
266
|
+
throw new Error(`${label} has duplicate platform "${identity}"`);
|
|
267
|
+
}
|
|
268
|
+
if (digests.has(platform.manifestDigest)) {
|
|
269
|
+
throw new Error(
|
|
270
|
+
`${label} reuses platform digest "${platform.manifestDigest}"`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
identities.add(identity);
|
|
274
|
+
digests.add(platform.manifestDigest);
|
|
275
|
+
}
|
|
276
|
+
// COVERAGE, not equality. `requiredPlatforms` is what the edition DEMANDS
|
|
277
|
+
// the published coordinate serve — `verify-image-platforms.mjs` reads it the
|
|
278
|
+
// same way at delivery (`missing = required.filter(not present)`), and this
|
|
279
|
+
// gate must not contradict it.
|
|
280
|
+
//
|
|
281
|
+
// Equality here was a latent, total break of the whole plane. The canonical
|
|
282
|
+
// `<imageRef>:<contentHash>` coordinate becomes a MULTI-ARCH INDEX once the
|
|
283
|
+
// arm64 lane merges, so a statement produced against the delivered digest
|
|
284
|
+
// necessarily proves BOTH platforms — while every non-appliance edition
|
|
285
|
+
// declares `[linux/amd64]` and both appliance editions declare
|
|
286
|
+
// `[linux/arm64]`. Under equality NO single statement could ever satisfy any
|
|
287
|
+
// of them, and the same subject would need contradictory evidence per
|
|
288
|
+
// edition. The producer signs ONE statement about ONE published artifact;
|
|
289
|
+
// the edition's requirement is a subset of what that artifact serves.
|
|
290
|
+
//
|
|
291
|
+
// Nothing is weakened: every entry in `platforms` was proven at assembly
|
|
292
|
+
// time through its own hash-verified image config
|
|
293
|
+
// (`resolvePublishedPlatforms`), the ordering/uniqueness invariants above
|
|
294
|
+
// still hold exactly, and a REQUIRED platform that the statement does not
|
|
295
|
+
// attest is still a hard failure — which is the property that matters.
|
|
296
|
+
const actualIdentities = new Set(platforms.map(platformIdentity));
|
|
297
|
+
const missing = [
|
|
298
|
+
...new Set(requiredPlatforms.map(platformIdentity)),
|
|
299
|
+
]
|
|
300
|
+
.filter((identity) => !actualIdentities.has(identity))
|
|
301
|
+
.sort();
|
|
302
|
+
if (missing.length > 0) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
`${label} attests platforms ${[...actualIdentities].sort().join(', ')}, ` +
|
|
305
|
+
`which omit ${missing.join(', ')} required by images.lock.json`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function normalizedImageLockDigest(imageLock) {
|
|
311
|
+
return sha256Digest(canonicalJsonBytes(imageLock));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Membership in an OPTIONAL policy allowlist.
|
|
316
|
+
*
|
|
317
|
+
* `imageProducer.buildDefinitionDigests` / `.parametersDigests` are optional in
|
|
318
|
+
* `SigstoreIdentityPolicySchema`. Both are derived from the BuildKit SLSA v1
|
|
319
|
+
* `buildDefinition`, which carries the build's VCS revision and resolved
|
|
320
|
+
* dependencies — they change on essentially every build, so an armed policy
|
|
321
|
+
* cannot enumerate them ahead of time. OMITTED therefore means "this dimension
|
|
322
|
+
* is not pinned", and the build stays bound by builder id, build type, OCI
|
|
323
|
+
* media type and the Sigstore certificate identity / workflow binding.
|
|
324
|
+
*
|
|
325
|
+
* PRESENT means a hermetic deployment pinned an exact build, and membership is
|
|
326
|
+
* then STRICT — there is no "empty list means anything" escape: the kernel
|
|
327
|
+
* schema rejects an empty list, and one reaching here anyway admits nothing.
|
|
328
|
+
*/
|
|
329
|
+
function pinnedAllowlistAdmits(allowlist, value) {
|
|
330
|
+
return allowlist === undefined || allowlist.includes(value);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function validateProducerStatementBinding({
|
|
334
|
+
statement,
|
|
335
|
+
subject,
|
|
336
|
+
sourceHashAlgorithm,
|
|
337
|
+
expectedContractAuthority,
|
|
338
|
+
now,
|
|
339
|
+
maxTtlSeconds,
|
|
340
|
+
producerPolicy,
|
|
341
|
+
repositoryPin,
|
|
342
|
+
consumedNonces = new Set(),
|
|
343
|
+
}) {
|
|
344
|
+
if (
|
|
345
|
+
typeof sourceHashAlgorithm !== 'string' ||
|
|
346
|
+
sourceHashAlgorithm.length === 0
|
|
347
|
+
) {
|
|
348
|
+
throw new Error(
|
|
349
|
+
"producer evidence validation requires the image lock's declared source hash algorithm",
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
const { predicate } = statement;
|
|
353
|
+
const label = `producer evidence for ${subject.kind}:${subject.id}`;
|
|
354
|
+
assertKernelContractAuthorityEqual(
|
|
355
|
+
predicate.contractAuthority,
|
|
356
|
+
expectedContractAuthority,
|
|
357
|
+
label,
|
|
358
|
+
);
|
|
359
|
+
assertTimeWindow({
|
|
360
|
+
issuedAt: predicate.producerRun.issuedAt,
|
|
361
|
+
expiresAt: predicate.producerRun.expiresAt,
|
|
362
|
+
now,
|
|
363
|
+
maxTtlSeconds,
|
|
364
|
+
label,
|
|
365
|
+
});
|
|
366
|
+
if (consumedNonces.has(predicate.producerRun.nonce)) {
|
|
367
|
+
throw new Error(`${label} replays an already-consumed nonce`);
|
|
368
|
+
}
|
|
369
|
+
if (
|
|
370
|
+
repositoryPin?.repo !== subject.repo ||
|
|
371
|
+
repositoryPin.sourceCommit !== subject.sourceCommit
|
|
372
|
+
) {
|
|
373
|
+
throw new Error(`${label} does not match the committed repository pin`);
|
|
374
|
+
}
|
|
375
|
+
const expectedSource = {
|
|
376
|
+
kind: subject.kind,
|
|
377
|
+
id: subject.id,
|
|
378
|
+
repositoryUrl: repositoryPin.repositoryUrl,
|
|
379
|
+
repo: subject.repo,
|
|
380
|
+
path: subject.path,
|
|
381
|
+
sourceCommit: subject.sourceCommit,
|
|
382
|
+
sourceManifestHash: subject.sourceManifestHash,
|
|
383
|
+
// Read from the lock, never a literal: the lock's declared algorithm IS the
|
|
384
|
+
// coordinate's identity, and a hardcoded copy here would silently keep
|
|
385
|
+
// validating against a retired algorithm after the coordinate widened.
|
|
386
|
+
sourceHashAlgorithm,
|
|
387
|
+
sourceTreeHash: subject.contentHash,
|
|
388
|
+
};
|
|
389
|
+
if (canonicalJson(predicate.source) !== canonicalJson(expectedSource)) {
|
|
390
|
+
throw new Error(`${label} source identity does not match images.lock.json`);
|
|
391
|
+
}
|
|
392
|
+
if (
|
|
393
|
+
statement.subject[0].name !== subject.imageRef ||
|
|
394
|
+
`sha256:${statement.subject[0].digest.sha256}` !==
|
|
395
|
+
predicate.artifact.manifestDigest ||
|
|
396
|
+
predicate.artifact.imageRepository !== subject.imageRef
|
|
397
|
+
) {
|
|
398
|
+
throw new Error(`${label} has an OCI subject/digest substitution`);
|
|
399
|
+
}
|
|
400
|
+
// THE SEALED ARTIFACT. `imageDigest` names the exact manifest that SHIPS
|
|
401
|
+
// (`seal-image-digests.mjs` writes it from the delivery-time platform
|
|
402
|
+
// verifier; the chart plane installs by it). The producer statement attests
|
|
403
|
+
// one manifest digest. If the two differ, the evidence proves an image OTHER
|
|
404
|
+
// than the one the lock installs — every check above still passes, because
|
|
405
|
+
// they bind the SOURCE coordinate and the statement's internal consistency,
|
|
406
|
+
// not the shipped bytes. That is the one substitution a self-consistent
|
|
407
|
+
// evidence set can carry, and it is the one a customer verifying its own
|
|
408
|
+
// edition most needs refused. A subject with NO `imageDigest` gives the
|
|
409
|
+
// statement nothing to be bound to, so it is refused, never read as "binds
|
|
410
|
+
// nothing": evidence is verified against a sealed lock only.
|
|
411
|
+
if (typeof subject.imageDigest !== 'string') {
|
|
412
|
+
throw new Error(
|
|
413
|
+
`${label}: images.lock.json seals no imageDigest for ${subject.kind}:${subject.id}, so the ` +
|
|
414
|
+
'attested manifest is bound to nothing. Run `xema edition seal` first.',
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
if (subject.imageDigest !== predicate.artifact.manifestDigest) {
|
|
418
|
+
throw new Error(
|
|
419
|
+
`${label} attests manifest ${predicate.artifact.manifestDigest} but ` +
|
|
420
|
+
`images.lock.json seals ${subject.imageDigest} for ${subject.imageRef}`,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
assertPlatforms(predicate.build.platforms, subject.requiredPlatforms, label);
|
|
424
|
+
// The always-required anchors are checked unconditionally; the two per-build
|
|
425
|
+
// digests only narrow the identity when the policy actually pinned them (see
|
|
426
|
+
// `pinnedAllowlistAdmits`). Reading them unconditionally would throw a
|
|
427
|
+
// TypeError on `undefined.includes` for every ordinary policy, and simply
|
|
428
|
+
// ignoring a PRESENT list would be a gate that cannot fail.
|
|
429
|
+
const authorizedBuildIdentity =
|
|
430
|
+
Boolean(producerPolicy) &&
|
|
431
|
+
producerPolicy.builderIds.includes(predicate.build.builderId) &&
|
|
432
|
+
producerPolicy.buildTypes.includes(predicate.build.buildType) &&
|
|
433
|
+
producerPolicy.manifestMediaTypes.includes(
|
|
434
|
+
predicate.artifact.manifestMediaType,
|
|
435
|
+
) &&
|
|
436
|
+
pinnedAllowlistAdmits(
|
|
437
|
+
producerPolicy.buildDefinitionDigests,
|
|
438
|
+
predicate.build.buildDefinitionDigest,
|
|
439
|
+
) &&
|
|
440
|
+
pinnedAllowlistAdmits(
|
|
441
|
+
producerPolicy.parametersDigests,
|
|
442
|
+
predicate.build.parametersDigest,
|
|
443
|
+
);
|
|
444
|
+
if (!authorizedBuildIdentity) {
|
|
445
|
+
throw new Error(
|
|
446
|
+
`${label} build identity, definition, parameters, or OCI media type is not authorized`,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
const protectedDigests = new Set([
|
|
450
|
+
predicate.artifact.manifestDigest,
|
|
451
|
+
predicate.artifact.sbom.digest,
|
|
452
|
+
predicate.artifact.provenance.digest,
|
|
453
|
+
]);
|
|
454
|
+
if (protectedDigests.size !== 3) {
|
|
455
|
+
throw new Error(
|
|
456
|
+
`${label} substitutes an image, SBOM, or provenance digest`,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
return statement;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* The canonical order of a completeness `images[]` entry.
|
|
464
|
+
*
|
|
465
|
+
* Exported because the PRODUCER must emit exactly the order the verifier
|
|
466
|
+
* demands: `validateCompletenessStatementBinding` REFUSES a statement whose
|
|
467
|
+
* `images[]` is not in this order. Two copies of a comparator that a signature
|
|
468
|
+
* is bound to is a signed artifact waiting to become unverifiable.
|
|
469
|
+
*/
|
|
470
|
+
export function compareCompletenessImages(left, right) {
|
|
471
|
+
return (
|
|
472
|
+
compareStrings(left.subjectKind, right.subjectKind) ||
|
|
473
|
+
compareStrings(left.subjectId, right.subjectId)
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function normalizedCompletenessImages(images) {
|
|
478
|
+
return [...images].sort(compareCompletenessImages);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export function validateCompletenessStatementBinding({
|
|
482
|
+
statement,
|
|
483
|
+
distributionLock,
|
|
484
|
+
imageLock,
|
|
485
|
+
verifiedProducers,
|
|
486
|
+
evidenceDescriptors,
|
|
487
|
+
now,
|
|
488
|
+
maxTtlSeconds,
|
|
489
|
+
expectedReleaseSourceCommit,
|
|
490
|
+
evidenceNonces = new Set(),
|
|
491
|
+
}) {
|
|
492
|
+
const { predicate } = statement;
|
|
493
|
+
const label = `distribution completeness for ${imageLock.distributionId}`;
|
|
494
|
+
assertTimeWindow({
|
|
495
|
+
issuedAt: predicate.issuedAt,
|
|
496
|
+
expiresAt: predicate.expiresAt,
|
|
497
|
+
now,
|
|
498
|
+
maxTtlSeconds,
|
|
499
|
+
label,
|
|
500
|
+
});
|
|
501
|
+
if (evidenceNonces.has(predicate.nonce)) {
|
|
502
|
+
throw new Error(`${label} reuses a producer nonce`);
|
|
503
|
+
}
|
|
504
|
+
if (predicate.releaseSourceCommit !== expectedReleaseSourceCommit) {
|
|
505
|
+
throw new Error(
|
|
506
|
+
`${label} does not bind the exact editions release source commit`,
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
const lockDigest = normalizedImageLockDigest(imageLock);
|
|
510
|
+
if (
|
|
511
|
+
predicate.distributionId !== distributionLock.distributionId ||
|
|
512
|
+
predicate.distributionInputHash !== distributionLock.inputHash ||
|
|
513
|
+
predicate.normalizedImageLockDigest !== lockDigest ||
|
|
514
|
+
statement.subject[0].name !==
|
|
515
|
+
`xema-distribution/${distributionLock.distributionId}` ||
|
|
516
|
+
`sha256:${statement.subject[0].digest.sha256}` !== lockDigest
|
|
517
|
+
) {
|
|
518
|
+
throw new Error(`${label} does not bind the selected distribution locks`);
|
|
519
|
+
}
|
|
520
|
+
const expectedImages = imageLock.subjects.map((subject) => {
|
|
521
|
+
const key = sourceIdentity(subject);
|
|
522
|
+
const producer = verifiedProducers.get(key);
|
|
523
|
+
const descriptor = evidenceDescriptors.get(key);
|
|
524
|
+
if (!producer || !descriptor) {
|
|
525
|
+
throw new Error(`${label} omits producer evidence for ${key}`);
|
|
526
|
+
}
|
|
527
|
+
return {
|
|
528
|
+
subjectKind: subject.kind,
|
|
529
|
+
subjectId: subject.id,
|
|
530
|
+
imageRepository: subject.imageRef,
|
|
531
|
+
manifestDigest: producer.predicate.artifact.manifestDigest,
|
|
532
|
+
producerStatementDigest: descriptor.statementDigest,
|
|
533
|
+
producerBundleDigest: descriptor.bundleDigest,
|
|
534
|
+
};
|
|
535
|
+
});
|
|
536
|
+
const actual = normalizedCompletenessImages(predicate.images);
|
|
537
|
+
const expected = normalizedCompletenessImages(expectedImages);
|
|
538
|
+
if (
|
|
539
|
+
canonicalJson(predicate.images) !== canonicalJson(actual) ||
|
|
540
|
+
canonicalJson(actual) !== canonicalJson(expected)
|
|
541
|
+
) {
|
|
542
|
+
throw new Error(
|
|
543
|
+
`${label} image set is incomplete, contains extras, or is not canonically ordered`,
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
return statement;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export function validateEntitlementStatementBinding({
|
|
550
|
+
statement,
|
|
551
|
+
completenessStatementDigest,
|
|
552
|
+
customerId,
|
|
553
|
+
distributionId,
|
|
554
|
+
mode,
|
|
555
|
+
targetRegistry,
|
|
556
|
+
now,
|
|
557
|
+
maxTtlSeconds,
|
|
558
|
+
consumedNonces = new Set(),
|
|
559
|
+
}) {
|
|
560
|
+
const { predicate } = statement;
|
|
561
|
+
const label = `delivery entitlement for ${customerId}`;
|
|
562
|
+
assertTimeWindow({
|
|
563
|
+
issuedAt: predicate.issuedAt,
|
|
564
|
+
expiresAt: predicate.expiresAt,
|
|
565
|
+
now,
|
|
566
|
+
maxTtlSeconds,
|
|
567
|
+
label,
|
|
568
|
+
});
|
|
569
|
+
// The predicate's `idempotencyKey` is the issuer's own request key. Nothing
|
|
570
|
+
// on the verifying side records keys across runs, so it is not checked here:
|
|
571
|
+
// replay is refused by the nonce, and by the replay state's release sequence.
|
|
572
|
+
if (consumedNonces.has(predicate.nonce)) {
|
|
573
|
+
throw new Error(`${label} is replayed`);
|
|
574
|
+
}
|
|
575
|
+
if (
|
|
576
|
+
predicate.customerId !== customerId ||
|
|
577
|
+
predicate.distributionId !== distributionId ||
|
|
578
|
+
predicate.distributionStatementDigest !== completenessStatementDigest ||
|
|
579
|
+
predicate.mode !== mode ||
|
|
580
|
+
predicate.targetRegistry !== targetRegistry
|
|
581
|
+
) {
|
|
582
|
+
throw new Error(
|
|
583
|
+
`${label} does not authorize the requested customer, distribution, mode, and target`,
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
if (
|
|
587
|
+
statement.subject[0].name !== `xema-distribution/${distributionId}` ||
|
|
588
|
+
`sha256:${statement.subject[0].digest.sha256}` !==
|
|
589
|
+
completenessStatementDigest
|
|
590
|
+
) {
|
|
591
|
+
throw new Error(`${label} substitutes the distribution release`);
|
|
592
|
+
}
|
|
593
|
+
return statement;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function assertEvidenceChannel(channel) {
|
|
597
|
+
if (typeof channel !== 'string' || channel === '') {
|
|
598
|
+
throw new Error('evidence verification requires the channel of the edition being verified');
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* The signers a policy authorizes for one predicate on ONE channel. Producer
|
|
604
|
+
* evidence is further bound to the exact image repository the subject's
|
|
605
|
+
* imageRef names (no prefix match).
|
|
606
|
+
*
|
|
607
|
+
* A signer belongs to one channel: the kernel policy schema, which every policy
|
|
608
|
+
* this plane loads is parsed by, holds a row's `githubWorkflow.ref` to the ref
|
|
609
|
+
* of its channel (`EVIDENCE_CHANNEL_REFS`) and its `certificateIdentity` to
|
|
610
|
+
* that ref, so no signer can be authorized on two channels. The signature
|
|
611
|
+
* therefore authenticates the channel: an edition descriptor that states the
|
|
612
|
+
* other channel selects signers whose certificates the evidence does not carry,
|
|
613
|
+
* or none at all.
|
|
614
|
+
*/
|
|
615
|
+
function policyCandidates(policy, predicateType, channel, imageRepository) {
|
|
616
|
+
return policy.policies.filter(
|
|
617
|
+
(candidate) =>
|
|
618
|
+
candidate.channel === channel &&
|
|
619
|
+
candidate.predicateTypes.includes(predicateType) &&
|
|
620
|
+
(imageRepository === undefined ||
|
|
621
|
+
candidate.imageRepositories?.includes(imageRepository) === true),
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function cosignVersion(cosignBinary) {
|
|
626
|
+
const output = exactExecutableOutput(cosignBinary, ['version'], {
|
|
627
|
+
env: COSIGN_ENV,
|
|
628
|
+
timeout: 10_000,
|
|
629
|
+
});
|
|
630
|
+
const match = /\bGitVersion:\s*(v\d+\.\d+\.\d+)\b/.exec(output);
|
|
631
|
+
if (!match) {
|
|
632
|
+
throw new Error('cosign version output has no exact GitVersion');
|
|
633
|
+
}
|
|
634
|
+
return match[1];
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
export function prepareTrustedCosignBinary({ cosignBinary, policy }) {
|
|
638
|
+
return prepareDigestPinnedExecutable({
|
|
639
|
+
executablePath: cosignBinary,
|
|
640
|
+
trustedDigests: policy.trustedCosignBinaryDigests,
|
|
641
|
+
label: 'cosign',
|
|
642
|
+
verifyVersion(privateBinary) {
|
|
643
|
+
if (cosignVersion(privateBinary) !== policy.cosignVersion) {
|
|
644
|
+
throw new Error(
|
|
645
|
+
`cosign version does not match pinned ${policy.cosignVersion}`,
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
},
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
export function verifySigstoreEvidenceDescriptor({
|
|
653
|
+
evidenceRoot,
|
|
654
|
+
descriptor,
|
|
655
|
+
policy,
|
|
656
|
+
predicateType,
|
|
657
|
+
channel,
|
|
658
|
+
imageRepository,
|
|
659
|
+
expectedWorkflowSha,
|
|
660
|
+
parseStatement,
|
|
661
|
+
policyRoot = evidenceRoot,
|
|
662
|
+
trustedCosign,
|
|
663
|
+
}) {
|
|
664
|
+
assertEvidenceChannel(channel);
|
|
665
|
+
const statementFile = readExactDescriptorFile(
|
|
666
|
+
evidenceRoot,
|
|
667
|
+
descriptor.statementPath,
|
|
668
|
+
descriptor.statementDigest,
|
|
669
|
+
'evidence statement',
|
|
670
|
+
MAX_STATEMENT_BYTES,
|
|
671
|
+
);
|
|
672
|
+
const bundleFile = readExactDescriptorFile(
|
|
673
|
+
evidenceRoot,
|
|
674
|
+
descriptor.bundlePath,
|
|
675
|
+
descriptor.bundleDigest,
|
|
676
|
+
'Sigstore bundle',
|
|
677
|
+
MAX_SIGSTORE_BUNDLE_BYTES,
|
|
678
|
+
);
|
|
679
|
+
const bundle = JSON.parse(
|
|
680
|
+
decodeExactUtf8(bundleFile.bytes, 'Sigstore bundle'),
|
|
681
|
+
);
|
|
682
|
+
if (bundle.mediaType !== descriptor.bundleMediaType) {
|
|
683
|
+
throw new Error('Sigstore bundle mediaType does not match its descriptor');
|
|
684
|
+
}
|
|
685
|
+
if (
|
|
686
|
+
bundle.messageSignature !== undefined ||
|
|
687
|
+
typeof bundle.dsseEnvelope !== 'object' ||
|
|
688
|
+
bundle.dsseEnvelope === null ||
|
|
689
|
+
bundle.dsseEnvelope.payloadType !== 'application/vnd.in-toto+json' ||
|
|
690
|
+
!Array.isArray(bundle.dsseEnvelope.signatures) ||
|
|
691
|
+
bundle.dsseEnvelope.signatures.length !== 1 ||
|
|
692
|
+
typeof bundle.dsseEnvelope.payload !== 'string' ||
|
|
693
|
+
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(
|
|
694
|
+
bundle.dsseEnvelope.payload,
|
|
695
|
+
)
|
|
696
|
+
) {
|
|
697
|
+
throw new Error(
|
|
698
|
+
'Sigstore bundle must contain exactly one DSSE in-toto signature',
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
const dssePayload = Buffer.from(bundle.dsseEnvelope.payload, 'base64');
|
|
702
|
+
if (
|
|
703
|
+
dssePayload.toString('base64') !== bundle.dsseEnvelope.payload ||
|
|
704
|
+
!dssePayload.equals(statementFile.bytes)
|
|
705
|
+
) {
|
|
706
|
+
throw new Error(
|
|
707
|
+
'Sigstore DSSE payload does not exactly match the canonical statement',
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
const statement = parseStatement(
|
|
711
|
+
parseCanonicalJsonBytes(statementFile.bytes, 'evidence statement'),
|
|
712
|
+
);
|
|
713
|
+
if (statement.predicateType !== predicateType) {
|
|
714
|
+
throw new Error('evidence statement predicateType is not authorized');
|
|
715
|
+
}
|
|
716
|
+
const candidates = policyCandidates(
|
|
717
|
+
policy,
|
|
718
|
+
predicateType,
|
|
719
|
+
channel,
|
|
720
|
+
imageRepository,
|
|
721
|
+
);
|
|
722
|
+
if (candidates.length === 0) {
|
|
723
|
+
throw new Error(
|
|
724
|
+
`offline trust policy authorizes no signer for ${predicateType} on channel "${channel}"` +
|
|
725
|
+
(imageRepository === undefined
|
|
726
|
+
? ''
|
|
727
|
+
: ` and image repository "${imageRepository}"`),
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
const trustedCosignPath = assertPreparedExecutable(trustedCosign, 'cosign');
|
|
731
|
+
const trustedRoot = validatePinnedTrustedRoot(policyRoot, policy);
|
|
732
|
+
const verifiedWorkflowSha =
|
|
733
|
+
expectedWorkflowSha ??
|
|
734
|
+
statement.predicate?.producerRun?.producerWorkflowSha;
|
|
735
|
+
let verified = false;
|
|
736
|
+
let verifiedIdentityPolicy;
|
|
737
|
+
const verificationErrors = [];
|
|
738
|
+
for (const candidate of candidates) {
|
|
739
|
+
if (!verifiedWorkflowSha) {
|
|
740
|
+
throw new Error(
|
|
741
|
+
`policy "${candidate.policyId}" requires an exact workflow SHA`,
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
const verifiedOutputDirectory = mkdtempSync(
|
|
745
|
+
join(tmpdir(), 'xema-verified-dsse-'),
|
|
746
|
+
);
|
|
747
|
+
try {
|
|
748
|
+
const verifiedBundlePath = join(
|
|
749
|
+
verifiedOutputDirectory,
|
|
750
|
+
'bundle.sigstore.json',
|
|
751
|
+
);
|
|
752
|
+
const verifiedTrustedRootPath = join(
|
|
753
|
+
verifiedOutputDirectory,
|
|
754
|
+
'trusted-root.json',
|
|
755
|
+
);
|
|
756
|
+
writeFileSync(verifiedBundlePath, bundleFile.bytes, {
|
|
757
|
+
flag: 'wx',
|
|
758
|
+
mode: 0o600,
|
|
759
|
+
});
|
|
760
|
+
writeFileSync(verifiedTrustedRootPath, trustedRoot.bytes, {
|
|
761
|
+
flag: 'wx',
|
|
762
|
+
mode: 0o600,
|
|
763
|
+
});
|
|
764
|
+
// `--check-claims=false`: the attested claim is bound to the statement,
|
|
765
|
+
// not to a blob — the DSSE payload was already proven byte-identical to
|
|
766
|
+
// the canonical statement above, so cosign only has to prove the
|
|
767
|
+
// signature under the exact certificate constraints.
|
|
768
|
+
const args = [
|
|
769
|
+
'verify-blob-attestation',
|
|
770
|
+
'--check-claims=false',
|
|
771
|
+
'--offline',
|
|
772
|
+
'--new-bundle-format',
|
|
773
|
+
'--trusted-root',
|
|
774
|
+
verifiedTrustedRootPath,
|
|
775
|
+
'--bundle',
|
|
776
|
+
verifiedBundlePath,
|
|
777
|
+
'--certificate-identity',
|
|
778
|
+
candidate.certificateIdentity,
|
|
779
|
+
'--certificate-oidc-issuer',
|
|
780
|
+
candidate.oidcIssuer,
|
|
781
|
+
'--type',
|
|
782
|
+
predicateType,
|
|
783
|
+
];
|
|
784
|
+
args.push(
|
|
785
|
+
'--certificate-github-workflow-repository',
|
|
786
|
+
candidate.githubWorkflow.repository,
|
|
787
|
+
'--certificate-github-workflow-ref',
|
|
788
|
+
candidate.githubWorkflow.ref,
|
|
789
|
+
'--certificate-github-workflow-sha',
|
|
790
|
+
verifiedWorkflowSha,
|
|
791
|
+
);
|
|
792
|
+
execFileSync(trustedCosignPath, args, {
|
|
793
|
+
env: COSIGN_ENV,
|
|
794
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
795
|
+
timeout: 60_000,
|
|
796
|
+
});
|
|
797
|
+
verified = true;
|
|
798
|
+
verifiedIdentityPolicy = candidate;
|
|
799
|
+
break;
|
|
800
|
+
} catch (error) {
|
|
801
|
+
verificationErrors.push(
|
|
802
|
+
`${candidate.policyId}: ${
|
|
803
|
+
error instanceof Error ? error.message : String(error)
|
|
804
|
+
}`,
|
|
805
|
+
);
|
|
806
|
+
} finally {
|
|
807
|
+
rmSync(verifiedOutputDirectory, {
|
|
808
|
+
recursive: true,
|
|
809
|
+
force: true,
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
if (!verified) {
|
|
814
|
+
throw new Error(
|
|
815
|
+
'offline Sigstore verification failed for every trusted identity: ' +
|
|
816
|
+
verificationErrors.join('; '),
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
return {
|
|
820
|
+
statement,
|
|
821
|
+
statementPath: statementFile.path,
|
|
822
|
+
identityPolicy: verifiedIdentityPolicy,
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
export async function verifyDistributionImageEvidenceSet(options) {
|
|
827
|
+
assertEvidenceChannel(options.channel);
|
|
828
|
+
const trustedCosign = prepareTrustedCosignBinary({
|
|
829
|
+
cosignBinary: options.cosignBinary,
|
|
830
|
+
policy: options.policy,
|
|
831
|
+
});
|
|
832
|
+
try {
|
|
833
|
+
return await verifyDistributionImageEvidenceSetWithCosign({
|
|
834
|
+
...options,
|
|
835
|
+
trustedCosign,
|
|
836
|
+
});
|
|
837
|
+
} finally {
|
|
838
|
+
trustedCosign.dispose();
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
async function verifyDistributionImageEvidenceSetWithCosign({
|
|
843
|
+
distributionId,
|
|
844
|
+
channel,
|
|
845
|
+
dataRoot,
|
|
846
|
+
// WHERE THE LOCKS ARE READ FROM: the editions root. The runtime index comes
|
|
847
|
+
// from `dataRoot`: the index is the data root's, the lock is the edition's.
|
|
848
|
+
editionsRoot,
|
|
849
|
+
evidenceRoot,
|
|
850
|
+
evidenceSet,
|
|
851
|
+
policy,
|
|
852
|
+
now,
|
|
853
|
+
expectedReleaseSourceCommit,
|
|
854
|
+
policyRoot = evidenceRoot,
|
|
855
|
+
trustedCosign,
|
|
856
|
+
schemas,
|
|
857
|
+
}) {
|
|
858
|
+
// The authority the producer statements are held to is the one the data
|
|
859
|
+
// root's index states, which the entry has already held to the data root's
|
|
860
|
+
// evidence-trust pin (`readTrustAnchors`); the bundled kernel is held to that
|
|
861
|
+
// authority by the `--kernel` equality check at the entry, and the schemas that
|
|
862
|
+
// parse the index are the bundled ones, held to their edition protocol.
|
|
863
|
+
const {
|
|
864
|
+
distributionLock,
|
|
865
|
+
imageLock,
|
|
866
|
+
repositoryPins,
|
|
867
|
+
runtimeSourceIndex: { contractAuthority: expectedContractAuthority },
|
|
868
|
+
} = await loadValidatedLocks(distributionId, dataRoot, schemas, {
|
|
869
|
+
editionsRoot,
|
|
870
|
+
});
|
|
871
|
+
if (evidenceSet.distributionId !== distributionId) {
|
|
872
|
+
throw new Error(
|
|
873
|
+
`evidence set "${evidenceSet.distributionId}" does not match requested distribution "${distributionId}"`,
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const evidenceDescriptors = new Map();
|
|
878
|
+
for (const producerEvidence of evidenceSet.producerEvidence) {
|
|
879
|
+
const key = `${producerEvidence.subjectKind}\0${producerEvidence.subjectId}`;
|
|
880
|
+
if (evidenceDescriptors.has(key)) {
|
|
881
|
+
throw new Error(`evidence set duplicates producer subject ${key}`);
|
|
882
|
+
}
|
|
883
|
+
evidenceDescriptors.set(key, producerEvidence.evidence);
|
|
884
|
+
}
|
|
885
|
+
if (evidenceDescriptors.size !== imageLock.subjects.length) {
|
|
886
|
+
throw new Error(
|
|
887
|
+
`evidence set has ${evidenceDescriptors.size} producer subjects; images.lock.json requires exactly ${imageLock.subjects.length}`,
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const verifiedProducers = new Map();
|
|
892
|
+
const verifiedNonces = new Set();
|
|
893
|
+
for (const subject of imageLock.subjects) {
|
|
894
|
+
const key = sourceIdentity(subject);
|
|
895
|
+
const descriptor = evidenceDescriptors.get(key);
|
|
896
|
+
if (!descriptor) {
|
|
897
|
+
throw new Error(`evidence set omits producer subject ${key}`);
|
|
898
|
+
}
|
|
899
|
+
const repositoryPin = repositoryPins.get(subject.repo);
|
|
900
|
+
if (!repositoryPin) {
|
|
901
|
+
throw new Error(
|
|
902
|
+
`runtime subject "${subject.kind}:${subject.id}" has no committed repository pin`,
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
const { statement, identityPolicy } = verifySigstoreEvidenceDescriptor({
|
|
906
|
+
evidenceRoot,
|
|
907
|
+
descriptor,
|
|
908
|
+
policy,
|
|
909
|
+
predicateType: schemas.IMAGE_PRODUCER_PREDICATE_TYPE,
|
|
910
|
+
channel,
|
|
911
|
+
imageRepository: subject.imageRef,
|
|
912
|
+
parseStatement: schemas.parseImageProducerStatement,
|
|
913
|
+
policyRoot,
|
|
914
|
+
trustedCosign,
|
|
915
|
+
});
|
|
916
|
+
validateProducerStatementBinding({
|
|
917
|
+
statement,
|
|
918
|
+
subject,
|
|
919
|
+
sourceHashAlgorithm: imageLock.sourceHashAlgorithm,
|
|
920
|
+
expectedContractAuthority,
|
|
921
|
+
now,
|
|
922
|
+
maxTtlSeconds: policy.maxProducerTtlSeconds,
|
|
923
|
+
producerPolicy: identityPolicy.imageProducer,
|
|
924
|
+
repositoryPin,
|
|
925
|
+
consumedNonces: verifiedNonces,
|
|
926
|
+
});
|
|
927
|
+
verifiedNonces.add(statement.predicate.producerRun.nonce);
|
|
928
|
+
verifiedProducers.set(key, statement);
|
|
929
|
+
}
|
|
930
|
+
for (const key of evidenceDescriptors.keys()) {
|
|
931
|
+
if (!verifiedProducers.has(key)) {
|
|
932
|
+
throw new Error(`evidence set contains unknown producer subject ${key}`);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const { statement: completenessStatement } = verifySigstoreEvidenceDescriptor(
|
|
937
|
+
{
|
|
938
|
+
evidenceRoot,
|
|
939
|
+
descriptor: evidenceSet.completeness,
|
|
940
|
+
policy,
|
|
941
|
+
predicateType: schemas.DISTRIBUTION_IMAGES_PREDICATE_TYPE,
|
|
942
|
+
channel,
|
|
943
|
+
expectedWorkflowSha: expectedReleaseSourceCommit,
|
|
944
|
+
parseStatement: schemas.parseDistributionImagesStatement,
|
|
945
|
+
policyRoot,
|
|
946
|
+
trustedCosign,
|
|
947
|
+
},
|
|
948
|
+
);
|
|
949
|
+
validateCompletenessStatementBinding({
|
|
950
|
+
statement: completenessStatement,
|
|
951
|
+
distributionLock,
|
|
952
|
+
imageLock,
|
|
953
|
+
verifiedProducers,
|
|
954
|
+
evidenceDescriptors,
|
|
955
|
+
now,
|
|
956
|
+
maxTtlSeconds: policy.maxCompletenessTtlSeconds,
|
|
957
|
+
expectedReleaseSourceCommit,
|
|
958
|
+
evidenceNonces: verifiedNonces,
|
|
959
|
+
});
|
|
960
|
+
verifiedNonces.add(completenessStatement.predicate.nonce);
|
|
961
|
+
|
|
962
|
+
return {
|
|
963
|
+
distributionLock,
|
|
964
|
+
imageLock,
|
|
965
|
+
producerStatements: verifiedProducers,
|
|
966
|
+
completenessStatement,
|
|
967
|
+
completenessStatementDigest: evidenceSet.completeness.statementDigest,
|
|
968
|
+
verifiedNonces,
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
export async function verifyImageDeliveryEntitlement(options) {
|
|
973
|
+
assertEvidenceChannel(options.channel);
|
|
974
|
+
const trustedCosign = prepareTrustedCosignBinary({
|
|
975
|
+
cosignBinary: options.cosignBinary,
|
|
976
|
+
policy: options.policy,
|
|
977
|
+
});
|
|
978
|
+
try {
|
|
979
|
+
return await verifyImageDeliveryEntitlementWithCosign({
|
|
980
|
+
...options,
|
|
981
|
+
trustedCosign,
|
|
982
|
+
});
|
|
983
|
+
} finally {
|
|
984
|
+
trustedCosign.dispose();
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
async function verifyImageDeliveryEntitlementWithCosign({
|
|
989
|
+
evidenceRoot,
|
|
990
|
+
descriptor,
|
|
991
|
+
policy,
|
|
992
|
+
completenessStatementDigest,
|
|
993
|
+
customerId,
|
|
994
|
+
distributionId,
|
|
995
|
+
channel,
|
|
996
|
+
mode,
|
|
997
|
+
targetRegistry,
|
|
998
|
+
now,
|
|
999
|
+
expectedWorkflowSha,
|
|
1000
|
+
consumedNonces = new Set(),
|
|
1001
|
+
policyRoot = evidenceRoot,
|
|
1002
|
+
trustedCosign,
|
|
1003
|
+
schemas,
|
|
1004
|
+
}) {
|
|
1005
|
+
const { statement } = verifySigstoreEvidenceDescriptor({
|
|
1006
|
+
evidenceRoot,
|
|
1007
|
+
descriptor,
|
|
1008
|
+
policy,
|
|
1009
|
+
predicateType: schemas.IMAGE_DELIVERY_ENTITLEMENT_PREDICATE_TYPE,
|
|
1010
|
+
channel,
|
|
1011
|
+
expectedWorkflowSha,
|
|
1012
|
+
parseStatement: schemas.parseImageDeliveryEntitlementStatement,
|
|
1013
|
+
policyRoot,
|
|
1014
|
+
trustedCosign,
|
|
1015
|
+
});
|
|
1016
|
+
validateEntitlementStatementBinding({
|
|
1017
|
+
statement,
|
|
1018
|
+
completenessStatementDigest,
|
|
1019
|
+
customerId,
|
|
1020
|
+
distributionId,
|
|
1021
|
+
mode,
|
|
1022
|
+
targetRegistry,
|
|
1023
|
+
now,
|
|
1024
|
+
maxTtlSeconds: policy.maxEntitlementTtlSeconds,
|
|
1025
|
+
consumedNonces,
|
|
1026
|
+
});
|
|
1027
|
+
return statement;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Repository pins for the sources an EDITION declares itself.
|
|
1032
|
+
*
|
|
1033
|
+
* The runtime image source index pins the VENDOR fleet — every `repos/…` and
|
|
1034
|
+
* `submodules/…` repository the vendor builds images from. A customer edition
|
|
1035
|
+
* additionally draws biomes from sources declared in its own manifest
|
|
1036
|
+
* (`sources[]`, `kind: git`), and the images built from those have a `repo`
|
|
1037
|
+
* the index has never heard of and never will: `presenca-biomes`, not
|
|
1038
|
+
* `repos/xema-base`. Without a pin for it every such subject is refused as
|
|
1039
|
+
* "has no committed repository pin", so a customer could verify the vendor's
|
|
1040
|
+
* images in its edition and never its own.
|
|
1041
|
+
*
|
|
1042
|
+
* The pin for an external source is two facts from two records, and each is
|
|
1043
|
+
* the record that OWNS the fact:
|
|
1044
|
+
*
|
|
1045
|
+
* * `repositoryUrl` — the edition manifest's declaration, by source name.
|
|
1046
|
+
* The manifest is read from the editions checkout and is HEAD-bound there
|
|
1047
|
+
* exactly like the locks (the verifier names it a required edition path).
|
|
1048
|
+
* * `sourceCommit` — the image lock's subjects. A declared `ref` may be a
|
|
1049
|
+
* branch; the lock records the exact commit the walk materialised, and it
|
|
1050
|
+
* is the authority for what was built. Every subject of one source must
|
|
1051
|
+
* agree on one commit, or the lock describes a walk that never happened.
|
|
1052
|
+
*
|
|
1053
|
+
* A declared source that ships no image yields no pin. A source whose name
|
|
1054
|
+
* collides with an index pin is refused: the index is the vendor's record and
|
|
1055
|
+
* an edition may not restate it. Non-git sources build no images from a
|
|
1056
|
+
* repository and yield no pin; a subject naming one stays unpinned and is
|
|
1057
|
+
* refused downstream, as before.
|
|
1058
|
+
*/
|
|
1059
|
+
export function externalSourceRepositoryPins({
|
|
1060
|
+
distribution,
|
|
1061
|
+
imageLock,
|
|
1062
|
+
repositoryPins,
|
|
1063
|
+
}) {
|
|
1064
|
+
const derived = new Map();
|
|
1065
|
+
for (const source of distribution.sources ?? []) {
|
|
1066
|
+
if (source.kind !== 'git') {continue;}
|
|
1067
|
+
if (repositoryPins.has(source.name)) {
|
|
1068
|
+
throw new Error(
|
|
1069
|
+
`edition source "${source.name}" shadows a repository pin the runtime image source index already commits`,
|
|
1070
|
+
);
|
|
1071
|
+
}
|
|
1072
|
+
const subjects = imageLock.subjects.filter(
|
|
1073
|
+
(subject) => subject.repo === source.name,
|
|
1074
|
+
);
|
|
1075
|
+
if (subjects.length === 0) {continue;}
|
|
1076
|
+
const commits = [...new Set(subjects.map((subject) => subject.sourceCommit))];
|
|
1077
|
+
if (commits.length !== 1) {
|
|
1078
|
+
throw new Error(
|
|
1079
|
+
`edition source "${source.name}" is built at ${commits.length} different commits in images.lock.json (${commits.join(', ')}); one source resolves to one commit`,
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
derived.set(source.name, {
|
|
1083
|
+
repo: source.name,
|
|
1084
|
+
repositoryUrl: source.url,
|
|
1085
|
+
sourceCommit: commits[0],
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
return derived;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* The ONLY facts image evidence binds from `distribution.lock.json`, read
|
|
1093
|
+
* TOLERANTLY and nothing else.
|
|
1094
|
+
*
|
|
1095
|
+
* WHY THIS IS NOT `schemas.parseDistributionLock`. The standalone verifier is a
|
|
1096
|
+
* bundle of the kernel at its contract-authority commit, and that commit's lock
|
|
1097
|
+
* schema is the strict WRITER schema of its day. Measured 2026-10-01 against
|
|
1098
|
+
* the kernel at `24e9f12` (the authority every committed image lock names): a
|
|
1099
|
+
* committed lock parses, and the SAME lock with one additive biome key
|
|
1100
|
+
* (`provenance`) is refused — `[biomes.0] Unrecognized key: "provenance"`. So
|
|
1101
|
+
* every additive section the resolver ever learns would have made every
|
|
1102
|
+
* edition unverifiable until the verifier was rebuilt from a newer kernel, and
|
|
1103
|
+
* rebuilding it meant re-pinning the authority, which rewrites
|
|
1104
|
+
* `contractAuthority` in every `images.lock.json` and thereby invalidates every
|
|
1105
|
+
* signed producer statement and completeness digest. An edition's image
|
|
1106
|
+
* evidence would have been unsealed by a change that touched no image.
|
|
1107
|
+
*
|
|
1108
|
+
* What the evidence actually binds is two fields: the completeness predicate
|
|
1109
|
+
* names `distributionId` and `distributionInputHash`
|
|
1110
|
+
* (`validateCompletenessStatementBinding`), and every other caller reads the
|
|
1111
|
+
* same two. The lock's biome roster is the PRODUCER's contract, graded by
|
|
1112
|
+
* dist-check with the current kernel; the verifier never read it. So read
|
|
1113
|
+
* exactly those, validate exactly those, and ignore every other key — the
|
|
1114
|
+
* tolerant read-only posture `parseDistributionLockForReading` gives a
|
|
1115
|
+
* runtime, without depending on which kernel the bundle was built from.
|
|
1116
|
+
*
|
|
1117
|
+
* `schemaVersion` must be 5, the one grammar there is (the kernel's
|
|
1118
|
+
* `DISTRIBUTION_LOCK_SCHEMA_VERSIONS`): a pre-v5 lock is refused, with no
|
|
1119
|
+
* compatibility path.
|
|
1120
|
+
*/
|
|
1121
|
+
export function readDistributionLockBinding(raw) {
|
|
1122
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
1123
|
+
throw new Error('distribution.lock.json is not a JSON object');
|
|
1124
|
+
}
|
|
1125
|
+
const { schemaVersion, distributionId, inputHash } = raw;
|
|
1126
|
+
if (schemaVersion !== 5) {
|
|
1127
|
+
throw new Error(
|
|
1128
|
+
`distribution.lock.json has schemaVersion ${String(schemaVersion)}; the only accepted grammar is 5 (regenerate the lock)`,
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
// The id is format-checked only. Which edition it must equal is decided by
|
|
1132
|
+
// the bindings that USE it — the completeness predicate and the evidence set
|
|
1133
|
+
// compare it to the image lock and the signed statement — exactly as before
|
|
1134
|
+
// this reader existed; tightening that here would be a second, unasked rule.
|
|
1135
|
+
assertCanonicalDistributionId(distributionId);
|
|
1136
|
+
if (typeof inputHash !== 'string' || !/^[a-f0-9]{64}$/.test(inputHash)) {
|
|
1137
|
+
throw new Error(
|
|
1138
|
+
`distribution "${distributionId}" has invalid inputHash "${String(inputHash)}" — ` +
|
|
1139
|
+
'expected 64 lowercase sha256 hex characters',
|
|
1140
|
+
);
|
|
1141
|
+
}
|
|
1142
|
+
return Object.freeze({ schemaVersion, distributionId, inputHash });
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* Read and validate an edition's two locks plus the runtime image source index
|
|
1147
|
+
* they must agree with.
|
|
1148
|
+
*
|
|
1149
|
+
* `dataRoot` holds the runtime image source index, read from it always.
|
|
1150
|
+
* `editionsRoot` is where `<id>/distribution.lock.json`,
|
|
1151
|
+
* `<id>/images.lock.json` and `<id>/xema-distribution.json` are read from. The
|
|
1152
|
+
* manifest extends the repository pins with the edition's own declared sources
|
|
1153
|
+
* ({@link externalSourceRepositoryPins}); the verifier grades the editions
|
|
1154
|
+
* checkout so all three files are HEAD-bound.
|
|
1155
|
+
*/
|
|
1156
|
+
export async function loadValidatedLocks(
|
|
1157
|
+
distributionId,
|
|
1158
|
+
dataRoot,
|
|
1159
|
+
schemas,
|
|
1160
|
+
{ editionsRoot },
|
|
1161
|
+
) {
|
|
1162
|
+
if (!schemas) {
|
|
1163
|
+
throw new Error('trusted kernel distribution schemas are required');
|
|
1164
|
+
}
|
|
1165
|
+
const distributionLock = readDistributionLockBinding(
|
|
1166
|
+
JSON.parse(
|
|
1167
|
+
readFileSync(
|
|
1168
|
+
join(editionsRoot, distributionId, 'distribution.lock.json'),
|
|
1169
|
+
'utf8',
|
|
1170
|
+
),
|
|
1171
|
+
),
|
|
1172
|
+
);
|
|
1173
|
+
const imageLock = validateImmutableImageLock(
|
|
1174
|
+
schemas.parseImageLock(
|
|
1175
|
+
JSON.parse(
|
|
1176
|
+
readFileSync(
|
|
1177
|
+
join(editionsRoot, distributionId, 'images.lock.json'),
|
|
1178
|
+
'utf8',
|
|
1179
|
+
),
|
|
1180
|
+
),
|
|
1181
|
+
),
|
|
1182
|
+
schemas,
|
|
1183
|
+
);
|
|
1184
|
+
const runtimeSourceIndex = schemas.parseRuntimeImageSourceIndex(
|
|
1185
|
+
JSON.parse(
|
|
1186
|
+
readFileSync(
|
|
1187
|
+
join(dataRoot, 'runtime-image-source-index.json'),
|
|
1188
|
+
'utf8',
|
|
1189
|
+
),
|
|
1190
|
+
),
|
|
1191
|
+
);
|
|
1192
|
+
if (
|
|
1193
|
+
canonicalJson(imageLock.contractAuthority) !==
|
|
1194
|
+
canonicalJson(runtimeSourceIndex.contractAuthority)
|
|
1195
|
+
) {
|
|
1196
|
+
throw new Error(
|
|
1197
|
+
'images.lock.json kernel contract authority does not exactly match runtime-image-source-index.json',
|
|
1198
|
+
);
|
|
1199
|
+
}
|
|
1200
|
+
const repositoryPins = new Map(
|
|
1201
|
+
runtimeSourceIndex.repositoryPins.map((repository) => [
|
|
1202
|
+
repository.repo,
|
|
1203
|
+
repository,
|
|
1204
|
+
]),
|
|
1205
|
+
);
|
|
1206
|
+
const distribution = schemas.parseDistribution(
|
|
1207
|
+
JSON.parse(
|
|
1208
|
+
readFileSync(
|
|
1209
|
+
join(editionsRoot, distributionId, 'xema-distribution.json'),
|
|
1210
|
+
'utf8',
|
|
1211
|
+
),
|
|
1212
|
+
),
|
|
1213
|
+
);
|
|
1214
|
+
if (distribution.id !== distributionId) {
|
|
1215
|
+
throw new Error(
|
|
1216
|
+
`edition manifest at ${editionsRoot} declares id "${distribution.id}", not "${distributionId}"`,
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
1219
|
+
for (const [repo, pin] of externalSourceRepositoryPins({
|
|
1220
|
+
distribution,
|
|
1221
|
+
imageLock,
|
|
1222
|
+
repositoryPins,
|
|
1223
|
+
})) {
|
|
1224
|
+
repositoryPins.set(repo, pin);
|
|
1225
|
+
}
|
|
1226
|
+
return {
|
|
1227
|
+
schemas,
|
|
1228
|
+
distributionLock,
|
|
1229
|
+
imageLock,
|
|
1230
|
+
runtimeSourceIndex,
|
|
1231
|
+
repositoryPins,
|
|
1232
|
+
};
|
|
1233
|
+
}
|