@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
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { lstatSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { resolve, posix } from 'node:path';
|
|
3
|
+
import { sha256, validateStatement, validRelativePath as pathOK } from './signature.mjs';
|
|
4
|
+
import { FILE_LIMIT, SIGNATURE_FILE, safePath, readBounded, parseJSON, refuse } from './signing-io.mjs';
|
|
5
|
+
|
|
6
|
+
const required = ['public-source.tar', 'release-receipt.json', 'publish-check.json'];
|
|
7
|
+
export function repositoryURL(value) {
|
|
8
|
+
if (typeof value !== 'string' || !/^https:\/\/github\.com\/[a-z0-9-]+\/[a-z0-9_.-]+(?:\.git)?$/i.test(value)) refuse('SUBJECT_MISMATCH');
|
|
9
|
+
return value.toLowerCase().replace(/\.git$/, '');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function readArtifact(directory) {
|
|
13
|
+
const root = safePath(directory);
|
|
14
|
+
if (!lstatSync(root).isDirectory()) refuse('PATH_UNSAFE');
|
|
15
|
+
const receiptBytes = readBounded(resolve(root, 'release-receipt.json'));
|
|
16
|
+
const receipt = parseJSON(receiptBytes);
|
|
17
|
+
const manifestPath = receipt?.siteRelease?.path;
|
|
18
|
+
if (!pathOK(manifestPath) || !/^site\/[a-z0-9]+(?:-[a-z0-9]+)*\/(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\/release\.json$/.test(manifestPath)) refuse('PATH_UNSAFE');
|
|
19
|
+
const base = posix.dirname(manifestPath);
|
|
20
|
+
const selected = new Map();
|
|
21
|
+
const walk = (relative = '') => {
|
|
22
|
+
for (const name of readdirSync(resolve(root, relative)).sort()) {
|
|
23
|
+
const path = relative ? `${relative}/${name}` : name;
|
|
24
|
+
const ancillary = path === 'public-git' || path.startsWith('public-git/');
|
|
25
|
+
// Unsigned source names may be Unicode; only the root Git metadata may
|
|
26
|
+
// use .git. Signed paths retain the statement's printable ASCII rules.
|
|
27
|
+
if (ancillary) {
|
|
28
|
+
if (path.includes('\0') || path.includes('\\') || path.split('/').some((part, index) =>
|
|
29
|
+
!part || part === '.' || part === '..' || (part === '.git' && index !== 1))) refuse('PATH_UNSAFE');
|
|
30
|
+
} else if (!pathOK(path)) refuse('PATH_UNSAFE');
|
|
31
|
+
const stat = lstatSync(resolve(root, path));
|
|
32
|
+
if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) refuse('PATH_UNSAFE');
|
|
33
|
+
if (stat.isFile() && stat.size > FILE_LIMIT) refuse('INPUT_TOO_LARGE');
|
|
34
|
+
if (stat.isDirectory()) {
|
|
35
|
+
if (!ancillary && path !== 'site' && path !== posix.dirname(base) && path !== base && path !== `${base}/documents` && !path.startsWith(`${base}/documents/`)) refuse('FILE_UNDECLARED');
|
|
36
|
+
walk(path);
|
|
37
|
+
} else if (required.includes(path) || path === manifestPath || path.startsWith(`${base}/documents/`)) {
|
|
38
|
+
selected.set(path, stat.size);
|
|
39
|
+
if (selected.size > 256) refuse('INPUT_TOO_LARGE');
|
|
40
|
+
} else if (!ancillary && !['PUBLISH.md', 'site/registry.json', SIGNATURE_FILE].includes(path)) refuse('FILE_UNDECLARED');
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
walk();
|
|
44
|
+
for (const path of [...required, manifestPath]) if (!selected.has(path)) refuse('FILE_MISSING');
|
|
45
|
+
const files = new Map();
|
|
46
|
+
for (const path of [...selected.keys()].sort()) files.set(path, path === 'release-receipt.json' ? receiptBytes : readBounded(resolve(root, path)));
|
|
47
|
+
const manifest = parseJSON(files.get(manifestPath));
|
|
48
|
+
const publicExport = receipt.publicExport;
|
|
49
|
+
if (!publicExport || publicExport.path !== 'public-source.tar' || !manifest?.provenance || !manifest?.listing) refuse('SUBJECT_MISMATCH');
|
|
50
|
+
const subject = { publisher: manifest.project?.split('/')[0], project: manifest.project, version: manifest.release,
|
|
51
|
+
repository: repositoryURL(manifest.provenance.repository), tag: publicExport.tag,
|
|
52
|
+
publicCommit: publicExport.commit, publicTree: publicExport.tree, tagObject: publicExport.tagObject };
|
|
53
|
+
if (manifestPath !== `site/${manifest.listing.id}/${subject.version}/release.json` || manifest.listing.version !== subject.version || repositoryURL(manifest.listing.repository) !== subject.repository ||
|
|
54
|
+
manifest.provenance.revision !== subject.publicCommit || manifest.provenance.tree !== subject.publicTree || manifest.provenance.tag !== subject.tag || receipt.tag?.name !== subject.tag) refuse('SUBJECT_MISMATCH');
|
|
55
|
+
const entries = [];
|
|
56
|
+
for (const [path, bytes] of files) entries.push({ path, sha256: await sha256(bytes), size: bytes.length });
|
|
57
|
+
const digests = new Map(entries.map(entry => [entry.path, entry.sha256]));
|
|
58
|
+
const match = (path, digest) => { if (typeof digest !== 'string' || digests.get(path) !== digest) refuse('DIGEST_MISMATCH'); };
|
|
59
|
+
match('public-source.tar', publicExport.sha256);
|
|
60
|
+
match('publish-check.json', receipt.gates?.publishCheck?.reportSha256);
|
|
61
|
+
match(manifestPath, receipt.siteRelease.sha256);
|
|
62
|
+
if (!Array.isArray(manifest.artifacts)) refuse('STATEMENT_INVALID');
|
|
63
|
+
const declared = new Set();
|
|
64
|
+
for (const artifact of manifest.artifacts) {
|
|
65
|
+
if (!pathOK(artifact?.path) || !artifact.path.startsWith('documents/')) refuse('PATH_UNSAFE');
|
|
66
|
+
const path = `${base}/${artifact.path}`;
|
|
67
|
+
if (declared.has(path)) refuse('STATEMENT_INVALID');
|
|
68
|
+
declared.add(path);
|
|
69
|
+
if (!files.has(path)) refuse('FILE_MISSING');
|
|
70
|
+
match(path, artifact.sha256);
|
|
71
|
+
}
|
|
72
|
+
for (const path of files.keys()) if (path.startsWith(`${base}/documents/`) && !declared.has(path)) refuse('FILE_UNDECLARED');
|
|
73
|
+
return { root, receipt, subject, files, entries };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function statementFor(artifact, keyId, issuedAt) {
|
|
77
|
+
const statement = { schemaVersion: 1, ...artifact.subject, files: artifact.entries, issuedAt, keyId };
|
|
78
|
+
validateStatement(statement);
|
|
79
|
+
return statement;
|
|
80
|
+
}
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import {
|
|
3
|
+
ENROLMENT_TYPE,
|
|
4
|
+
PAYLOAD_TYPE,
|
|
5
|
+
b64,
|
|
6
|
+
canonical,
|
|
7
|
+
canonicalBytes,
|
|
8
|
+
keyIdFor,
|
|
9
|
+
pae,
|
|
10
|
+
sha256,
|
|
11
|
+
} from '../signature.mjs';
|
|
12
|
+
|
|
13
|
+
const encoder = new TextEncoder();
|
|
14
|
+
const issuedAt = '2026-10-13T00:00:00Z';
|
|
15
|
+
const now = Date.parse(issuedAt);
|
|
16
|
+
const notBefore = '2026-10-01T00:00:00Z';
|
|
17
|
+
const notAfter = '2028-10-01T00:00:00Z';
|
|
18
|
+
// Existing public Octocat example context; all bundle bytes are synthetic and keys
|
|
19
|
+
// are throwaway test keys kept in memory, with only public keys exported.
|
|
20
|
+
const owner = { login: 'octocat', id: 583231 };
|
|
21
|
+
const repository = 'https://github.com/octocat/hello-world';
|
|
22
|
+
|
|
23
|
+
async function throwawayKey(publisher, githubOwner) {
|
|
24
|
+
const pair = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
|
|
25
|
+
const raw = new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey));
|
|
26
|
+
return {
|
|
27
|
+
pair,
|
|
28
|
+
publicKey: b64(raw),
|
|
29
|
+
keyId: await keyIdFor('test', raw),
|
|
30
|
+
publisher,
|
|
31
|
+
githubOwner,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function signature(key, type, body) {
|
|
36
|
+
return new Uint8Array(await crypto.subtle.sign('Ed25519', key.pair.privateKey, pae(type, body)));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function envelope(key, body, options = {}) {
|
|
40
|
+
const signer = options.signer ?? key;
|
|
41
|
+
const type = options.type ?? PAYLOAD_TYPE;
|
|
42
|
+
return encoder.encode(JSON.stringify({
|
|
43
|
+
payloadType: type,
|
|
44
|
+
payload: b64(body),
|
|
45
|
+
signatures: [{ keyid: options.keyid ?? key.keyId, sig: b64(await signature(signer, type, body)) }],
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function enrolment(key, options = {}) {
|
|
50
|
+
const claim = {
|
|
51
|
+
schemaVersion: 1,
|
|
52
|
+
keyId: key.keyId,
|
|
53
|
+
publicKey: key.publicKey,
|
|
54
|
+
publisher: key.publisher,
|
|
55
|
+
githubOwner: key.githubOwner,
|
|
56
|
+
notBefore,
|
|
57
|
+
notAfter,
|
|
58
|
+
issuedAt: notBefore,
|
|
59
|
+
unsignedThrough: options.unsignedThrough ?? {},
|
|
60
|
+
};
|
|
61
|
+
return envelope(key, canonicalBytes(claim), {
|
|
62
|
+
type: ENROLMENT_TYPE,
|
|
63
|
+
signer: options.signer ?? key,
|
|
64
|
+
keyid: options.keyid ?? key.keyId,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function anchor(key, options = {}) {
|
|
69
|
+
return {
|
|
70
|
+
keyId: key.keyId,
|
|
71
|
+
publicKey: key.publicKey,
|
|
72
|
+
publisher: key.publisher,
|
|
73
|
+
githubOwner: key.githubOwner,
|
|
74
|
+
notBefore,
|
|
75
|
+
notAfter,
|
|
76
|
+
status: 'active',
|
|
77
|
+
enrolment: JSON.parse(new TextDecoder().decode(await enrolment(key, options))),
|
|
78
|
+
review: 'public conformance vector',
|
|
79
|
+
...options.entry,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function copy(value) {
|
|
84
|
+
return structuredClone(value);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function withAnchor(anchors, keyId, changes) {
|
|
88
|
+
return {
|
|
89
|
+
...copy(anchors),
|
|
90
|
+
keys: anchors.keys.map(key => key.keyId === keyId ? { ...copy(key), ...changes } : copy(key)),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function bytes(text) {
|
|
95
|
+
return encoder.encode(text);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function changed(input, offset = 0) {
|
|
99
|
+
const output = new Uint8Array(input);
|
|
100
|
+
output[offset] ^= 1;
|
|
101
|
+
return output;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function editEnvelope(raw, change) {
|
|
105
|
+
const value = JSON.parse(new TextDecoder().decode(raw));
|
|
106
|
+
change(value);
|
|
107
|
+
return encoder.encode(JSON.stringify(value));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function decoded(text) {
|
|
111
|
+
return new Uint8Array(Buffer.from(text, 'base64'));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function filesAsBase64(files) {
|
|
115
|
+
return Object.fromEntries([...files].map(([path, value]) => [path, b64(value)]));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function manifestFor(sourceFiles) {
|
|
119
|
+
const manifest = [];
|
|
120
|
+
for (const [path, content] of [...sourceFiles].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)) {
|
|
121
|
+
manifest.push({ path, sha256: await sha256(content), size: content.length });
|
|
122
|
+
}
|
|
123
|
+
return manifest;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function filesForVersion(version) {
|
|
127
|
+
const sourceFiles = new Map();
|
|
128
|
+
for (const [path, content] of rawFiles) {
|
|
129
|
+
const releasePath = path.endsWith('/release.json');
|
|
130
|
+
const versionedPath = path.replace('/1.1.0/', `/${version}/`);
|
|
131
|
+
sourceFiles.set(versionedPath, releasePath ? bytes(`{"id":"hello-world","version":"${version}"}\n`) : content);
|
|
132
|
+
}
|
|
133
|
+
return sourceFiles;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const keyA = await throwawayKey('octocat', owner);
|
|
137
|
+
const keyA2 = await throwawayKey('octocat', owner);
|
|
138
|
+
const keyB = await throwawayKey('other-pub', { login: 'other-pub', id: 71002 });
|
|
139
|
+
const unknownKey = await throwawayKey('unlisted-pub', { login: 'unlisted-pub', id: 71003 });
|
|
140
|
+
const unsafeAnchorKey = await throwawayKey('octocat', { login: '../octocat', id: 71004 });
|
|
141
|
+
|
|
142
|
+
const rawFiles = new Map([
|
|
143
|
+
['public-source.tar', bytes('tar\n')],
|
|
144
|
+
['release-receipt.json', bytes('{"status":"RELEASED"}\n')],
|
|
145
|
+
['publish-check.json', bytes('{"status":"PASS"}\n')],
|
|
146
|
+
['site/hello-world/1.1.0/release.json', bytes('{"id":"hello-world","version":"1.1.0"}\n')],
|
|
147
|
+
['site/hello-world/1.1.0/documents/CONTRACT.md', bytes('Public contract.\n')],
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
const files = await manifestFor(rawFiles);
|
|
151
|
+
|
|
152
|
+
const baseStatement = {
|
|
153
|
+
schemaVersion: 1,
|
|
154
|
+
publisher: 'octocat',
|
|
155
|
+
project: 'octocat/hello-world',
|
|
156
|
+
version: '1.1.0',
|
|
157
|
+
tag: 'v1.1.0',
|
|
158
|
+
repository,
|
|
159
|
+
publicCommit: '1'.repeat(40),
|
|
160
|
+
publicTree: '2'.repeat(40),
|
|
161
|
+
tagObject: '3'.repeat(40),
|
|
162
|
+
files,
|
|
163
|
+
issuedAt,
|
|
164
|
+
keyId: keyA.keyId,
|
|
165
|
+
};
|
|
166
|
+
const basePayload = canonicalBytes(baseStatement);
|
|
167
|
+
const statementSha256 = await sha256(basePayload);
|
|
168
|
+
const good = await envelope(keyA, basePayload);
|
|
169
|
+
const signedFloor = { 'octocat/hello-world': '1.1.0' };
|
|
170
|
+
const testAnchors = {
|
|
171
|
+
schemaVersion: 1,
|
|
172
|
+
environment: 'test',
|
|
173
|
+
serial: 1,
|
|
174
|
+
legacyUnsigned: { 'never-enrolled/app': '0.1.0' },
|
|
175
|
+
keys: [await anchor(keyA, { unsignedThrough: signedFloor })],
|
|
176
|
+
};
|
|
177
|
+
const otherPublisherAnchors = {
|
|
178
|
+
...copy(testAnchors),
|
|
179
|
+
keys: [...copy(testAnchors.keys), await anchor(keyB)],
|
|
180
|
+
};
|
|
181
|
+
const successorAnchors = {
|
|
182
|
+
...copy(testAnchors),
|
|
183
|
+
keys: [...copy(testAnchors.keys), await anchor(keyA2, { unsignedThrough: signedFloor })],
|
|
184
|
+
};
|
|
185
|
+
const productionAnchors = {
|
|
186
|
+
schemaVersion: 1,
|
|
187
|
+
environment: 'production',
|
|
188
|
+
serial: 1,
|
|
189
|
+
legacyUnsigned: {},
|
|
190
|
+
keys: [],
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
function context(extra = {}) {
|
|
194
|
+
return {
|
|
195
|
+
environment: 'test',
|
|
196
|
+
now,
|
|
197
|
+
expect: {
|
|
198
|
+
project: baseStatement.project,
|
|
199
|
+
repository,
|
|
200
|
+
tag: baseStatement.tag,
|
|
201
|
+
publicCommit: baseStatement.publicCommit,
|
|
202
|
+
publicTree: baseStatement.publicTree,
|
|
203
|
+
tagObject: baseStatement.tagObject,
|
|
204
|
+
},
|
|
205
|
+
...extra,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function unsignedContext(project, version) {
|
|
210
|
+
return {
|
|
211
|
+
environment: 'test',
|
|
212
|
+
now,
|
|
213
|
+
expect: { project, version, tag: `v${version}` },
|
|
214
|
+
unsigned: { project, version },
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function resign(key, fields = {}) {
|
|
219
|
+
const statement = { ...baseStatement, ...fields, keyId: key.keyId };
|
|
220
|
+
return envelope(key, canonicalBytes(statement));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const vectors = [];
|
|
224
|
+
function add(name, envelopeValue, anchors, contextValue, expected) {
|
|
225
|
+
vectors.push({
|
|
226
|
+
name,
|
|
227
|
+
envelope: envelopeValue === null || envelopeValue === false ? envelopeValue : b64(envelopeValue),
|
|
228
|
+
anchors: copy(anchors),
|
|
229
|
+
context: copy(contextValue),
|
|
230
|
+
expected,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const allFiles = filesAsBase64(rawFiles);
|
|
235
|
+
add('baseline synthetic signed bundle', good, testAnchors, context({ files: allFiles }), 'VERIFIED');
|
|
236
|
+
|
|
237
|
+
const asciiFiles = new Map(rawFiles);
|
|
238
|
+
asciiFiles.set('site/hello-world/1.1.0/documents/price + terms%.md', bytes('Public price and terms.\n'));
|
|
239
|
+
add('signed document path contains printable ASCII punctuation and space',
|
|
240
|
+
await resign(keyA, { files: await manifestFor(asciiFiles) }), testAnchors,
|
|
241
|
+
context({ files: filesAsBase64(asciiFiles) }), 'VERIFIED');
|
|
242
|
+
const uppercaseProject = 'octocat/Hello-World';
|
|
243
|
+
add('uppercase project and signed unsigned-through floor',
|
|
244
|
+
await resign(keyA, { project: uppercaseProject }), {
|
|
245
|
+
...copy(testAnchors),
|
|
246
|
+
keys: [await anchor(keyA, { unsignedThrough: { [uppercaseProject]: '1.1.0' } })],
|
|
247
|
+
}, context({ expect: { ...context().expect, project: uppercaseProject }, files: allFiles }), 'VERIFIED');
|
|
248
|
+
|
|
249
|
+
for (const [name, path] of [
|
|
250
|
+
['flipped document digest', 'site/hello-world/1.1.0/documents/CONTRACT.md'],
|
|
251
|
+
['flipped release.json digest', 'site/hello-world/1.1.0/release.json'],
|
|
252
|
+
['flipped public source archive digest', 'public-source.tar'],
|
|
253
|
+
]) {
|
|
254
|
+
const mutated = new Map(rawFiles);
|
|
255
|
+
mutated.set(path, changed(mutated.get(path)));
|
|
256
|
+
add(name, good, testAnchors, context({ files: filesAsBase64(mutated) }), 'DIGEST_MISMATCH');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
add('changed payload with original signature', editEnvelope(good, value => {
|
|
260
|
+
value.payload = b64(changed(decoded(value.payload), 40));
|
|
261
|
+
}), testAnchors, context(), 'SIGNATURE_INVALID');
|
|
262
|
+
add('changed signature bit', editEnvelope(good, value => {
|
|
263
|
+
value.signatures[0].sig = b64(changed(decoded(value.signatures[0].sig), 5));
|
|
264
|
+
}), testAnchors, context(), 'SIGNATURE_INVALID');
|
|
265
|
+
add('changed statement keyId without resigning', editEnvelope(good, value => {
|
|
266
|
+
const statement = JSON.parse(new TextDecoder().decode(decoded(value.payload)));
|
|
267
|
+
statement.keyId = keyA2.keyId;
|
|
268
|
+
value.payload = b64(canonicalBytes(statement));
|
|
269
|
+
}), testAnchors, context(), 'SIGNATURE_INVALID');
|
|
270
|
+
add('changed envelope keyId to an unenrolled id', editEnvelope(good, value => {
|
|
271
|
+
const keyId = value.signatures[0].keyid;
|
|
272
|
+
value.signatures[0].keyid = keyId.slice(0, -1) + (keyId.endsWith('0') ? '1' : '0');
|
|
273
|
+
}), testAnchors, context(), 'KEY_UNKNOWN');
|
|
274
|
+
add('validly signed by an unenrolled key', await resign(unknownKey), testAnchors, context(), 'KEY_UNKNOWN');
|
|
275
|
+
add('re-signed by another enrolled publisher', await resign(keyB), otherPublisherAnchors, context(), 'PUBLISHER_MISMATCH');
|
|
276
|
+
|
|
277
|
+
const canonicalStatement = canonical(baseStatement);
|
|
278
|
+
const reordered = '{' + Object.keys(baseStatement).sort().reverse()
|
|
279
|
+
.map(key => `${JSON.stringify(key)}:${canonical(baseStatement[key])}`).join(',') + '}';
|
|
280
|
+
const escaped = canonicalStatement.replace('"octocat/hello-world"', '"octocat/\\u0068ello-world"');
|
|
281
|
+
const pretty = JSON.stringify(baseStatement, null, 2);
|
|
282
|
+
const duplicate = canonicalStatement.replace('{', '{"version":"9.9.9",');
|
|
283
|
+
for (const [name, payload] of [
|
|
284
|
+
['reordered statement keys', encoder.encode(reordered)],
|
|
285
|
+
['escaped ASCII string', encoder.encode(escaped)],
|
|
286
|
+
['pretty-printed statement', encoder.encode(pretty)],
|
|
287
|
+
['duplicate statement key', encoder.encode(duplicate)],
|
|
288
|
+
]) {
|
|
289
|
+
add(name, await envelope(keyA, payload), testAnchors, context(), 'STATEMENT_NOT_CANONICAL');
|
|
290
|
+
}
|
|
291
|
+
add('NFD Unicode project name', await resign(keyA, { project: 'octocat/he\u0301llo-world' }), testAnchors, context(), 'STATEMENT_INVALID');
|
|
292
|
+
add('invalid statement schema version', await resign(keyA, { schemaVersion: 2 }), testAnchors, context(), 'STATEMENT_INVALID');
|
|
293
|
+
|
|
294
|
+
const olderFiles = await manifestFor(filesForVersion('1.0.0'));
|
|
295
|
+
add('older version after a newer admitted version', await resign(keyA, {
|
|
296
|
+
version: '1.0.0',
|
|
297
|
+
tag: 'v1.0.0',
|
|
298
|
+
files: olderFiles,
|
|
299
|
+
}), testAnchors, { environment: 'test', now, highest: { version: '1.1.0', statementSha256 } }, 'VERSION_ROLLBACK');
|
|
300
|
+
add('same version with a different statement digest', await resign(keyA, { issuedAt: '2026-10-12T23:59:59Z' }),
|
|
301
|
+
testAnchors, { environment: 'test', now, highest: { version: '1.1.0', statementSha256 } }, 'VERSION_REUSED');
|
|
302
|
+
add('identical statement replay is accepted', good, testAnchors,
|
|
303
|
+
{ environment: 'test', now, highest: { version: '1.1.0', statementSha256 } }, 'VERIFIED');
|
|
304
|
+
|
|
305
|
+
add('test key presented to a production verifier', good, productionAnchors,
|
|
306
|
+
{ environment: 'production', now }, 'KEY_TEST_REFUSED');
|
|
307
|
+
add('production anchors list a test key', null, {
|
|
308
|
+
...copy(productionAnchors),
|
|
309
|
+
keys: [await anchor(keyA, { unsignedThrough: signedFloor })],
|
|
310
|
+
}, { environment: 'production' }, 'ANCHORS_INVALID');
|
|
311
|
+
add('enrolment proof signed by a different key', null, withAnchor(testAnchors, keyA.keyId, {
|
|
312
|
+
enrolment: JSON.parse(new TextDecoder().decode(await enrolment(keyA, { unsignedThrough: signedFloor, signer: keyB }))),
|
|
313
|
+
}), { environment: 'test' }, 'ANCHORS_INVALID');
|
|
314
|
+
add('anchor environment differs from verifier', null, {
|
|
315
|
+
...copy(testAnchors),
|
|
316
|
+
environment: 'production',
|
|
317
|
+
}, { environment: 'test' }, 'ANCHORS_INVALID');
|
|
318
|
+
add('unsafe GitHub owner in a validly signed anchor claim', null, {
|
|
319
|
+
...copy(testAnchors),
|
|
320
|
+
keys: [...copy(testAnchors.keys), await anchor(unsafeAnchorKey)],
|
|
321
|
+
}, { environment: 'test' }, 'ANCHORS_INVALID');
|
|
322
|
+
add('legacy floor overlaps an enrolled publisher', null, {
|
|
323
|
+
...copy(testAnchors),
|
|
324
|
+
legacyUnsigned: { 'octocat/hello-world': '9.9.9' },
|
|
325
|
+
}, { environment: 'test' }, 'ANCHORS_INVALID');
|
|
326
|
+
add('anchor serial is equal to the last accepted serial', null, testAnchors,
|
|
327
|
+
{ environment: 'test', minimumSerial: 1 }, 'ANCHORS_STALE');
|
|
328
|
+
|
|
329
|
+
add('compromised key without a reviewed keep digest', good, withAnchor(testAnchors, keyA.keyId, {
|
|
330
|
+
status: 'compromised', compromisedAt: issuedAt, keepStatements: [],
|
|
331
|
+
}), context(), 'KEY_REVOKED');
|
|
332
|
+
add('compromised key with a reviewed keep digest', good, withAnchor(testAnchors, keyA.keyId, {
|
|
333
|
+
status: 'compromised', compromisedAt: issuedAt, keepStatements: [statementSha256],
|
|
334
|
+
}), context(), 'VERIFIED');
|
|
335
|
+
add('retired key verifies statement from before retirement', good, withAnchor(testAnchors, keyA.keyId, {
|
|
336
|
+
status: 'retired', retiredAt: '2026-10-13T00:00:01Z',
|
|
337
|
+
}), context(), 'VERIFIED');
|
|
338
|
+
add('retired key refuses statement at retirement time', good, withAnchor(testAnchors, keyA.keyId, {
|
|
339
|
+
status: 'retired', retiredAt: issuedAt,
|
|
340
|
+
}), context(), 'KEY_RETIRED');
|
|
341
|
+
const successorSourceFiles = filesForVersion('1.2.0');
|
|
342
|
+
const successorFiles = await manifestFor(successorSourceFiles);
|
|
343
|
+
add('successor key signs the next release', await resign(keyA2, {
|
|
344
|
+
version: '1.2.0',
|
|
345
|
+
tag: 'v1.2.0',
|
|
346
|
+
files: successorFiles,
|
|
347
|
+
}), withAnchor(successorAnchors, keyA.keyId, { status: 'retired', retiredAt: issuedAt }), context({
|
|
348
|
+
expect: { ...context().expect, tag: 'v1.2.0' },
|
|
349
|
+
files: filesAsBase64(successorSourceFiles),
|
|
350
|
+
}), 'VERIFIED');
|
|
351
|
+
add('statement issued before key validity starts', await resign(keyA, { issuedAt: '2026-09-30T23:59:59Z' }), testAnchors, context(), 'KEY_OUTSIDE_VALIDITY');
|
|
352
|
+
add('statement issued one year in the future', await resign(keyA, { issuedAt: '2027-10-13T00:00:00Z' }), testAnchors, context(), 'ISSUED_IN_FUTURE');
|
|
353
|
+
|
|
354
|
+
add('statement tag differs from expected release', good, testAnchors,
|
|
355
|
+
context({ expect: { ...context().expect, tag: 'v1.1.1' } }), 'SUBJECT_MISMATCH');
|
|
356
|
+
const missingFiles = { ...allFiles };
|
|
357
|
+
delete missingFiles['site/hello-world/1.1.0/documents/CONTRACT.md'];
|
|
358
|
+
add('declared document missing from bundle', good, testAnchors, context({ files: missingFiles }), 'FILE_MISSING');
|
|
359
|
+
add('bundle contains an undeclared document', good, testAnchors, context({ files: {
|
|
360
|
+
...allFiles,
|
|
361
|
+
'site/hello-world/1.1.0/documents/EXTRA.md': b64(bytes('extra\n')),
|
|
362
|
+
} }), 'FILE_UNDECLARED');
|
|
363
|
+
add('bundle contains an unsafe path', good, testAnchors, context({ files: {
|
|
364
|
+
...allFiles,
|
|
365
|
+
'site/hello-world/1.1.0/documents/../escape.md': b64(bytes('unsafe\n')),
|
|
366
|
+
} }), 'PATH_UNSAFE');
|
|
367
|
+
|
|
368
|
+
add('malformed DSSE envelope', bytes('not an envelope'), testAnchors, context(), 'ENVELOPE_INVALID');
|
|
369
|
+
add('envelope exceeds the 64 KiB limit', new Uint8Array(65537).fill(0x20), testAnchors, context(), 'INPUT_TOO_LARGE');
|
|
370
|
+
add('unsigned release inside the enrolled publisher floor', false, testAnchors,
|
|
371
|
+
unsignedContext('octocat/hello-world', '1.1.0'), 'UNSIGNED_LEGACY');
|
|
372
|
+
add('stripped signature above the enrolled publisher floor', false, testAnchors,
|
|
373
|
+
unsignedContext('octocat/hello-world', '1.2.0'), 'SIGNATURE_MISSING');
|
|
374
|
+
add('unsigned release inside the legacy floor for an unenrolled publisher', false, testAnchors,
|
|
375
|
+
unsignedContext('never-enrolled/app', '0.1.0'), 'UNSIGNED_LEGACY');
|
|
376
|
+
add('unsigned release outside every legacy floor', false, testAnchors,
|
|
377
|
+
unsignedContext('new-community/app', '1.0.0'), 'SIGNATURE_MISSING');
|
|
378
|
+
|
|
379
|
+
const output = new URL('./release-signature-vectors.json', import.meta.url);
|
|
380
|
+
writeFileSync(output, JSON.stringify(vectors, null, 2) + '\n');
|
|
381
|
+
console.log(`Wrote ${vectors.length} public release-signature vectors.`);
|