@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.
Files changed (239) hide show
  1. package/README.md +70 -37
  2. package/dist/assets/appliance-base/images.lock.json +126 -126
  3. package/dist/assets/appliance-lean/images.lock.json +123 -123
  4. package/dist/assets/cloud/images.lock.json +126 -126
  5. package/dist/assets/core/images.lock.json +120 -120
  6. package/dist/assets/distribution-assets.manifest.json +15 -16
  7. package/dist/assets/minimal/images.lock.json +108 -108
  8. package/dist/assets/oss/images.lock.json +126 -126
  9. package/dist/assets/starter/images.lock.json +123 -123
  10. package/dist/cli.js +2 -2
  11. package/dist/cli.js.map +1 -1
  12. package/dist/commands/dev.js +1 -0
  13. package/dist/commands/dev.js.map +1 -1
  14. package/dist/commands/down.d.ts.map +1 -1
  15. package/dist/commands/down.js +7 -1
  16. package/dist/commands/down.js.map +1 -1
  17. package/dist/commands/edition.d.ts +3 -0
  18. package/dist/commands/edition.d.ts.map +1 -0
  19. package/dist/commands/edition.js +46 -0
  20. package/dist/commands/edition.js.map +1 -0
  21. package/dist/commands/init.js +2 -2
  22. package/dist/commands/init.js.map +1 -1
  23. package/dist/commands/serve.js +1 -0
  24. package/dist/commands/serve.js.map +1 -1
  25. package/dist/commands/up.js +1 -0
  26. package/dist/commands/up.js.map +1 -1
  27. package/dist/dev/env-defaults.d.ts.map +1 -1
  28. package/dist/dev/env-defaults.js +6 -1
  29. package/dist/dev/env-defaults.js.map +1 -1
  30. package/dist/dev/infra-preflight.d.ts +3 -2
  31. package/dist/dev/infra-preflight.d.ts.map +1 -1
  32. package/dist/dev/infra-preflight.js +18 -8
  33. package/dist/dev/infra-preflight.js.map +1 -1
  34. package/dist/edition/verify-image-evidence.bundle.mjs +54179 -0
  35. package/dist/edition/verify-image-evidence.bundle.provenance.json +2871 -0
  36. package/dist/runtime/backing-registration.d.ts.map +1 -1
  37. package/dist/runtime/backing-registration.js +7 -2
  38. package/dist/runtime/backing-registration.js.map +1 -1
  39. package/dist/runtime/config/schema.d.ts +1 -0
  40. package/dist/runtime/config/schema.d.ts.map +1 -1
  41. package/dist/runtime/custody/local-custody.d.ts +12 -0
  42. package/dist/runtime/custody/local-custody.d.ts.map +1 -0
  43. package/dist/runtime/custody/local-custody.js +22 -0
  44. package/dist/runtime/custody/local-custody.js.map +1 -0
  45. package/dist/runtime/custody/openbao-custody-bootstrap.d.ts +18 -0
  46. package/dist/runtime/custody/openbao-custody-bootstrap.d.ts.map +1 -0
  47. package/dist/runtime/custody/openbao-custody-bootstrap.js +50 -0
  48. package/dist/runtime/custody/openbao-custody-bootstrap.js.map +1 -0
  49. package/dist/runtime/custody/openbao-custody-store.d.ts +18 -0
  50. package/dist/runtime/custody/openbao-custody-store.d.ts.map +1 -0
  51. package/dist/runtime/custody/openbao-custody-store.js +55 -0
  52. package/dist/runtime/custody/openbao-custody-store.js.map +1 -0
  53. package/dist/runtime/custody/openbao-http-client.d.ts +34 -0
  54. package/dist/runtime/custody/openbao-http-client.d.ts.map +1 -0
  55. package/dist/runtime/custody/openbao-http-client.js +154 -0
  56. package/dist/runtime/custody/openbao-http-client.js.map +1 -0
  57. package/dist/runtime/custody/openbao-local-constants.d.ts +22 -0
  58. package/dist/runtime/custody/openbao-local-constants.d.ts.map +1 -0
  59. package/dist/runtime/custody/openbao-local-constants.js +48 -0
  60. package/dist/runtime/custody/openbao-local-constants.js.map +1 -0
  61. package/dist/runtime/custody/openbao-local-runner.d.ts +16 -0
  62. package/dist/runtime/custody/openbao-local-runner.d.ts.map +1 -0
  63. package/dist/runtime/custody/openbao-local-runner.js +86 -0
  64. package/dist/runtime/custody/openbao-local-runner.js.map +1 -0
  65. package/dist/runtime/edition/run-entry.d.ts +9 -0
  66. package/dist/runtime/edition/run-entry.d.ts.map +1 -0
  67. package/dist/runtime/edition/run-entry.js +36 -0
  68. package/dist/runtime/edition/run-entry.js.map +1 -0
  69. package/dist/runtime/edition/verb-map.d.ts +60 -0
  70. package/dist/runtime/edition/verb-map.d.ts.map +1 -0
  71. package/dist/runtime/edition/verb-map.js +146 -0
  72. package/dist/runtime/edition/verb-map.js.map +1 -0
  73. package/dist/runtime/env-seeder.d.ts +3 -0
  74. package/dist/runtime/env-seeder.d.ts.map +1 -1
  75. package/dist/runtime/env-seeder.js +20 -3
  76. package/dist/runtime/env-seeder.js.map +1 -1
  77. package/dist/runtime/launch.d.ts +3 -1
  78. package/dist/runtime/launch.d.ts.map +1 -1
  79. package/dist/runtime/launch.js +37 -8
  80. package/dist/runtime/launch.js.map +1 -1
  81. package/dist/runtime/paths.d.ts +1 -2
  82. package/dist/runtime/paths.d.ts.map +1 -1
  83. package/dist/runtime/paths.js +0 -10
  84. package/dist/runtime/paths.js.map +1 -1
  85. package/dist/runtime/runner/chart-local-provenance.d.ts +20 -0
  86. package/dist/runtime/runner/chart-local-provenance.d.ts.map +1 -0
  87. package/dist/runtime/runner/chart-local-provenance.js +114 -0
  88. package/dist/runtime/runner/chart-local-provenance.js.map +1 -0
  89. package/dist/runtime/runner/chart-preflight.d.ts +8 -8
  90. package/dist/runtime/runner/chart-preflight.d.ts.map +1 -1
  91. package/dist/runtime/runner/chart-preflight.js +30 -69
  92. package/dist/runtime/runner/chart-preflight.js.map +1 -1
  93. package/dist/runtime/runner/chart-pull.d.ts +14 -0
  94. package/dist/runtime/runner/chart-pull.d.ts.map +1 -0
  95. package/dist/runtime/runner/chart-pull.js +96 -0
  96. package/dist/runtime/runner/chart-pull.js.map +1 -0
  97. package/dist/runtime/runner/chart-resolver.d.ts +4 -12
  98. package/dist/runtime/runner/chart-resolver.d.ts.map +1 -1
  99. package/dist/runtime/runner/chart-resolver.js +16 -62
  100. package/dist/runtime/runner/chart-resolver.js.map +1 -1
  101. package/dist/runtime/runner/chart-set-lock.d.ts +6 -20
  102. package/dist/runtime/runner/chart-set-lock.d.ts.map +1 -1
  103. package/dist/runtime/runner/chart-set-lock.js +55 -99
  104. package/dist/runtime/runner/chart-set-lock.js.map +1 -1
  105. package/dist/runtime/runner/helm-cli.d.ts +3 -1
  106. package/dist/runtime/runner/helm-cli.d.ts.map +1 -1
  107. package/dist/runtime/runner/helm-cli.js +9 -7
  108. package/dist/runtime/runner/helm-cli.js.map +1 -1
  109. package/dist/runtime/runner/helm-runner.d.ts +4 -4
  110. package/dist/runtime/runner/helm-runner.d.ts.map +1 -1
  111. package/dist/runtime/runner/helm-runner.js +18 -16
  112. package/dist/runtime/runner/helm-runner.js.map +1 -1
  113. package/dist/runtime/sources/kinds/git.d.ts +1 -2
  114. package/dist/runtime/sources/kinds/git.d.ts.map +1 -1
  115. package/dist/runtime/sources/kinds/git.js +8 -5
  116. package/dist/runtime/sources/kinds/git.js.map +1 -1
  117. package/dist/runtime/sources/manifest-walk.d.ts +3 -1
  118. package/dist/runtime/sources/manifest-walk.d.ts.map +1 -1
  119. package/dist/runtime/sources/manifest-walk.js +8 -3
  120. package/dist/runtime/sources/manifest-walk.js.map +1 -1
  121. package/dist/runtime/sources/schema.d.ts +3 -0
  122. package/dist/runtime/sources/schema.d.ts.map +1 -1
  123. package/dist/runtime/sources/schema.js +6 -1
  124. package/dist/runtime/sources/schema.js.map +1 -1
  125. package/dist/runtime/supervisor.d.ts +3 -0
  126. package/dist/runtime/supervisor.d.ts.map +1 -1
  127. package/dist/runtime/supervisor.js +2 -0
  128. package/dist/runtime/supervisor.js.map +1 -1
  129. package/edition/README.md +315 -0
  130. package/edition/assemble-producer-input.mjs +1251 -0
  131. package/edition/authorization-base.mjs +347 -0
  132. package/edition/biome-content.mjs +268 -0
  133. package/edition/build-biome-index.mjs +175 -0
  134. package/edition/build-platform-service-index.mjs +288 -0
  135. package/edition/build-runtime-image-source-index.mjs +821 -0
  136. package/edition/built-verifier.mjs +55 -0
  137. package/edition/catalogue-edition.mjs +49 -0
  138. package/edition/catalogue-fetch.mjs +64 -0
  139. package/edition/catalogue.mjs +135 -0
  140. package/edition/chart-set-source-commit.mjs +92 -0
  141. package/edition/check-committed-image-evidence.mjs +383 -0
  142. package/edition/checkout-indexed-source-repositories.mjs +105 -0
  143. package/edition/comment-blanker.mjs +75 -0
  144. package/edition/connector-source-authority.mjs +60 -0
  145. package/edition/customer-definition.mjs +183 -0
  146. package/edition/customer-flags.mjs +7 -0
  147. package/edition/develop-customer-edition.mjs +99 -0
  148. package/edition/develop-edition-resolve.mjs +65 -0
  149. package/edition/develop-edition.mjs +274 -0
  150. package/edition/develop-freeze.mjs +173 -0
  151. package/edition/develop-image-coordinate.mjs +54 -0
  152. package/edition/develop-seal.mjs +77 -0
  153. package/edition/develop-snapshot-inputs.mjs +104 -0
  154. package/edition/dist-check.mjs +986 -0
  155. package/edition/distribution-evidence-set.mjs +987 -0
  156. package/edition/distribution-paths.mjs +191 -0
  157. package/edition/edition-artifact.mjs +270 -0
  158. package/edition/edition-biome-manifests.mjs +85 -0
  159. package/edition/edition-channel.mjs +6 -0
  160. package/edition/edition-chart-set.mjs +93 -0
  161. package/edition/edition-config.mjs +207 -0
  162. package/edition/edition-constants.mjs +57 -0
  163. package/edition/edition-contract.json +23 -0
  164. package/edition/edition-list.mjs +35 -0
  165. package/edition/edition-locks.mjs +69 -0
  166. package/edition/edition-protocol.mjs +228 -0
  167. package/edition/edition-workspace.mjs +176 -0
  168. package/edition/evidence-checkout.mjs +418 -0
  169. package/edition/evidence-grammars.mjs +16 -0
  170. package/edition/evidence-trust-pin.mjs +304 -0
  171. package/edition/evidence-trust-policy.mjs +256 -0
  172. package/edition/external-source-walk.mjs +463 -0
  173. package/edition/first-party-source-ids.mjs +59 -0
  174. package/edition/fleet-coordinate-finality.mjs +350 -0
  175. package/edition/git-source-auth.mjs +52 -0
  176. package/edition/image-evidence-replay-state.mjs +242 -0
  177. package/edition/image-evidence.mjs +1225 -0
  178. package/edition/kernel-contract-authority.mjs +443 -0
  179. package/edition/lib.mjs +3018 -0
  180. package/edition/no-follow-file.mjs +94 -0
  181. package/edition/oci-evidence-verbs.mjs +392 -0
  182. package/edition/oci-image-source-evidence.mjs +239 -0
  183. package/edition/oras.mjs +648 -0
  184. package/edition/pin-source.mjs +227 -0
  185. package/edition/plan-image-evidence-refresh.mjs +408 -0
  186. package/edition/platform-lock-readability.mjs +892 -0
  187. package/edition/platform-service-class.mjs +18 -0
  188. package/edition/platform-service-source-authority.mjs +105 -0
  189. package/edition/positive-closure.mjs +162 -0
  190. package/edition/produce-distribution-completeness-evidence.mjs +311 -0
  191. package/edition/produce-image-delivery-entitlement-evidence.mjs +294 -0
  192. package/edition/produce-image-source-evidence.mjs +765 -0
  193. package/edition/producer-evidence-bundle.mjs +157 -0
  194. package/edition/registry-credentials.mjs +193 -0
  195. package/edition/release-catalogue.mjs +39 -0
  196. package/edition/release-channel-guard.mjs +99 -0
  197. package/edition/release-edition-artifact.mjs +158 -0
  198. package/edition/remedy-command.mjs +89 -0
  199. package/edition/repin-evidence-trust.mjs +101 -0
  200. package/edition/required-resource-provider.mjs +58 -0
  201. package/edition/resolve-chart-set.mjs +415 -0
  202. package/edition/resolve-customer-edition.mjs +288 -0
  203. package/edition/resolve-distribution.mjs +2318 -0
  204. package/edition/resolve-edition.mjs +186 -0
  205. package/edition/resolve-images.mjs +1606 -0
  206. package/edition/seal-image-digests.mjs +855 -0
  207. package/edition/sealed-evidence-accept.mjs +82 -0
  208. package/edition/source-authority.mjs +725 -0
  209. package/edition/source-closure.mjs +116 -0
  210. package/edition/strict-json.mjs +121 -0
  211. package/edition/string-order.mjs +5 -0
  212. package/edition/trust-policy-arming.mjs +120 -0
  213. package/edition/trusted-executable.mjs +166 -0
  214. package/edition/verifier-binding.mjs +45 -0
  215. package/edition/verifier-files.mjs +8 -0
  216. package/edition/verifier-kernel-schemas.mjs +22 -0
  217. package/edition/verifier-provenance.mjs +64 -0
  218. package/edition/verify-image-evidence.mjs +430 -0
  219. package/edition/verify-image-platforms.mjs +495 -0
  220. package/edition/why.mjs +56 -0
  221. package/edition/workspace-sections.mjs +195 -0
  222. package/examples/06-biome-sources.yaml +1 -0
  223. package/package.json +18 -7
  224. package/dist/commands/dist.d.ts +0 -16
  225. package/dist/commands/dist.d.ts.map +0 -1
  226. package/dist/commands/dist.js +0 -182
  227. package/dist/commands/dist.js.map +0 -1
  228. package/dist/runtime/distribution/available-biome.d.ts +0 -10
  229. package/dist/runtime/distribution/available-biome.d.ts.map +0 -1
  230. package/dist/runtime/distribution/available-biome.js +0 -154
  231. package/dist/runtime/distribution/available-biome.js.map +0 -1
  232. package/dist/runtime/distribution/compose.d.ts +0 -23
  233. package/dist/runtime/distribution/compose.d.ts.map +0 -1
  234. package/dist/runtime/distribution/compose.js +0 -48
  235. package/dist/runtime/distribution/compose.js.map +0 -1
  236. package/dist/runtime/runner/chart-trust.d.ts +0 -57
  237. package/dist/runtime/runner/chart-trust.d.ts.map +0 -1
  238. package/dist/runtime/runner/chart-trust.js +0 -270
  239. package/dist/runtime/runner/chart-trust.js.map +0 -1
@@ -0,0 +1,1225 @@
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
+ function policyCandidates(policy, predicateType, channel, imageRepository) {
608
+ return policy.policies.filter(
609
+ (candidate) =>
610
+ candidate.channel === channel &&
611
+ candidate.predicateTypes.includes(predicateType) &&
612
+ (imageRepository === undefined ||
613
+ candidate.imageRepositories?.includes(imageRepository) === true),
614
+ );
615
+ }
616
+
617
+ function cosignVersion(cosignBinary) {
618
+ const output = exactExecutableOutput(cosignBinary, ['version'], {
619
+ env: COSIGN_ENV,
620
+ timeout: 10_000,
621
+ });
622
+ const match = /\bGitVersion:\s*(v\d+\.\d+\.\d+)\b/.exec(output);
623
+ if (!match) {
624
+ throw new Error('cosign version output has no exact GitVersion');
625
+ }
626
+ return match[1];
627
+ }
628
+
629
+ export function prepareTrustedCosignBinary({ cosignBinary, policy }) {
630
+ return prepareDigestPinnedExecutable({
631
+ executablePath: cosignBinary,
632
+ trustedDigests: policy.trustedCosignBinaryDigests,
633
+ label: 'cosign',
634
+ verifyVersion(privateBinary) {
635
+ if (cosignVersion(privateBinary) !== policy.cosignVersion) {
636
+ throw new Error(
637
+ `cosign version does not match pinned ${policy.cosignVersion}`,
638
+ );
639
+ }
640
+ },
641
+ });
642
+ }
643
+
644
+ export function verifySigstoreEvidenceDescriptor({
645
+ evidenceRoot,
646
+ descriptor,
647
+ policy,
648
+ predicateType,
649
+ channel,
650
+ imageRepository,
651
+ expectedWorkflowSha,
652
+ parseStatement,
653
+ policyRoot = evidenceRoot,
654
+ trustedCosign,
655
+ }) {
656
+ assertEvidenceChannel(channel);
657
+ const statementFile = readExactDescriptorFile(
658
+ evidenceRoot,
659
+ descriptor.statementPath,
660
+ descriptor.statementDigest,
661
+ 'evidence statement',
662
+ MAX_STATEMENT_BYTES,
663
+ );
664
+ const bundleFile = readExactDescriptorFile(
665
+ evidenceRoot,
666
+ descriptor.bundlePath,
667
+ descriptor.bundleDigest,
668
+ 'Sigstore bundle',
669
+ MAX_SIGSTORE_BUNDLE_BYTES,
670
+ );
671
+ const bundle = JSON.parse(
672
+ decodeExactUtf8(bundleFile.bytes, 'Sigstore bundle'),
673
+ );
674
+ if (bundle.mediaType !== descriptor.bundleMediaType) {
675
+ throw new Error('Sigstore bundle mediaType does not match its descriptor');
676
+ }
677
+ if (
678
+ bundle.messageSignature !== undefined ||
679
+ typeof bundle.dsseEnvelope !== 'object' ||
680
+ bundle.dsseEnvelope === null ||
681
+ bundle.dsseEnvelope.payloadType !== 'application/vnd.in-toto+json' ||
682
+ !Array.isArray(bundle.dsseEnvelope.signatures) ||
683
+ bundle.dsseEnvelope.signatures.length !== 1 ||
684
+ typeof bundle.dsseEnvelope.payload !== 'string' ||
685
+ !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(
686
+ bundle.dsseEnvelope.payload,
687
+ )
688
+ ) {
689
+ throw new Error(
690
+ 'Sigstore bundle must contain exactly one DSSE in-toto signature',
691
+ );
692
+ }
693
+ const dssePayload = Buffer.from(bundle.dsseEnvelope.payload, 'base64');
694
+ if (
695
+ dssePayload.toString('base64') !== bundle.dsseEnvelope.payload ||
696
+ !dssePayload.equals(statementFile.bytes)
697
+ ) {
698
+ throw new Error(
699
+ 'Sigstore DSSE payload does not exactly match the canonical statement',
700
+ );
701
+ }
702
+ const statement = parseStatement(
703
+ parseCanonicalJsonBytes(statementFile.bytes, 'evidence statement'),
704
+ );
705
+ if (statement.predicateType !== predicateType) {
706
+ throw new Error('evidence statement predicateType is not authorized');
707
+ }
708
+ const candidates = policyCandidates(
709
+ policy,
710
+ predicateType,
711
+ channel,
712
+ imageRepository,
713
+ );
714
+ if (candidates.length === 0) {
715
+ throw new Error(
716
+ `offline trust policy authorizes no signer for ${predicateType} on channel "${channel}"` +
717
+ (imageRepository === undefined
718
+ ? ''
719
+ : ` and image repository "${imageRepository}"`),
720
+ );
721
+ }
722
+ const trustedCosignPath = assertPreparedExecutable(trustedCosign, 'cosign');
723
+ const trustedRoot = validatePinnedTrustedRoot(policyRoot, policy);
724
+ const verifiedWorkflowSha =
725
+ expectedWorkflowSha ??
726
+ statement.predicate?.producerRun?.producerWorkflowSha;
727
+ let verified = false;
728
+ let verifiedIdentityPolicy;
729
+ const verificationErrors = [];
730
+ for (const candidate of candidates) {
731
+ if (!verifiedWorkflowSha) {
732
+ throw new Error(
733
+ `policy "${candidate.policyId}" requires an exact workflow SHA`,
734
+ );
735
+ }
736
+ const verifiedOutputDirectory = mkdtempSync(
737
+ join(tmpdir(), 'xema-verified-dsse-'),
738
+ );
739
+ try {
740
+ const verifiedBundlePath = join(
741
+ verifiedOutputDirectory,
742
+ 'bundle.sigstore.json',
743
+ );
744
+ const verifiedTrustedRootPath = join(
745
+ verifiedOutputDirectory,
746
+ 'trusted-root.json',
747
+ );
748
+ writeFileSync(verifiedBundlePath, bundleFile.bytes, {
749
+ flag: 'wx',
750
+ mode: 0o600,
751
+ });
752
+ writeFileSync(verifiedTrustedRootPath, trustedRoot.bytes, {
753
+ flag: 'wx',
754
+ mode: 0o600,
755
+ });
756
+ // `--check-claims=false`: the attested claim is bound to the statement,
757
+ // not to a blob — the DSSE payload was already proven byte-identical to
758
+ // the canonical statement above, so cosign only has to prove the
759
+ // signature under the exact certificate constraints.
760
+ const args = [
761
+ 'verify-blob-attestation',
762
+ '--check-claims=false',
763
+ '--offline',
764
+ '--new-bundle-format',
765
+ '--trusted-root',
766
+ verifiedTrustedRootPath,
767
+ '--bundle',
768
+ verifiedBundlePath,
769
+ '--certificate-identity',
770
+ candidate.certificateIdentity,
771
+ '--certificate-oidc-issuer',
772
+ candidate.oidcIssuer,
773
+ '--type',
774
+ predicateType,
775
+ ];
776
+ args.push(
777
+ '--certificate-github-workflow-repository',
778
+ candidate.githubWorkflow.repository,
779
+ '--certificate-github-workflow-ref',
780
+ candidate.githubWorkflow.ref,
781
+ '--certificate-github-workflow-sha',
782
+ verifiedWorkflowSha,
783
+ );
784
+ execFileSync(trustedCosignPath, args, {
785
+ env: COSIGN_ENV,
786
+ stdio: ['ignore', 'ignore', 'pipe'],
787
+ timeout: 60_000,
788
+ });
789
+ verified = true;
790
+ verifiedIdentityPolicy = candidate;
791
+ break;
792
+ } catch (error) {
793
+ verificationErrors.push(
794
+ `${candidate.policyId}: ${
795
+ error instanceof Error ? error.message : String(error)
796
+ }`,
797
+ );
798
+ } finally {
799
+ rmSync(verifiedOutputDirectory, {
800
+ recursive: true,
801
+ force: true,
802
+ });
803
+ }
804
+ }
805
+ if (!verified) {
806
+ throw new Error(
807
+ 'offline Sigstore verification failed for every trusted identity: ' +
808
+ verificationErrors.join('; '),
809
+ );
810
+ }
811
+ return {
812
+ statement,
813
+ statementPath: statementFile.path,
814
+ identityPolicy: verifiedIdentityPolicy,
815
+ };
816
+ }
817
+
818
+ export async function verifyDistributionImageEvidenceSet(options) {
819
+ assertEvidenceChannel(options.channel);
820
+ const trustedCosign = prepareTrustedCosignBinary({
821
+ cosignBinary: options.cosignBinary,
822
+ policy: options.policy,
823
+ });
824
+ try {
825
+ return await verifyDistributionImageEvidenceSetWithCosign({
826
+ ...options,
827
+ trustedCosign,
828
+ });
829
+ } finally {
830
+ trustedCosign.dispose();
831
+ }
832
+ }
833
+
834
+ async function verifyDistributionImageEvidenceSetWithCosign({
835
+ distributionId,
836
+ channel,
837
+ dataRoot,
838
+ // WHERE THE LOCKS ARE READ FROM: the editions root. The runtime index comes
839
+ // from `dataRoot`: the index is the data root's, the lock is the edition's.
840
+ editionsRoot,
841
+ evidenceRoot,
842
+ evidenceSet,
843
+ policy,
844
+ now,
845
+ expectedReleaseSourceCommit,
846
+ policyRoot = evidenceRoot,
847
+ trustedCosign,
848
+ schemas,
849
+ }) {
850
+ // The authority the producer statements are held to is the one the data
851
+ // root's index states, which the entry has already held to the data root's
852
+ // evidence-trust pin (`readTrustAnchors`); the bundled kernel is held to that
853
+ // authority by the `--kernel` equality check at the entry, and the schemas that
854
+ // parse the index are the bundled ones, held to their edition protocol.
855
+ const {
856
+ distributionLock,
857
+ imageLock,
858
+ repositoryPins,
859
+ runtimeSourceIndex: { contractAuthority: expectedContractAuthority },
860
+ } = await loadValidatedLocks(distributionId, dataRoot, schemas, {
861
+ editionsRoot,
862
+ });
863
+ if (evidenceSet.distributionId !== distributionId) {
864
+ throw new Error(
865
+ `evidence set "${evidenceSet.distributionId}" does not match requested distribution "${distributionId}"`,
866
+ );
867
+ }
868
+
869
+ const evidenceDescriptors = new Map();
870
+ for (const producerEvidence of evidenceSet.producerEvidence) {
871
+ const key = `${producerEvidence.subjectKind}\0${producerEvidence.subjectId}`;
872
+ if (evidenceDescriptors.has(key)) {
873
+ throw new Error(`evidence set duplicates producer subject ${key}`);
874
+ }
875
+ evidenceDescriptors.set(key, producerEvidence.evidence);
876
+ }
877
+ if (evidenceDescriptors.size !== imageLock.subjects.length) {
878
+ throw new Error(
879
+ `evidence set has ${evidenceDescriptors.size} producer subjects; images.lock.json requires exactly ${imageLock.subjects.length}`,
880
+ );
881
+ }
882
+
883
+ const verifiedProducers = new Map();
884
+ const verifiedNonces = new Set();
885
+ for (const subject of imageLock.subjects) {
886
+ const key = sourceIdentity(subject);
887
+ const descriptor = evidenceDescriptors.get(key);
888
+ if (!descriptor) {
889
+ throw new Error(`evidence set omits producer subject ${key}`);
890
+ }
891
+ const repositoryPin = repositoryPins.get(subject.repo);
892
+ if (!repositoryPin) {
893
+ throw new Error(
894
+ `runtime subject "${subject.kind}:${subject.id}" has no committed repository pin`,
895
+ );
896
+ }
897
+ const { statement, identityPolicy } = verifySigstoreEvidenceDescriptor({
898
+ evidenceRoot,
899
+ descriptor,
900
+ policy,
901
+ predicateType: schemas.IMAGE_PRODUCER_PREDICATE_TYPE,
902
+ channel,
903
+ imageRepository: subject.imageRef,
904
+ parseStatement: schemas.parseImageProducerStatement,
905
+ policyRoot,
906
+ trustedCosign,
907
+ });
908
+ validateProducerStatementBinding({
909
+ statement,
910
+ subject,
911
+ sourceHashAlgorithm: imageLock.sourceHashAlgorithm,
912
+ expectedContractAuthority,
913
+ now,
914
+ maxTtlSeconds: policy.maxProducerTtlSeconds,
915
+ producerPolicy: identityPolicy.imageProducer,
916
+ repositoryPin,
917
+ consumedNonces: verifiedNonces,
918
+ });
919
+ verifiedNonces.add(statement.predicate.producerRun.nonce);
920
+ verifiedProducers.set(key, statement);
921
+ }
922
+ for (const key of evidenceDescriptors.keys()) {
923
+ if (!verifiedProducers.has(key)) {
924
+ throw new Error(`evidence set contains unknown producer subject ${key}`);
925
+ }
926
+ }
927
+
928
+ const { statement: completenessStatement } = verifySigstoreEvidenceDescriptor(
929
+ {
930
+ evidenceRoot,
931
+ descriptor: evidenceSet.completeness,
932
+ policy,
933
+ predicateType: schemas.DISTRIBUTION_IMAGES_PREDICATE_TYPE,
934
+ channel,
935
+ expectedWorkflowSha: expectedReleaseSourceCommit,
936
+ parseStatement: schemas.parseDistributionImagesStatement,
937
+ policyRoot,
938
+ trustedCosign,
939
+ },
940
+ );
941
+ validateCompletenessStatementBinding({
942
+ statement: completenessStatement,
943
+ distributionLock,
944
+ imageLock,
945
+ verifiedProducers,
946
+ evidenceDescriptors,
947
+ now,
948
+ maxTtlSeconds: policy.maxCompletenessTtlSeconds,
949
+ expectedReleaseSourceCommit,
950
+ evidenceNonces: verifiedNonces,
951
+ });
952
+ verifiedNonces.add(completenessStatement.predicate.nonce);
953
+
954
+ return {
955
+ distributionLock,
956
+ imageLock,
957
+ producerStatements: verifiedProducers,
958
+ completenessStatement,
959
+ completenessStatementDigest: evidenceSet.completeness.statementDigest,
960
+ verifiedNonces,
961
+ };
962
+ }
963
+
964
+ export async function verifyImageDeliveryEntitlement(options) {
965
+ assertEvidenceChannel(options.channel);
966
+ const trustedCosign = prepareTrustedCosignBinary({
967
+ cosignBinary: options.cosignBinary,
968
+ policy: options.policy,
969
+ });
970
+ try {
971
+ return await verifyImageDeliveryEntitlementWithCosign({
972
+ ...options,
973
+ trustedCosign,
974
+ });
975
+ } finally {
976
+ trustedCosign.dispose();
977
+ }
978
+ }
979
+
980
+ async function verifyImageDeliveryEntitlementWithCosign({
981
+ evidenceRoot,
982
+ descriptor,
983
+ policy,
984
+ completenessStatementDigest,
985
+ customerId,
986
+ distributionId,
987
+ channel,
988
+ mode,
989
+ targetRegistry,
990
+ now,
991
+ expectedWorkflowSha,
992
+ consumedNonces = new Set(),
993
+ policyRoot = evidenceRoot,
994
+ trustedCosign,
995
+ schemas,
996
+ }) {
997
+ const { statement } = verifySigstoreEvidenceDescriptor({
998
+ evidenceRoot,
999
+ descriptor,
1000
+ policy,
1001
+ predicateType: schemas.IMAGE_DELIVERY_ENTITLEMENT_PREDICATE_TYPE,
1002
+ channel,
1003
+ expectedWorkflowSha,
1004
+ parseStatement: schemas.parseImageDeliveryEntitlementStatement,
1005
+ policyRoot,
1006
+ trustedCosign,
1007
+ });
1008
+ validateEntitlementStatementBinding({
1009
+ statement,
1010
+ completenessStatementDigest,
1011
+ customerId,
1012
+ distributionId,
1013
+ mode,
1014
+ targetRegistry,
1015
+ now,
1016
+ maxTtlSeconds: policy.maxEntitlementTtlSeconds,
1017
+ consumedNonces,
1018
+ });
1019
+ return statement;
1020
+ }
1021
+
1022
+ /**
1023
+ * Repository pins for the sources an EDITION declares itself.
1024
+ *
1025
+ * The runtime image source index pins the VENDOR fleet — every `repos/…` and
1026
+ * `submodules/…` repository the vendor builds images from. A customer edition
1027
+ * additionally draws biomes from sources declared in its own manifest
1028
+ * (`sources[]`, `kind: git`), and the images built from those have a `repo`
1029
+ * the index has never heard of and never will: `presenca-biomes`, not
1030
+ * `repos/xema-base`. Without a pin for it every such subject is refused as
1031
+ * "has no committed repository pin", so a customer could verify the vendor's
1032
+ * images in its edition and never its own.
1033
+ *
1034
+ * The pin for an external source is two facts from two records, and each is
1035
+ * the record that OWNS the fact:
1036
+ *
1037
+ * * `repositoryUrl` — the edition manifest's declaration, by source name.
1038
+ * The manifest is read from the editions checkout and is HEAD-bound there
1039
+ * exactly like the locks (the verifier names it a required edition path).
1040
+ * * `sourceCommit` — the image lock's subjects. A declared `ref` may be a
1041
+ * branch; the lock records the exact commit the walk materialised, and it
1042
+ * is the authority for what was built. Every subject of one source must
1043
+ * agree on one commit, or the lock describes a walk that never happened.
1044
+ *
1045
+ * A declared source that ships no image yields no pin. A source whose name
1046
+ * collides with an index pin is refused: the index is the vendor's record and
1047
+ * an edition may not restate it. Non-git sources build no images from a
1048
+ * repository and yield no pin; a subject naming one stays unpinned and is
1049
+ * refused downstream, as before.
1050
+ */
1051
+ export function externalSourceRepositoryPins({
1052
+ distribution,
1053
+ imageLock,
1054
+ repositoryPins,
1055
+ }) {
1056
+ const derived = new Map();
1057
+ for (const source of distribution.sources ?? []) {
1058
+ if (source.kind !== 'git') {continue;}
1059
+ if (repositoryPins.has(source.name)) {
1060
+ throw new Error(
1061
+ `edition source "${source.name}" shadows a repository pin the runtime image source index already commits`,
1062
+ );
1063
+ }
1064
+ const subjects = imageLock.subjects.filter(
1065
+ (subject) => subject.repo === source.name,
1066
+ );
1067
+ if (subjects.length === 0) {continue;}
1068
+ const commits = [...new Set(subjects.map((subject) => subject.sourceCommit))];
1069
+ if (commits.length !== 1) {
1070
+ throw new Error(
1071
+ `edition source "${source.name}" is built at ${commits.length} different commits in images.lock.json (${commits.join(', ')}); one source resolves to one commit`,
1072
+ );
1073
+ }
1074
+ derived.set(source.name, {
1075
+ repo: source.name,
1076
+ repositoryUrl: source.url,
1077
+ sourceCommit: commits[0],
1078
+ });
1079
+ }
1080
+ return derived;
1081
+ }
1082
+
1083
+ /**
1084
+ * The ONLY facts image evidence binds from `distribution.lock.json`, read
1085
+ * TOLERANTLY and nothing else.
1086
+ *
1087
+ * WHY THIS IS NOT `schemas.parseDistributionLock`. The standalone verifier is a
1088
+ * bundle of the kernel at its contract-authority commit, and that commit's lock
1089
+ * schema is the strict WRITER schema of its day. Measured 2026-10-01 against
1090
+ * the kernel at `24e9f12` (the authority every committed image lock names): a
1091
+ * committed lock parses, and the SAME lock with one additive biome key
1092
+ * (`provenance`) is refused — `[biomes.0] Unrecognized key: "provenance"`. So
1093
+ * every additive section the resolver ever learns would have made every
1094
+ * edition unverifiable until the verifier was rebuilt from a newer kernel, and
1095
+ * rebuilding it meant re-pinning the authority, which rewrites
1096
+ * `contractAuthority` in every `images.lock.json` and thereby invalidates every
1097
+ * signed producer statement and completeness digest. An edition's image
1098
+ * evidence would have been unsealed by a change that touched no image.
1099
+ *
1100
+ * What the evidence actually binds is two fields: the completeness predicate
1101
+ * names `distributionId` and `distributionInputHash`
1102
+ * (`validateCompletenessStatementBinding`), and every other caller reads the
1103
+ * same two. The lock's biome roster is the PRODUCER's contract, graded by
1104
+ * dist-check with the current kernel; the verifier never read it. So read
1105
+ * exactly those, validate exactly those, and ignore every other key — the
1106
+ * tolerant read-only posture `parseDistributionLockForReading` gives a
1107
+ * runtime, without depending on which kernel the bundle was built from.
1108
+ *
1109
+ * `schemaVersion` must be 5, the one grammar there is (the kernel's
1110
+ * `DISTRIBUTION_LOCK_SCHEMA_VERSIONS`): a pre-v5 lock is refused, with no
1111
+ * compatibility path.
1112
+ */
1113
+ export function readDistributionLockBinding(raw) {
1114
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
1115
+ throw new Error('distribution.lock.json is not a JSON object');
1116
+ }
1117
+ const { schemaVersion, distributionId, inputHash } = raw;
1118
+ if (schemaVersion !== 5) {
1119
+ throw new Error(
1120
+ `distribution.lock.json has schemaVersion ${String(schemaVersion)}; the only accepted grammar is 5 (regenerate the lock)`,
1121
+ );
1122
+ }
1123
+ // The id is format-checked only. Which edition it must equal is decided by
1124
+ // the bindings that USE it — the completeness predicate and the evidence set
1125
+ // compare it to the image lock and the signed statement — exactly as before
1126
+ // this reader existed; tightening that here would be a second, unasked rule.
1127
+ assertCanonicalDistributionId(distributionId);
1128
+ if (typeof inputHash !== 'string' || !/^[a-f0-9]{64}$/.test(inputHash)) {
1129
+ throw new Error(
1130
+ `distribution "${distributionId}" has invalid inputHash "${String(inputHash)}" — ` +
1131
+ 'expected 64 lowercase sha256 hex characters',
1132
+ );
1133
+ }
1134
+ return Object.freeze({ schemaVersion, distributionId, inputHash });
1135
+ }
1136
+
1137
+ /**
1138
+ * Read and validate an edition's two locks plus the runtime image source index
1139
+ * they must agree with.
1140
+ *
1141
+ * `dataRoot` holds the runtime image source index, read from it always.
1142
+ * `editionsRoot` is where `<id>/distribution.lock.json`,
1143
+ * `<id>/images.lock.json` and `<id>/xema-distribution.json` are read from. The
1144
+ * manifest extends the repository pins with the edition's own declared sources
1145
+ * ({@link externalSourceRepositoryPins}); the verifier grades the editions
1146
+ * checkout so all three files are HEAD-bound.
1147
+ */
1148
+ export async function loadValidatedLocks(
1149
+ distributionId,
1150
+ dataRoot,
1151
+ schemas,
1152
+ { editionsRoot },
1153
+ ) {
1154
+ if (!schemas) {
1155
+ throw new Error('trusted kernel distribution schemas are required');
1156
+ }
1157
+ const distributionLock = readDistributionLockBinding(
1158
+ JSON.parse(
1159
+ readFileSync(
1160
+ join(editionsRoot, distributionId, 'distribution.lock.json'),
1161
+ 'utf8',
1162
+ ),
1163
+ ),
1164
+ );
1165
+ const imageLock = validateImmutableImageLock(
1166
+ schemas.parseImageLock(
1167
+ JSON.parse(
1168
+ readFileSync(
1169
+ join(editionsRoot, distributionId, 'images.lock.json'),
1170
+ 'utf8',
1171
+ ),
1172
+ ),
1173
+ ),
1174
+ schemas,
1175
+ );
1176
+ const runtimeSourceIndex = schemas.parseRuntimeImageSourceIndex(
1177
+ JSON.parse(
1178
+ readFileSync(
1179
+ join(dataRoot, 'runtime-image-source-index.json'),
1180
+ 'utf8',
1181
+ ),
1182
+ ),
1183
+ );
1184
+ if (
1185
+ canonicalJson(imageLock.contractAuthority) !==
1186
+ canonicalJson(runtimeSourceIndex.contractAuthority)
1187
+ ) {
1188
+ throw new Error(
1189
+ 'images.lock.json kernel contract authority does not exactly match runtime-image-source-index.json',
1190
+ );
1191
+ }
1192
+ const repositoryPins = new Map(
1193
+ runtimeSourceIndex.repositoryPins.map((repository) => [
1194
+ repository.repo,
1195
+ repository,
1196
+ ]),
1197
+ );
1198
+ const distribution = schemas.parseDistribution(
1199
+ JSON.parse(
1200
+ readFileSync(
1201
+ join(editionsRoot, distributionId, 'xema-distribution.json'),
1202
+ 'utf8',
1203
+ ),
1204
+ ),
1205
+ );
1206
+ if (distribution.id !== distributionId) {
1207
+ throw new Error(
1208
+ `edition manifest at ${editionsRoot} declares id "${distribution.id}", not "${distributionId}"`,
1209
+ );
1210
+ }
1211
+ for (const [repo, pin] of externalSourceRepositoryPins({
1212
+ distribution,
1213
+ imageLock,
1214
+ repositoryPins,
1215
+ })) {
1216
+ repositoryPins.set(repo, pin);
1217
+ }
1218
+ return {
1219
+ schemas,
1220
+ distributionLock,
1221
+ imageLock,
1222
+ runtimeSourceIndex,
1223
+ repositoryPins,
1224
+ };
1225
+ }