@runeditrun/rer 0.1.1 → 0.2.1
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 +113 -2
- package/cli/README.md +32 -5
- package/cli/contract.mjs +5 -5
- package/cli/deploy.mjs +17 -3
- package/cli/detached-server.mjs +51 -0
- package/cli/dev-identity.mjs +2 -1
- package/cli/door-mode.mjs +134 -0
- package/cli/door.mjs +263 -0
- package/cli/hosted-output.mjs +17 -7
- package/cli/hydration/browser.mjs +91 -0
- package/cli/hydration/cases.mjs +106 -0
- package/cli/hydration/check.mjs +105 -0
- package/cli/hydration/probe.mjs +164 -0
- package/cli/hydration/scripts.mjs +39 -0
- package/cli/hydration/verdict.mjs +85 -0
- package/cli/inspect.mjs +57 -10
- package/cli/lifecycle.mjs +36 -12
- package/cli/local.mjs +9 -3
- package/cli/main.mjs +3 -0
- package/cli/managed-server.mjs +62 -21
- package/cli/package-calls.mjs +81 -0
- package/cli/package-mode.mjs +46 -0
- package/cli/parse.mjs +15 -4
- package/cli/pnpm-runtime.mjs +96 -0
- package/cli/portability-worker.mjs +2 -2
- package/cli/release.mjs +1 -0
- package/cli/runtime-front-door.mjs +4 -2
- package/cli/server-proxy.mjs +69 -5
- package/cli/server-registry.mjs +10 -7
- package/cli/settings.mjs +14 -0
- package/cli/site-bindings.mjs +291 -0
- package/cli/site-browser-objects.mjs +36 -0
- package/cli/site-check.mjs +111 -0
- package/cli/site-client.mjs +50 -0
- package/cli/site-data.mjs +54 -0
- package/cli/site-entities.json +1 -0
- package/cli/site-input-assets.mjs +58 -0
- package/cli/site-inputs.mjs +79 -0
- package/cli/site-markup.mjs +102 -0
- package/cli/site-preview.mjs +53 -0
- package/cli/site-private.mjs +36 -0
- package/cli/site-references.mjs +93 -0
- package/cli/site-render.mjs +79 -0
- package/cli/site-syntax.mjs +21 -0
- package/cli/site.mjs +130 -0
- package/global/CHANGELOG.md +88 -0
- package/global/README.md +113 -2
- package/global/checkout.mjs +24 -10
- package/global/cli/install.mjs +6 -2
- package/global/delegate.mjs +9 -7
- package/global/door/command.mjs +254 -0
- package/global/door/control.mjs +65 -0
- package/global/door/cookies.mjs +41 -0
- package/global/door/pages.mjs +49 -0
- package/global/door/policy.mjs +49 -0
- package/global/door/routes.mjs +45 -0
- package/global/door/server.mjs +255 -0
- package/global/door/sites.mjs +192 -0
- package/global/launch/dev.mjs +21 -7
- package/global/launch/door.mjs +36 -0
- package/global/launch/install-pinned.mjs +22 -0
- package/global/launch/prepare.mjs +38 -5
- package/global/launch/render.mjs +6 -1
- package/global/launch/steps.mjs +12 -7
- package/global/launch/worker.mjs +3 -3
- package/global/list.mjs +6 -3
- package/global/main.mjs +39 -3
- package/global/output.mjs +12 -0
- package/global/rer.mjs +3 -0
- package/global/version.mjs +8 -8
- package/modules/dependencies/protocol.mjs +2 -1
- package/modules/deploy-kit/README.md +34 -1
- package/modules/deploy-kit/artifact.mjs +10 -0
- package/modules/deploy-kit/deploy.mjs +5 -2
- package/modules/deploy-kit/diagnostics.mjs +55 -0
- package/modules/deploy-kit/owner-cli.mjs +10 -4
- package/modules/deploy-kit/owner.mjs +8 -4
- package/modules/deploy-kit/prepare.mjs +3 -2
- package/modules/deploy-kit/provider.mjs +51 -9
- package/modules/deploy-kit/site.mjs +135 -0
- package/modules/erasure/CONTRACT.md +6 -3
- package/modules/erasure/README.md +8 -1
- package/modules/erasure/audit.mjs +2 -2
- package/modules/erasure/cascade.mjs +52 -11
- package/modules/erasure/order.mjs +29 -22
- package/modules/erasure/plan.mjs +4 -2
- package/modules/erasure/queue.mjs +41 -0
- package/modules/erasure/scope.mjs +3 -3
- package/modules/erasure/verification.mjs +4 -2
- package/modules/mail/README.md +1 -1
- package/modules/mail/index.mjs +1 -1
- package/modules/mail/src/core/errors.mjs +9 -0
- package/modules/mail/src/development/index.d.mts +6 -0
- package/modules/mail/src/development/index.mjs +6 -3
- package/modules/mail/src/development/viewer-html.mjs +3 -3
- package/modules/mail/src/development/viewer.mjs +8 -1
- package/modules/operator-auth/accounts/native.mjs +1 -1
- package/modules/operator-auth/dev/badge.mjs +4 -1
- package/modules/operator-auth/dev/index.mjs +15 -5
- package/modules/operator-auth/http.mjs +2 -3
- package/modules/operator-auth/index.d.mts +4 -9
- package/modules/operator-auth/loopback.mjs +17 -0
- package/modules/operator-auth/page.mjs +1 -1
- package/modules/operator-auth/service.mjs +1 -0
- package/modules/operator-auth/signin/index.d.mts +11 -0
- package/modules/operator-auth/signin/index.mjs +10 -7
- package/modules/owner-claim/verify.mjs +2 -1
- package/modules/portability/README.md +8 -0
- package/modules/portability/hash.mjs +6 -2
- package/modules/portability/import.mjs +1 -1
- package/modules/portability/legacy-files.mjs +4 -4
- package/modules/portability/legacy-safety.mjs +51 -10
- package/modules/portability/migration-history.mjs +15 -0
- package/modules/release/README.md +68 -3
- package/modules/release/keygen.mjs +18 -0
- package/modules/release/produce.mjs +14 -2
- package/modules/release/sign.mjs +44 -0
- package/modules/release/signature.mjs +336 -0
- package/modules/release/signing-cli.mjs +56 -0
- package/modules/release/signing-io.mjs +61 -0
- package/modules/release/signing-key.mjs +22 -0
- package/modules/release/source.mjs +15 -4
- package/modules/release/statement.mjs +80 -0
- package/modules/release/vectors/generate.mjs +381 -0
- package/modules/release/vectors/release-signature-vectors.json +2305 -0
- package/modules/release/verify.mjs +27 -0
- package/onboarding/adapter-auth.mjs +4 -3
- package/onboarding/adapter-settings.mjs +21 -5
- package/onboarding/adapter.mjs +2 -1
- package/onboarding/app-fetch.mjs +79 -0
- package/onboarding/archive-output.d.mts +4 -2
- package/onboarding/archive-output.mjs +22 -10
- package/onboarding/dependency-approval.mjs +4 -3
- package/onboarding/local/environment.mjs +7 -2
- package/onboarding/operator-install.mjs +8 -2
- package/onboarding/operator-prerequisites.mjs +5 -2
- package/onboarding/production-assets.mjs +23 -0
- package/onboarding/production-runtime.mjs +8 -4
- package/onboarding/queued-entry.mjs +9 -5
- package/operations/operations.mjs +11 -2
- package/package.json +76 -1
- package/scripts/publish-check/README.md +27 -17
- package/scripts/publish-check/content_identities.py +14 -233
- package/scripts/publish-check/git_history.py +2 -2
- package/scripts/publish-check/github_refs.py +28 -2
- package/scripts/publish-check/gitleaks.toml +1 -1
- package/scripts/publish-check/history_scan.py +6 -4
- package/scripts/publish-check/hygiene_config.py +3 -1
- package/scripts/publish-check/public_hygiene.py +63 -8
- package/scripts/publish-check/publication-config.json +16 -1
- package/scripts/publish-check/source_metadata.py +62 -0
- package/scripts/publish-check/source_shapes.py +91 -0
- package/scripts/publish-check/tree_scan.py +2 -2
- package/scripts/publish-check/source_hashes.mjs +0 -6
|
@@ -8,6 +8,9 @@ import { publicationScan, validatePublicationDestination } from './publication-s
|
|
|
8
8
|
import { publicDestination, requirePublicDestination, remotePublicHead, publicChild, assertPublicTree } from './public-child.mjs';
|
|
9
9
|
import { writePublishHandoff } from './publish-handoff.mjs';
|
|
10
10
|
|
|
11
|
+
// Failure output belongs to the private receipt, not the site preview.
|
|
12
|
+
const withoutOutput = gates => JSON.parse(JSON.stringify(gates, (key, value) => key === 'output' ? undefined : value));
|
|
13
|
+
|
|
11
14
|
export async function release({ repo, ref = 'HEAD', output, project, repository, dryRun = false, tag = false, cleanHistory: clean = false, envFile, onto }) {
|
|
12
15
|
if (!/^[\w.-]+\/[\w.-]+$/.test(project ?? '')) throw Error('--project must be publisher/name');
|
|
13
16
|
if (!/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+(?:\.git)?$/i.test(repository ?? '')) throw Error('--repository must be a credential-free GitHub repository URL');
|
|
@@ -37,7 +40,7 @@ export async function release({ repo, ref = 'HEAD', output, project, repository,
|
|
|
37
40
|
// Honour the seed's committed install policy. Base's native-build settings
|
|
38
41
|
// are not interchangeable with pnpm 8's locked onlyBuiltDependencies.
|
|
39
42
|
const install = await command('pnpm', ['install', '--frozen-lockfile'], checkout);
|
|
40
|
-
const verify = install.status === 'PASS' ? await command('pnpm', ['run', 'verify'], checkout) : { status: 'FAIL', reason: 'frozen-install-failed', tests: 0, passed: 0, failed: 0 };
|
|
43
|
+
const verify = install.status === 'PASS' ? await command('pnpm', ['run', 'verify'], checkout) : { status: 'FAIL', reason: 'frozen-install-failed', tests: 0, passed: 0, failed: 0, output: install.output };
|
|
41
44
|
// Release-only scale and capacity cases stay outside routine CI. The
|
|
42
45
|
// committed seed script is an additional gate in previews and releases.
|
|
43
46
|
if (verify.status === 'PASS' && JSON.parse(source.bytes('package.json')).scripts['verify:release']) {
|
|
@@ -101,7 +104,7 @@ export async function release({ repo, ref = 'HEAD', output, project, repository,
|
|
|
101
104
|
// A blocked preview may prepare the site metadata/docs for local review.
|
|
102
105
|
// Its provenance explicitly records blocked gates; it is never a release.
|
|
103
106
|
const publicSource = exported ? { ...source, commit: exported.commit, publicTagStaged: Boolean(destination && tag) } : source;
|
|
104
|
-
if (passed || preview) receipt.siteRelease = writeCatalogue(publicSource, output, catalogue(publicSource, project, repository, gates, preview));
|
|
107
|
+
if (passed || preview) receipt.siteRelease = writeCatalogue(publicSource, output, catalogue(publicSource, project, repository, withoutOutput(gates), preview));
|
|
105
108
|
if (passed) {
|
|
106
109
|
if (tag) {
|
|
107
110
|
receipt.publicExport = publicExport(source, temporary, output);
|
|
@@ -118,6 +121,15 @@ export async function release({ repo, ref = 'HEAD', output, project, repository,
|
|
|
118
121
|
// so a failed write or changed remote cannot leave a private tag.
|
|
119
122
|
if (!destination) annotate(source.repo, source, source.commit);
|
|
120
123
|
receipt.tag.created = true;
|
|
124
|
+
// Bind the receipt to the annotated tag in the published history.
|
|
125
|
+
// Clean/public-child exports have a different commit from the source.
|
|
126
|
+
const tagRepo = exported?.repo ?? source.repo, tagRef = `refs/tags/${source.tag}`;
|
|
127
|
+
Object.assign(receipt.publicExport, {
|
|
128
|
+
tag: source.tag,
|
|
129
|
+
tagObject: git(tagRepo, 'rev-parse', `${tagRef}^{tag}`).toString().trim(),
|
|
130
|
+
commit: git(tagRepo, 'rev-parse', `${tagRef}^{commit}`).toString().trim(),
|
|
131
|
+
tree: git(tagRepo, 'rev-parse', `${tagRef}^{tree}`).toString().trim(),
|
|
132
|
+
});
|
|
121
133
|
}
|
|
122
134
|
}
|
|
123
135
|
if (passed && tag && destination) writePublishHandoff(output, destination, receipt);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createPrivateKey } from 'node:crypto';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { loadAnchors, PAYLOAD_TYPE } from './signature.mjs';
|
|
4
|
+
import { readArtifact, statementFor } from './statement.mjs';
|
|
5
|
+
import { verifyArtifact } from './verify.mjs';
|
|
6
|
+
import { publicEntry, anchorSet, envelope, validity, timestamp } from './signing-key.mjs';
|
|
7
|
+
import { SIGNATURE_FILE, readBounded, writeNew, refuse } from './signing-io.mjs';
|
|
8
|
+
|
|
9
|
+
async function keyInput(options) {
|
|
10
|
+
if (options['key-file']) return readBounded(options['key-file'], 16384);
|
|
11
|
+
const chunks = []; let size = 0;
|
|
12
|
+
for await (const chunk of process.stdin) {
|
|
13
|
+
size += chunk.length;
|
|
14
|
+
if (size > 16384) refuse('INPUT_TOO_LARGE');
|
|
15
|
+
chunks.push(chunk);
|
|
16
|
+
}
|
|
17
|
+
return Buffer.concat(chunks);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function signArtifact(options) {
|
|
21
|
+
let privateKey;
|
|
22
|
+
try {
|
|
23
|
+
const bytes = await keyInput(options);
|
|
24
|
+
if (!/^-----BEGIN PRIVATE KEY-----\r?\n[\s\S]+\r?\n-----END PRIVATE KEY-----\s*$/.test(bytes.toString('ascii'))) throw new Error();
|
|
25
|
+
privateKey = createPrivateKey({ key: bytes, format: 'pem', type: 'pkcs8' });
|
|
26
|
+
if (privateKey.asymmetricKeyType !== 'ed25519') throw new Error();
|
|
27
|
+
} catch { refuse('SIGNATURE_INVALID'); }
|
|
28
|
+
const artifact = await readArtifact(options.artifact);
|
|
29
|
+
if (artifact.receipt.status !== 'RELEASED' || artifact.receipt.tag?.created !== true) refuse('SUBJECT_MISMATCH');
|
|
30
|
+
const scope = options.scope ?? 'rer', issuedAt = timestamp(new Date());
|
|
31
|
+
// This ephemeral proof checks our output, not publisher admission. The
|
|
32
|
+
// consumer still needs a separately reviewed anchor with the real owner ID.
|
|
33
|
+
const entry = await publicEntry(privateKey, { scope, publisher: artifact.subject.publisher,
|
|
34
|
+
githubOwner: { login: artifact.subject.repository.split('/')[3], id: 1 }, ...validity(new Date(issuedAt)), issuedAt });
|
|
35
|
+
const anchors = anchorSet(entry, scope);
|
|
36
|
+
const loaded = await loadAnchors(anchors, anchors.environment);
|
|
37
|
+
const statement = statementFor(artifact, entry.keyId, issuedAt);
|
|
38
|
+
const bytes = Buffer.from(JSON.stringify(envelope(privateKey, entry.keyId, PAYLOAD_TYPE, statement)) + '\n');
|
|
39
|
+
await verifyArtifact(artifact, loaded, anchors.environment, bytes);
|
|
40
|
+
const target = resolve(artifact.root, SIGNATURE_FILE);
|
|
41
|
+
writeNew(target, bytes);
|
|
42
|
+
const shell = value => `'${value.replace(/'/g, `'\\''`)}'`;
|
|
43
|
+
return `gh release upload ${shell(statement.tag)} ${shell(target)} --repo ${shell(statement.repository.slice('https://github.com/'.length))}`;
|
|
44
|
+
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// Shared release authentication: dependency-free, with one WebCrypto code path.
|
|
2
|
+
export const PAYLOAD_TYPE = 'application/vnd.runeditrun.release-statement.v1+json';
|
|
3
|
+
export const ENROLMENT_TYPE = 'application/vnd.runeditrun.key-enrolment.v1+json';
|
|
4
|
+
export const LIMITS = Object.freeze({ envelopeBytes: 65536, files: 256, fileBytes: 1073741824 });
|
|
5
|
+
const encoder = new TextEncoder();
|
|
6
|
+
const trusted = new WeakMap();
|
|
7
|
+
const codes = new Set(('SIGNATURE_MISSING INPUT_TOO_LARGE ENVELOPE_INVALID KEY_TEST_REFUSED KEY_UNKNOWN SIGNATURE_INVALID STATEMENT_NOT_CANONICAL STATEMENT_INVALID PUBLISHER_MISMATCH KEY_OUTSIDE_VALIDITY KEY_RETIRED KEY_REVOKED ISSUED_IN_FUTURE SUBJECT_MISMATCH FILE_MISSING FILE_UNDECLARED DIGEST_MISMATCH PATH_UNSAFE VERSION_ROLLBACK VERSION_REUSED ANCHORS_INVALID ANCHORS_STALE').split(' '));
|
|
8
|
+
export class Refusal extends Error {
|
|
9
|
+
constructor(code) { const safe = codes.has(code) ? code : 'STATEMENT_INVALID'; super(safe); this.name = 'Refusal'; this.code = safe; }
|
|
10
|
+
}
|
|
11
|
+
const refuse = code => { throw new Refusal(code); };
|
|
12
|
+
function sanitized(error, code) { refuse(error instanceof Refusal && codes.has(error.code) ? error.code : code); }
|
|
13
|
+
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
14
|
+
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
|
15
|
+
const matches = (value, pattern) => typeof value === 'string' && pattern.test(value);
|
|
16
|
+
function shape(value, required, optional = []) {
|
|
17
|
+
if (!record(value)) return false;
|
|
18
|
+
const keys = Reflect.ownKeys(value);
|
|
19
|
+
return required.every(key => Object.hasOwn(value, key)) && keys.every(key => typeof key === 'string'
|
|
20
|
+
&& (required.includes(key) || optional.includes(key)) && Object.getOwnPropertyDescriptor(value, key)?.value !== undefined);
|
|
21
|
+
}
|
|
22
|
+
function ascii(value) { return typeof value === 'string' && value.length > 0 && !/[^\x20-\x7e]/.test(value); }
|
|
23
|
+
const namespace = /^[a-z0-9][a-z0-9-]{0,38}$/;
|
|
24
|
+
// Names are literal printable ASCII components, not URLs. Do not case-fold them.
|
|
25
|
+
export function validRelativePath(path) {
|
|
26
|
+
return ascii(path) && !path.includes('\\') && path.split('/').every(part => part && part !== '.' && part !== '..');
|
|
27
|
+
}
|
|
28
|
+
export function validProject(value) {
|
|
29
|
+
if (!validRelativePath(value)) return false;
|
|
30
|
+
const parts = value.split('/');
|
|
31
|
+
return parts.length === 2 && namespace.test(parts[0]);
|
|
32
|
+
}
|
|
33
|
+
const repositoryPattern = /^https:\/\/github\.com\/[a-z0-9][a-z0-9-]{0,38}\/[a-z0-9._-]{1,100}$/;
|
|
34
|
+
const hex40 = /^[a-f0-9]{40}$/, hex64 = /^[a-f0-9]{64}$/;
|
|
35
|
+
const keyPattern = /^(rer|test)-ed25519-[a-f0-9]{32}$/;
|
|
36
|
+
function version(value) {
|
|
37
|
+
return matches(value, /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/)
|
|
38
|
+
&& value.split('.').every(part => Number.isSafeInteger(Number(part)));
|
|
39
|
+
}
|
|
40
|
+
function instant(value) {
|
|
41
|
+
return matches(value, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
|
|
42
|
+
&& Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value.replace('Z', '.000Z');
|
|
43
|
+
}
|
|
44
|
+
function bytes(value, code) { if (!(value instanceof Uint8Array)) refuse(code); return value; }
|
|
45
|
+
function serialize(value, ancestors) {
|
|
46
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'string') return JSON.stringify(value);
|
|
47
|
+
if (typeof value === 'number' && Number.isSafeInteger(value)) return String(value);
|
|
48
|
+
if ((!Array.isArray(value) && !record(value)) || ancestors.has(value)) refuse('STATEMENT_INVALID');
|
|
49
|
+
ancestors.add(value);
|
|
50
|
+
let result;
|
|
51
|
+
if (Array.isArray(value)) {
|
|
52
|
+
if (Reflect.ownKeys(value).length !== value.length + 1) refuse('STATEMENT_INVALID');
|
|
53
|
+
result = '[' + Array.from({ length: value.length }, (_, i) => {
|
|
54
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(i));
|
|
55
|
+
if (!descriptor || !('value' in descriptor)) refuse('STATEMENT_INVALID');
|
|
56
|
+
return serialize(descriptor.value, ancestors);
|
|
57
|
+
}).join(',') + ']';
|
|
58
|
+
} else {
|
|
59
|
+
const keys = Reflect.ownKeys(value);
|
|
60
|
+
if (keys.some(key => typeof key !== 'string')) refuse('STATEMENT_INVALID');
|
|
61
|
+
result = '{' + keys.sort().map(key => {
|
|
62
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
63
|
+
if (!descriptor.enumerable || !('value' in descriptor)) refuse('STATEMENT_INVALID');
|
|
64
|
+
return JSON.stringify(key) + ':' + serialize(descriptor.value, ancestors);
|
|
65
|
+
}).join(',') + '}';
|
|
66
|
+
}
|
|
67
|
+
ancestors.delete(value);
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
export function canonical(value) {
|
|
71
|
+
try { return serialize(value, new Set()); } catch (error) { sanitized(error, 'STATEMENT_INVALID'); }
|
|
72
|
+
}
|
|
73
|
+
export const canonicalBytes = value => encoder.encode(canonical(value));
|
|
74
|
+
export function pae(type, body) {
|
|
75
|
+
try {
|
|
76
|
+
if (typeof type !== 'string') refuse('ENVELOPE_INVALID');
|
|
77
|
+
bytes(body, 'ENVELOPE_INVALID');
|
|
78
|
+
const prefix = encoder.encode(`DSSEv1 ${encoder.encode(type).length} ${type} ${body.length} `);
|
|
79
|
+
const result = new Uint8Array(prefix.length + body.length);
|
|
80
|
+
result.set(prefix); result.set(body, prefix.length); return result;
|
|
81
|
+
} catch (error) { sanitized(error, 'ENVELOPE_INVALID'); }
|
|
82
|
+
}
|
|
83
|
+
export function b64(value) {
|
|
84
|
+
try { bytes(value, 'ENVELOPE_INVALID'); let text = ''; for (const byte of value) text += String.fromCharCode(byte); return btoa(text); }
|
|
85
|
+
catch (error) { sanitized(error, 'ENVELOPE_INVALID'); }
|
|
86
|
+
}
|
|
87
|
+
function unb64(value) {
|
|
88
|
+
if (!matches(value, /^[A-Za-z0-9+/]*={0,2}$/)) refuse('ENVELOPE_INVALID');
|
|
89
|
+
let result;
|
|
90
|
+
try { result = Uint8Array.from(atob(value), char => char.charCodeAt(0)); } catch { refuse('ENVELOPE_INVALID'); }
|
|
91
|
+
if (b64(result) !== value) refuse('ENVELOPE_INVALID');
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
export async function sha256(value) {
|
|
95
|
+
try { return [...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes(value, 'STATEMENT_INVALID')))].map(byte => byte.toString(16).padStart(2, '0')).join(''); }
|
|
96
|
+
catch (error) { sanitized(error, 'STATEMENT_INVALID'); }
|
|
97
|
+
}
|
|
98
|
+
export async function keyIdFor(scope, raw) {
|
|
99
|
+
if (!['rer', 'test'].includes(scope) || !(raw instanceof Uint8Array) || raw.length !== 32) refuse('ANCHORS_INVALID');
|
|
100
|
+
return `${scope}-ed25519-${(await sha256(raw)).slice(0, 32)}`;
|
|
101
|
+
}
|
|
102
|
+
export function compareVersion(a, b) {
|
|
103
|
+
if (!version(a) || !version(b)) refuse('STATEMENT_INVALID');
|
|
104
|
+
const x = a.split('.').map(Number), y = b.split('.').map(Number);
|
|
105
|
+
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i] ? 1 : -1;
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
const requiredFiles = ['public-source.tar', 'release-receipt.json', 'publish-check.json'];
|
|
109
|
+
const filePattern = /^(public-source\.tar|release-receipt\.json|publish-check\.json|site\/[a-z0-9]+(?:-[a-z0-9]+)*\/[^/]+\/(?:release\.json|documents\/.+))$/;
|
|
110
|
+
function safePath(path) {
|
|
111
|
+
return validRelativePath(path) && matches(path, filePattern);
|
|
112
|
+
}
|
|
113
|
+
export function validateStatement(s) {
|
|
114
|
+
try {
|
|
115
|
+
if (!shape(s, ['schemaVersion', 'publisher', 'project', 'version', 'tag', 'repository', 'publicCommit', 'publicTree', 'tagObject', 'files', 'issuedAt', 'keyId'])
|
|
116
|
+
|| s.schemaVersion !== 1 || !matches(s.publisher, namespace) || !validProject(s.project)
|
|
117
|
+
|| !s.project.startsWith(s.publisher + '/') || !version(s.version) || s.tag !== 'v' + s.version
|
|
118
|
+
|| !matches(s.repository, repositoryPattern) || ['.', '..'].includes(s.repository.split('/')[4])
|
|
119
|
+
|| !matches(s.publicCommit, hex40) || !matches(s.publicTree, hex40) || !matches(s.tagObject, hex40)
|
|
120
|
+
|| !instant(s.issuedAt) || !matches(s.keyId, keyPattern) || !Array.isArray(s.files)) refuse('STATEMENT_INVALID');
|
|
121
|
+
if (s.files.length > LIMITS.files) refuse('INPUT_TOO_LARGE');
|
|
122
|
+
if (s.files.length < 4) refuse('STATEMENT_INVALID');
|
|
123
|
+
let previous = '';
|
|
124
|
+
for (const f of s.files) {
|
|
125
|
+
if (!shape(f, ['path', 'sha256', 'size']) || !safePath(f.path) || !matches(f.sha256, hex64)
|
|
126
|
+
|| !Number.isSafeInteger(f.size) || f.size < 0 || f.path <= previous) refuse('STATEMENT_INVALID');
|
|
127
|
+
if (f.size > LIMITS.fileBytes) refuse('INPUT_TOO_LARGE');
|
|
128
|
+
previous = f.path;
|
|
129
|
+
}
|
|
130
|
+
const releases = s.files.filter(f => /^site\/[^/]+\/[^/]+\/release\.json$/.test(f.path));
|
|
131
|
+
if (releases.length !== 1 || releases[0].path.split('/')[2] !== s.version) refuse('STATEMENT_INVALID');
|
|
132
|
+
const prefix = releases[0].path.slice(0, -'release.json'.length);
|
|
133
|
+
if (requiredFiles.some(path => !s.files.some(f => f.path === path))
|
|
134
|
+
|| s.files.some(f => f.path.startsWith('site/') && !f.path.startsWith(prefix))) refuse('STATEMENT_INVALID');
|
|
135
|
+
} catch (error) { sanitized(error, 'STATEMENT_INVALID'); }
|
|
136
|
+
}
|
|
137
|
+
function parseJSON(value, code) {
|
|
138
|
+
try { return JSON.parse(new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(value)); } catch { refuse(code); }
|
|
139
|
+
}
|
|
140
|
+
function envelopeParts(envelope, type) {
|
|
141
|
+
if (!shape(envelope, ['payloadType', 'payload', 'signatures']) || envelope.payloadType !== type
|
|
142
|
+
|| !Array.isArray(envelope.signatures) || envelope.signatures.length !== 1
|
|
143
|
+
|| !shape(envelope.signatures[0], ['keyid', 'sig']) || !matches(envelope.signatures[0].keyid, keyPattern)) refuse('ENVELOPE_INVALID');
|
|
144
|
+
const payload = unb64(envelope.payload), signature = unb64(envelope.signatures[0].sig);
|
|
145
|
+
if (signature.length !== 64) refuse('ENVELOPE_INVALID');
|
|
146
|
+
return { keyid: envelope.signatures[0].keyid, payload, signature };
|
|
147
|
+
}
|
|
148
|
+
function canonicalPayload(payload) {
|
|
149
|
+
const statement = parseJSON(payload, 'STATEMENT_NOT_CANONICAL');
|
|
150
|
+
let again;
|
|
151
|
+
try { again = canonicalBytes(statement); } catch { refuse('STATEMENT_NOT_CANONICAL'); }
|
|
152
|
+
if (again.length !== payload.length || again.some((byte, index) => byte !== payload[index])) refuse('STATEMENT_NOT_CANONICAL');
|
|
153
|
+
return statement;
|
|
154
|
+
}
|
|
155
|
+
async function checkSignature(key, type, payload, signature) {
|
|
156
|
+
try { if (await crypto.subtle.verify('Ed25519', key, signature, pae(type, payload))) return; } catch { /* Sanitized below. */ }
|
|
157
|
+
refuse('SIGNATURE_INVALID');
|
|
158
|
+
}
|
|
159
|
+
function freeze(value) {
|
|
160
|
+
if (value && typeof value === 'object') { for (const item of Object.values(value)) freeze(item); Object.freeze(value); }
|
|
161
|
+
return value;
|
|
162
|
+
}
|
|
163
|
+
function floors(value, publisher) {
|
|
164
|
+
return record(value) && Object.entries(value).every(([project, maximum]) => validProject(project)
|
|
165
|
+
&& (!publisher || project.startsWith(publisher + '/')) && version(maximum));
|
|
166
|
+
}
|
|
167
|
+
// Returns a separate immutable snapshot. Only this module can create trusted anchors.
|
|
168
|
+
// minimumSerial is the last accepted serial: equal and older imports are stale.
|
|
169
|
+
export async function loadAnchors(raw, environment = 'production', options = {}) {
|
|
170
|
+
try {
|
|
171
|
+
if (!['production', 'test'].includes(environment) || !shape(options, [], ['minimumSerial'])
|
|
172
|
+
|| (Object.hasOwn(options, 'minimumSerial') && (!Number.isSafeInteger(options.minimumSerial) || options.minimumSerial < 0))) refuse('ANCHORS_INVALID');
|
|
173
|
+
const anchors = raw instanceof Uint8Array ? parseJSON(raw, 'ANCHORS_INVALID')
|
|
174
|
+
: typeof raw === 'string' ? JSON.parse(raw) : JSON.parse(canonical(raw));
|
|
175
|
+
if (!shape(anchors, ['schemaVersion', 'environment', 'serial', 'legacyUnsigned', 'keys']) || anchors.schemaVersion !== 1
|
|
176
|
+
|| anchors.environment !== environment || !Number.isSafeInteger(anchors.serial) || anchors.serial < 1
|
|
177
|
+
|| !Array.isArray(anchors.keys) || !floors(anchors.legacyUnsigned)) refuse('ANCHORS_INVALID');
|
|
178
|
+
const lookup = new Map(), owners = new Map();
|
|
179
|
+
for (const k of anchors.keys) {
|
|
180
|
+
if (!shape(k, ['keyId', 'publicKey', 'publisher', 'githubOwner', 'notBefore', 'notAfter', 'status', 'enrolment', 'review'], ['retiredAt', 'compromisedAt', 'keepStatements'])
|
|
181
|
+
|| !matches(k.keyId, keyPattern) || lookup.has(k.keyId) || !matches(k.publisher, namespace)
|
|
182
|
+
|| !shape(k.githubOwner, ['login', 'id']) || !matches(k.githubOwner.login, namespace)
|
|
183
|
+
|| !Number.isSafeInteger(k.githubOwner.id) || k.githubOwner.id <= 0 || !ascii(k.review)
|
|
184
|
+
|| !instant(k.notBefore) || !instant(k.notAfter) || k.notBefore >= k.notAfter
|
|
185
|
+
|| !['active', 'retired', 'compromised'].includes(k.status)) refuse('ANCHORS_INVALID');
|
|
186
|
+
const maximum = new Date(k.notBefore); maximum.setUTCFullYear(maximum.getUTCFullYear() + 2);
|
|
187
|
+
if (Date.parse(k.notAfter) > maximum.getTime()) refuse('ANCHORS_INVALID');
|
|
188
|
+
if ((k.status === 'retired' && !Object.hasOwn(k, 'retiredAt'))
|
|
189
|
+
|| (k.status === 'active' && Object.hasOwn(k, 'retiredAt'))
|
|
190
|
+
|| (k.status === 'compromised') !== Object.hasOwn(k, 'compromisedAt')
|
|
191
|
+
|| (Object.hasOwn(k, 'retiredAt') && !instant(k.retiredAt))
|
|
192
|
+
|| (Object.hasOwn(k, 'compromisedAt') && !instant(k.compromisedAt))
|
|
193
|
+
|| (Object.hasOwn(k, 'keepStatements') && (k.status !== 'compromised' || !Array.isArray(k.keepStatements)
|
|
194
|
+
|| k.keepStatements.some(hash => !matches(hash, hex64)) || new Set(k.keepStatements).size !== k.keepStatements.length))) refuse('ANCHORS_INVALID');
|
|
195
|
+
const owner = canonical(k.githubOwner);
|
|
196
|
+
if (owners.has(k.publisher) && owners.get(k.publisher) !== owner) refuse('ANCHORS_INVALID');
|
|
197
|
+
owners.set(k.publisher, owner);
|
|
198
|
+
const scope = k.keyId.split('-')[0], publicBytes = unb64(k.publicKey);
|
|
199
|
+
if ((environment === 'production' && scope !== 'rer') || publicBytes.length !== 32
|
|
200
|
+
|| k.keyId !== await keyIdFor(scope, publicBytes)) refuse('ANCHORS_INVALID');
|
|
201
|
+
if (canonicalBytes(k.enrolment).length > LIMITS.envelopeBytes) refuse('ANCHORS_INVALID');
|
|
202
|
+
const { keyid, payload, signature } = envelopeParts(k.enrolment, ENROLMENT_TYPE);
|
|
203
|
+
if (keyid !== k.keyId) refuse('ANCHORS_INVALID');
|
|
204
|
+
const publicKey = await crypto.subtle.importKey('raw', publicBytes, 'Ed25519', false, ['verify']);
|
|
205
|
+
await checkSignature(publicKey, ENROLMENT_TYPE, payload, signature);
|
|
206
|
+
const claim = canonicalPayload(payload);
|
|
207
|
+
const claimFields = ['schemaVersion', 'keyId', 'publicKey', 'publisher', 'githubOwner', 'notBefore', 'notAfter', 'issuedAt', 'unsignedThrough'];
|
|
208
|
+
if (!shape(claim, claimFields) || claim.schemaVersion !== 1 || !instant(claim.issuedAt) || !floors(claim.unsignedThrough, k.publisher)
|
|
209
|
+
|| ['keyId', 'publicKey', 'publisher', 'notBefore', 'notAfter'].some(field => claim[field] !== k[field])
|
|
210
|
+
|| canonical(claim.githubOwner) !== owner) refuse('ANCHORS_INVALID');
|
|
211
|
+
k.unsignedThrough = claim.unsignedThrough;
|
|
212
|
+
lookup.set(k.keyId, { anchor: k, publicKey });
|
|
213
|
+
}
|
|
214
|
+
if (Object.keys(anchors.legacyUnsigned).some(project => owners.has(project.split('/')[0]))) refuse('ANCHORS_INVALID');
|
|
215
|
+
if (options.minimumSerial !== undefined && anchors.serial <= options.minimumSerial) refuse('ANCHORS_STALE');
|
|
216
|
+
freeze(anchors); trusted.set(anchors, lookup); return anchors;
|
|
217
|
+
} catch (error) { if (error instanceof Refusal && error.code === 'ANCHORS_STALE') throw error; refuse('ANCHORS_INVALID'); }
|
|
218
|
+
}
|
|
219
|
+
function contextRecord(context) {
|
|
220
|
+
if (!record(context)) refuse('STATEMENT_INVALID');
|
|
221
|
+
const allowed = ['environment', 'now', 'expect', 'files', 'checkPaths', 'highest', 'unsigned'];
|
|
222
|
+
if (Reflect.ownKeys(context).some(key => typeof key !== 'string' || !allowed.includes(key)
|
|
223
|
+
|| !('value' in Object.getOwnPropertyDescriptor(context, key)))) refuse('STATEMENT_INVALID');
|
|
224
|
+
return { ...context };
|
|
225
|
+
}
|
|
226
|
+
function expectedSubject(expect, statement) {
|
|
227
|
+
if (expect === undefined) return;
|
|
228
|
+
const fields = ['publisher', 'project', 'version', 'repository', 'tag', 'publicCommit', 'publicTree', 'tagObject'];
|
|
229
|
+
const valid = (field, value) => field === 'publisher' ? matches(value, namespace)
|
|
230
|
+
: field === 'project' ? validProject(value) : field === 'version' ? version(value)
|
|
231
|
+
: field === 'repository' ? matches(value, repositoryPattern) : field === 'tag' ? typeof value === 'string' && value.startsWith('v') && version(value.slice(1))
|
|
232
|
+
: matches(value, hex40);
|
|
233
|
+
if (!shape(expect, [], fields) || Object.entries(expect).some(([field, value]) => !valid(field, value)
|
|
234
|
+
|| (statement !== undefined && statement[field] !== value))) refuse('SUBJECT_MISMATCH');
|
|
235
|
+
}
|
|
236
|
+
function versionOrder(highest, versionValue, digest) {
|
|
237
|
+
if (highest === undefined) return;
|
|
238
|
+
if (!shape(highest, ['version', 'statementSha256']) || !version(highest.version) || !matches(highest.statementSha256, hex64)) refuse('STATEMENT_INVALID');
|
|
239
|
+
const order = compareVersion(versionValue, highest.version);
|
|
240
|
+
if (order < 0) refuse('VERSION_ROLLBACK');
|
|
241
|
+
if (order === 0 && digest !== highest.statementSha256) refuse('VERSION_REUSED');
|
|
242
|
+
}
|
|
243
|
+
async function checkFiles(statement, context) {
|
|
244
|
+
if (context.checkPaths !== undefined && typeof context.checkPaths !== 'function') refuse('PATH_UNSAFE');
|
|
245
|
+
if (context.files === undefined) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (!(context.files instanceof Map)) refuse('PATH_UNSAFE');
|
|
249
|
+
if (context.files.size > LIMITS.files) refuse('INPUT_TOO_LARGE');
|
|
250
|
+
const files = new Map(context.files), selected = new Map();
|
|
251
|
+
const select = path => {
|
|
252
|
+
if (!selected.has(path)) {
|
|
253
|
+
let answer;
|
|
254
|
+
try { answer = context.checkPaths === undefined ? true : context.checkPaths(path); } catch { refuse('PATH_UNSAFE'); }
|
|
255
|
+
if (typeof answer !== 'boolean') refuse('PATH_UNSAFE');
|
|
256
|
+
selected.set(path, answer);
|
|
257
|
+
}
|
|
258
|
+
return selected.get(path);
|
|
259
|
+
};
|
|
260
|
+
for (const [path, value] of files) {
|
|
261
|
+
if (!safePath(path) || !(value instanceof Uint8Array)) refuse('PATH_UNSAFE');
|
|
262
|
+
if (value.byteLength > LIMITS.fileBytes) refuse('INPUT_TOO_LARGE');
|
|
263
|
+
}
|
|
264
|
+
for (const entry of statement.files) {
|
|
265
|
+
if (!select(entry.path)) continue;
|
|
266
|
+
if (!files.has(entry.path)) refuse('FILE_MISSING');
|
|
267
|
+
const value = files.get(entry.path);
|
|
268
|
+
if (value.byteLength !== entry.size || await sha256(value) !== entry.sha256) refuse('DIGEST_MISMATCH');
|
|
269
|
+
}
|
|
270
|
+
const declared = new Set(statement.files.map(entry => entry.path));
|
|
271
|
+
for (const path of files.keys()) if (select(path) && !declared.has(path)) refuse('FILE_UNDECLARED');
|
|
272
|
+
}
|
|
273
|
+
// Context omits files for service admission, or selects site/ paths for a site
|
|
274
|
+
// build. Base passes all artifact files. Input failures never expose contents.
|
|
275
|
+
export async function verifyRelease(envelopeBytes, loadedAnchors, input = {}) {
|
|
276
|
+
try {
|
|
277
|
+
// Release failures follow the spec's order: size, envelope, scope, lookup,
|
|
278
|
+
// signature, canonical bytes, schema, identity, time, subject, files, version.
|
|
279
|
+
let parts;
|
|
280
|
+
if (envelopeBytes !== null) {
|
|
281
|
+
bytes(envelopeBytes, 'ENVELOPE_INVALID');
|
|
282
|
+
if (envelopeBytes.byteLength > LIMITS.envelopeBytes) refuse('INPUT_TOO_LARGE');
|
|
283
|
+
parts = envelopeParts(parseJSON(envelopeBytes, 'ENVELOPE_INVALID'), PAYLOAD_TYPE);
|
|
284
|
+
}
|
|
285
|
+
const context = contextRecord(input);
|
|
286
|
+
const environment = context.environment === undefined ? 'production' : context.environment;
|
|
287
|
+
if (!['production', 'test'].includes(environment)) refuse('ANCHORS_INVALID');
|
|
288
|
+
if (parts && environment === 'production' && parts.keyid.startsWith('test-')) refuse('KEY_TEST_REFUSED');
|
|
289
|
+
if (!trusted.has(loadedAnchors) || loadedAnchors.environment !== environment) refuse('ANCHORS_INVALID');
|
|
290
|
+
if (!parts) {
|
|
291
|
+
const unsigned = context.unsigned;
|
|
292
|
+
if (unsigned === undefined) refuse('SIGNATURE_MISSING');
|
|
293
|
+
if (!shape(unsigned, ['project', 'version']) || !validProject(unsigned.project) || !version(unsigned.version)) refuse('STATEMENT_INVALID');
|
|
294
|
+
// A legacy floor authenticates no bytes or Git subjects. Callers retain
|
|
295
|
+
// their existing pins; these optional inputs still must be well formed.
|
|
296
|
+
expectedSubject(context.expect);
|
|
297
|
+
if (context.files !== undefined || context.checkPaths !== undefined) {
|
|
298
|
+
// Bytes are not authenticated by a legacy floor, but malformed inputs still fail closed.
|
|
299
|
+
if ((context.files !== undefined && !(context.files instanceof Map)) || (context.checkPaths !== undefined && typeof context.checkPaths !== 'function')) refuse('PATH_UNSAFE');
|
|
300
|
+
if (context.files?.size > LIMITS.files) refuse('INPUT_TOO_LARGE');
|
|
301
|
+
for (const [path, value] of context.files ?? []) {
|
|
302
|
+
if (!safePath(path) || !(value instanceof Uint8Array)) refuse('PATH_UNSAFE');
|
|
303
|
+
if (value.byteLength > LIMITS.fileBytes) refuse('INPUT_TOO_LARGE');
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (context.now !== undefined && (!Number.isSafeInteger(context.now) || Math.abs(context.now) > 8640000000000000)) refuse('STATEMENT_INVALID');
|
|
307
|
+
const keys = loadedAnchors.keys.filter(key => key.publisher === unsigned.project.split('/')[0]);
|
|
308
|
+
const ceilings = keys.length ? keys.map(key => key.unsignedThrough[unsigned.project]) : [loadedAnchors.legacyUnsigned[unsigned.project]];
|
|
309
|
+
if (!ceilings.some(ceiling => ceiling !== undefined && compareVersion(unsigned.version, ceiling) <= 0)) refuse('SIGNATURE_MISSING');
|
|
310
|
+
versionOrder(context.highest, unsigned.version, undefined);
|
|
311
|
+
return { verdict: 'UNSIGNED_LEGACY', project: unsigned.project, version: unsigned.version };
|
|
312
|
+
}
|
|
313
|
+
const found = trusted.get(loadedAnchors).get(parts.keyid);
|
|
314
|
+
if (!found) refuse('KEY_UNKNOWN');
|
|
315
|
+
await checkSignature(found.publicKey, PAYLOAD_TYPE, parts.payload, parts.signature);
|
|
316
|
+
const statement = canonicalPayload(parts.payload);
|
|
317
|
+
validateStatement(statement);
|
|
318
|
+
if (statement.keyId !== parts.keyid) refuse('STATEMENT_INVALID');
|
|
319
|
+
const anchor = found.anchor;
|
|
320
|
+
if (statement.publisher !== anchor.publisher || statement.repository.split('/')[3] !== anchor.githubOwner.login) refuse('PUBLISHER_MISMATCH');
|
|
321
|
+
const statementSha256 = await sha256(parts.payload);
|
|
322
|
+
const now = context.now === undefined ? Date.now() : context.now;
|
|
323
|
+
if (!Number.isSafeInteger(now) || Math.abs(now) > 8640000000000000) refuse('STATEMENT_INVALID');
|
|
324
|
+
const issued = Date.parse(statement.issuedAt);
|
|
325
|
+
if (issued > now + 300000) refuse('ISSUED_IN_FUTURE');
|
|
326
|
+
if (issued < Date.parse(anchor.notBefore) || issued >= Date.parse(anchor.notAfter)) refuse('KEY_OUTSIDE_VALIDITY');
|
|
327
|
+
if (anchor.status === 'retired' && issued >= Date.parse(anchor.retiredAt)) refuse('KEY_RETIRED');
|
|
328
|
+
if (anchor.status === 'compromised' && !(anchor.keepStatements ?? []).includes(statementSha256)) refuse('KEY_REVOKED');
|
|
329
|
+
if (context.unsigned !== undefined && (!shape(context.unsigned, ['project', 'version'])
|
|
330
|
+
|| context.unsigned.project !== statement.project || context.unsigned.version !== statement.version)) refuse('SUBJECT_MISMATCH');
|
|
331
|
+
expectedSubject(context.expect, statement);
|
|
332
|
+
await checkFiles(statement, context);
|
|
333
|
+
versionOrder(context.highest, statement.version, statementSha256);
|
|
334
|
+
return { verdict: 'VERIFIED', statementSha256, keyId: parts.keyid, publisher: statement.publisher, project: statement.project, version: statement.version };
|
|
335
|
+
} catch (error) { sanitized(error, 'STATEMENT_INVALID'); }
|
|
336
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Refusal, validProject } from './signature.mjs';
|
|
2
|
+
|
|
3
|
+
const semver = '(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)';
|
|
4
|
+
const versionOK = value => new RegExp(`^${semver}$`).test(value) && value.split('.').every(part => Number.isSafeInteger(Number(part)));
|
|
5
|
+
const dateOK = value => /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(value) && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value.replace('Z', '.000Z');
|
|
6
|
+
function parse(verb, args) {
|
|
7
|
+
const fields = { sign: ['artifact', 'key-file', 'scope'], receipt: ['artifact', 'anchors', 'environment', 'after'],
|
|
8
|
+
keygen: ['scope', 'publisher', 'github-owner', 'github-owner-id', 'anchor-out', 'not-before', 'not-after', 'unsigned-through'] }[verb];
|
|
9
|
+
const options = { unsignedThrough: {} };
|
|
10
|
+
const invalid = () => { throw new TypeError('ARGUMENT_INVALID'); };
|
|
11
|
+
for (let index = 0; index < args.length; index++) {
|
|
12
|
+
const flag = args[index];
|
|
13
|
+
if (verb === 'sign' && flag === '--key-stdin' && !options['key-stdin']) { options['key-stdin'] = true; continue; }
|
|
14
|
+
if (!flag.startsWith('--') || !fields.includes(flag.slice(2))) invalid();
|
|
15
|
+
const name = flag.slice(2), value = args[++index];
|
|
16
|
+
if (!value || value.startsWith('--') || (name !== 'unsigned-through' && options[name] !== undefined)) invalid();
|
|
17
|
+
if (name === 'unsigned-through') {
|
|
18
|
+
const match = new RegExp(`^(.+):(${semver})$`).exec(value);
|
|
19
|
+
if (!match || !validProject(match[1]) || !versionOK(match[2]) || Object.hasOwn(options.unsignedThrough, match[1])) invalid();
|
|
20
|
+
options.unsignedThrough[match[1]] = match[2];
|
|
21
|
+
} else options[name] = value;
|
|
22
|
+
}
|
|
23
|
+
if (options.scope !== undefined && !['test', 'rer'].includes(options.scope)) invalid();
|
|
24
|
+
if (verb === 'receipt' && (!options.artifact || !options.anchors || (options.environment !== undefined && options.environment !== 'test') || (options.after && (!new RegExp(`^${semver}:[a-f0-9]{64}$`).test(options.after) || !versionOK(options.after.split(':')[0]))))) invalid();
|
|
25
|
+
if (verb === 'sign' && (!options.artifact || Boolean(options['key-file']) === Boolean(options['key-stdin']))) invalid();
|
|
26
|
+
if (verb === 'keygen') {
|
|
27
|
+
if (!options.scope || !options['anchor-out'] || !/^[a-z0-9][a-z0-9-]{0,38}$/.test(options.publisher ?? '') || !/^[a-z0-9][a-z0-9-]{0,38}$/.test(options['github-owner'] ?? '') || !/^[1-9]\d*$/.test(options['github-owner-id'] ?? '') || !Number.isSafeInteger(Number(options['github-owner-id']))) invalid();
|
|
28
|
+
for (const name of ['not-before', 'not-after']) if (options[name] !== undefined && !dateOK(options[name])) invalid();
|
|
29
|
+
const start = new Date(options['not-before'] ?? Date.now()), maximum = new Date(start);
|
|
30
|
+
maximum.setUTCFullYear(maximum.getUTCFullYear() + 2);
|
|
31
|
+
if (options['not-after'] && (Date.parse(options['not-after']) <= start.getTime() || Date.parse(options['not-after']) > maximum.getTime())) invalid();
|
|
32
|
+
if (Object.keys(options.unsignedThrough).some(project => !project.startsWith(options.publisher + '/'))) invalid();
|
|
33
|
+
if (process.stdout.isTTY) invalid();
|
|
34
|
+
}
|
|
35
|
+
return options;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function dispatchSigning(verb, args) {
|
|
39
|
+
let options;
|
|
40
|
+
try { options = parse(verb, args); }
|
|
41
|
+
catch { console.log(JSON.stringify({ verdict: 'REFUSED', code: 'ARGUMENT_INVALID' })); return 2; }
|
|
42
|
+
if (verb === 'sign' && options['key-stdin'] && process.stdin.isTTY) {
|
|
43
|
+
console.log(JSON.stringify({ verdict: 'REFUSED', code: 'ARGUMENT_INVALID', message: 'Pipe the private key from a vault or file; --key-stdin does not accept terminal input.' }));
|
|
44
|
+
return 2;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
if (verb === 'sign') console.log(await (await import('./sign.mjs')).signArtifact(options));
|
|
48
|
+
else if (verb === 'keygen') await (await import('./keygen.mjs')).keygen(options);
|
|
49
|
+
else { await (await import('./verify.mjs')).receipt(options); console.log(JSON.stringify({ verdict: 'VERIFIED' })); }
|
|
50
|
+
return 0;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
const code = error instanceof Refusal ? error.code : 'STATEMENT_INVALID';
|
|
53
|
+
console.log(JSON.stringify({ verdict: 'REFUSED', code }));
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { constants, lstatSync, openSync, closeSync, readSync, writeFileSync, fstatSync } from 'node:fs';
|
|
2
|
+
import { resolve, dirname, parse } from 'node:path';
|
|
3
|
+
import { Refusal } from './signature.mjs';
|
|
4
|
+
|
|
5
|
+
export const SIGNATURE_FILE = 'rer-release.dsse.json';
|
|
6
|
+
export const FILE_LIMIT = 1024 ** 3;
|
|
7
|
+
export function refuse(code) { throw new Refusal(code); }
|
|
8
|
+
|
|
9
|
+
// Check every parent too: O_NOFOLLOW alone only protects the final component.
|
|
10
|
+
export function safePath(path, { missing = false } = {}) {
|
|
11
|
+
const absolute = resolve(path), root = parse(absolute).root;
|
|
12
|
+
let current = root;
|
|
13
|
+
for (const part of absolute.slice(root.length).split('/').filter(Boolean)) {
|
|
14
|
+
current = resolve(current, part);
|
|
15
|
+
let stat;
|
|
16
|
+
try { stat = lstatSync(current); }
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error.code === 'ENOENT' && missing && current === absolute) return absolute;
|
|
19
|
+
refuse('FILE_MISSING');
|
|
20
|
+
}
|
|
21
|
+
if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile()) || (current !== absolute && !stat.isDirectory())) refuse('PATH_UNSAFE');
|
|
22
|
+
}
|
|
23
|
+
return absolute;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function readBounded(path, limit = FILE_LIMIT) {
|
|
27
|
+
const absolute = safePath(path);
|
|
28
|
+
let fd;
|
|
29
|
+
try {
|
|
30
|
+
const before = lstatSync(absolute);
|
|
31
|
+
if (!before.isFile()) refuse('PATH_UNSAFE');
|
|
32
|
+
if (before.size > limit) refuse('INPUT_TOO_LARGE');
|
|
33
|
+
fd = openSync(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
34
|
+
const stat = fstatSync(fd);
|
|
35
|
+
if (!stat.isFile() || stat.dev !== before.dev || stat.ino !== before.ino) refuse('PATH_UNSAFE');
|
|
36
|
+
if (stat.size > limit) refuse('INPUT_TOO_LARGE');
|
|
37
|
+
// A bounded read also prevents a concurrently growing file from exhausting memory.
|
|
38
|
+
const bytes = Buffer.alloc(stat.size);
|
|
39
|
+
let offset = 0;
|
|
40
|
+
while (offset < bytes.length) {
|
|
41
|
+
const count = readSync(fd, bytes, offset, bytes.length - offset, offset);
|
|
42
|
+
if (!count) refuse('DIGEST_MISMATCH');
|
|
43
|
+
offset += count;
|
|
44
|
+
}
|
|
45
|
+
const after = fstatSync(fd);
|
|
46
|
+
if (after.size !== stat.size || after.mtimeMs !== stat.mtimeMs) refuse('DIGEST_MISMATCH');
|
|
47
|
+
return bytes;
|
|
48
|
+
} finally { if (fd !== undefined) closeSync(fd); }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function writeNew(path, bytes) {
|
|
52
|
+
safePath(dirname(resolve(path)));
|
|
53
|
+
safePath(path, { missing: true });
|
|
54
|
+
// Exclusive creation never replaces another receipt, key entry or symlink.
|
|
55
|
+
try { writeFileSync(path, bytes, { flag: 'wx', mode: 0o600 }); }
|
|
56
|
+
catch { refuse('PATH_UNSAFE'); }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function parseJSON(bytes, code = 'STATEMENT_INVALID') {
|
|
60
|
+
try { return JSON.parse(new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes)); } catch { refuse(code); }
|
|
61
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createPublicKey, sign } from 'node:crypto';
|
|
2
|
+
import { b64, canonicalBytes, pae, keyIdFor, ENROLMENT_TYPE } from './signature.mjs';
|
|
3
|
+
|
|
4
|
+
export const timestamp = date => date.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
5
|
+
export function validity(now = new Date()) {
|
|
6
|
+
const start = new Date(timestamp(now)), end = new Date(start);
|
|
7
|
+
end.setUTCFullYear(end.getUTCFullYear() + 2);
|
|
8
|
+
return { notBefore: timestamp(start), notAfter: timestamp(end) };
|
|
9
|
+
}
|
|
10
|
+
export function envelope(privateKey, keyId, type, claim) {
|
|
11
|
+
const payload = canonicalBytes(claim);
|
|
12
|
+
return { payloadType: type, payload: b64(payload), signatures: [{ keyid: keyId, sig: b64(sign(null, pae(type, payload), privateKey)) }] };
|
|
13
|
+
}
|
|
14
|
+
export async function publicEntry(privateKey, { scope, publisher, githubOwner, notBefore, notAfter, unsignedThrough = {}, issuedAt = timestamp(new Date()) }) {
|
|
15
|
+
const jwk = createPublicKey(privateKey).export({ format: 'jwk' });
|
|
16
|
+
const raw = Buffer.from(jwk.x, 'base64url');
|
|
17
|
+
const keyId = await keyIdFor(scope, raw), publicKey = b64(raw);
|
|
18
|
+
const claim = { schemaVersion: 1, keyId, publicKey, publisher, githubOwner, notBefore, notAfter, issuedAt, unsignedThrough };
|
|
19
|
+
return { keyId, publicKey, publisher, githubOwner, notBefore, notAfter, status: 'active',
|
|
20
|
+
enrolment: envelope(privateKey, keyId, ENROLMENT_TYPE, claim), review: 'pending' };
|
|
21
|
+
}
|
|
22
|
+
export const anchorSet = (entry, scope) => ({ schemaVersion: 1, environment: scope === 'test' ? 'test' : 'production', serial: 1, legacyUnsigned: {}, keys: [entry] });
|
|
@@ -4,6 +4,7 @@ import { shared } from '../../cli/shared.mjs';
|
|
|
4
4
|
import { execFileSync, spawn } from 'node:child_process';
|
|
5
5
|
import { realpathSync, existsSync, lstatSync, mkdirSync } from 'node:fs';
|
|
6
6
|
import { resolve, dirname, relative, isAbsolute } from 'node:path';
|
|
7
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
7
8
|
import { testSummary } from './test-summary.mjs';
|
|
8
9
|
|
|
9
10
|
export function git(repo, ...args) {
|
|
@@ -56,16 +57,26 @@ export function newOutput(repo, output) {
|
|
|
56
57
|
return output;
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
//
|
|
60
|
-
//
|
|
60
|
+
// Child output is redacted by the command store before it is kept. A failing
|
|
61
|
+
// step's receipt carries the last 40 lines of it, at most 8 KB; a passing step
|
|
62
|
+
// carries only its exit status and numeric test summaries.
|
|
63
|
+
const TAIL_LINES = 40, TAIL_BYTES = 8192;
|
|
64
|
+
function failureTail(logs) {
|
|
65
|
+
const lines = stripVTControlCharacters(logs.join('\n')).split('\n');
|
|
66
|
+
while (lines.length && lines.at(-1).trim() === '') lines.pop();
|
|
67
|
+
const bytes = Buffer.from(lines.slice(-TAIL_LINES).join('\n'));
|
|
68
|
+
return bytes.subarray(Math.max(0, bytes.length - TAIL_BYTES)).toString('utf8').replace(/^\uFFFD+/, '');
|
|
69
|
+
}
|
|
70
|
+
|
|
61
71
|
export async function command(command, args, cwd) {
|
|
62
72
|
const {createCommands}=await shared('commands.mjs');
|
|
63
73
|
const directory=await mkdtemp(resolve(tmpdir(),'rer-release-'));
|
|
64
|
-
const
|
|
74
|
+
const state={logs:[]};
|
|
75
|
+
const store=await createCommands(directory,state,{terminal:()=>{},queue:{verb:'release'}});
|
|
65
76
|
const summary=testSummary(),env={...process.env,FORCE_COLOR:'0'};delete env.NODE_TEST_CONTEXT;
|
|
66
77
|
try {
|
|
67
78
|
await store.run('Release verification',command,args,{cwd,env,onChild:child=>child.stdout.on('data',bytes=>summary.write(bytes))});
|
|
68
79
|
return {status:'PASS',exitCode:0,...summary.finish()};
|
|
69
|
-
}catch(error){return {status:'FAIL',exitCode:error.childExitCode??null,...summary.finish()};}
|
|
80
|
+
}catch(error){return {status:'FAIL',exitCode:error.childExitCode??null,...summary.finish(),output:failureTail(state.logs)};}
|
|
70
81
|
finally{await store.closeCommands();await rm(directory,{recursive:true,force:true});}
|
|
71
82
|
}
|