@holoscript/holosystem 0.2.2 → 0.2.4
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 +588 -0
- package/bin/holosystem.mjs +434 -7
- package/docs/native-build-threat-model.md +150 -0
- package/docs/vm-launch-threat-model.md +121 -0
- package/native/windows-sandbox/Program.cs +667 -0
- package/native/windows-sandbox/README.md +19 -0
- package/native/windows-sandbox/build.ps1 +18 -0
- package/native/windows-x64/holosystem-sandbox-launcher.exe +0 -0
- package/package.json +4 -2
- package/src/index.mjs +45 -0
- package/src/native-build.mjs +970 -0
- package/src/substrate-debian-release.mjs +852 -0
- package/src/substrate-import-debian.mjs +979 -0
- package/src/substrate-import.mjs +578 -0
- package/src/substrate.mjs +789 -0
- package/src/vm-launch.mjs +1292 -0
|
@@ -0,0 +1,852 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { _substrateImportInternals } from './substrate-import.mjs';
|
|
7
|
+
|
|
8
|
+
const { compareText, hashReceipt, issue } = _substrateImportInternals;
|
|
9
|
+
|
|
10
|
+
export const HOLOSYSTEM_DEBIAN_RELEASE_AUTH_SCHEMA = 'holoscript.holosystem.debian-release-auth.v1';
|
|
11
|
+
|
|
12
|
+
const MAX_PATH_LENGTH = 4096;
|
|
13
|
+
const MAX_RELEASE_BYTES = 16 * 1024 * 1024;
|
|
14
|
+
const MAX_SIGNATURE_BYTES = 2 * 1024 * 1024;
|
|
15
|
+
const MAX_KEYRING_BYTES = 64 * 1024 * 1024;
|
|
16
|
+
const MAX_VERIFIER_BYTES = 128 * 1024 * 1024;
|
|
17
|
+
const DIGEST_PATTERN = /^(?:sha256:[a-f0-9]{64}|sha512:[a-f0-9]{128})$/u;
|
|
18
|
+
const FINGERPRINT_PATTERN = /^(?:[A-F0-9]{40}|[A-F0-9]{64})!?$/u;
|
|
19
|
+
const RELEASE_TOKEN_PATTERN = /^[a-z0-9][a-z0-9+._/-]{0,127}$/u;
|
|
20
|
+
const FIELD_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/u;
|
|
21
|
+
const STRONG_OPENPGP_HASH_ALGORITHMS = new Set([8, 9, 10, 11]);
|
|
22
|
+
const FAILURE_STATUSES = new Set([
|
|
23
|
+
'BADSIG',
|
|
24
|
+
'ERRSIG',
|
|
25
|
+
'EXPKEYSIG',
|
|
26
|
+
'EXPSIG',
|
|
27
|
+
'KEYEXPIRED',
|
|
28
|
+
'KEYREVOKED',
|
|
29
|
+
'NO_PUBKEY',
|
|
30
|
+
'REVKEYSIG',
|
|
31
|
+
'SIGEXPIRED',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
function validDigest(value) {
|
|
35
|
+
return typeof value === 'string' && DIGEST_PATTERN.test(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function digestBytes(value, algorithm = 'sha256') {
|
|
39
|
+
return `${algorithm}:${createHash(algorithm).update(value).digest('hex')}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function validPath(value) {
|
|
43
|
+
return (
|
|
44
|
+
typeof value === 'string' &&
|
|
45
|
+
value.length > 0 &&
|
|
46
|
+
value.length <= MAX_PATH_LENGTH &&
|
|
47
|
+
!value.includes('\0')
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function verifierArgumentPath(path, style) {
|
|
52
|
+
if (style === 'msys' && /^[A-Za-z]:[\\/]/u.test(path)) {
|
|
53
|
+
return `/${path[0].toLowerCase()}${path.slice(2).replaceAll('\\', '/')}`;
|
|
54
|
+
}
|
|
55
|
+
return path;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function portableReleasePath(value) {
|
|
59
|
+
return (
|
|
60
|
+
typeof value === 'string' &&
|
|
61
|
+
value.length > 0 &&
|
|
62
|
+
value.length <= 512 &&
|
|
63
|
+
!value.startsWith('/') &&
|
|
64
|
+
!value.includes('\\') &&
|
|
65
|
+
!value.includes('%') &&
|
|
66
|
+
!value.includes('?') &&
|
|
67
|
+
!value.includes('#') &&
|
|
68
|
+
!value.split('/').some((part) => part === '' || part === '.' || part === '..')
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readBoundedFile(runtime, path, maximumBytes, issuePath, issues) {
|
|
73
|
+
if (!validPath(path)) {
|
|
74
|
+
issue(issues, 'authentication-path-invalid', issuePath, 'File path is missing or malformed.');
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const value = runtime.readFile(resolve(path));
|
|
79
|
+
const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
80
|
+
if (bytes.length > maximumBytes) {
|
|
81
|
+
issue(
|
|
82
|
+
issues,
|
|
83
|
+
'authentication-file-too-large',
|
|
84
|
+
issuePath,
|
|
85
|
+
'File exceeds its verification limit.'
|
|
86
|
+
);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
return bytes;
|
|
90
|
+
} catch {
|
|
91
|
+
issue(issues, 'authentication-file-unreadable', issuePath, 'Cannot read verification input.');
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function verifyPinnedFile(runtime, descriptor, maximumBytes, path, issues) {
|
|
97
|
+
const expected = validDigest(descriptor?.digest) ? descriptor.digest : null;
|
|
98
|
+
if (!expected) {
|
|
99
|
+
issue(
|
|
100
|
+
issues,
|
|
101
|
+
'authentication-file-digest-invalid',
|
|
102
|
+
`${path}.digest`,
|
|
103
|
+
'Verifier and keyring files require a sha256 or sha512 digest.'
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
const bytes = readBoundedFile(runtime, descriptor?.path, maximumBytes, `${path}.path`, issues);
|
|
107
|
+
const algorithm = expected?.slice(0, expected.indexOf(':')) || 'sha256';
|
|
108
|
+
const actual = bytes ? digestBytes(bytes, algorithm) : null;
|
|
109
|
+
if (expected && actual && expected !== actual) {
|
|
110
|
+
issue(
|
|
111
|
+
issues,
|
|
112
|
+
'authentication-file-digest-mismatch',
|
|
113
|
+
path,
|
|
114
|
+
'Verification file does not match its caller-provided digest.'
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
digest: actual,
|
|
119
|
+
path: validPath(descriptor?.path) ? resolve(descriptor.path) : null,
|
|
120
|
+
verified: Boolean(expected && actual === expected),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function parseReleaseControl(text, issues) {
|
|
125
|
+
if (typeof text !== 'string' || Buffer.byteLength(text, 'utf8') > MAX_RELEASE_BYTES) {
|
|
126
|
+
issue(
|
|
127
|
+
issues,
|
|
128
|
+
'release-output-invalid',
|
|
129
|
+
'release',
|
|
130
|
+
'Verified Release output must be bounded UTF-8 text.'
|
|
131
|
+
);
|
|
132
|
+
return {};
|
|
133
|
+
}
|
|
134
|
+
if (text.includes('\0') || text.includes('-----BEGIN PGP SIGNED MESSAGE-----')) {
|
|
135
|
+
issue(
|
|
136
|
+
issues,
|
|
137
|
+
'release-output-not-plaintext',
|
|
138
|
+
'release',
|
|
139
|
+
'Verifier must return the authenticated cleartext Release payload.'
|
|
140
|
+
);
|
|
141
|
+
return {};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const fields = {};
|
|
145
|
+
let current = null;
|
|
146
|
+
let sawContent = false;
|
|
147
|
+
const lines = text.replaceAll('\r\n', '\n').split('\n');
|
|
148
|
+
for (const [index, rawLine] of lines.entries()) {
|
|
149
|
+
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
|
|
150
|
+
if (line === '') {
|
|
151
|
+
if (sawContent && lines.slice(index + 1).some((candidate) => candidate.trim())) {
|
|
152
|
+
issue(
|
|
153
|
+
issues,
|
|
154
|
+
'release-paragraph-count-invalid',
|
|
155
|
+
`release.line-${index + 1}`,
|
|
156
|
+
'Release metadata must contain exactly one control paragraph.'
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
current = null;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
sawContent = true;
|
|
163
|
+
if (/^[\t ]/u.test(line)) {
|
|
164
|
+
if (!current) {
|
|
165
|
+
issue(
|
|
166
|
+
issues,
|
|
167
|
+
'release-continuation-orphan',
|
|
168
|
+
`release.line-${index + 1}`,
|
|
169
|
+
'Continuation line has no preceding field.'
|
|
170
|
+
);
|
|
171
|
+
} else {
|
|
172
|
+
fields[current] += `\n${line.slice(1)}`;
|
|
173
|
+
}
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const separator = line.indexOf(':');
|
|
177
|
+
const name = separator > 0 ? line.slice(0, separator) : '';
|
|
178
|
+
if (!FIELD_NAME_PATTERN.test(name)) {
|
|
179
|
+
issue(
|
|
180
|
+
issues,
|
|
181
|
+
'release-field-malformed',
|
|
182
|
+
`release.line-${index + 1}`,
|
|
183
|
+
'Release line must contain a valid field name followed by a colon.'
|
|
184
|
+
);
|
|
185
|
+
current = null;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
current = name.toLowerCase();
|
|
189
|
+
if (Object.hasOwn(fields, current)) {
|
|
190
|
+
issue(
|
|
191
|
+
issues,
|
|
192
|
+
'release-field-duplicate',
|
|
193
|
+
`release.line-${index + 1}`,
|
|
194
|
+
'Release metadata contains a duplicate field.'
|
|
195
|
+
);
|
|
196
|
+
current = null;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
fields[current] = line.slice(separator + 1).trim();
|
|
200
|
+
}
|
|
201
|
+
return fields;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function parseStatusOutput(value) {
|
|
205
|
+
const records = [];
|
|
206
|
+
for (const line of String(value || '')
|
|
207
|
+
.replaceAll('\r\n', '\n')
|
|
208
|
+
.split('\n')) {
|
|
209
|
+
const match = line.match(/^\[GNUPG:\] ([A-Z_]+)(?: (.*))?$/u);
|
|
210
|
+
if (match)
|
|
211
|
+
records.push({ name: match[1], values: (match[2] || '').split(/\s+/u).filter(Boolean) });
|
|
212
|
+
}
|
|
213
|
+
return records;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function extractInReleasePayload(value, issues) {
|
|
217
|
+
const text = String(value || '').replaceAll('\r\n', '\n');
|
|
218
|
+
const header = '-----BEGIN PGP SIGNED MESSAGE-----\n';
|
|
219
|
+
const signature = '\n-----BEGIN PGP SIGNATURE-----\n';
|
|
220
|
+
if (!text.startsWith(header)) {
|
|
221
|
+
issue(
|
|
222
|
+
issues,
|
|
223
|
+
'inrelease-armor-invalid',
|
|
224
|
+
'inReleasePath',
|
|
225
|
+
'InRelease must be one OpenPGP cleartext-signed message.'
|
|
226
|
+
);
|
|
227
|
+
return '';
|
|
228
|
+
}
|
|
229
|
+
const payloadStart = text.indexOf('\n\n', header.length);
|
|
230
|
+
const signatureStart = text.indexOf(signature, payloadStart + 2);
|
|
231
|
+
if (
|
|
232
|
+
payloadStart === -1 ||
|
|
233
|
+
signatureStart === -1 ||
|
|
234
|
+
text.indexOf(signature, signatureStart + 1) !== -1
|
|
235
|
+
) {
|
|
236
|
+
issue(
|
|
237
|
+
issues,
|
|
238
|
+
'inrelease-armor-invalid',
|
|
239
|
+
'inReleasePath',
|
|
240
|
+
'InRelease cleartext and signature armor boundaries are malformed.'
|
|
241
|
+
);
|
|
242
|
+
return '';
|
|
243
|
+
}
|
|
244
|
+
return text
|
|
245
|
+
.slice(payloadStart + 2, signatureStart + 1)
|
|
246
|
+
.split('\n')
|
|
247
|
+
.map((line) => (line.startsWith('- ') ? line.slice(2) : line))
|
|
248
|
+
.join('\n');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function normalizeTrustedFingerprints(value, issues) {
|
|
252
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
253
|
+
issue(
|
|
254
|
+
issues,
|
|
255
|
+
'trusted-fingerprints-missing',
|
|
256
|
+
'trustedFingerprints',
|
|
257
|
+
'At least one exact OpenPGP fingerprint is required.'
|
|
258
|
+
);
|
|
259
|
+
return [];
|
|
260
|
+
}
|
|
261
|
+
const normalized = [];
|
|
262
|
+
for (const [index, item] of value.entries()) {
|
|
263
|
+
const fingerprint = typeof item === 'string' ? item.toUpperCase() : '';
|
|
264
|
+
if (!FINGERPRINT_PATTERN.test(fingerprint)) {
|
|
265
|
+
issue(
|
|
266
|
+
issues,
|
|
267
|
+
'trusted-fingerprint-invalid',
|
|
268
|
+
`trustedFingerprints[${index}]`,
|
|
269
|
+
'Fingerprint must be 40 or 64 hexadecimal characters; append ! to disallow subkeys.'
|
|
270
|
+
);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
normalized.push({ exact: fingerprint.endsWith('!'), value: fingerprint.replace(/!$/u, '') });
|
|
274
|
+
}
|
|
275
|
+
return normalized;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function signerAllowed(signer, trusted) {
|
|
279
|
+
return trusted.some((candidate) =>
|
|
280
|
+
candidate.exact
|
|
281
|
+
? signer.fingerprint === candidate.value
|
|
282
|
+
: signer.fingerprint === candidate.value || signer.primaryFingerprint === candidate.value
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function validateGpgvStatus(statusText, trusted, nowSeconds, maxFutureSeconds, issues) {
|
|
287
|
+
const records = parseStatusOutput(statusText);
|
|
288
|
+
for (const record of records) {
|
|
289
|
+
if (FAILURE_STATUSES.has(record.name)) {
|
|
290
|
+
issue(
|
|
291
|
+
issues,
|
|
292
|
+
'openpgp-status-failure',
|
|
293
|
+
'signature',
|
|
294
|
+
`OpenPGP verifier reported ${record.name}.`
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const signers = records
|
|
299
|
+
.filter((record) => record.name === 'VALIDSIG')
|
|
300
|
+
.map((record, index) => {
|
|
301
|
+
const fingerprint = record.values[0]?.toUpperCase() || null;
|
|
302
|
+
const signatureTimestamp = Number(record.values[2]);
|
|
303
|
+
const expiresAt = Number(record.values[3]);
|
|
304
|
+
const hashAlgorithm = Number(record.values[7]);
|
|
305
|
+
const primaryFingerprint = record.values[9]?.toUpperCase() || fingerprint;
|
|
306
|
+
const signer = {
|
|
307
|
+
fingerprint,
|
|
308
|
+
primaryFingerprint,
|
|
309
|
+
signatureTimestamp: Number.isInteger(signatureTimestamp) ? signatureTimestamp : null,
|
|
310
|
+
expiresAt: Number.isInteger(expiresAt) && expiresAt > 0 ? expiresAt : null,
|
|
311
|
+
hashAlgorithm: Number.isInteger(hashAlgorithm) ? hashAlgorithm : null,
|
|
312
|
+
};
|
|
313
|
+
if (!fingerprint || !FINGERPRINT_PATTERN.test(fingerprint)) {
|
|
314
|
+
issue(
|
|
315
|
+
issues,
|
|
316
|
+
'openpgp-fingerprint-invalid',
|
|
317
|
+
`signature.signers[${index}]`,
|
|
318
|
+
'VALIDSIG fingerprint is malformed.'
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
if (!STRONG_OPENPGP_HASH_ALGORITHMS.has(hashAlgorithm)) {
|
|
322
|
+
issue(
|
|
323
|
+
issues,
|
|
324
|
+
'openpgp-hash-weak',
|
|
325
|
+
`signature.signers[${index}]`,
|
|
326
|
+
'OpenPGP signature must use a SHA-2 hash algorithm.'
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
if (!Number.isInteger(signatureTimestamp) || signatureTimestamp < 1) {
|
|
330
|
+
issue(
|
|
331
|
+
issues,
|
|
332
|
+
'openpgp-timestamp-invalid',
|
|
333
|
+
`signature.signers[${index}]`,
|
|
334
|
+
'OpenPGP signature timestamp is missing or malformed.'
|
|
335
|
+
);
|
|
336
|
+
} else if (signatureTimestamp > nowSeconds + maxFutureSeconds) {
|
|
337
|
+
issue(
|
|
338
|
+
issues,
|
|
339
|
+
'openpgp-timestamp-future',
|
|
340
|
+
`signature.signers[${index}]`,
|
|
341
|
+
'OpenPGP signature timestamp is too far in the future.'
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
if (Number.isInteger(expiresAt) && expiresAt > 0 && expiresAt < nowSeconds) {
|
|
345
|
+
issue(
|
|
346
|
+
issues,
|
|
347
|
+
'openpgp-signature-expired',
|
|
348
|
+
`signature.signers[${index}]`,
|
|
349
|
+
'OpenPGP signature has expired.'
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
if (!signerAllowed(signer, trusted)) {
|
|
353
|
+
issue(
|
|
354
|
+
issues,
|
|
355
|
+
'openpgp-signer-untrusted',
|
|
356
|
+
`signature.signers[${index}]`,
|
|
357
|
+
'Signature was not made by a configured trusted fingerprint.'
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
return signer;
|
|
361
|
+
});
|
|
362
|
+
if (signers.length === 0) {
|
|
363
|
+
issue(
|
|
364
|
+
issues,
|
|
365
|
+
'openpgp-valid-signature-missing',
|
|
366
|
+
'signature',
|
|
367
|
+
'Verifier emitted no VALIDSIG proof.'
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
return signers.sort((left, right) => compareText(left.fingerprint, right.fingerprint));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function parseChecksumEntry(fields, algorithm, path, issues) {
|
|
374
|
+
const field = fields[algorithm];
|
|
375
|
+
if (typeof field !== 'string') {
|
|
376
|
+
issue(
|
|
377
|
+
issues,
|
|
378
|
+
'release-index-hash-field-missing',
|
|
379
|
+
`release.${algorithm}`,
|
|
380
|
+
`Release metadata does not contain ${algorithm.toUpperCase()}.`
|
|
381
|
+
);
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
const expectedLength = algorithm === 'sha512' ? 128 : 64;
|
|
385
|
+
const matches = [];
|
|
386
|
+
for (const [index, line] of field.split('\n').entries()) {
|
|
387
|
+
const trimmed = line.trim();
|
|
388
|
+
if (!trimmed) continue;
|
|
389
|
+
const match = trimmed.match(/^([a-fA-F0-9]+)\s+([0-9]+)\s+(.+)$/u);
|
|
390
|
+
if (!match || match[1].length !== expectedLength || !portableReleasePath(match[3])) {
|
|
391
|
+
issue(
|
|
392
|
+
issues,
|
|
393
|
+
'release-checksum-entry-malformed',
|
|
394
|
+
`release.${algorithm}.line-${index + 1}`,
|
|
395
|
+
'Release checksum entry must contain a strong digest, byte size, and portable relative path.'
|
|
396
|
+
);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (match[3] === path) {
|
|
400
|
+
matches.push({
|
|
401
|
+
digest: `${algorithm}:${match[1].toLowerCase()}`,
|
|
402
|
+
size: Number(match[2]),
|
|
403
|
+
path,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (matches.length !== 1) {
|
|
408
|
+
issue(
|
|
409
|
+
issues,
|
|
410
|
+
'release-index-entry-count-invalid',
|
|
411
|
+
`release.${algorithm}`,
|
|
412
|
+
'Requested Packages path must occur exactly once in the selected Release hash field.'
|
|
413
|
+
);
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
return matches[0];
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function validateReleaseMetadata(
|
|
420
|
+
releaseText,
|
|
421
|
+
{
|
|
422
|
+
expected,
|
|
423
|
+
packagesIndex,
|
|
424
|
+
packagesIndexDigest,
|
|
425
|
+
packagesIndexPath,
|
|
426
|
+
maxReleaseAgeSeconds,
|
|
427
|
+
minimumReleaseDate,
|
|
428
|
+
},
|
|
429
|
+
now,
|
|
430
|
+
maxFutureSeconds,
|
|
431
|
+
issues
|
|
432
|
+
) {
|
|
433
|
+
const fields = parseReleaseControl(releaseText, issues);
|
|
434
|
+
const requiredExpected = ['suite', 'codename', 'architecture', 'component'];
|
|
435
|
+
for (const key of requiredExpected) {
|
|
436
|
+
if (typeof expected?.[key] !== 'string' || !RELEASE_TOKEN_PATTERN.test(expected[key])) {
|
|
437
|
+
issue(
|
|
438
|
+
issues,
|
|
439
|
+
'release-expectation-invalid',
|
|
440
|
+
`expected.${key}`,
|
|
441
|
+
`Expected Release ${key} must be an exact portable token.`
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (fields.suite !== expected?.suite) {
|
|
446
|
+
issue(
|
|
447
|
+
issues,
|
|
448
|
+
'release-suite-mismatch',
|
|
449
|
+
'release.Suite',
|
|
450
|
+
'Signed Release suite changed unexpectedly.'
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
if (fields.codename !== expected?.codename) {
|
|
454
|
+
issue(
|
|
455
|
+
issues,
|
|
456
|
+
'release-codename-mismatch',
|
|
457
|
+
'release.Codename',
|
|
458
|
+
'Signed Release codename changed unexpectedly.'
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
for (const [field, expectedKey] of [
|
|
462
|
+
['architectures', 'architecture'],
|
|
463
|
+
['components', 'component'],
|
|
464
|
+
]) {
|
|
465
|
+
const values = String(fields[field] || '')
|
|
466
|
+
.split(/\s+/u)
|
|
467
|
+
.filter(Boolean);
|
|
468
|
+
if (!values.includes(expected?.[expectedKey])) {
|
|
469
|
+
issue(
|
|
470
|
+
issues,
|
|
471
|
+
`release-${expectedKey}-missing`,
|
|
472
|
+
`release.${field}`,
|
|
473
|
+
`Signed Release does not include expected ${expectedKey}.`
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const expectedPath = `${expected?.component}/binary-${expected?.architecture}/Packages`;
|
|
479
|
+
if (!portableReleasePath(packagesIndexPath) || packagesIndexPath !== expectedPath) {
|
|
480
|
+
issue(
|
|
481
|
+
issues,
|
|
482
|
+
'release-index-path-invalid',
|
|
483
|
+
'packagesIndexPath',
|
|
484
|
+
`Uncompressed Packages path must be exactly ${expectedPath}.`
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
if (!validDigest(packagesIndexDigest)) {
|
|
488
|
+
issue(
|
|
489
|
+
issues,
|
|
490
|
+
'release-index-anchor-invalid',
|
|
491
|
+
'packagesIndexDigest',
|
|
492
|
+
'Packages index requires its existing sha256 or sha512 caller anchor.'
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
const algorithm = validDigest(packagesIndexDigest)
|
|
496
|
+
? packagesIndexDigest.slice(0, packagesIndexDigest.indexOf(':'))
|
|
497
|
+
: 'sha256';
|
|
498
|
+
const entry = parseChecksumEntry(fields, algorithm, packagesIndexPath, issues);
|
|
499
|
+
const actualBytes = typeof packagesIndex === 'string' ? Buffer.from(packagesIndex, 'utf8') : null;
|
|
500
|
+
if (!actualBytes) {
|
|
501
|
+
issue(
|
|
502
|
+
issues,
|
|
503
|
+
'release-index-input-invalid',
|
|
504
|
+
'packagesIndex',
|
|
505
|
+
'Packages index must be UTF-8 text.'
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
const actualDigest = actualBytes ? digestBytes(actualBytes, algorithm) : null;
|
|
509
|
+
if (entry && entry.digest !== packagesIndexDigest) {
|
|
510
|
+
issue(
|
|
511
|
+
issues,
|
|
512
|
+
'release-index-anchor-mismatch',
|
|
513
|
+
'packagesIndexDigest',
|
|
514
|
+
'Caller Packages anchor is not the digest certified by the signed Release.'
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
if (entry && actualDigest !== entry.digest) {
|
|
518
|
+
issue(
|
|
519
|
+
issues,
|
|
520
|
+
'release-index-digest-mismatch',
|
|
521
|
+
'packagesIndex',
|
|
522
|
+
'Packages bytes do not match the digest certified by the signed Release.'
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
if (entry && actualBytes && entry.size !== actualBytes.length) {
|
|
526
|
+
issue(
|
|
527
|
+
issues,
|
|
528
|
+
'release-index-size-mismatch',
|
|
529
|
+
'packagesIndex',
|
|
530
|
+
'Packages byte size does not match the signed Release.'
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const nowMs = now.getTime();
|
|
535
|
+
const releaseDateMs = Date.parse(fields.date || '');
|
|
536
|
+
const validUntilMs = fields['valid-until'] ? Date.parse(fields['valid-until']) : null;
|
|
537
|
+
const minimumReleaseDateMs = minimumReleaseDate == null ? null : Date.parse(minimumReleaseDate);
|
|
538
|
+
if (minimumReleaseDate != null && !Number.isFinite(minimumReleaseDateMs)) {
|
|
539
|
+
issue(
|
|
540
|
+
issues,
|
|
541
|
+
'minimum-release-date-invalid',
|
|
542
|
+
'minimumReleaseDate',
|
|
543
|
+
'Minimum Release date must be an ISO-8601 timestamp from a previously accepted receipt.'
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
if (!Number.isFinite(releaseDateMs)) {
|
|
547
|
+
issue(
|
|
548
|
+
issues,
|
|
549
|
+
'release-date-invalid',
|
|
550
|
+
'release.Date',
|
|
551
|
+
'Signed Release Date is missing or malformed.'
|
|
552
|
+
);
|
|
553
|
+
} else if (releaseDateMs > nowMs + maxFutureSeconds * 1000) {
|
|
554
|
+
issue(
|
|
555
|
+
issues,
|
|
556
|
+
'release-date-future',
|
|
557
|
+
'release.Date',
|
|
558
|
+
'Signed Release Date is too far in the future.'
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
if (
|
|
562
|
+
Number.isFinite(releaseDateMs) &&
|
|
563
|
+
Number.isFinite(minimumReleaseDateMs) &&
|
|
564
|
+
releaseDateMs < minimumReleaseDateMs
|
|
565
|
+
) {
|
|
566
|
+
issue(
|
|
567
|
+
issues,
|
|
568
|
+
'release-date-rollback',
|
|
569
|
+
'release.Date',
|
|
570
|
+
'Signed Release predates the caller-owned monotonic checkpoint.'
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
if (
|
|
574
|
+
!Number.isInteger(maxReleaseAgeSeconds) ||
|
|
575
|
+
maxReleaseAgeSeconds < 1 ||
|
|
576
|
+
maxReleaseAgeSeconds > 366 * 24 * 60 * 60
|
|
577
|
+
) {
|
|
578
|
+
issue(
|
|
579
|
+
issues,
|
|
580
|
+
'release-max-age-invalid',
|
|
581
|
+
'maxReleaseAgeSeconds',
|
|
582
|
+
'Replay window must be an integer from 1 second to 366 days even when Valid-Until is absent.'
|
|
583
|
+
);
|
|
584
|
+
} else if (
|
|
585
|
+
Number.isFinite(releaseDateMs) &&
|
|
586
|
+
nowMs > releaseDateMs + maxReleaseAgeSeconds * 1000
|
|
587
|
+
) {
|
|
588
|
+
issue(
|
|
589
|
+
issues,
|
|
590
|
+
'release-too-old',
|
|
591
|
+
'release.Date',
|
|
592
|
+
'Signed Release exceeds the configured replay window.'
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
if (fields['valid-until']) {
|
|
596
|
+
if (!Number.isFinite(validUntilMs)) {
|
|
597
|
+
issue(
|
|
598
|
+
issues,
|
|
599
|
+
'release-valid-until-invalid',
|
|
600
|
+
'release.Valid-Until',
|
|
601
|
+
'Valid-Until is malformed.'
|
|
602
|
+
);
|
|
603
|
+
} else {
|
|
604
|
+
if (Number.isFinite(releaseDateMs) && validUntilMs < releaseDateMs) {
|
|
605
|
+
issue(
|
|
606
|
+
issues,
|
|
607
|
+
'release-valid-until-before-date',
|
|
608
|
+
'release.Valid-Until',
|
|
609
|
+
'Valid-Until precedes Date.'
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
if (nowMs > validUntilMs) {
|
|
613
|
+
issue(issues, 'release-expired', 'release.Valid-Until', 'Signed Release has expired.');
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return {
|
|
619
|
+
codename: fields.codename || null,
|
|
620
|
+
date: Number.isFinite(releaseDateMs) ? new Date(releaseDateMs).toISOString() : null,
|
|
621
|
+
digest: typeof releaseText === 'string' ? digestBytes(Buffer.from(releaseText, 'utf8')) : null,
|
|
622
|
+
index: entry,
|
|
623
|
+
minimumAcceptedDate: Number.isFinite(minimumReleaseDateMs)
|
|
624
|
+
? new Date(minimumReleaseDateMs).toISOString()
|
|
625
|
+
: null,
|
|
626
|
+
suite: fields.suite || null,
|
|
627
|
+
validUntil: Number.isFinite(validUntilMs) ? new Date(validUntilMs).toISOString() : null,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function outputText(value) {
|
|
632
|
+
if (typeof value === 'string') return value;
|
|
633
|
+
if (Buffer.isBuffer(value)) return value.toString('utf8');
|
|
634
|
+
return '';
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function verifyWithRuntime(
|
|
638
|
+
{
|
|
639
|
+
inReleasePath,
|
|
640
|
+
releasePath,
|
|
641
|
+
releaseSignaturePath,
|
|
642
|
+
verifier,
|
|
643
|
+
keyrings,
|
|
644
|
+
trustedFingerprints,
|
|
645
|
+
expected,
|
|
646
|
+
packagesIndex,
|
|
647
|
+
packagesIndexDigest,
|
|
648
|
+
packagesIndexPath,
|
|
649
|
+
maxReleaseAgeSeconds,
|
|
650
|
+
minimumReleaseDate = null,
|
|
651
|
+
maxFutureSeconds = 10,
|
|
652
|
+
now = new Date(),
|
|
653
|
+
} = {},
|
|
654
|
+
runtime
|
|
655
|
+
) {
|
|
656
|
+
const issues = [];
|
|
657
|
+
const instant =
|
|
658
|
+
now instanceof Date && Number.isFinite(now.getTime()) ? now : new Date(Number.NaN);
|
|
659
|
+
if (!Number.isFinite(instant.getTime())) {
|
|
660
|
+
issue(issues, 'authentication-time-invalid', 'now', 'Verification time must be a valid Date.');
|
|
661
|
+
}
|
|
662
|
+
if (!Number.isInteger(maxFutureSeconds) || maxFutureSeconds < 0 || maxFutureSeconds > 3600) {
|
|
663
|
+
issue(
|
|
664
|
+
issues,
|
|
665
|
+
'authentication-future-window-invalid',
|
|
666
|
+
'maxFutureSeconds',
|
|
667
|
+
'Future clock tolerance must be an integer from 0 to 3600 seconds.'
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
const safeFutureSeconds =
|
|
671
|
+
Number.isInteger(maxFutureSeconds) && maxFutureSeconds >= 0 && maxFutureSeconds <= 3600
|
|
672
|
+
? maxFutureSeconds
|
|
673
|
+
: 0;
|
|
674
|
+
|
|
675
|
+
const inline = validPath(inReleasePath);
|
|
676
|
+
const detached = validPath(releasePath) && validPath(releaseSignaturePath);
|
|
677
|
+
if (inline === detached) {
|
|
678
|
+
issue(
|
|
679
|
+
issues,
|
|
680
|
+
'release-signature-input-conflict',
|
|
681
|
+
'signature',
|
|
682
|
+
'Provide exactly one InRelease path or a Release plus Release.gpg path pair.'
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
const mode = inline ? 'inrelease' : detached ? 'release-gpg' : null;
|
|
686
|
+
const signedInput = inline
|
|
687
|
+
? readBoundedFile(runtime, inReleasePath, MAX_RELEASE_BYTES, 'inReleasePath', issues)
|
|
688
|
+
: detached
|
|
689
|
+
? readBoundedFile(
|
|
690
|
+
runtime,
|
|
691
|
+
releaseSignaturePath,
|
|
692
|
+
MAX_SIGNATURE_BYTES,
|
|
693
|
+
'releaseSignaturePath',
|
|
694
|
+
issues
|
|
695
|
+
)
|
|
696
|
+
: null;
|
|
697
|
+
const detachedRelease = detached
|
|
698
|
+
? readBoundedFile(runtime, releasePath, MAX_RELEASE_BYTES, 'releasePath', issues)
|
|
699
|
+
: null;
|
|
700
|
+
|
|
701
|
+
const pinnedVerifier = verifyPinnedFile(
|
|
702
|
+
runtime,
|
|
703
|
+
verifier,
|
|
704
|
+
MAX_VERIFIER_BYTES,
|
|
705
|
+
'verifier',
|
|
706
|
+
issues
|
|
707
|
+
);
|
|
708
|
+
const verifierPathStyle = verifier?.pathStyle || 'native';
|
|
709
|
+
if (verifierPathStyle !== 'native' && verifierPathStyle !== 'msys') {
|
|
710
|
+
issue(
|
|
711
|
+
issues,
|
|
712
|
+
'verifier-path-style-invalid',
|
|
713
|
+
'verifier.pathStyle',
|
|
714
|
+
'Verifier path style must be native or msys.'
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
if (!Array.isArray(keyrings) || keyrings.length === 0) {
|
|
718
|
+
issue(
|
|
719
|
+
issues,
|
|
720
|
+
'keyrings-missing',
|
|
721
|
+
'keyrings',
|
|
722
|
+
'At least one digest-pinned OpenPGP keyring is required.'
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
const pinnedKeyrings = (Array.isArray(keyrings) ? keyrings : []).map((keyring, index) =>
|
|
726
|
+
verifyPinnedFile(runtime, keyring, MAX_KEYRING_BYTES, `keyrings[${index}]`, issues)
|
|
727
|
+
);
|
|
728
|
+
const trusted = normalizeTrustedFingerprints(trustedFingerprints, issues);
|
|
729
|
+
|
|
730
|
+
let releaseText = detachedRelease?.toString('utf8') || '';
|
|
731
|
+
let signers = [];
|
|
732
|
+
if (
|
|
733
|
+
mode &&
|
|
734
|
+
signedInput &&
|
|
735
|
+
pinnedVerifier.verified &&
|
|
736
|
+
pinnedKeyrings.length > 0 &&
|
|
737
|
+
pinnedKeyrings.every((keyring) => keyring.verified)
|
|
738
|
+
) {
|
|
739
|
+
const args = ['--status-fd', '1'];
|
|
740
|
+
for (const keyring of pinnedKeyrings) {
|
|
741
|
+
args.push('--keyring', verifierArgumentPath(keyring.path, verifierPathStyle));
|
|
742
|
+
}
|
|
743
|
+
if (inline) {
|
|
744
|
+
args.push(verifierArgumentPath(resolve(inReleasePath), verifierPathStyle));
|
|
745
|
+
} else {
|
|
746
|
+
args.push(
|
|
747
|
+
verifierArgumentPath(resolve(releaseSignaturePath), verifierPathStyle),
|
|
748
|
+
verifierArgumentPath(resolve(releasePath), verifierPathStyle)
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
let result;
|
|
752
|
+
try {
|
|
753
|
+
result = runtime.spawn(pinnedVerifier.path, args, {
|
|
754
|
+
encoding: 'utf8',
|
|
755
|
+
maxBuffer: MAX_RELEASE_BYTES,
|
|
756
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
757
|
+
timeout: 15_000,
|
|
758
|
+
windowsHide: true,
|
|
759
|
+
});
|
|
760
|
+
} catch {
|
|
761
|
+
result = { error: new Error('verifier execution failed'), status: null, stdout: '' };
|
|
762
|
+
}
|
|
763
|
+
if (result.error) {
|
|
764
|
+
issue(
|
|
765
|
+
issues,
|
|
766
|
+
'openpgp-verifier-unavailable',
|
|
767
|
+
'verifier',
|
|
768
|
+
'Cannot execute pinned OpenPGP verifier.'
|
|
769
|
+
);
|
|
770
|
+
} else if (result.status !== 0) {
|
|
771
|
+
issue(
|
|
772
|
+
issues,
|
|
773
|
+
'openpgp-verification-failed',
|
|
774
|
+
'signature',
|
|
775
|
+
`Pinned OpenPGP verifier exited with status ${String(result.status)}.`
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
const statusText = outputText(result.stdout);
|
|
779
|
+
signers = validateGpgvStatus(
|
|
780
|
+
statusText,
|
|
781
|
+
trusted,
|
|
782
|
+
Number.isFinite(instant.getTime()) ? Math.floor(instant.getTime() / 1000) : 0,
|
|
783
|
+
safeFutureSeconds,
|
|
784
|
+
issues
|
|
785
|
+
);
|
|
786
|
+
if (inline) releaseText = extractInReleasePayload(signedInput.toString('utf8'), issues);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const release = validateReleaseMetadata(
|
|
790
|
+
releaseText,
|
|
791
|
+
{
|
|
792
|
+
expected,
|
|
793
|
+
packagesIndex,
|
|
794
|
+
packagesIndexDigest,
|
|
795
|
+
packagesIndexPath,
|
|
796
|
+
maxReleaseAgeSeconds,
|
|
797
|
+
minimumReleaseDate,
|
|
798
|
+
},
|
|
799
|
+
instant,
|
|
800
|
+
safeFutureSeconds,
|
|
801
|
+
issues
|
|
802
|
+
);
|
|
803
|
+
issues.sort(
|
|
804
|
+
(left, right) => compareText(left.path, right.path) || compareText(left.code, right.code)
|
|
805
|
+
);
|
|
806
|
+
const verified = issues.length === 0;
|
|
807
|
+
const receipt = {
|
|
808
|
+
schema: HOLOSYSTEM_DEBIAN_RELEASE_AUTH_SCHEMA,
|
|
809
|
+
generatedAt: Number.isFinite(instant.getTime()) ? instant.toISOString() : null,
|
|
810
|
+
verified,
|
|
811
|
+
mode,
|
|
812
|
+
signatureInputDigest: signedInput ? digestBytes(signedInput) : null,
|
|
813
|
+
verifierDigest: pinnedVerifier.digest,
|
|
814
|
+
verifierPathStyle,
|
|
815
|
+
keyringDigests: pinnedKeyrings
|
|
816
|
+
.map((keyring) => keyring.digest)
|
|
817
|
+
.filter(Boolean)
|
|
818
|
+
.sort(compareText),
|
|
819
|
+
signers,
|
|
820
|
+
release,
|
|
821
|
+
packagesIndexDigest: validDigest(packagesIndexDigest) ? packagesIndexDigest : null,
|
|
822
|
+
issues,
|
|
823
|
+
boundaries: {
|
|
824
|
+
verifierAndKeyringDigestsAreCallerTrustAnchors: true,
|
|
825
|
+
exactSignerFingerprintsAreRequired: true,
|
|
826
|
+
releaseFreshnessIsBounded: true,
|
|
827
|
+
monotonicRollbackProtectionRequiresPriorReleaseState: release.minimumAcceptedDate == null,
|
|
828
|
+
trustedClockRemainsCallerOwned: true,
|
|
829
|
+
packagePayloadsAreBoundThroughTheSignedPackagesIndex: true,
|
|
830
|
+
packageContentsAndMaintainerBehaviorAreNotVerified: true,
|
|
831
|
+
keyLifecycleAndTrustBootstrapRemainCallerOwned: true,
|
|
832
|
+
},
|
|
833
|
+
};
|
|
834
|
+
receipt.receiptHash = hashReceipt({ ...receipt, generatedAt: null });
|
|
835
|
+
return receipt;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const defaultRuntime = Object.freeze({ readFile: readFileSync, spawn: spawnSync });
|
|
839
|
+
|
|
840
|
+
export function verifyDebianRepositoryRelease(options) {
|
|
841
|
+
return verifyWithRuntime(options, defaultRuntime);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
export const _debianReleaseInternals = Object.freeze({
|
|
845
|
+
digestBytes,
|
|
846
|
+
extractInReleasePayload,
|
|
847
|
+
parseReleaseControl,
|
|
848
|
+
parseStatusOutput,
|
|
849
|
+
validateReleaseMetadata,
|
|
850
|
+
verifierArgumentPath,
|
|
851
|
+
verifyWithRuntime,
|
|
852
|
+
});
|