@dsh-enhanced/plugin-control-plane 0.1.6 → 0.1.12
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 +153 -32
- package/bin/dsh-local-release-adapter.js +1381 -0
- package/cordis.patch.yml +1 -0
- package/lib/approval.d.ts +13 -0
- package/lib/approval.d.ts.map +1 -0
- package/lib/approval.js +79 -0
- package/lib/approval.js.map +1 -0
- package/lib/attestation.d.ts +14 -0
- package/lib/attestation.d.ts.map +1 -0
- package/lib/attestation.js +222 -0
- package/lib/attestation.js.map +1 -0
- package/lib/catalog-interpreter.d.ts +12 -0
- package/lib/catalog-interpreter.d.ts.map +1 -0
- package/lib/catalog-interpreter.js +100 -0
- package/lib/catalog-interpreter.js.map +1 -0
- package/lib/catalog.d.ts +95 -10
- package/lib/catalog.d.ts.map +1 -1
- package/lib/catalog.js +1031 -18
- package/lib/catalog.js.map +1 -1
- package/lib/cli.d.ts +9 -0
- package/lib/cli.d.ts.map +1 -1
- package/lib/cli.js +1160 -162
- package/lib/cli.js.map +1 -1
- package/lib/host-attestor.d.ts +13 -0
- package/lib/host-attestor.d.ts.map +1 -0
- package/lib/host-attestor.js +139 -0
- package/lib/host-attestor.js.map +1 -0
- package/lib/index.d.ts +9 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +9 -0
- package/lib/index.js.map +1 -1
- package/lib/lockfile.d.ts +7 -0
- package/lib/lockfile.d.ts.map +1 -0
- package/lib/lockfile.js +271 -0
- package/lib/lockfile.js.map +1 -0
- package/lib/release.d.ts +51 -0
- package/lib/release.d.ts.map +1 -0
- package/lib/release.js +1188 -0
- package/lib/release.js.map +1 -0
- package/lib/service.d.ts +9 -11
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +57 -29
- package/lib/service.js.map +1 -1
- package/lib/sqlite.d.ts +9 -0
- package/lib/sqlite.d.ts.map +1 -0
- package/lib/sqlite.js +705 -0
- package/lib/sqlite.js.map +1 -0
- package/lib/store.d.ts +258 -0
- package/lib/store.d.ts.map +1 -0
- package/lib/store.js +1848 -0
- package/lib/store.js.map +1 -0
- package/lib/tools.d.ts.map +1 -1
- package/lib/tools.js +23 -3
- package/lib/tools.js.map +1 -1
- package/lib/trust.d.ts +79 -0
- package/lib/trust.d.ts.map +1 -0
- package/lib/trust.js +477 -0
- package/lib/trust.js.map +1 -0
- package/lib/types.d.ts +740 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -0
- package/lib/version.d.ts +1 -1
- package/lib/version.d.ts.map +1 -1
- package/lib/version.js +1 -1
- package/lib/version.js.map +1 -1
- package/package.json +2 -2
package/lib/release.js
ADDED
|
@@ -0,0 +1,1188 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash, createPublicKey, verify } from 'node:crypto';
|
|
3
|
+
import { constants as fsConstants } from 'node:fs';
|
|
4
|
+
import { lstat, open, realpath } from 'node:fs/promises';
|
|
5
|
+
import { dirname, isAbsolute, posix, resolve } from 'node:path';
|
|
6
|
+
import { catalogAdmissionId, parseCatalog } from './catalog.js';
|
|
7
|
+
import { ControlPlaneStoreError } from './store.js';
|
|
8
|
+
import { inheritedReleaseAdapterEnvironment, openTrustedExecutable, verifyOpenTrustedExecutable } from './trust.js';
|
|
9
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/u;
|
|
10
|
+
const DIGEST = /^[a-f0-9]{64}$/u;
|
|
11
|
+
const COMMIT = /^[a-f0-9]{40}$/u;
|
|
12
|
+
const SIGNATURE = /^[A-Za-z0-9+/]+={0,2}$/u;
|
|
13
|
+
const PACKAGE = /^@[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/u;
|
|
14
|
+
const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/u;
|
|
15
|
+
const SOURCE_PATH = /^(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+$/u;
|
|
16
|
+
const phases = new Set(['pr', 'review', 'merge', 'build', 'sign', 'publish', 'registry-verify', 'catalog-admission']);
|
|
17
|
+
function canonical(value) {
|
|
18
|
+
if (Array.isArray(value))
|
|
19
|
+
return `[${value.map(canonical).join(',')}]`;
|
|
20
|
+
if (typeof value === 'object' && value !== null)
|
|
21
|
+
return `{${Object.entries(value)
|
|
22
|
+
.filter(([, item]) => item !== undefined).sort(([left], [right]) => left.localeCompare(right))
|
|
23
|
+
.map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(',')}}`;
|
|
24
|
+
return JSON.stringify(value);
|
|
25
|
+
}
|
|
26
|
+
function digest(value) { return createHash('sha256').update(canonical(value)).digest('hex'); }
|
|
27
|
+
function record(value, label) {
|
|
28
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
29
|
+
throw new ControlPlaneStoreError('invalid-input', `${label} must be an object`);
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
function exact(value, fields, label) {
|
|
33
|
+
if (Object.keys(value).sort().join('\0') !== [...fields].sort().join('\0'))
|
|
34
|
+
throw new ControlPlaneStoreError('invalid-input', `${label} has unknown or missing fields`);
|
|
35
|
+
}
|
|
36
|
+
function text(value, label, pattern = ID, maximum = 2_000) {
|
|
37
|
+
if (typeof value !== 'string' || Buffer.byteLength(value) > maximum || !pattern.test(value))
|
|
38
|
+
throw new ControlPlaneStoreError('invalid-input', `${label} is invalid`);
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
function opaqueLine(value, label, maximum = 2_000) {
|
|
42
|
+
const result = text(value, label, /^.+$/u, maximum);
|
|
43
|
+
if (result.includes('\0') || result.includes('\r') || result.includes('\n')) {
|
|
44
|
+
throw new ControlPlaneStoreError('invalid-input', `${label} is invalid`);
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
function branch(value, label) {
|
|
49
|
+
const result = opaqueLine(value, label, 128);
|
|
50
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/u.test(result) || result.includes('..') || result.includes('//')
|
|
51
|
+
|| result.endsWith('/') || result.endsWith('.') || result.endsWith('.lock') || result.includes('@{')) {
|
|
52
|
+
throw new ControlPlaneStoreError('invalid-input', `${label} is not a canonical branch name`);
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
function absolutePath(value, label) {
|
|
57
|
+
const result = opaqueLine(value, label);
|
|
58
|
+
if (!result.startsWith('/') || result === '/')
|
|
59
|
+
throw new ControlPlaneStoreError('invalid-input', `${label} is invalid`);
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
function integer(value, label, minimum = 0) {
|
|
63
|
+
if (!Number.isSafeInteger(value) || Number(value) < minimum)
|
|
64
|
+
throw new ControlPlaneStoreError('invalid-input', `${label} must be a bounded integer`);
|
|
65
|
+
return Number(value);
|
|
66
|
+
}
|
|
67
|
+
function same(left, right) { return canonical(left) === canonical(right); }
|
|
68
|
+
function digestText(value, label) { return text(value, label, DIGEST); }
|
|
69
|
+
function commit(value, label) { return text(value, label, COMMIT); }
|
|
70
|
+
function signature(value, label) {
|
|
71
|
+
const result = text(value, label, SIGNATURE, 16_384);
|
|
72
|
+
const bytes = Buffer.from(result, 'base64');
|
|
73
|
+
if (bytes.length !== 64 || bytes.toString('base64') !== result)
|
|
74
|
+
throw new ControlPlaneStoreError('invalid-input', `${label} must be one canonical Ed25519 signature`);
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
function integrity(value, label) {
|
|
78
|
+
const result = text(value, label, /^sha512-[A-Za-z0-9+/]+={0,2}$/u);
|
|
79
|
+
const encoded = result.slice('sha512-'.length);
|
|
80
|
+
const bytes = Buffer.from(encoded, 'base64');
|
|
81
|
+
if (bytes.length !== 64 || bytes.toString('base64') !== encoded)
|
|
82
|
+
throw new ControlPlaneStoreError('invalid-input', `${label} must be one canonical SHA-512 integrity`);
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
function relativePath(value, label) {
|
|
86
|
+
const result = opaqueLine(value, label, 500);
|
|
87
|
+
if (!SOURCE_PATH.test(result) || result.startsWith('.') || posix.normalize(result) !== result
|
|
88
|
+
|| result.split('/').some(part => part === '.' || part === '..'))
|
|
89
|
+
throw new ControlPlaneStoreError('invalid-input', `${label} is not a canonical repository-relative path`);
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
function stringList(value, label, maximum = 64) {
|
|
93
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > maximum)
|
|
94
|
+
throw new ControlPlaneStoreError('invalid-input', `${label} must be a bounded non-empty array`);
|
|
95
|
+
const result = value.map((item, index) => {
|
|
96
|
+
const raw = opaqueLine(item, `${label}[${index}]`, 500);
|
|
97
|
+
const normalized = raw.normalize('NFC').trim();
|
|
98
|
+
if (raw !== normalized || normalized === '')
|
|
99
|
+
throw new ControlPlaneStoreError('invalid-input', `${label} must use canonical text`);
|
|
100
|
+
return normalized;
|
|
101
|
+
});
|
|
102
|
+
const sorted = [...result].sort();
|
|
103
|
+
if (new Set(result).size !== result.length || !result.every((item, index) => item === sorted[index])) {
|
|
104
|
+
throw new ControlPlaneStoreError('invalid-input', `${label} must be unique and sorted`);
|
|
105
|
+
}
|
|
106
|
+
return Object.freeze(result);
|
|
107
|
+
}
|
|
108
|
+
function scopeList(value) { return Object.freeze(stringList(value, 'scope', 32).map((item, index) => relativePath(item, `scope[${index}]`))); }
|
|
109
|
+
function requirements(value, primaryPackage, label) {
|
|
110
|
+
if (!Array.isArray(value) || value.length > 64)
|
|
111
|
+
throw new ControlPlaneStoreError('invalid-input', `${label} must be a bounded array`);
|
|
112
|
+
const parsed = parseCatalog({ schemaVersion: 1, entries: [{ id: 'release-policy', package: primaryPackage, version: '0.0.0',
|
|
113
|
+
integrity: `sha512-${Buffer.alloc(64).toString('base64')}`, dshBaseline: '0.0.0', capabilities: ['placeholder'],
|
|
114
|
+
authorities: ['placeholder'], requires: value }] }).entries[0].requires;
|
|
115
|
+
if (!same(value, parsed))
|
|
116
|
+
throw new ControlPlaneStoreError('invalid-input', `${label} must be canonical and sorted`);
|
|
117
|
+
return parsed;
|
|
118
|
+
}
|
|
119
|
+
function catalogEntry(value, label) {
|
|
120
|
+
const item = record(value, label);
|
|
121
|
+
exact(item, ['id', 'package', 'version', 'integrity', ...(item.registry === undefined ? [] : ['registry']),
|
|
122
|
+
'requires', 'dshBaseline', 'capabilities', 'authorities'], label);
|
|
123
|
+
const parsed = parseCatalog({ schemaVersion: 1, entries: [item] }).entries[0];
|
|
124
|
+
if (!same(item, parsed))
|
|
125
|
+
throw new ControlPlaneStoreError('invalid-input', `${label} must use canonical values and ordering`);
|
|
126
|
+
const integrityBytes = Buffer.from(parsed.integrity.slice('sha512-'.length), 'base64');
|
|
127
|
+
if (integrityBytes.length !== 64 || `sha512-${integrityBytes.toString('base64')}` !== parsed.integrity) {
|
|
128
|
+
throw new ControlPlaneStoreError('invalid-input', `${label}.integrity must be one canonical SHA-512 integrity`);
|
|
129
|
+
}
|
|
130
|
+
return parsed;
|
|
131
|
+
}
|
|
132
|
+
function releasePolicy(value) {
|
|
133
|
+
const item = record(value, 'release policy');
|
|
134
|
+
exact(item, ['targetBranch', 'candidateId', 'packageName', 'packageVersion', 'packagePath', 'dshBaseline', 'capabilities',
|
|
135
|
+
'authorities', 'requires', 'registryId', 'registryLocator', 'registryReference', 'catalogId', 'catalogPath',
|
|
136
|
+
'minimumReproducibleBuilds'], 'release policy');
|
|
137
|
+
const candidateId = text(item.candidateId, 'releasePolicy.candidateId', /^[a-z0-9][a-z0-9-]{0,63}$/u);
|
|
138
|
+
const packageName = text(item.packageName, 'releasePolicy.packageName', PACKAGE);
|
|
139
|
+
const packageVersion = text(item.packageVersion, 'releasePolicy.packageVersion', VERSION);
|
|
140
|
+
const dshBaseline = text(item.dshBaseline, 'releasePolicy.dshBaseline', VERSION);
|
|
141
|
+
const capabilities = stringList(item.capabilities, 'releasePolicy.capabilities');
|
|
142
|
+
const authorities = stringList(item.authorities, 'releasePolicy.authorities');
|
|
143
|
+
const minimumReproducibleBuilds = integer(item.minimumReproducibleBuilds, 'releasePolicy.minimumReproducibleBuilds', 2);
|
|
144
|
+
if (minimumReproducibleBuilds > 16)
|
|
145
|
+
throw new ControlPlaneStoreError('invalid-input', 'releasePolicy.minimumReproducibleBuilds is too large');
|
|
146
|
+
return Object.freeze({ targetBranch: branch(item.targetBranch, 'releasePolicy.targetBranch'), candidateId, packageName, packageVersion,
|
|
147
|
+
packagePath: relativePath(item.packagePath, 'releasePolicy.packagePath'), dshBaseline, capabilities, authorities,
|
|
148
|
+
requires: requirements(item.requires, packageName, 'releasePolicy.requires'), registryId: text(item.registryId, 'releasePolicy.registryId'),
|
|
149
|
+
registryLocator: opaqueLine(item.registryLocator, 'releasePolicy.registryLocator'),
|
|
150
|
+
registryReference: opaqueLine(item.registryReference, 'releasePolicy.registryReference'), catalogId: text(item.catalogId, 'releasePolicy.catalogId'),
|
|
151
|
+
catalogPath: absolutePath(item.catalogPath, 'releasePolicy.catalogPath'),
|
|
152
|
+
minimumReproducibleBuilds });
|
|
153
|
+
}
|
|
154
|
+
const authorizationFields = ['schemaVersion', 'kind', 'authorizationId', 'authority', 'keyId', 'planId', 'planDigest', 'baseCommit',
|
|
155
|
+
'checkedTreeDigest', 'checkedPatchDigest', 'scope', 'releasePolicy', 'authorizedAt', 'expiresAt', 'signature'];
|
|
156
|
+
export function parseSourceReleaseAuthorization(value) {
|
|
157
|
+
const item = record(value, 'source release authorization');
|
|
158
|
+
exact(item, authorizationFields, 'source release authorization');
|
|
159
|
+
if (item.schemaVersion !== 1 || item.kind !== 'dsh-source-release-authorization')
|
|
160
|
+
throw new ControlPlaneStoreError('invalid-input', 'unsupported source release authorization schema');
|
|
161
|
+
const authorization = { schemaVersion: 1, kind: 'dsh-source-release-authorization',
|
|
162
|
+
authorizationId: text(item.authorizationId, 'authorizationId'), authority: text(item.authority, 'authority'), keyId: text(item.keyId, 'keyId'),
|
|
163
|
+
planId: text(item.planId, 'planId'), planDigest: digestText(item.planDigest, 'planDigest'), baseCommit: commit(item.baseCommit, 'baseCommit'),
|
|
164
|
+
checkedTreeDigest: digestText(item.checkedTreeDigest, 'checkedTreeDigest'), checkedPatchDigest: digestText(item.checkedPatchDigest, 'checkedPatchDigest'),
|
|
165
|
+
scope: scopeList(item.scope), releasePolicy: releasePolicy(item.releasePolicy), authorizedAt: integer(item.authorizedAt, 'authorizedAt'),
|
|
166
|
+
expiresAt: integer(item.expiresAt, 'expiresAt'), signature: signature(item.signature, 'authorization signature') };
|
|
167
|
+
if (authorization.expiresAt <= authorization.authorizedAt || authorization.expiresAt - authorization.authorizedAt > 86_400_000) {
|
|
168
|
+
throw new ControlPlaneStoreError('invalid-input', 'source release authorization validity interval is invalid');
|
|
169
|
+
}
|
|
170
|
+
return Object.freeze(authorization);
|
|
171
|
+
}
|
|
172
|
+
export function parseVerifiedSourceReleaseAuthorization(value) {
|
|
173
|
+
const item = record(value, 'verified source release authorization');
|
|
174
|
+
exact(item, [...authorizationFields, 'signatureDigest'], 'verified source release authorization');
|
|
175
|
+
const { signatureDigest: rawSignatureDigest, ...unsignedVerification } = item;
|
|
176
|
+
const authorization = parseSourceReleaseAuthorization(unsignedVerification);
|
|
177
|
+
const signatureDigest = digestText(rawSignatureDigest, 'signatureDigest');
|
|
178
|
+
if (signatureDigest !== createHash('sha256').update(Buffer.from(authorization.signature, 'base64')).digest('hex')) {
|
|
179
|
+
throw new ControlPlaneStoreError('invalid-input', 'verified source release authorization signature digest is invalid');
|
|
180
|
+
}
|
|
181
|
+
return Object.freeze({ ...authorization, signatureDigest });
|
|
182
|
+
}
|
|
183
|
+
function canonicalAuthorization(authorization) {
|
|
184
|
+
const { signature: _signature, ...fields } = authorization;
|
|
185
|
+
return canonical(fields);
|
|
186
|
+
}
|
|
187
|
+
export function sourceReleaseAuthorizationSigningPayload(authorization) {
|
|
188
|
+
return canonicalAuthorization({ ...authorization, signature: '' });
|
|
189
|
+
}
|
|
190
|
+
function pathAllowedByScope(path, scope) {
|
|
191
|
+
return scope.some(root => path === root || path.startsWith(`${root}/`));
|
|
192
|
+
}
|
|
193
|
+
export class Ed25519SourceReleaseAuthorizationAuthority {
|
|
194
|
+
publicKey;
|
|
195
|
+
expectedAuthority;
|
|
196
|
+
expectedKeyId;
|
|
197
|
+
now;
|
|
198
|
+
constructor(publicKey, expectedAuthority, expectedKeyId, now = Date.now) {
|
|
199
|
+
this.publicKey = publicKey;
|
|
200
|
+
this.expectedAuthority = expectedAuthority;
|
|
201
|
+
this.expectedKeyId = expectedKeyId;
|
|
202
|
+
this.now = now;
|
|
203
|
+
}
|
|
204
|
+
async verify(input, plan) {
|
|
205
|
+
const authorization = parseSourceReleaseAuthorization(input);
|
|
206
|
+
const now = this.now();
|
|
207
|
+
if (authorization.authority !== this.expectedAuthority || authorization.keyId !== this.expectedKeyId
|
|
208
|
+
|| authorization.planId !== plan.id || authorization.planDigest !== plan.digest || authorization.baseCommit !== plan.baseCommit
|
|
209
|
+
|| !same(authorization.scope, plan.scope) || plan.sourceCheck === undefined
|
|
210
|
+
|| authorization.checkedTreeDigest !== plan.sourceCheck.treeDigest || authorization.checkedPatchDigest !== plan.sourceCheck.patchDigest
|
|
211
|
+
|| authorization.releasePolicy.candidateId !== plan.name || !pathAllowedByScope(authorization.releasePolicy.packagePath, plan.scope)) {
|
|
212
|
+
throw new ControlPlaneStoreError('conflict', 'release authorization is not bound to the exact checked source plan and policy');
|
|
213
|
+
}
|
|
214
|
+
if (!Number.isSafeInteger(plan.sourceCheck.checkedAt) || plan.sourceCheck.checkedAt < 0
|
|
215
|
+
|| authorization.authorizedAt < plan.sourceCheck.checkedAt || authorization.authorizedAt > now || now > authorization.expiresAt) {
|
|
216
|
+
throw new ControlPlaneStoreError('expired', 'release authorization is outside its post-check validity interval');
|
|
217
|
+
}
|
|
218
|
+
const signatureBytes = Buffer.from(authorization.signature, 'base64');
|
|
219
|
+
if (!verify(null, Buffer.from(canonicalAuthorization(authorization)), createPublicKey(this.publicKey), signatureBytes)) {
|
|
220
|
+
throw new ControlPlaneStoreError('invalid-input', 'release authorization signature is invalid');
|
|
221
|
+
}
|
|
222
|
+
return Object.freeze({ ...authorization, signatureDigest: createHash('sha256').update(signatureBytes).digest('hex') });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const reconciliationRequestFields = ['schemaVersion', 'kind', 'operationId', 'attempt', 'requestedAt', 'receiptTtlMs', 'installationId',
|
|
226
|
+
'ledger', 'plan', 'release', 'authorization', 'adapter', 'registry', 'ambiguousPublish', 'artifact',
|
|
227
|
+
'expectedArtifactStatementDigest', 'expectedArtifactSignatureDigest', 'expectedRegistryReference'];
|
|
228
|
+
export function parseSourcePublishReconciliationRequest(value) {
|
|
229
|
+
const item = record(value, 'source publish reconciliation request');
|
|
230
|
+
exact(item, reconciliationRequestFields, 'source publish reconciliation request');
|
|
231
|
+
if (item.schemaVersion !== 1 || item.kind !== 'dsh-source-publish-reconciliation-request') {
|
|
232
|
+
throw new ControlPlaneStoreError('invalid-input', 'unsupported source publish reconciliation request schema');
|
|
233
|
+
}
|
|
234
|
+
const ledger = record(item.ledger, 'reconciliation ledger');
|
|
235
|
+
exact(ledger, ['id', 'path'], 'reconciliation ledger');
|
|
236
|
+
const plan = record(item.plan, 'reconciliation plan');
|
|
237
|
+
exact(plan, ['id', 'digest', 'revision'], 'reconciliation plan');
|
|
238
|
+
const release = record(item.release, 'reconciliation release');
|
|
239
|
+
exact(release, ['id', 'fence'], 'reconciliation release');
|
|
240
|
+
const registry = record(item.registry, 'reconciliation registry');
|
|
241
|
+
exact(registry, ['id', 'locator'], 'reconciliation registry');
|
|
242
|
+
const ambiguous = record(item.ambiguousPublish, 'ambiguous publish binding');
|
|
243
|
+
exact(ambiguous, ['operationId', 'receiptId', 'receiptDigest', 'evidenceDigest'], 'ambiguous publish binding');
|
|
244
|
+
const rawArtifact = record(item.artifact, 'reconciliation artifact');
|
|
245
|
+
exact(rawArtifact, ['packageName', 'packageVersion', 'tarballSha256', 'tarballIntegrity'], 'reconciliation artifact');
|
|
246
|
+
const encodedIntegrity = integrity(rawArtifact.tarballIntegrity, 'artifact.tarballIntegrity');
|
|
247
|
+
const authorization = parseVerifiedSourceReleaseAuthorization(item.authorization);
|
|
248
|
+
const request = Object.freeze({ schemaVersion: 1, kind: 'dsh-source-publish-reconciliation-request',
|
|
249
|
+
operationId: text(item.operationId, 'operationId'), attempt: integer(item.attempt, 'attempt', 1),
|
|
250
|
+
requestedAt: integer(item.requestedAt, 'requestedAt'),
|
|
251
|
+
receiptTtlMs: integer(item.receiptTtlMs, 'receiptTtlMs', 1_000),
|
|
252
|
+
installationId: text(item.installationId, 'installationId', /^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/u),
|
|
253
|
+
ledger: Object.freeze({ id: text(ledger.id, 'ledger.id'), path: absolutePath(ledger.path, 'ledger.path') }),
|
|
254
|
+
plan: Object.freeze({ id: text(plan.id, 'plan.id'), digest: digestText(plan.digest, 'plan.digest'), revision: integer(plan.revision, 'plan.revision', 1) }),
|
|
255
|
+
release: Object.freeze({ id: text(release.id, 'release.id'), fence: integer(release.fence, 'release.fence', 1) }), authorization,
|
|
256
|
+
adapter: adapterIdentity(item.adapter), registry: Object.freeze({ id: text(registry.id, 'registry.id'), locator: opaqueLine(registry.locator, 'registry.locator') }),
|
|
257
|
+
ambiguousPublish: Object.freeze({ operationId: text(ambiguous.operationId, 'ambiguousPublish.operationId'),
|
|
258
|
+
receiptId: text(ambiguous.receiptId, 'ambiguousPublish.receiptId'), receiptDigest: digestText(ambiguous.receiptDigest, 'ambiguousPublish.receiptDigest'),
|
|
259
|
+
evidenceDigest: digestText(ambiguous.evidenceDigest, 'ambiguousPublish.evidenceDigest') }),
|
|
260
|
+
artifact: Object.freeze({ packageName: text(rawArtifact.packageName, 'artifact.packageName', PACKAGE),
|
|
261
|
+
packageVersion: text(rawArtifact.packageVersion, 'artifact.packageVersion', VERSION),
|
|
262
|
+
tarballSha256: digestText(rawArtifact.tarballSha256, 'artifact.tarballSha256'), tarballIntegrity: encodedIntegrity }),
|
|
263
|
+
expectedArtifactStatementDigest: digestText(item.expectedArtifactStatementDigest, 'expectedArtifactStatementDigest'),
|
|
264
|
+
expectedArtifactSignatureDigest: digestText(item.expectedArtifactSignatureDigest, 'expectedArtifactSignatureDigest'),
|
|
265
|
+
expectedRegistryReference: opaqueLine(item.expectedRegistryReference, 'expectedRegistryReference') });
|
|
266
|
+
const policy = authorization.releasePolicy;
|
|
267
|
+
if (request.receiptTtlMs > 300_000 || request.requestedAt < authorization.authorizedAt || request.requestedAt > authorization.expiresAt
|
|
268
|
+
|| request.plan.id !== authorization.planId || request.plan.digest !== authorization.planDigest
|
|
269
|
+
|| request.registry.id !== policy.registryId || request.registry.locator !== policy.registryLocator
|
|
270
|
+
|| request.expectedRegistryReference !== policy.registryReference
|
|
271
|
+
|| request.artifact.packageName !== policy.packageName || request.artifact.packageVersion !== policy.packageVersion) {
|
|
272
|
+
throw new ControlPlaneStoreError('conflict', 'publish reconciliation request is not bound to the authorized release');
|
|
273
|
+
}
|
|
274
|
+
return request;
|
|
275
|
+
}
|
|
276
|
+
function parseSourcePublishReconciliationEvidence(value) {
|
|
277
|
+
const item = record(value, 'source publish reconciliation evidence');
|
|
278
|
+
exact(item, ['kind', 'outcome', 'registryId', 'registryReference', 'packageName', 'packageVersion', 'expectedTarballSha256',
|
|
279
|
+
'expectedTarballIntegrity', 'expectedArtifactStatementDigest', 'expectedArtifactSignatureDigest',
|
|
280
|
+
'observedTarballSha256', 'observedTarballIntegrity', 'observedArtifactStatementDigest',
|
|
281
|
+
'observedArtifactSignatureDigest', 'ambiguousPublishOperationId',
|
|
282
|
+
'ambiguousPublishReceiptDigest', 'detailDigest'], 'source publish reconciliation evidence');
|
|
283
|
+
if (item.kind !== 'publish-reconciliation' || !['exists-match', 'absent', 'unknown', 'digest-conflict'].includes(String(item.outcome))) {
|
|
284
|
+
throw new ControlPlaneStoreError('invalid-input', 'publish reconciliation evidence outcome is invalid');
|
|
285
|
+
}
|
|
286
|
+
const nullableDigest = (raw, label) => raw === null ? null : digestText(raw, label);
|
|
287
|
+
const nullableIntegrity = (raw) => {
|
|
288
|
+
if (raw === null)
|
|
289
|
+
return null;
|
|
290
|
+
return integrity(raw, 'observedTarballIntegrity');
|
|
291
|
+
};
|
|
292
|
+
const evidence = { kind: 'publish-reconciliation',
|
|
293
|
+
outcome: item.outcome, registryId: text(item.registryId, 'registryId'),
|
|
294
|
+
registryReference: item.registryReference === null ? null : opaqueLine(item.registryReference, 'registryReference'),
|
|
295
|
+
packageName: text(item.packageName, 'packageName', PACKAGE), packageVersion: text(item.packageVersion, 'packageVersion', VERSION),
|
|
296
|
+
expectedTarballSha256: digestText(item.expectedTarballSha256, 'expectedTarballSha256'),
|
|
297
|
+
expectedTarballIntegrity: integrity(item.expectedTarballIntegrity, 'expectedTarballIntegrity'),
|
|
298
|
+
expectedArtifactStatementDigest: digestText(item.expectedArtifactStatementDigest, 'expectedArtifactStatementDigest'),
|
|
299
|
+
expectedArtifactSignatureDigest: digestText(item.expectedArtifactSignatureDigest, 'expectedArtifactSignatureDigest'),
|
|
300
|
+
observedTarballSha256: nullableDigest(item.observedTarballSha256, 'observedTarballSha256'),
|
|
301
|
+
observedTarballIntegrity: nullableIntegrity(item.observedTarballIntegrity),
|
|
302
|
+
observedArtifactStatementDigest: nullableDigest(item.observedArtifactStatementDigest, 'observedArtifactStatementDigest'),
|
|
303
|
+
observedArtifactSignatureDigest: nullableDigest(item.observedArtifactSignatureDigest, 'observedArtifactSignatureDigest'),
|
|
304
|
+
ambiguousPublishOperationId: text(item.ambiguousPublishOperationId, 'ambiguousPublishOperationId'),
|
|
305
|
+
ambiguousPublishReceiptDigest: digestText(item.ambiguousPublishReceiptDigest, 'ambiguousPublishReceiptDigest'),
|
|
306
|
+
detailDigest: digestText(item.detailDigest, 'detailDigest') };
|
|
307
|
+
if (evidence.outcome === 'exists-match' && (evidence.registryReference === null
|
|
308
|
+
|| evidence.observedTarballSha256 !== evidence.expectedTarballSha256
|
|
309
|
+
|| evidence.observedTarballIntegrity !== evidence.expectedTarballIntegrity
|
|
310
|
+
|| evidence.observedArtifactStatementDigest !== evidence.expectedArtifactStatementDigest
|
|
311
|
+
|| evidence.observedArtifactSignatureDigest !== evidence.expectedArtifactSignatureDigest)) {
|
|
312
|
+
throw new ControlPlaneStoreError('invalid-input', 'exists-match reconciliation does not prove the exact artifact');
|
|
313
|
+
}
|
|
314
|
+
if (evidence.outcome === 'absent' && (evidence.registryReference !== null || evidence.observedTarballSha256 !== null
|
|
315
|
+
|| evidence.observedTarballIntegrity !== null || evidence.observedArtifactStatementDigest !== null
|
|
316
|
+
|| evidence.observedArtifactSignatureDigest !== null))
|
|
317
|
+
throw new ControlPlaneStoreError('invalid-input', 'absent reconciliation contains observed registry state');
|
|
318
|
+
if (evidence.outcome === 'digest-conflict' && (evidence.registryReference === null || evidence.observedTarballSha256 === null
|
|
319
|
+
|| evidence.observedTarballIntegrity === null || evidence.observedArtifactStatementDigest === null
|
|
320
|
+
|| evidence.observedArtifactSignatureDigest === null
|
|
321
|
+
|| (evidence.observedTarballSha256 === evidence.expectedTarballSha256
|
|
322
|
+
&& evidence.observedTarballIntegrity === evidence.expectedTarballIntegrity
|
|
323
|
+
&& evidence.observedArtifactStatementDigest === evidence.expectedArtifactStatementDigest
|
|
324
|
+
&& evidence.observedArtifactSignatureDigest === evidence.expectedArtifactSignatureDigest))) {
|
|
325
|
+
throw new ControlPlaneStoreError('invalid-input', 'digest-conflict reconciliation does not prove a conflicting artifact');
|
|
326
|
+
}
|
|
327
|
+
return Object.freeze(evidence);
|
|
328
|
+
}
|
|
329
|
+
export function sourcePublishReconciliationRequestDigest(value) { return digest(value); }
|
|
330
|
+
export function sourcePublishReconciliationEvidenceDigest(value) { return digest(value); }
|
|
331
|
+
export function parseSourcePublishReconciliationReceipt(value) {
|
|
332
|
+
const item = record(value, 'source publish reconciliation receipt');
|
|
333
|
+
exact(item, ['schemaVersion', 'kind', 'receiptId', 'authority', 'keyId', 'installationId', 'planId', 'planDigest', 'releaseId',
|
|
334
|
+
'fence', 'operationId', 'requestDigest', 'evidence', 'evidenceDigest', 'observedAt', 'expiresAt', 'signature'], 'source publish reconciliation receipt');
|
|
335
|
+
if (item.schemaVersion !== 1 || item.kind !== 'dsh-source-publish-reconciliation-receipt') {
|
|
336
|
+
throw new ControlPlaneStoreError('invalid-input', 'unsupported source publish reconciliation receipt schema');
|
|
337
|
+
}
|
|
338
|
+
const evidence = parseSourcePublishReconciliationEvidence(item.evidence);
|
|
339
|
+
const receipt = { schemaVersion: 1, kind: 'dsh-source-publish-reconciliation-receipt',
|
|
340
|
+
receiptId: text(item.receiptId, 'receiptId'), authority: text(item.authority, 'authority'), keyId: text(item.keyId, 'keyId'),
|
|
341
|
+
installationId: text(item.installationId, 'installationId', /^[a-f0-9-]{36}$/u), planId: text(item.planId, 'planId'),
|
|
342
|
+
planDigest: digestText(item.planDigest, 'planDigest'), releaseId: text(item.releaseId, 'releaseId'), fence: integer(item.fence, 'fence', 1),
|
|
343
|
+
operationId: text(item.operationId, 'operationId'), requestDigest: digestText(item.requestDigest, 'requestDigest'), evidence,
|
|
344
|
+
evidenceDigest: digestText(item.evidenceDigest, 'evidenceDigest'), observedAt: integer(item.observedAt, 'observedAt'),
|
|
345
|
+
expiresAt: integer(item.expiresAt, 'expiresAt'), signature: signature(item.signature, 'publish reconciliation receipt signature') };
|
|
346
|
+
if (receipt.expiresAt <= receipt.observedAt || receipt.evidenceDigest !== sourcePublishReconciliationEvidenceDigest(evidence)) {
|
|
347
|
+
throw new ControlPlaneStoreError('invalid-input', 'publish reconciliation evidence digest or validity interval is invalid');
|
|
348
|
+
}
|
|
349
|
+
return Object.freeze(receipt);
|
|
350
|
+
}
|
|
351
|
+
function canonicalReconciliationReceipt(receipt) {
|
|
352
|
+
const { signature: _signature, ...fields } = receipt;
|
|
353
|
+
return canonical(fields);
|
|
354
|
+
}
|
|
355
|
+
export function sourcePublishReconciliationSigningPayload(receipt) {
|
|
356
|
+
return canonicalReconciliationReceipt({ ...receipt, signature: '' });
|
|
357
|
+
}
|
|
358
|
+
export class Ed25519SourcePublishReconciliationAuthority {
|
|
359
|
+
publicKey;
|
|
360
|
+
expectedAuthority;
|
|
361
|
+
expectedKeyId;
|
|
362
|
+
now;
|
|
363
|
+
constructor(publicKey, expectedAuthority, expectedKeyId, now = Date.now) {
|
|
364
|
+
this.publicKey = publicKey;
|
|
365
|
+
this.expectedAuthority = expectedAuthority;
|
|
366
|
+
this.expectedKeyId = expectedKeyId;
|
|
367
|
+
this.now = now;
|
|
368
|
+
}
|
|
369
|
+
async verify(input, plan, rawRequest) {
|
|
370
|
+
const request = parseSourcePublishReconciliationRequest(rawRequest);
|
|
371
|
+
const receipt = parseSourcePublishReconciliationReceipt(input);
|
|
372
|
+
const evidence = receipt.evidence;
|
|
373
|
+
const now = this.now();
|
|
374
|
+
if (receipt.authority !== this.expectedAuthority || receipt.keyId !== this.expectedKeyId
|
|
375
|
+
|| receipt.authority !== request.adapter.authority || receipt.keyId !== request.adapter.keyId
|
|
376
|
+
|| receipt.installationId !== request.installationId || receipt.planId !== plan.id || receipt.planDigest !== plan.digest
|
|
377
|
+
|| request.plan.id !== plan.id || request.plan.digest !== plan.digest || request.plan.revision !== plan.revision
|
|
378
|
+
|| plan.releaseAuthorization === undefined || !same(request.authorization, plan.releaseAuthorization)
|
|
379
|
+
|| plan.release?.id !== request.release.id || plan.release.fence !== request.release.fence
|
|
380
|
+
|| receipt.releaseId !== request.release.id || receipt.fence !== request.release.fence || receipt.operationId !== request.operationId
|
|
381
|
+
|| receipt.requestDigest !== sourcePublishReconciliationRequestDigest(request)
|
|
382
|
+
|| evidence.registryId !== request.registry.id || evidence.packageName !== request.artifact.packageName
|
|
383
|
+
|| evidence.packageVersion !== request.artifact.packageVersion || evidence.expectedTarballSha256 !== request.artifact.tarballSha256
|
|
384
|
+
|| evidence.expectedTarballIntegrity !== request.artifact.tarballIntegrity
|
|
385
|
+
|| evidence.expectedArtifactStatementDigest !== request.expectedArtifactStatementDigest
|
|
386
|
+
|| evidence.expectedArtifactSignatureDigest !== request.expectedArtifactSignatureDigest
|
|
387
|
+
|| (evidence.outcome === 'exists-match' && (evidence.observedArtifactStatementDigest !== request.expectedArtifactStatementDigest
|
|
388
|
+
|| evidence.observedArtifactSignatureDigest !== request.expectedArtifactSignatureDigest))
|
|
389
|
+
|| evidence.ambiguousPublishOperationId !== request.ambiguousPublish.operationId
|
|
390
|
+
|| evidence.ambiguousPublishReceiptDigest !== request.ambiguousPublish.receiptDigest
|
|
391
|
+
|| (evidence.registryReference !== null && evidence.registryReference !== request.expectedRegistryReference)) {
|
|
392
|
+
throw new ControlPlaneStoreError('conflict', 'publish reconciliation receipt is not bound to the exact ambiguous publish and artifact');
|
|
393
|
+
}
|
|
394
|
+
if (receipt.observedAt < request.requestedAt || receipt.observedAt > now || now > receipt.expiresAt
|
|
395
|
+
|| now > request.authorization.expiresAt || receipt.expiresAt > request.authorization.expiresAt
|
|
396
|
+
|| receipt.expiresAt - receipt.observedAt > request.receiptTtlMs) {
|
|
397
|
+
throw new ControlPlaneStoreError('expired', 'publish reconciliation receipt is outside its request-bound validity interval');
|
|
398
|
+
}
|
|
399
|
+
const signatureBytes = Buffer.from(receipt.signature, 'base64');
|
|
400
|
+
if (!verify(null, Buffer.from(canonicalReconciliationReceipt(receipt)), createPublicKey(this.publicKey), signatureBytes)) {
|
|
401
|
+
throw new ControlPlaneStoreError('invalid-input', 'publish reconciliation receipt signature is invalid');
|
|
402
|
+
}
|
|
403
|
+
const { signature: _signature, ...fields } = receipt;
|
|
404
|
+
return Object.freeze({ ...fields, signatureDigest: createHash('sha256').update(signatureBytes).digest('hex') });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const artifactFields = ['candidateId', 'sourceName', 'packagePath', 'packageName', 'packageVersion', 'tarballPath', 'tarballBytes', 'tarballSha256', 'tarballIntegrity',
|
|
408
|
+
'sbomPath', 'sbomSha256', 'provenancePath', 'provenanceSha256', 'mergedCommit', 'dshBaseline', 'capabilities', 'authorities', 'requires'];
|
|
409
|
+
function artifact(value, exactObject = true) {
|
|
410
|
+
const item = record(value, 'release artifact');
|
|
411
|
+
if (exactObject)
|
|
412
|
+
exact(item, artifactFields, 'release artifact');
|
|
413
|
+
const candidate = parseCatalog({ schemaVersion: 1, entries: [{ id: 'released-artifact', package: item.packageName,
|
|
414
|
+
version: item.packageVersion, integrity: item.tarballIntegrity, requires: item.requires,
|
|
415
|
+
dshBaseline: item.dshBaseline, capabilities: item.capabilities, authorities: item.authorities }] }).entries[0];
|
|
416
|
+
const tarballPath = absolutePath(item.tarballPath, 'tarballPath');
|
|
417
|
+
const sbomPath = absolutePath(item.sbomPath, 'sbomPath');
|
|
418
|
+
const provenancePath = absolutePath(item.provenancePath, 'provenancePath');
|
|
419
|
+
const integrityBytes = Buffer.from(candidate.integrity.slice('sha512-'.length), 'base64');
|
|
420
|
+
if (integrityBytes.length !== 64 || `sha512-${integrityBytes.toString('base64')}` !== candidate.integrity)
|
|
421
|
+
throw new ControlPlaneStoreError('invalid-input', 'tarballIntegrity is not canonical SHA-512');
|
|
422
|
+
if (!same(item.capabilities, candidate.capabilities) || !same(item.authorities, candidate.authorities) || !same(item.requires, candidate.requires)) {
|
|
423
|
+
throw new ControlPlaneStoreError('invalid-input', 'release artifact metadata must be canonical and sorted');
|
|
424
|
+
}
|
|
425
|
+
return { candidateId: text(item.candidateId, 'candidateId', /^[a-z0-9][a-z0-9-]{0,63}$/u),
|
|
426
|
+
sourceName: text(item.sourceName, 'sourceName', /^[a-z0-9][a-z0-9-]{0,63}$/u), packagePath: relativePath(item.packagePath, 'packagePath'),
|
|
427
|
+
packageName: candidate.package, packageVersion: candidate.version, tarballPath,
|
|
428
|
+
tarballBytes: integer(item.tarballBytes, 'tarballBytes', 1), tarballSha256: text(item.tarballSha256, 'tarballSha256', DIGEST),
|
|
429
|
+
tarballIntegrity: candidate.integrity, sbomPath, sbomSha256: text(item.sbomSha256, 'sbomSha256', DIGEST),
|
|
430
|
+
provenancePath, provenanceSha256: text(item.provenanceSha256, 'provenanceSha256', DIGEST),
|
|
431
|
+
mergedCommit: text(item.mergedCommit, 'mergedCommit', COMMIT), dshBaseline: candidate.dshBaseline,
|
|
432
|
+
capabilities: candidate.capabilities, authorities: candidate.authorities, requires: candidate.requires };
|
|
433
|
+
}
|
|
434
|
+
function adapterIdentity(value) {
|
|
435
|
+
const item = record(value, 'release adapter identity');
|
|
436
|
+
exact(item, ['id', 'version', 'path', 'sha256', 'interpreter', 'authority', 'keyId'], 'release adapter identity');
|
|
437
|
+
let interpreter = null;
|
|
438
|
+
if (item.interpreter !== null) {
|
|
439
|
+
const raw = record(item.interpreter, 'release adapter interpreter');
|
|
440
|
+
exact(raw, ['path', 'sha256'], 'release adapter interpreter');
|
|
441
|
+
interpreter = Object.freeze({ path: absolutePath(raw.path, 'adapter.interpreter.path'),
|
|
442
|
+
sha256: digestText(raw.sha256, 'adapter.interpreter.sha256') });
|
|
443
|
+
}
|
|
444
|
+
return Object.freeze({ id: text(item.id, 'adapter.id'), version: text(item.version, 'adapter.version', /^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$/u),
|
|
445
|
+
path: absolutePath(item.path, 'adapter.path'), sha256: digestText(item.sha256, 'adapter.sha256'), interpreter,
|
|
446
|
+
authority: text(item.authority, 'adapter.authority'), keyId: text(item.keyId, 'adapter.keyId') });
|
|
447
|
+
}
|
|
448
|
+
function artifactMatchesPolicy(value, policy) {
|
|
449
|
+
return value.candidateId === policy.candidateId && value.sourceName === policy.candidateId && value.packagePath === policy.packagePath
|
|
450
|
+
&& value.packageName === policy.packageName && value.packageVersion === policy.packageVersion
|
|
451
|
+
&& value.dshBaseline === policy.dshBaseline && same(value.capabilities, policy.capabilities)
|
|
452
|
+
&& same(value.authorities, policy.authorities) && same(value.requires, policy.requires);
|
|
453
|
+
}
|
|
454
|
+
function requestBase(value) {
|
|
455
|
+
const ledger = record(value.ledger, 'release request ledger');
|
|
456
|
+
exact(ledger, ['id', 'path'], 'release request ledger');
|
|
457
|
+
const plan = record(value.plan, 'release request plan');
|
|
458
|
+
exact(plan, ['id', 'digest', 'revision'], 'release request plan');
|
|
459
|
+
const release = record(value.release, 'release request release');
|
|
460
|
+
exact(release, ['id', 'fence'], 'release request release');
|
|
461
|
+
const registry = record(value.registry, 'release request registry');
|
|
462
|
+
exact(registry, ['id', 'locator'], 'release request registry');
|
|
463
|
+
const catalog = record(value.catalog, 'release request catalog');
|
|
464
|
+
exact(catalog, ['id', 'path'], 'release request catalog');
|
|
465
|
+
const authorization = parseVerifiedSourceReleaseAuthorization(value.authorization);
|
|
466
|
+
const result = { schemaVersion: 1, kind: 'dsh-source-release-request', operationId: text(value.operationId, 'operationId'),
|
|
467
|
+
attempt: integer(value.attempt, 'attempt', 1), requestedAt: integer(value.requestedAt, 'requestedAt'),
|
|
468
|
+
receiptTtlMs: integer(value.receiptTtlMs, 'receiptTtlMs', 1_000),
|
|
469
|
+
installationId: text(value.installationId, 'installationId', /^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/u),
|
|
470
|
+
ledger: Object.freeze({ id: text(ledger.id, 'ledger.id'), path: absolutePath(ledger.path, 'ledger.path') }),
|
|
471
|
+
plan: Object.freeze({ id: text(plan.id, 'plan.id'), digest: digestText(plan.digest, 'plan.digest'), revision: integer(plan.revision, 'plan.revision', 1) }),
|
|
472
|
+
release: Object.freeze({ id: text(release.id, 'release.id'), fence: integer(release.fence, 'release.fence', 1) }),
|
|
473
|
+
authorization, adapter: adapterIdentity(value.adapter),
|
|
474
|
+
registry: Object.freeze({ id: text(registry.id, 'registry.id'), locator: opaqueLine(registry.locator, 'registry.locator') }),
|
|
475
|
+
catalog: Object.freeze({ id: text(catalog.id, 'catalog.id'), path: absolutePath(catalog.path, 'catalog.path') }) };
|
|
476
|
+
if (result.receiptTtlMs > 300_000 || authorization.planId !== result.plan.id || authorization.planDigest !== result.plan.digest
|
|
477
|
+
|| result.requestedAt < authorization.authorizedAt || result.requestedAt > authorization.expiresAt
|
|
478
|
+
|| authorization.releasePolicy.registryId !== result.registry.id || authorization.releasePolicy.registryLocator !== result.registry.locator
|
|
479
|
+
|| authorization.releasePolicy.catalogId !== result.catalog.id || authorization.releasePolicy.catalogPath !== result.catalog.path) {
|
|
480
|
+
throw new ControlPlaneStoreError('conflict', 'release request is not bound to its authorization, plan, registry, and catalog');
|
|
481
|
+
}
|
|
482
|
+
return result;
|
|
483
|
+
}
|
|
484
|
+
export function parseSourceReleaseRequest(value) {
|
|
485
|
+
const item = record(value, 'source release request');
|
|
486
|
+
exact(item, ['schemaVersion', 'kind', 'operationId', 'attempt', 'requestedAt', 'receiptTtlMs', 'installationId', 'ledger', 'plan',
|
|
487
|
+
'release', 'authorization', 'adapter', 'registry', 'catalog', 'phase', 'input'], 'source release request');
|
|
488
|
+
if (item.schemaVersion !== 1 || item.kind !== 'dsh-source-release-request' || typeof item.phase !== 'string'
|
|
489
|
+
|| !phases.has(item.phase))
|
|
490
|
+
throw new ControlPlaneStoreError('invalid-input', 'source release request fields are invalid');
|
|
491
|
+
const base = requestBase(item);
|
|
492
|
+
const input = record(item.input, `${item.phase} request input`);
|
|
493
|
+
const policy = base.authorization.releasePolicy;
|
|
494
|
+
let result;
|
|
495
|
+
if (item.phase === 'pr') {
|
|
496
|
+
exact(input, ['repository', 'worktree', 'baseCommit', 'name', 'scope', 'expectedTreeDigest', 'expectedPatchDigest'], 'PR request input');
|
|
497
|
+
result = { ...base, phase: 'pr', input: { repository: absolutePath(input.repository, 'repository'), worktree: absolutePath(input.worktree, 'worktree'),
|
|
498
|
+
baseCommit: commit(input.baseCommit, 'baseCommit'), name: text(input.name, 'name', /^[a-z0-9][a-z0-9-]{0,63}$/u), scope: scopeList(input.scope),
|
|
499
|
+
expectedTreeDigest: digestText(input.expectedTreeDigest, 'expectedTreeDigest'), expectedPatchDigest: digestText(input.expectedPatchDigest, 'expectedPatchDigest') } };
|
|
500
|
+
if (result.input.baseCommit !== base.authorization.baseCommit || result.input.name !== policy.candidateId
|
|
501
|
+
|| !same(result.input.scope, base.authorization.scope) || result.input.expectedTreeDigest !== base.authorization.checkedTreeDigest
|
|
502
|
+
|| result.input.expectedPatchDigest !== base.authorization.checkedPatchDigest)
|
|
503
|
+
throw new ControlPlaneStoreError('conflict', 'PR request does not bind the authorized checked source');
|
|
504
|
+
}
|
|
505
|
+
else if (item.phase === 'review') {
|
|
506
|
+
exact(input, ['prId', 'headCommit', 'baseCommit', 'prEvidenceDigest'], 'review request input');
|
|
507
|
+
result = { ...base, phase: 'review', input: { prId: text(input.prId, 'prId'), headCommit: commit(input.headCommit, 'headCommit'),
|
|
508
|
+
baseCommit: commit(input.baseCommit, 'baseCommit'), prEvidenceDigest: digestText(input.prEvidenceDigest, 'prEvidenceDigest') } };
|
|
509
|
+
if (result.input.baseCommit !== base.authorization.baseCommit)
|
|
510
|
+
throw new ControlPlaneStoreError('conflict', 'review request does not bind the authorized base commit');
|
|
511
|
+
}
|
|
512
|
+
else if (item.phase === 'merge') {
|
|
513
|
+
exact(input, ['prId', 'headCommit', 'reviewId', 'reviewEvidenceDigest', 'targetBranch'], 'merge request input');
|
|
514
|
+
result = { ...base, phase: 'merge', input: { prId: text(input.prId, 'prId'), headCommit: commit(input.headCommit, 'headCommit'),
|
|
515
|
+
reviewId: text(input.reviewId, 'reviewId'), reviewEvidenceDigest: digestText(input.reviewEvidenceDigest, 'reviewEvidenceDigest'),
|
|
516
|
+
targetBranch: branch(input.targetBranch, 'targetBranch') } };
|
|
517
|
+
if (result.input.targetBranch !== policy.targetBranch)
|
|
518
|
+
throw new ControlPlaneStoreError('conflict', 'merge request does not bind the authorized target branch');
|
|
519
|
+
}
|
|
520
|
+
else if (item.phase === 'build') {
|
|
521
|
+
exact(input, ['repository', 'mergeCommit', 'mergeEvidenceDigest', 'name', 'expectedCandidateId', 'expectedPackageName',
|
|
522
|
+
'expectedPackageVersion', 'expectedPackagePath', 'expectedDshBaseline', 'expectedCapabilities', 'expectedAuthorities', 'expectedRequires'], 'build request input');
|
|
523
|
+
result = { ...base, phase: 'build', input: { repository: absolutePath(input.repository, 'repository'), mergeCommit: commit(input.mergeCommit, 'mergeCommit'),
|
|
524
|
+
mergeEvidenceDigest: digestText(input.mergeEvidenceDigest, 'mergeEvidenceDigest'), name: text(input.name, 'name', /^[a-z0-9][a-z0-9-]{0,63}$/u),
|
|
525
|
+
expectedCandidateId: text(input.expectedCandidateId, 'expectedCandidateId', /^[a-z0-9][a-z0-9-]{0,63}$/u),
|
|
526
|
+
expectedPackageName: text(input.expectedPackageName, 'expectedPackageName', PACKAGE), expectedPackageVersion: text(input.expectedPackageVersion, 'expectedPackageVersion', VERSION),
|
|
527
|
+
expectedPackagePath: relativePath(input.expectedPackagePath, 'expectedPackagePath'), expectedDshBaseline: text(input.expectedDshBaseline, 'expectedDshBaseline', VERSION),
|
|
528
|
+
expectedCapabilities: stringList(input.expectedCapabilities, 'expectedCapabilities'), expectedAuthorities: stringList(input.expectedAuthorities, 'expectedAuthorities'),
|
|
529
|
+
expectedRequires: requirements(input.expectedRequires, String(input.expectedPackageName), 'expectedRequires') } };
|
|
530
|
+
if (result.input.name !== policy.candidateId || result.input.expectedCandidateId !== policy.candidateId
|
|
531
|
+
|| result.input.expectedPackageName !== policy.packageName || result.input.expectedPackageVersion !== policy.packageVersion
|
|
532
|
+
|| result.input.expectedPackagePath !== policy.packagePath || result.input.expectedDshBaseline !== policy.dshBaseline
|
|
533
|
+
|| !same(result.input.expectedCapabilities, policy.capabilities) || !same(result.input.expectedAuthorities, policy.authorities)
|
|
534
|
+
|| !same(result.input.expectedRequires, policy.requires))
|
|
535
|
+
throw new ControlPlaneStoreError('conflict', 'build request does not bind the authorized candidate policy');
|
|
536
|
+
}
|
|
537
|
+
else if (item.phase === 'sign') {
|
|
538
|
+
exact(input, ['artifact', 'buildEvidenceDigest'], 'sign request input');
|
|
539
|
+
result = { ...base, phase: 'sign', input: { artifact: artifact(input.artifact), buildEvidenceDigest: digestText(input.buildEvidenceDigest, 'buildEvidenceDigest') } };
|
|
540
|
+
}
|
|
541
|
+
else if (item.phase === 'publish') {
|
|
542
|
+
exact(input, ['artifact', 'artifactStatementDigest', 'artifactSignature', 'signEvidenceDigest'], 'publish request input');
|
|
543
|
+
result = { ...base, phase: 'publish', input: { artifact: artifact(input.artifact), artifactStatementDigest: digestText(input.artifactStatementDigest, 'artifactStatementDigest'),
|
|
544
|
+
artifactSignature: signature(input.artifactSignature, 'artifactSignature'), signEvidenceDigest: digestText(input.signEvidenceDigest, 'signEvidenceDigest') } };
|
|
545
|
+
}
|
|
546
|
+
else if (item.phase === 'registry-verify') {
|
|
547
|
+
exact(input, ['artifact', 'artifactStatementDigest', 'artifactSignature', 'registryReference', 'publishEvidenceDigest'], 'registry verification request input');
|
|
548
|
+
result = { ...base, phase: 'registry-verify', input: { artifact: artifact(input.artifact), artifactStatementDigest: digestText(input.artifactStatementDigest, 'artifactStatementDigest'),
|
|
549
|
+
artifactSignature: signature(input.artifactSignature, 'artifactSignature'), registryReference: opaqueLine(input.registryReference, 'registryReference'),
|
|
550
|
+
publishEvidenceDigest: digestText(input.publishEvidenceDigest, 'publishEvidenceDigest') } };
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
exact(input, ['artifact', 'artifactStatementDigest', 'artifactSignature', 'registryReference', 'registryVerificationRequest',
|
|
554
|
+
'registryVerificationReceipt', 'verificationEvidenceDigest',
|
|
555
|
+
'expectedBeforeCatalogDigest', 'expectedAfterCatalogDigest', 'candidate'], 'catalog admission request input');
|
|
556
|
+
const rawRegistryVerificationRequest = record(input.registryVerificationRequest, 'registry verification request');
|
|
557
|
+
if (rawRegistryVerificationRequest.phase !== 'registry-verify') {
|
|
558
|
+
throw new ControlPlaneStoreError('invalid-input', 'nested registry verification request must use registry-verify phase');
|
|
559
|
+
}
|
|
560
|
+
const registryVerificationRequest = parseSourceReleaseRequest(rawRegistryVerificationRequest);
|
|
561
|
+
if (registryVerificationRequest.phase !== 'registry-verify') {
|
|
562
|
+
throw new ControlPlaneStoreError('invalid-input', 'nested registry verification request must use registry-verify phase');
|
|
563
|
+
}
|
|
564
|
+
const registryVerificationReceipt = parseSourceReleaseReceipt(input.registryVerificationReceipt);
|
|
565
|
+
result = { ...base, phase: 'catalog-admission', input: { artifact: artifact(input.artifact), artifactStatementDigest: digestText(input.artifactStatementDigest, 'artifactStatementDigest'),
|
|
566
|
+
artifactSignature: signature(input.artifactSignature, 'artifactSignature'), registryReference: opaqueLine(input.registryReference, 'registryReference'),
|
|
567
|
+
registryVerificationRequest, registryVerificationReceipt,
|
|
568
|
+
verificationEvidenceDigest: digestText(input.verificationEvidenceDigest, 'verificationEvidenceDigest'),
|
|
569
|
+
expectedBeforeCatalogDigest: digestText(input.expectedBeforeCatalogDigest, 'expectedBeforeCatalogDigest'),
|
|
570
|
+
expectedAfterCatalogDigest: digestText(input.expectedAfterCatalogDigest, 'expectedAfterCatalogDigest'), candidate: catalogEntry(input.candidate, 'catalog candidate') } };
|
|
571
|
+
if (result.input.expectedBeforeCatalogDigest === result.input.expectedAfterCatalogDigest)
|
|
572
|
+
throw new ControlPlaneStoreError('invalid-input', 'catalog admission must authorize one exact catalog change');
|
|
573
|
+
}
|
|
574
|
+
if ('artifact' in result.input && !artifactMatchesPolicy(result.input.artifact, policy)) {
|
|
575
|
+
throw new ControlPlaneStoreError('conflict', 'release artifact does not match the authorized candidate policy');
|
|
576
|
+
}
|
|
577
|
+
if ('artifact' in result.input && 'artifactStatementDigest' in result.input
|
|
578
|
+
&& result.input.artifactStatementDigest !== sourceArtifactStatementDigest(result.input.artifact)) {
|
|
579
|
+
throw new ControlPlaneStoreError('conflict', 'release request statement digest does not match its exact artifact');
|
|
580
|
+
}
|
|
581
|
+
if ((result.phase === 'registry-verify' || result.phase === 'catalog-admission') && result.input.registryReference !== policy.registryReference) {
|
|
582
|
+
throw new ControlPlaneStoreError('conflict', 'release request does not bind the authorized immutable registry reference');
|
|
583
|
+
}
|
|
584
|
+
if (result.phase === 'catalog-admission') {
|
|
585
|
+
const registryReceipt = result.input.registryVerificationReceipt;
|
|
586
|
+
const registryRequest = result.input.registryVerificationRequest;
|
|
587
|
+
const registryEvidence = registryReceipt.evidence;
|
|
588
|
+
const artifactSignatureDigest = createHash('sha256').update(Buffer.from(result.input.artifactSignature, 'base64')).digest('hex');
|
|
589
|
+
if (registryReceipt.phase !== 'registry-verify' || registryReceipt.outcome !== 'passed' || registryEvidence.kind !== 'registry-verify'
|
|
590
|
+
|| registryReceipt.installationId !== result.installationId || registryReceipt.planId !== result.plan.id
|
|
591
|
+
|| registryReceipt.planDigest !== result.plan.digest || registryReceipt.releaseId !== result.release.id
|
|
592
|
+
|| registryReceipt.fence !== result.release.fence || registryReceipt.evidenceDigest !== result.input.verificationEvidenceDigest
|
|
593
|
+
|| registryReceipt.operationId !== registryRequest.operationId
|
|
594
|
+
|| registryReceipt.requestDigest !== sourceReleaseRequestDigest(registryRequest)
|
|
595
|
+
|| registryRequest.installationId !== result.installationId || registryRequest.plan.id !== result.plan.id
|
|
596
|
+
|| registryRequest.plan.digest !== result.plan.digest || registryRequest.plan.revision + 1 !== result.plan.revision
|
|
597
|
+
|| registryRequest.requestedAt < registryRequest.authorization.authorizedAt
|
|
598
|
+
|| registryRequest.requestedAt > registryRequest.authorization.expiresAt
|
|
599
|
+
|| registryRequest.release.id !== result.release.id
|
|
600
|
+
|| registryRequest.release.fence !== result.release.fence
|
|
601
|
+
|| registryReceipt.observedAt < registryRequest.requestedAt
|
|
602
|
+
|| registryReceipt.expiresAt > registryRequest.authorization.expiresAt
|
|
603
|
+
|| registryReceipt.expiresAt - registryReceipt.observedAt > registryRequest.receiptTtlMs
|
|
604
|
+
|| registryReceipt.authority !== registryRequest.adapter.authority || registryReceipt.keyId !== registryRequest.adapter.keyId
|
|
605
|
+
|| !same(registryRequest.authorization, result.authorization) || !same(registryRequest.ledger, result.ledger)
|
|
606
|
+
|| !same(registryRequest.registry, result.registry) || !same(registryRequest.catalog, result.catalog)
|
|
607
|
+
|| !same(registryRequest.input.artifact, result.input.artifact)
|
|
608
|
+
|| registryRequest.input.registryReference !== result.input.registryReference
|
|
609
|
+
|| registryRequest.input.artifactStatementDigest !== result.input.artifactStatementDigest
|
|
610
|
+
|| registryRequest.input.artifactSignature !== result.input.artifactSignature
|
|
611
|
+
|| registryEvidence.registryId !== result.registry.id || registryEvidence.registryReference !== result.input.registryReference
|
|
612
|
+
|| registryEvidence.downloadedBytes !== result.input.artifact.tarballBytes
|
|
613
|
+
|| registryEvidence.downloadedSha256 !== result.input.artifact.tarballSha256
|
|
614
|
+
|| registryEvidence.downloadedIntegrity !== result.input.artifact.tarballIntegrity
|
|
615
|
+
|| registryEvidence.artifactStatementDigest !== result.input.artifactStatementDigest
|
|
616
|
+
|| registryEvidence.artifactSignatureDigest !== artifactSignatureDigest
|
|
617
|
+
|| registryEvidence.publishEvidenceDigest !== registryRequest.input.publishEvidenceDigest) {
|
|
618
|
+
throw new ControlPlaneStoreError('conflict', 'catalog admission registry verification receipt does not bind the exact request, release fence, and signed artifact');
|
|
619
|
+
}
|
|
620
|
+
const expectedCandidate = { id: policy.candidateId, package: policy.packageName, version: policy.packageVersion,
|
|
621
|
+
integrity: result.input.artifact.tarballIntegrity, registry: { id: policy.registryId, locator: policy.registryLocator,
|
|
622
|
+
reference: policy.registryReference }, requires: policy.requires, dshBaseline: policy.dshBaseline,
|
|
623
|
+
capabilities: policy.capabilities, authorities: policy.authorities };
|
|
624
|
+
if (!same(result.input.candidate, expectedCandidate))
|
|
625
|
+
throw new ControlPlaneStoreError('conflict', 'catalog candidate does not match the authorized verified artifact');
|
|
626
|
+
}
|
|
627
|
+
return Object.freeze(result);
|
|
628
|
+
}
|
|
629
|
+
function parseSuccessEvidence(value) {
|
|
630
|
+
const kind = value.kind;
|
|
631
|
+
if (kind === 'pr') {
|
|
632
|
+
exact(value, ['kind', 'prId', 'baseCommit', 'headCommit', 'treeDigest', 'patchDigest', 'repositoryDigest'], 'PR evidence');
|
|
633
|
+
return { kind, prId: text(value.prId, 'prId'), baseCommit: text(value.baseCommit, 'baseCommit', COMMIT),
|
|
634
|
+
headCommit: text(value.headCommit, 'headCommit', COMMIT), treeDigest: digestText(value.treeDigest, 'treeDigest'),
|
|
635
|
+
patchDigest: digestText(value.patchDigest, 'patchDigest'), repositoryDigest: digestText(value.repositoryDigest, 'repositoryDigest') };
|
|
636
|
+
}
|
|
637
|
+
if (kind === 'review') {
|
|
638
|
+
exact(value, ['kind', 'prId', 'headCommit', 'reviewId', 'decision', 'reviewerPrincipalDigest', 'prEvidenceDigest'], 'review evidence');
|
|
639
|
+
if (value.decision !== 'approved')
|
|
640
|
+
throw new ControlPlaneStoreError('invalid-input', 'review decision is not approved');
|
|
641
|
+
return { kind, prId: text(value.prId, 'prId'), headCommit: text(value.headCommit, 'headCommit', COMMIT),
|
|
642
|
+
reviewId: text(value.reviewId, 'reviewId'), decision: value.decision,
|
|
643
|
+
reviewerPrincipalDigest: digestText(value.reviewerPrincipalDigest, 'reviewerPrincipalDigest'),
|
|
644
|
+
prEvidenceDigest: digestText(value.prEvidenceDigest, 'prEvidenceDigest') };
|
|
645
|
+
}
|
|
646
|
+
if (kind === 'merge') {
|
|
647
|
+
exact(value, ['kind', 'prId', 'reviewedHeadCommit', 'reviewId', 'reviewEvidenceDigest', 'mergeCommit', 'targetBranch'], 'merge evidence');
|
|
648
|
+
return { kind, prId: text(value.prId, 'prId'), reviewedHeadCommit: text(value.reviewedHeadCommit, 'reviewedHeadCommit', COMMIT),
|
|
649
|
+
reviewId: text(value.reviewId, 'reviewId'), reviewEvidenceDigest: digestText(value.reviewEvidenceDigest, 'reviewEvidenceDigest'),
|
|
650
|
+
mergeCommit: text(value.mergeCommit, 'mergeCommit', COMMIT), targetBranch: opaqueLine(value.targetBranch, 'targetBranch', 255) };
|
|
651
|
+
}
|
|
652
|
+
if (kind === 'build') {
|
|
653
|
+
exact(value, ['kind', 'isolated', 'reproducibleBuilds', 'firstBuildSha256', 'secondBuildSha256', 'mergeEvidenceDigest', ...artifactFields], 'build evidence');
|
|
654
|
+
if (value.isolated !== true)
|
|
655
|
+
throw new ControlPlaneStoreError('invalid-input', 'build was not isolated');
|
|
656
|
+
return { kind, isolated: true, reproducibleBuilds: integer(value.reproducibleBuilds, 'reproducibleBuilds', 2),
|
|
657
|
+
firstBuildSha256: text(value.firstBuildSha256, 'firstBuildSha256', DIGEST), secondBuildSha256: text(value.secondBuildSha256, 'secondBuildSha256', DIGEST),
|
|
658
|
+
mergeEvidenceDigest: digestText(value.mergeEvidenceDigest, 'mergeEvidenceDigest'), ...artifact(value, false) };
|
|
659
|
+
}
|
|
660
|
+
if (kind === 'sign') {
|
|
661
|
+
exact(value, ['kind', 'artifactStatementDigest', 'artifactSignature', 'artifactSignatureDigest', 'buildEvidenceDigest'], 'sign evidence');
|
|
662
|
+
const artifactSignature = signature(value.artifactSignature, 'artifactSignature');
|
|
663
|
+
return { kind, artifactStatementDigest: text(value.artifactStatementDigest, 'artifactStatementDigest', DIGEST), artifactSignature,
|
|
664
|
+
artifactSignatureDigest: text(value.artifactSignatureDigest, 'artifactSignatureDigest', DIGEST),
|
|
665
|
+
buildEvidenceDigest: digestText(value.buildEvidenceDigest, 'buildEvidenceDigest') };
|
|
666
|
+
}
|
|
667
|
+
if (kind === 'publish') {
|
|
668
|
+
exact(value, ['kind', 'registryId', 'registryReference', 'packageName', 'packageVersion', 'tarballSha256', 'tarballIntegrity',
|
|
669
|
+
'artifactStatementDigest', 'artifactSignatureDigest', 'signEvidenceDigest', 'immutable'], 'publish evidence');
|
|
670
|
+
if (value.immutable !== true)
|
|
671
|
+
throw new ControlPlaneStoreError('invalid-input', 'registry publication is not immutable');
|
|
672
|
+
return { kind, registryId: text(value.registryId, 'registryId'), registryReference: opaqueLine(value.registryReference, 'registryReference'),
|
|
673
|
+
packageName: text(value.packageName, 'packageName', /^[a-z0-9@/._-]+$/u), packageVersion: text(value.packageVersion, 'packageVersion', /^[A-Za-z0-9._+-]+$/u),
|
|
674
|
+
tarballSha256: text(value.tarballSha256, 'tarballSha256', DIGEST), tarballIntegrity: integrity(value.tarballIntegrity, 'tarballIntegrity'),
|
|
675
|
+
artifactStatementDigest: text(value.artifactStatementDigest, 'artifactStatementDigest', DIGEST),
|
|
676
|
+
artifactSignatureDigest: text(value.artifactSignatureDigest, 'artifactSignatureDigest', DIGEST),
|
|
677
|
+
signEvidenceDigest: digestText(value.signEvidenceDigest, 'signEvidenceDigest'), immutable: true };
|
|
678
|
+
}
|
|
679
|
+
if (kind === 'registry-verify') {
|
|
680
|
+
exact(value, ['kind', 'registryId', 'registryReference', 'independentlyDownloaded', 'downloadedBytes', 'downloadedSha256',
|
|
681
|
+
'downloadedIntegrity', 'artifactStatementDigest', 'artifactSignatureDigest', 'publishEvidenceDigest'], 'registry verification evidence');
|
|
682
|
+
if (value.independentlyDownloaded !== true)
|
|
683
|
+
throw new ControlPlaneStoreError('invalid-input', 'artifact was not independently downloaded');
|
|
684
|
+
return { kind, registryId: text(value.registryId, 'registryId'), registryReference: opaqueLine(value.registryReference, 'registryReference'),
|
|
685
|
+
independentlyDownloaded: true, downloadedBytes: integer(value.downloadedBytes, 'downloadedBytes', 1),
|
|
686
|
+
downloadedSha256: text(value.downloadedSha256, 'downloadedSha256', DIGEST), downloadedIntegrity: integrity(value.downloadedIntegrity, 'downloadedIntegrity'),
|
|
687
|
+
artifactStatementDigest: text(value.artifactStatementDigest, 'artifactStatementDigest', DIGEST),
|
|
688
|
+
artifactSignatureDigest: text(value.artifactSignatureDigest, 'artifactSignatureDigest', DIGEST),
|
|
689
|
+
publishEvidenceDigest: digestText(value.publishEvidenceDigest, 'publishEvidenceDigest') };
|
|
690
|
+
}
|
|
691
|
+
if (kind === 'catalog-admission') {
|
|
692
|
+
exact(value, ['kind', 'admissionId', 'catalogId', 'beforeCatalogDigest', 'afterCatalogDigest', 'registryReference',
|
|
693
|
+
'artifactStatementDigest', 'artifactSignatureDigest', 'verificationEvidenceDigest', 'candidate'], 'catalog admission evidence');
|
|
694
|
+
const candidate = catalogEntry(value.candidate, 'catalog admission candidate');
|
|
695
|
+
return { kind, admissionId: text(value.admissionId, 'admissionId'), catalogId: text(value.catalogId, 'catalogId'),
|
|
696
|
+
beforeCatalogDigest: text(value.beforeCatalogDigest, 'beforeCatalogDigest', DIGEST), afterCatalogDigest: text(value.afterCatalogDigest, 'afterCatalogDigest', DIGEST),
|
|
697
|
+
registryReference: opaqueLine(value.registryReference, 'registryReference'), artifactStatementDigest: digestText(value.artifactStatementDigest, 'artifactStatementDigest'),
|
|
698
|
+
artifactSignatureDigest: digestText(value.artifactSignatureDigest, 'artifactSignatureDigest'),
|
|
699
|
+
verificationEvidenceDigest: text(value.verificationEvidenceDigest, 'verificationEvidenceDigest', DIGEST), candidate };
|
|
700
|
+
}
|
|
701
|
+
throw new ControlPlaneStoreError('invalid-input', 'release success evidence kind is invalid');
|
|
702
|
+
}
|
|
703
|
+
export function sourceReleaseEvidenceDigest(value) { return digest(value); }
|
|
704
|
+
export function sourceReleaseRequestDigest(value) { return digest(value); }
|
|
705
|
+
export function sourceArtifactStatementDigest(value) { return digest(value); }
|
|
706
|
+
export function sourceArtifactSigningPayload(value) { return canonical({ schemaVersion: 1, kind: 'dsh-release-artifact', artifact: value }); }
|
|
707
|
+
export function parseSourceReleaseReceipt(value) {
|
|
708
|
+
const item = record(value, 'source release receipt');
|
|
709
|
+
exact(item, ['schemaVersion', 'receiptId', 'authority', 'keyId', 'installationId', 'planId', 'planDigest', 'releaseId', 'fence',
|
|
710
|
+
'operationId', 'requestDigest', 'phase', 'outcome', 'evidence', 'evidenceDigest', 'observedAt', 'expiresAt', 'signature'], 'source release receipt');
|
|
711
|
+
if (item.schemaVersion !== 1 || typeof item.phase !== 'string' || !phases.has(item.phase)
|
|
712
|
+
|| !['passed', 'failed', 'ambiguous'].includes(String(item.outcome))) {
|
|
713
|
+
throw new ControlPlaneStoreError('invalid-input', 'source release receipt fields are invalid');
|
|
714
|
+
}
|
|
715
|
+
const evidenceItem = record(item.evidence, 'source release evidence');
|
|
716
|
+
let evidence;
|
|
717
|
+
if (item.outcome === 'passed')
|
|
718
|
+
evidence = parseSuccessEvidence(evidenceItem);
|
|
719
|
+
else if (item.outcome === 'failed') {
|
|
720
|
+
exact(evidenceItem, ['kind', 'phase', 'code', 'remoteState', 'detailDigest'], 'release failure evidence');
|
|
721
|
+
if (evidenceItem.kind !== 'failure' || evidenceItem.phase !== item.phase
|
|
722
|
+
|| !['unchanged', 'created-not-reverted', 'unknown'].includes(String(evidenceItem.remoteState)))
|
|
723
|
+
throw new ControlPlaneStoreError('invalid-input', 'release failure evidence is invalid');
|
|
724
|
+
evidence = { kind: 'failure', phase: item.phase, code: text(evidenceItem.code, 'failure code'),
|
|
725
|
+
remoteState: evidenceItem.remoteState, detailDigest: text(evidenceItem.detailDigest, 'detailDigest', DIGEST) };
|
|
726
|
+
}
|
|
727
|
+
else {
|
|
728
|
+
exact(evidenceItem, ['kind', 'registryId', 'packageName', 'packageVersion', 'tarballSha256', 'detailDigest'], 'publish ambiguity evidence');
|
|
729
|
+
if (item.phase !== 'publish' || evidenceItem.kind !== 'publish-ambiguity')
|
|
730
|
+
throw new ControlPlaneStoreError('invalid-input', 'only publish may be ambiguous');
|
|
731
|
+
evidence = { kind: 'publish-ambiguity', registryId: text(evidenceItem.registryId, 'registryId'),
|
|
732
|
+
packageName: text(evidenceItem.packageName, 'packageName', /^[a-z0-9@/._-]+$/u), packageVersion: text(evidenceItem.packageVersion, 'packageVersion', /^[A-Za-z0-9._+-]+$/u),
|
|
733
|
+
tarballSha256: text(evidenceItem.tarballSha256, 'tarballSha256', DIGEST), detailDigest: text(evidenceItem.detailDigest, 'detailDigest', DIGEST) };
|
|
734
|
+
}
|
|
735
|
+
const receipt = { schemaVersion: 1, receiptId: text(item.receiptId, 'receiptId'), authority: text(item.authority, 'authority'),
|
|
736
|
+
keyId: text(item.keyId, 'keyId'), installationId: text(item.installationId, 'installationId', /^[a-f0-9-]{36}$/u), planId: text(item.planId, 'planId'),
|
|
737
|
+
planDigest: text(item.planDigest, 'planDigest', DIGEST), releaseId: text(item.releaseId, 'releaseId'), fence: integer(item.fence, 'fence', 1),
|
|
738
|
+
operationId: text(item.operationId, 'operationId'), requestDigest: text(item.requestDigest, 'requestDigest', DIGEST), phase: item.phase,
|
|
739
|
+
outcome: item.outcome, evidence, evidenceDigest: text(item.evidenceDigest, 'evidenceDigest', DIGEST),
|
|
740
|
+
observedAt: integer(item.observedAt, 'observedAt'), expiresAt: integer(item.expiresAt, 'expiresAt'),
|
|
741
|
+
signature: signature(item.signature, 'release receipt signature') };
|
|
742
|
+
if (receipt.expiresAt <= receipt.observedAt || sourceReleaseEvidenceDigest(evidence) !== receipt.evidenceDigest)
|
|
743
|
+
throw new ControlPlaneStoreError('invalid-input', 'release evidence digest or validity interval is invalid');
|
|
744
|
+
return receipt;
|
|
745
|
+
}
|
|
746
|
+
function canonicalReceipt(receipt) {
|
|
747
|
+
const { signature: _signature, ...fields } = receipt;
|
|
748
|
+
return canonical(fields);
|
|
749
|
+
}
|
|
750
|
+
export function sourceReleaseSigningPayload(receipt) {
|
|
751
|
+
return canonicalReceipt({ ...receipt, signature: '' });
|
|
752
|
+
}
|
|
753
|
+
async function snapshotStableFile(path, handle, maximum) {
|
|
754
|
+
const parentPath = dirname(path);
|
|
755
|
+
const parentBefore = await lstat(parentPath, { bigint: true });
|
|
756
|
+
const uid = process.getuid?.();
|
|
757
|
+
const expectedUid = uid === undefined ? undefined : BigInt(uid);
|
|
758
|
+
const before = await handle.stat({ bigint: true });
|
|
759
|
+
const pathBefore = await lstat(path, { bigint: true });
|
|
760
|
+
if (!parentBefore.isDirectory() || parentBefore.isSymbolicLink() || (parentBefore.mode & 18n) !== 0n
|
|
761
|
+
|| (expectedUid !== undefined && parentBefore.uid !== expectedUid && parentBefore.uid !== 0n)
|
|
762
|
+
|| await realpath(parentPath) !== resolve(parentPath))
|
|
763
|
+
throw new ControlPlaneStoreError('invalid-input', 'artifact evidence directory is unsafe');
|
|
764
|
+
if (!before.isFile() || before.nlink !== 1n || before.size < 1n || before.size > BigInt(maximum) || (before.mode & 18n) !== 0n
|
|
765
|
+
|| (expectedUid !== undefined && before.uid !== expectedUid && before.uid !== 0n) || !pathBefore.isFile() || pathBefore.isSymbolicLink()
|
|
766
|
+
|| pathBefore.dev !== before.dev || pathBefore.ino !== before.ino)
|
|
767
|
+
throw new ControlPlaneStoreError('invalid-input', 'artifact evidence file is unsafe');
|
|
768
|
+
const bytes = Buffer.alloc(Number(before.size));
|
|
769
|
+
let offset = 0;
|
|
770
|
+
while (offset < bytes.length) {
|
|
771
|
+
const result = await handle.read(bytes, offset, bytes.length - offset, offset);
|
|
772
|
+
if (result.bytesRead === 0)
|
|
773
|
+
break;
|
|
774
|
+
offset += result.bytesRead;
|
|
775
|
+
}
|
|
776
|
+
const after = await handle.stat({ bigint: true });
|
|
777
|
+
const pathAfter = await lstat(path, { bigint: true });
|
|
778
|
+
const parentAfter = await lstat(parentPath, { bigint: true });
|
|
779
|
+
if (after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size || after.mtimeNs !== before.mtimeNs
|
|
780
|
+
|| after.ctimeNs !== before.ctimeNs || pathAfter.dev !== before.dev || pathAfter.ino !== before.ino || BigInt(offset) !== before.size
|
|
781
|
+
|| parentAfter.dev !== parentBefore.dev || parentAfter.ino !== parentBefore.ino) {
|
|
782
|
+
throw new ControlPlaneStoreError('invalid-input', 'artifact evidence file changed or its path was replaced during verification');
|
|
783
|
+
}
|
|
784
|
+
return { bytes, device: before.dev, inode: before.ino, parentDevice: parentAfter.dev, parentInode: parentAfter.ino,
|
|
785
|
+
parentMtimeNs: parentAfter.mtimeNs, parentCtimeNs: parentAfter.ctimeNs };
|
|
786
|
+
}
|
|
787
|
+
async function openStableFile(path, maximum) {
|
|
788
|
+
if (!isAbsolute(path) || await realpath(path) !== resolve(path))
|
|
789
|
+
throw new ControlPlaneStoreError('invalid-input', 'artifact evidence path is not canonical');
|
|
790
|
+
const handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
791
|
+
try {
|
|
792
|
+
return { path, handle, maximum, ...await snapshotStableFile(path, handle, maximum) };
|
|
793
|
+
}
|
|
794
|
+
catch (error) {
|
|
795
|
+
await handle.close();
|
|
796
|
+
throw error;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
async function readStableFile(path, maximum) {
|
|
800
|
+
const opened = await openStableFile(path, maximum);
|
|
801
|
+
try {
|
|
802
|
+
return opened;
|
|
803
|
+
}
|
|
804
|
+
finally {
|
|
805
|
+
await opened.handle.close();
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
async function digestFile(path, maximum) {
|
|
809
|
+
const snapshot = await readStableFile(path, maximum);
|
|
810
|
+
return { bytes: snapshot.bytes.length, sha256: createHash('sha256').update(snapshot.bytes).digest('hex'),
|
|
811
|
+
integrity: `sha512-${createHash('sha512').update(snapshot.bytes).digest('base64')}`, device: snapshot.device, inode: snapshot.inode,
|
|
812
|
+
parentDevice: snapshot.parentDevice, parentInode: snapshot.parentInode, parentMtimeNs: snapshot.parentMtimeNs, parentCtimeNs: snapshot.parentCtimeNs };
|
|
813
|
+
}
|
|
814
|
+
async function artifactSnapshot(value) {
|
|
815
|
+
const tarball = await digestFile(value.tarballPath, 268_435_456);
|
|
816
|
+
const sbom = await digestFile(value.sbomPath, 16_777_216);
|
|
817
|
+
const provenance = await digestFile(value.provenancePath, 16_777_216);
|
|
818
|
+
const identities = new Set([`${tarball.device}:${tarball.inode}`, `${sbom.device}:${sbom.inode}`, `${provenance.device}:${provenance.inode}`]);
|
|
819
|
+
if (identities.size !== 3 || tarball.bytes !== value.tarballBytes || tarball.sha256 !== value.tarballSha256
|
|
820
|
+
|| tarball.integrity !== value.tarballIntegrity || sbom.sha256 !== value.sbomSha256 || provenance.sha256 !== value.provenanceSha256) {
|
|
821
|
+
throw new ControlPlaneStoreError('invalid-input', 'artifact/SBOM/provenance bytes do not match signed evidence');
|
|
822
|
+
}
|
|
823
|
+
return Object.freeze({ tarball, sbom, provenance });
|
|
824
|
+
}
|
|
825
|
+
async function openArtifactSnapshot(value) {
|
|
826
|
+
const opened = [];
|
|
827
|
+
try {
|
|
828
|
+
opened.push(await openStableFile(value.tarballPath, 268_435_456));
|
|
829
|
+
opened.push(await openStableFile(value.sbomPath, 16_777_216));
|
|
830
|
+
opened.push(await openStableFile(value.provenancePath, 16_777_216));
|
|
831
|
+
const [tarballOpen, sbomOpen, provenanceOpen] = opened;
|
|
832
|
+
const tarball = { ...tarballOpen, bytes: tarballOpen.bytes.length, sha256: createHash('sha256').update(tarballOpen.bytes).digest('hex'),
|
|
833
|
+
integrity: `sha512-${createHash('sha512').update(tarballOpen.bytes).digest('base64')}` };
|
|
834
|
+
const sbom = { ...sbomOpen, bytes: sbomOpen.bytes.length, sha256: createHash('sha256').update(sbomOpen.bytes).digest('hex'),
|
|
835
|
+
integrity: `sha512-${createHash('sha512').update(sbomOpen.bytes).digest('base64')}` };
|
|
836
|
+
const provenance = { ...provenanceOpen, bytes: provenanceOpen.bytes.length, sha256: createHash('sha256').update(provenanceOpen.bytes).digest('hex'),
|
|
837
|
+
integrity: `sha512-${createHash('sha512').update(provenanceOpen.bytes).digest('base64')}` };
|
|
838
|
+
const identities = new Set([`${tarball.device}:${tarball.inode}`, `${sbom.device}:${sbom.inode}`, `${provenance.device}:${provenance.inode}`]);
|
|
839
|
+
if (identities.size !== 3 || tarball.bytes !== value.tarballBytes || tarball.sha256 !== value.tarballSha256
|
|
840
|
+
|| tarball.integrity !== value.tarballIntegrity || sbom.sha256 !== value.sbomSha256 || provenance.sha256 !== value.provenanceSha256) {
|
|
841
|
+
throw new ControlPlaneStoreError('invalid-input', 'artifact/SBOM/provenance bytes do not match signed evidence');
|
|
842
|
+
}
|
|
843
|
+
return { tarball, sbom, provenance, handles: [tarballOpen.handle, sbomOpen.handle, provenanceOpen.handle] };
|
|
844
|
+
}
|
|
845
|
+
catch (error) {
|
|
846
|
+
await Promise.all(opened.map(async (item) => item.handle.close()));
|
|
847
|
+
throw error;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
async function verifyOpenArtifactSnapshot(value) {
|
|
851
|
+
const [tarball, sbom, provenance] = await Promise.all([snapshotStableFile(value.tarball.path, value.handles[0], value.tarball.maximum),
|
|
852
|
+
snapshotStableFile(value.sbom.path, value.handles[1], value.sbom.maximum),
|
|
853
|
+
snapshotStableFile(value.provenance.path, value.handles[2], value.provenance.maximum)]);
|
|
854
|
+
const current = {
|
|
855
|
+
tarball: { ...tarball, bytes: tarball.bytes.length, sha256: createHash('sha256').update(tarball.bytes).digest('hex'),
|
|
856
|
+
integrity: `sha512-${createHash('sha512').update(tarball.bytes).digest('base64')}` },
|
|
857
|
+
sbom: { ...sbom, bytes: sbom.bytes.length, sha256: createHash('sha256').update(sbom.bytes).digest('hex'),
|
|
858
|
+
integrity: `sha512-${createHash('sha512').update(sbom.bytes).digest('base64')}` },
|
|
859
|
+
provenance: { ...provenance, bytes: provenance.bytes.length, sha256: createHash('sha256').update(provenance.bytes).digest('hex'),
|
|
860
|
+
integrity: `sha512-${createHash('sha512').update(provenance.bytes).digest('base64')}` },
|
|
861
|
+
};
|
|
862
|
+
if (!sameArtifactSnapshot(value, current))
|
|
863
|
+
throw new ControlPlaneStoreError('invalid-input', 'artifact descriptors changed during adapter execution');
|
|
864
|
+
}
|
|
865
|
+
function sameArtifactSnapshot(left, right) {
|
|
866
|
+
const file = (first, second) => first.bytes === second.bytes && first.sha256 === second.sha256 && first.integrity === second.integrity
|
|
867
|
+
&& first.device === second.device && first.inode === second.inode && first.parentDevice === second.parentDevice
|
|
868
|
+
&& first.parentInode === second.parentInode && first.parentMtimeNs === second.parentMtimeNs
|
|
869
|
+
&& first.parentCtimeNs === second.parentCtimeNs;
|
|
870
|
+
return file(left.tarball, right.tarball) && file(left.sbom, right.sbom) && file(left.provenance, right.provenance);
|
|
871
|
+
}
|
|
872
|
+
async function verifyArtifactFiles(value) { await artifactSnapshot(value); }
|
|
873
|
+
async function loadCatalogSnapshot(path) {
|
|
874
|
+
const { bytes } = await readStableFile(path, 1_048_576);
|
|
875
|
+
let raw;
|
|
876
|
+
try {
|
|
877
|
+
raw = JSON.parse(bytes.toString('utf8'));
|
|
878
|
+
}
|
|
879
|
+
catch {
|
|
880
|
+
throw new ControlPlaneStoreError('invalid-input', 'admitted catalog is not valid JSON');
|
|
881
|
+
}
|
|
882
|
+
const catalog = parseCatalog(raw);
|
|
883
|
+
if (!same(raw, catalog))
|
|
884
|
+
throw new ControlPlaneStoreError('invalid-input', 'admitted catalog is not a canonical exact catalog');
|
|
885
|
+
if (new Set(catalog.entries.map(entry => entry.package)).size !== catalog.entries.length) {
|
|
886
|
+
throw new ControlPlaneStoreError('invalid-input', 'admitted catalog has ambiguous package identities');
|
|
887
|
+
}
|
|
888
|
+
return Object.freeze({ catalog, digest: createHash('sha256').update(JSON.stringify(catalog)).digest('hex') });
|
|
889
|
+
}
|
|
890
|
+
function success(receipt) {
|
|
891
|
+
if (receipt.outcome !== 'passed' || receipt.evidence.kind === 'failure' || receipt.evidence.kind === 'publish-ambiguity')
|
|
892
|
+
throw new ControlPlaneStoreError('invalid-input', 'release success evidence is missing');
|
|
893
|
+
return receipt.evidence;
|
|
894
|
+
}
|
|
895
|
+
export class Ed25519SourceReleaseAuthority {
|
|
896
|
+
publicKey;
|
|
897
|
+
expectedAuthority;
|
|
898
|
+
expectedKeyId;
|
|
899
|
+
now;
|
|
900
|
+
resolveRegistryVerifier;
|
|
901
|
+
constructor(publicKey, expectedAuthority, expectedKeyId, now = Date.now, resolveRegistryVerifier) {
|
|
902
|
+
this.publicKey = publicKey;
|
|
903
|
+
this.expectedAuthority = expectedAuthority;
|
|
904
|
+
this.expectedKeyId = expectedKeyId;
|
|
905
|
+
this.now = now;
|
|
906
|
+
this.resolveRegistryVerifier = resolveRegistryVerifier;
|
|
907
|
+
}
|
|
908
|
+
async verify(input, plan, request) {
|
|
909
|
+
const parsedRequest = parseSourceReleaseRequest(request);
|
|
910
|
+
const receipt = parseSourceReleaseReceipt(input);
|
|
911
|
+
const now = this.now();
|
|
912
|
+
if (receipt.authority !== this.expectedAuthority || receipt.keyId !== this.expectedKeyId
|
|
913
|
+
|| receipt.authority !== parsedRequest.adapter.authority || receipt.keyId !== parsedRequest.adapter.keyId
|
|
914
|
+
|| receipt.installationId !== parsedRequest.installationId || receipt.planId !== plan.id || receipt.planDigest !== plan.digest
|
|
915
|
+
|| parsedRequest.plan.id !== plan.id || parsedRequest.plan.digest !== plan.digest || parsedRequest.plan.revision !== plan.revision
|
|
916
|
+
|| plan.releaseAuthorization === undefined || !same(parsedRequest.authorization, plan.releaseAuthorization)
|
|
917
|
+
|| plan.release?.id !== parsedRequest.release.id || plan.release.fence !== parsedRequest.release.fence
|
|
918
|
+
|| receipt.releaseId !== parsedRequest.release.id || receipt.fence !== parsedRequest.release.fence || receipt.operationId !== parsedRequest.operationId
|
|
919
|
+
|| receipt.requestDigest !== sourceReleaseRequestDigest(parsedRequest) || receipt.phase !== parsedRequest.phase)
|
|
920
|
+
throw new ControlPlaneStoreError('conflict', 'release receipt is not bound to the exact adapter/request/plan/release fence');
|
|
921
|
+
if (receipt.observedAt < parsedRequest.requestedAt || receipt.observedAt > now || now > receipt.expiresAt
|
|
922
|
+
|| now > parsedRequest.authorization.expiresAt || receipt.expiresAt > parsedRequest.authorization.expiresAt
|
|
923
|
+
|| receipt.expiresAt - receipt.observedAt > parsedRequest.receiptTtlMs)
|
|
924
|
+
throw new ControlPlaneStoreError('expired', 'release receipt is outside its request-bound validity interval');
|
|
925
|
+
const signature = Buffer.from(receipt.signature, 'base64');
|
|
926
|
+
if (!verify(null, Buffer.from(canonicalReceipt(receipt)), createPublicKey(this.publicKey), signature))
|
|
927
|
+
throw new ControlPlaneStoreError('invalid-input', 'release receipt signature is invalid');
|
|
928
|
+
if (parsedRequest.phase === 'catalog-admission') {
|
|
929
|
+
const registryReceipt = parsedRequest.input.registryVerificationReceipt;
|
|
930
|
+
const registryRequest = parsedRequest.input.registryVerificationRequest;
|
|
931
|
+
const registryKey = this.resolveRegistryVerifier?.(registryReceipt.authority, registryReceipt.keyId);
|
|
932
|
+
if (registryKey === undefined || registryReceipt.authority !== registryRequest.adapter.authority
|
|
933
|
+
|| registryReceipt.keyId !== registryRequest.adapter.keyId
|
|
934
|
+
|| !verify(null, Buffer.from(canonicalReceipt(registryReceipt)), createPublicKey(registryKey), Buffer.from(registryReceipt.signature, 'base64'))) {
|
|
935
|
+
throw new ControlPlaneStoreError('invalid-input', 'catalog admission registry verification receipt signature is invalid');
|
|
936
|
+
}
|
|
937
|
+
if (registryReceipt.observedAt < registryRequest.requestedAt || registryReceipt.observedAt > parsedRequest.requestedAt
|
|
938
|
+
|| registryReceipt.expiresAt > parsedRequest.authorization.expiresAt
|
|
939
|
+
|| registryReceipt.expiresAt - registryReceipt.observedAt > registryRequest.receiptTtlMs) {
|
|
940
|
+
throw new ControlPlaneStoreError('expired', 'catalog admission registry verification receipt is outside its release-bound validity interval');
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
if (receipt.outcome === 'passed') {
|
|
944
|
+
const evidence = success(receipt);
|
|
945
|
+
if (evidence.kind !== parsedRequest.phase)
|
|
946
|
+
throw new ControlPlaneStoreError('conflict', 'release evidence is for a different phase');
|
|
947
|
+
if (evidence.kind === 'pr' && parsedRequest.phase === 'pr' && (evidence.baseCommit !== parsedRequest.input.baseCommit
|
|
948
|
+
|| evidence.headCommit === evidence.baseCommit || evidence.treeDigest !== parsedRequest.input.expectedTreeDigest
|
|
949
|
+
|| evidence.patchDigest !== parsedRequest.input.expectedPatchDigest))
|
|
950
|
+
throw new ControlPlaneStoreError('invalid-input', 'PR evidence does not bind the exact checked source and new head commit');
|
|
951
|
+
if (evidence.kind === 'review' && parsedRequest.phase === 'review' && (evidence.prId !== parsedRequest.input.prId
|
|
952
|
+
|| evidence.headCommit !== parsedRequest.input.headCommit || evidence.prEvidenceDigest !== parsedRequest.input.prEvidenceDigest)) {
|
|
953
|
+
throw new ControlPlaneStoreError('invalid-input', 'review evidence does not bind the exact PR evidence and head');
|
|
954
|
+
}
|
|
955
|
+
if (evidence.kind === 'merge' && parsedRequest.phase === 'merge' && (evidence.prId !== parsedRequest.input.prId
|
|
956
|
+
|| evidence.reviewedHeadCommit !== parsedRequest.input.headCommit || evidence.reviewId !== parsedRequest.input.reviewId
|
|
957
|
+
|| evidence.reviewEvidenceDigest !== parsedRequest.input.reviewEvidenceDigest || evidence.targetBranch !== parsedRequest.input.targetBranch)) {
|
|
958
|
+
throw new ControlPlaneStoreError('invalid-input', 'merge evidence does not bind the exact review, head, and target branch');
|
|
959
|
+
}
|
|
960
|
+
if (evidence.kind === 'build' && parsedRequest.phase === 'build') {
|
|
961
|
+
if (evidence.mergedCommit !== parsedRequest.input.mergeCommit || evidence.mergeEvidenceDigest !== parsedRequest.input.mergeEvidenceDigest
|
|
962
|
+
|| evidence.reproducibleBuilds < parsedRequest.authorization.releasePolicy.minimumReproducibleBuilds
|
|
963
|
+
|| evidence.firstBuildSha256 !== evidence.secondBuildSha256 || evidence.firstBuildSha256 !== evidence.tarballSha256
|
|
964
|
+
|| !artifactMatchesPolicy(evidence, parsedRequest.authorization.releasePolicy))
|
|
965
|
+
throw new ControlPlaneStoreError('invalid-input', 'build is not reproducible or merge/candidate-bound');
|
|
966
|
+
await verifyArtifactFiles(evidence);
|
|
967
|
+
}
|
|
968
|
+
if (evidence.kind === 'sign' && parsedRequest.phase === 'sign') {
|
|
969
|
+
await verifyArtifactFiles(parsedRequest.input.artifact);
|
|
970
|
+
const signatureBytes = Buffer.from(evidence.artifactSignature, 'base64');
|
|
971
|
+
if (evidence.buildEvidenceDigest !== parsedRequest.input.buildEvidenceDigest
|
|
972
|
+
|| evidence.artifactStatementDigest !== sourceArtifactStatementDigest(parsedRequest.input.artifact)
|
|
973
|
+
|| evidence.artifactSignatureDigest !== createHash('sha256').update(signatureBytes).digest('hex')
|
|
974
|
+
|| !verify(null, Buffer.from(sourceArtifactSigningPayload(parsedRequest.input.artifact)), createPublicKey(this.publicKey), signatureBytes)) {
|
|
975
|
+
throw new ControlPlaneStoreError('invalid-input', 'artifact signature does not verify against the exact build statement');
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
if (evidence.kind === 'publish' && parsedRequest.phase === 'publish'
|
|
979
|
+
&& (evidence.registryId !== parsedRequest.registry.id || evidence.registryReference !== parsedRequest.authorization.releasePolicy.registryReference
|
|
980
|
+
|| evidence.packageName !== parsedRequest.input.artifact.packageName || evidence.packageVersion !== parsedRequest.input.artifact.packageVersion
|
|
981
|
+
|| evidence.tarballSha256 !== parsedRequest.input.artifact.tarballSha256 || evidence.tarballIntegrity !== parsedRequest.input.artifact.tarballIntegrity
|
|
982
|
+
|| evidence.artifactStatementDigest !== parsedRequest.input.artifactStatementDigest || evidence.signEvidenceDigest !== parsedRequest.input.signEvidenceDigest
|
|
983
|
+
|| evidence.artifactSignatureDigest !== createHash('sha256').update(Buffer.from(parsedRequest.input.artifactSignature, 'base64')).digest('hex'))) {
|
|
984
|
+
throw new ControlPlaneStoreError('invalid-input', 'publish evidence does not bind the signed artifact and immutable registry target');
|
|
985
|
+
}
|
|
986
|
+
if (evidence.kind === 'registry-verify' && parsedRequest.phase === 'registry-verify'
|
|
987
|
+
&& (evidence.registryId !== parsedRequest.registry.id || evidence.registryReference !== parsedRequest.input.registryReference
|
|
988
|
+
|| evidence.downloadedBytes !== parsedRequest.input.artifact.tarballBytes || evidence.downloadedSha256 !== parsedRequest.input.artifact.tarballSha256
|
|
989
|
+
|| evidence.downloadedIntegrity !== parsedRequest.input.artifact.tarballIntegrity || evidence.artifactStatementDigest !== parsedRequest.input.artifactStatementDigest
|
|
990
|
+
|| evidence.publishEvidenceDigest !== parsedRequest.input.publishEvidenceDigest
|
|
991
|
+
|| evidence.artifactSignatureDigest !== createHash('sha256').update(Buffer.from(parsedRequest.input.artifactSignature, 'base64')).digest('hex'))) {
|
|
992
|
+
throw new ControlPlaneStoreError('invalid-input', 'independent registry download does not match the exact signed artifact bytes');
|
|
993
|
+
}
|
|
994
|
+
if (evidence.kind === 'catalog-admission' && parsedRequest.phase === 'catalog-admission') {
|
|
995
|
+
const signatureDigest = createHash('sha256').update(Buffer.from(parsedRequest.input.artifactSignature, 'base64')).digest('hex');
|
|
996
|
+
const expectedAdmissionId = catalogAdmissionId({ catalog: parsedRequest.catalog, installationId: parsedRequest.installationId,
|
|
997
|
+
registry: parsedRequest.registry,
|
|
998
|
+
operationId: parsedRequest.operationId, plan: parsedRequest.plan, release: parsedRequest.release,
|
|
999
|
+
expectedBeforeCatalogDigest: parsedRequest.input.expectedBeforeCatalogDigest, expectedAfterCatalogDigest: parsedRequest.input.expectedAfterCatalogDigest,
|
|
1000
|
+
registryReference: parsedRequest.input.registryReference, artifactStatementDigest: parsedRequest.input.artifactStatementDigest,
|
|
1001
|
+
artifactSignature: parsedRequest.input.artifactSignature, verificationEvidenceDigest: parsedRequest.input.verificationEvidenceDigest,
|
|
1002
|
+
candidate: parsedRequest.input.candidate });
|
|
1003
|
+
if (evidence.admissionId !== expectedAdmissionId || evidence.catalogId !== parsedRequest.catalog.id
|
|
1004
|
+
|| evidence.beforeCatalogDigest !== parsedRequest.input.expectedBeforeCatalogDigest
|
|
1005
|
+
|| evidence.afterCatalogDigest !== parsedRequest.input.expectedAfterCatalogDigest || evidence.registryReference !== parsedRequest.input.registryReference
|
|
1006
|
+
|| evidence.artifactStatementDigest !== parsedRequest.input.artifactStatementDigest || evidence.artifactSignatureDigest !== signatureDigest
|
|
1007
|
+
|| evidence.verificationEvidenceDigest !== parsedRequest.input.verificationEvidenceDigest || !same(evidence.candidate, parsedRequest.input.candidate)) {
|
|
1008
|
+
throw new ControlPlaneStoreError('invalid-input', 'catalog admission does not bind the independently verified candidate and catalog transition');
|
|
1009
|
+
}
|
|
1010
|
+
const loaded = await loadCatalogSnapshot(parsedRequest.catalog.path);
|
|
1011
|
+
const admitted = loaded.catalog.entries.find(candidate => candidate.id === parsedRequest.input.candidate.id);
|
|
1012
|
+
if (loaded.digest !== parsedRequest.input.expectedAfterCatalogDigest || admitted === undefined || !same(admitted, parsedRequest.input.candidate)) {
|
|
1013
|
+
throw new ControlPlaneStoreError('invalid-input', 'catalog admission result is not present in the exact owner catalog snapshot');
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
if (receipt.outcome === 'ambiguous' && parsedRequest.phase === 'publish') {
|
|
1018
|
+
const evidence = receipt.evidence;
|
|
1019
|
+
if (evidence.kind !== 'publish-ambiguity' || evidence.registryId !== parsedRequest.registry.id
|
|
1020
|
+
|| evidence.packageName !== parsedRequest.input.artifact.packageName || evidence.packageVersion !== parsedRequest.input.artifact.packageVersion
|
|
1021
|
+
|| evidence.tarballSha256 !== parsedRequest.input.artifact.tarballSha256)
|
|
1022
|
+
throw new ControlPlaneStoreError('invalid-input', 'publish ambiguity does not bind the exact artifact');
|
|
1023
|
+
}
|
|
1024
|
+
const { signature: _signature, ...fields } = receipt;
|
|
1025
|
+
return Object.freeze({ ...fields, signatureDigest: createHash('sha256').update(signature).digest('hex') });
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
export class ReleaseAdapterError extends Error {
|
|
1029
|
+
code;
|
|
1030
|
+
phase;
|
|
1031
|
+
constructor(code, phase, message) {
|
|
1032
|
+
super(`plugin-control-plane release-adapter[${phase}:${code}]: ${message}`);
|
|
1033
|
+
this.code = code;
|
|
1034
|
+
this.phase = phase;
|
|
1035
|
+
this.name = 'ReleaseAdapterError';
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
async function executePinned(executable, interpreter, args, environment, timeoutMs, stdin, maximumOutput, phase, inherited = []) {
|
|
1039
|
+
if (process.platform !== 'linux')
|
|
1040
|
+
throw new ReleaseAdapterError('FAILED', phase, 'descriptor-pinned adapters require Linux');
|
|
1041
|
+
try {
|
|
1042
|
+
await realpath('/proc/self/fd');
|
|
1043
|
+
}
|
|
1044
|
+
catch {
|
|
1045
|
+
throw new ReleaseAdapterError('FAILED', phase, 'descriptor-pinned adapters require /proc/self/fd');
|
|
1046
|
+
}
|
|
1047
|
+
return new Promise((resolvePromise, reject) => {
|
|
1048
|
+
const executableFd = 3 + inherited.length;
|
|
1049
|
+
const interpreterFd = interpreter === undefined ? undefined : executableFd + 1;
|
|
1050
|
+
const command = `/proc/self/fd/${interpreterFd ?? executableFd}`;
|
|
1051
|
+
const commandArguments = interpreter === undefined ? [...args] : [`/proc/self/fd/${executableFd}`, ...args];
|
|
1052
|
+
const stdio = ['pipe', 'pipe', 'ignore', ...inherited.map(handle => handle.fd), executable.handle.fd];
|
|
1053
|
+
if (interpreter !== undefined)
|
|
1054
|
+
stdio.push(interpreter.handle.fd);
|
|
1055
|
+
const child = spawn(command, commandArguments, { env: environment, shell: false, stdio });
|
|
1056
|
+
const chunks = [];
|
|
1057
|
+
let bytes = 0;
|
|
1058
|
+
let timedOut = false;
|
|
1059
|
+
let outputLimit = false;
|
|
1060
|
+
let settled = false;
|
|
1061
|
+
child.stdout.on('data', (chunk) => { bytes += chunk.length; if (bytes > maximumOutput) {
|
|
1062
|
+
outputLimit = true;
|
|
1063
|
+
child.kill('SIGKILL');
|
|
1064
|
+
}
|
|
1065
|
+
else
|
|
1066
|
+
chunks.push(chunk); });
|
|
1067
|
+
child.once('error', () => { if (!settled) {
|
|
1068
|
+
settled = true;
|
|
1069
|
+
reject(new ReleaseAdapterError('FAILED', phase, 'adapter could not start'));
|
|
1070
|
+
} });
|
|
1071
|
+
child.once('close', code => {
|
|
1072
|
+
if (settled)
|
|
1073
|
+
return;
|
|
1074
|
+
settled = true;
|
|
1075
|
+
if (timedOut)
|
|
1076
|
+
reject(new ReleaseAdapterError('TIMEOUT', phase, 'adapter exceeded its deadline'));
|
|
1077
|
+
else if (outputLimit)
|
|
1078
|
+
reject(new ReleaseAdapterError('OUTPUT_LIMIT', phase, 'adapter exceeded its output bound'));
|
|
1079
|
+
else if (code !== 0)
|
|
1080
|
+
reject(new ReleaseAdapterError('FAILED', phase, 'adapter returned a non-zero status'));
|
|
1081
|
+
else
|
|
1082
|
+
resolvePromise(Buffer.concat(chunks).toString('utf8'));
|
|
1083
|
+
});
|
|
1084
|
+
const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeoutMs);
|
|
1085
|
+
child.once('close', () => clearTimeout(timer));
|
|
1086
|
+
child.stdin.end(stdin, 'utf8');
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
async function assertPinnedAdapterCapabilities(executable, interpreter, adapter, phase, environment) {
|
|
1090
|
+
const source = await executePinned(executable, interpreter, ['--capabilities'], environment, adapter.timeoutMs, undefined, 1_024, phase);
|
|
1091
|
+
let value;
|
|
1092
|
+
try {
|
|
1093
|
+
value = JSON.parse(source);
|
|
1094
|
+
}
|
|
1095
|
+
catch {
|
|
1096
|
+
throw new ReleaseAdapterError('FAILED', phase, 'adapter did not declare the pinned-fd contract');
|
|
1097
|
+
}
|
|
1098
|
+
const item = typeof value === 'object' && value !== null && !Array.isArray(value) ? value : undefined;
|
|
1099
|
+
if (item === undefined || Object.keys(item).sort().join('\0') !== ['artifactInput', 'schemaVersion'].join('\0')
|
|
1100
|
+
|| item.schemaVersion !== 1 || item.artifactInput !== 'inherited-fd-v1') {
|
|
1101
|
+
throw new ReleaseAdapterError('FAILED', phase, 'adapter does not support the required pinned-fd contract');
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
async function withPinnedAdapter(adapter, phase, action) {
|
|
1105
|
+
const executable = await openTrustedExecutable(adapter.path, adapter.sha256);
|
|
1106
|
+
let interpreter;
|
|
1107
|
+
try {
|
|
1108
|
+
interpreter = adapter.interpreter === null ? undefined : await openTrustedExecutable(adapter.interpreter.path, adapter.interpreter.sha256);
|
|
1109
|
+
const result = await action(executable, interpreter);
|
|
1110
|
+
await verifyOpenTrustedExecutable(executable);
|
|
1111
|
+
if (interpreter !== undefined)
|
|
1112
|
+
await verifyOpenTrustedExecutable(interpreter);
|
|
1113
|
+
return result;
|
|
1114
|
+
}
|
|
1115
|
+
catch (error) {
|
|
1116
|
+
if (error instanceof ReleaseAdapterError || error instanceof ControlPlaneStoreError)
|
|
1117
|
+
throw error;
|
|
1118
|
+
throw new ReleaseAdapterError('EXECUTABLE_CHANGED', phase, error instanceof Error ? error.message : 'adapter identity could not be retained');
|
|
1119
|
+
}
|
|
1120
|
+
finally {
|
|
1121
|
+
await interpreter?.handle.close();
|
|
1122
|
+
await executable.handle.close();
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
export async function invokeSourceReleaseAdapter(trust, request) {
|
|
1126
|
+
const parsedRequest = parseSourceReleaseRequest(request);
|
|
1127
|
+
const adapter = trust.releaseAdapters?.[parsedRequest.phase];
|
|
1128
|
+
if (adapter === undefined)
|
|
1129
|
+
throw new ReleaseAdapterError('NOT_CONFIGURED', parsedRequest.phase, 'no owner-configured adapter is registered');
|
|
1130
|
+
const identity = { id: adapter.id, version: adapter.version, path: adapter.path, sha256: adapter.sha256,
|
|
1131
|
+
interpreter: adapter.interpreter, authority: adapter.authority, keyId: adapter.keyId };
|
|
1132
|
+
if (digest(identity) !== digest(parsedRequest.adapter))
|
|
1133
|
+
throw new ReleaseAdapterError('FAILED', parsedRequest.phase, 'durable request is not bound to the configured adapter');
|
|
1134
|
+
return withPinnedAdapter(adapter, parsedRequest.phase, async (executable, interpreter) => {
|
|
1135
|
+
const environment = inheritedReleaseAdapterEnvironment(trust, parsedRequest.phase);
|
|
1136
|
+
const version = (await executePinned(executable, interpreter, ['--version'], environment, adapter.timeoutMs, undefined, 1_024, parsedRequest.phase)).trim();
|
|
1137
|
+
if (version !== adapter.version)
|
|
1138
|
+
throw new ReleaseAdapterError('VERSION_MISMATCH', parsedRequest.phase, 'adapter reported a different version');
|
|
1139
|
+
await assertPinnedAdapterCapabilities(executable, interpreter, adapter, parsedRequest.phase, environment);
|
|
1140
|
+
const artifact = 'artifact' in parsedRequest.input ? await openArtifactSnapshot(parsedRequest.input.artifact) : undefined;
|
|
1141
|
+
try {
|
|
1142
|
+
const artifactEnvironment = artifact === undefined ? environment : { ...environment, DSH_RELEASE_TARBALL_FD: '3',
|
|
1143
|
+
DSH_RELEASE_SBOM_FD: '4', DSH_RELEASE_PROVENANCE_FD: '5' };
|
|
1144
|
+
const source = await executePinned(executable, interpreter, ['release'], artifactEnvironment, adapter.timeoutMs, `${JSON.stringify(parsedRequest)}\n`, 262_144, parsedRequest.phase, artifact?.handles ?? []);
|
|
1145
|
+
if (artifact !== undefined)
|
|
1146
|
+
await verifyOpenArtifactSnapshot(artifact);
|
|
1147
|
+
let value;
|
|
1148
|
+
try {
|
|
1149
|
+
value = JSON.parse(source);
|
|
1150
|
+
}
|
|
1151
|
+
catch {
|
|
1152
|
+
throw new ReleaseAdapterError('FAILED', parsedRequest.phase, 'adapter did not return one JSON receipt');
|
|
1153
|
+
}
|
|
1154
|
+
return parseSourceReleaseReceipt(value);
|
|
1155
|
+
}
|
|
1156
|
+
finally {
|
|
1157
|
+
if (artifact !== undefined)
|
|
1158
|
+
await Promise.all(artifact.handles.map(async (handle) => handle.close()));
|
|
1159
|
+
}
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
export async function invokeSourcePublishReconciliationAdapter(trust, request) {
|
|
1163
|
+
const parsedRequest = parseSourcePublishReconciliationRequest(request);
|
|
1164
|
+
const adapter = trust.releaseAdapters?.['registry-verify'];
|
|
1165
|
+
if (adapter === undefined)
|
|
1166
|
+
throw new ReleaseAdapterError('NOT_CONFIGURED', 'registry-verify', 'no independent registry verifier is registered');
|
|
1167
|
+
const identity = { id: adapter.id, version: adapter.version, path: adapter.path, sha256: adapter.sha256,
|
|
1168
|
+
interpreter: adapter.interpreter, authority: adapter.authority, keyId: adapter.keyId };
|
|
1169
|
+
if (!same(identity, parsedRequest.adapter))
|
|
1170
|
+
throw new ReleaseAdapterError('FAILED', 'registry-verify', 'reconciliation request is not bound to the configured verifier');
|
|
1171
|
+
return withPinnedAdapter(adapter, 'registry-verify', async (executable, interpreter) => {
|
|
1172
|
+
const environment = inheritedReleaseAdapterEnvironment(trust, 'registry-verify');
|
|
1173
|
+
const version = (await executePinned(executable, interpreter, ['--version'], environment, adapter.timeoutMs, undefined, 1_024, 'registry-verify')).trim();
|
|
1174
|
+
if (version !== adapter.version)
|
|
1175
|
+
throw new ReleaseAdapterError('VERSION_MISMATCH', 'registry-verify', 'adapter reported a different version');
|
|
1176
|
+
await assertPinnedAdapterCapabilities(executable, interpreter, adapter, 'registry-verify', environment);
|
|
1177
|
+
const source = await executePinned(executable, interpreter, ['reconcile'], environment, adapter.timeoutMs, `${JSON.stringify(parsedRequest)}\n`, 262_144, 'registry-verify');
|
|
1178
|
+
let value;
|
|
1179
|
+
try {
|
|
1180
|
+
value = JSON.parse(source);
|
|
1181
|
+
}
|
|
1182
|
+
catch {
|
|
1183
|
+
throw new ReleaseAdapterError('FAILED', 'registry-verify', 'adapter did not return one JSON reconciliation receipt');
|
|
1184
|
+
}
|
|
1185
|
+
return parseSourcePublishReconciliationReceipt(value);
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
//# sourceMappingURL=release.js.map
|