mirror of
https://github.com/LamGC/quickly-conf-sshd.git
synced 2026-10-13 07:43:57 +00:00
feat: pin client GPG trust and publish signed Git/Gist key releases
This commit is contained in:
@@ -12,7 +12,7 @@ export async function startBrowserFixture() {
|
||||
readFile(new URL("./fixtures/keys.pub", import.meta.url), "utf8"),
|
||||
]);
|
||||
const [type, encoded] = fixtureKey.trim().split(/\s+/);
|
||||
const comment = 'CI test <img src=x onerror="window.__injected=true"> & "quoted" 中文 ' + "long-comment-".repeat(90);
|
||||
const comment = 'HOMEPAGE-MUST-NOT-LEAK-COMMENT CI test <img src=x onerror="window.__injected=true"> & "quoted" 中文 ' + "long-comment-".repeat(90);
|
||||
const text = `${type} ${encoded} ${comment}`;
|
||||
const key = {
|
||||
type,
|
||||
@@ -32,13 +32,15 @@ export async function startBrowserFixture() {
|
||||
...config,
|
||||
commands: commandsFor(config),
|
||||
keys: [key],
|
||||
signedMetadata: null,
|
||||
};
|
||||
const snapshots = {
|
||||
repository: { ...base, source: { type: "repository" } },
|
||||
repository: { ...base, publicCertificate: "HOMEPAGE-MUST-NOT-LEAK-CERTIFICATE", source: { type: "repository" } },
|
||||
gist: { ...base, keyRevision: "d".repeat(64), signedMetadata: {listId: "fixture-signing-list", sequence: 42, documentSha256: "d".repeat(64)}, keysText: text, signedDocument: text, signature: "-----BEGIN PGP SIGNATURE-----\nComment: HOMEPAGE-MUST-NOT-LEAK-SIGNATURE\n\nAQID\n-----END PGP SIGNATURE-----\n", signedBundle: text, source: {type: "gist", id: "f".repeat(32), gistRevision: "e".repeat(40), url: "https://gist.github.com/example/" + "f".repeat(32) + "/" + "e".repeat(40), extraPrivateComment: comment} },
|
||||
fallback: { ...base, source: { type: "fallback", url: "https://keys.example.test/team.keys?comment=<script>window.__injected=true</script>" } },
|
||||
unavailable: { ...base, keys: [], keyCount: 0, sha256: null, source: { type: "unavailable" } },
|
||||
pending: { ...base, keys: [], keyCount: 0, sha256: null, source: { type: "pending" } },
|
||||
"explicit-empty": { ...base, allowEmpty: true, keys: [], keyCount: 0, sha256: createHash("sha256").update("").digest("hex"), source: { type: "explicit-empty" } },
|
||||
"explicit-empty": { ...base, allowEmpty: true, signedMetadata: {listId: "fixture-signing-list", sequence: 43, revoke: true}, keys: [], keyCount: 0, sha256: createHash("sha256").update("").digest("hex"), source: { type: "explicit-empty" } },
|
||||
};
|
||||
const pages = new Map(Object.entries(snapshots).map(([name, snapshot]) => ["/" + name, renderPage(template, snapshot)]));
|
||||
const requests = [];
|
||||
|
||||
+52
-41
@@ -81,10 +81,15 @@ test("real browser homepage integration", { skip: process.env.BROWSER_TESTS !==
|
||||
await page.goto(fixture.origin + "/repository");
|
||||
await page.waitForSelector("html.enhanced");
|
||||
await noPageOverflow(page, width);
|
||||
assert.equal(await page.locator("#key-0").textContent(), fixture.snapshots.repository.keys[0].text);
|
||||
assert.equal(await page.locator(".key-card img").count(), 0, "key comments must remain text");
|
||||
const install = fixture.snapshots.repository.commands.find((command) => command.id === "install").command;
|
||||
assert.equal(await page.locator("#command-install").textContent(), install);
|
||||
assert.equal(await page.locator(".key-card, [data-copy-target^='key-'], #copy-all-keys, #copy-all-commands").count(), 0);
|
||||
const contents = await page.content();
|
||||
assert.ok(!contents.includes(fixture.snapshots.repository.keys[0].text));
|
||||
assert.ok(!contents.includes(fixture.snapshots.repository.keys[0].fingerprint));
|
||||
assert.doesNotMatch(contents, /HOMEPAGE-MUST-NOT-LEAK|ssh-ed25519|SHA256:/);
|
||||
assert.equal(await page.evaluate(() => window.__injected), undefined);
|
||||
assert.equal(await page.locator(".key-card pre").evaluate((pre) => pre.scrollWidth > pre.clientWidth), true);
|
||||
assert.equal(await page.locator("#command-install").locator("xpath=..").evaluate((pre) => pre.scrollWidth > pre.clientWidth), true);
|
||||
assert.equal(await page.locator("#theme-picker").inputValue(), "system");
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), null);
|
||||
const lightBackground = await page.locator("body").evaluate((body) => getComputedStyle(body).backgroundColor);
|
||||
@@ -108,103 +113,108 @@ test("real browser homepage integration", { skip: process.env.BROWSER_TESTS !==
|
||||
assert.equal(await page.locator("body").evaluate((body) => getComputedStyle(body).backgroundColor), lightBackground);
|
||||
assert.equal(await page.evaluate(() => localStorage.getItem("quickly-conf-sshd-theme")), "system");
|
||||
|
||||
const keyButton = page.locator('[data-copy-target="key-0"]');
|
||||
const commandButton = page.locator('[data-copy-target="command-install"]');
|
||||
if (width < 600) {
|
||||
assert.equal(await keyButton.evaluate((button) => getComputedStyle(button).opacity), "1", "touch copy buttons stay visible");
|
||||
assert.equal(await commandButton.evaluate((button) => getComputedStyle(button).opacity), "1", "touch copy buttons stay visible");
|
||||
} else {
|
||||
await page.locator("#page-title").hover();
|
||||
await page.waitForFunction(() => getComputedStyle(document.querySelector('[data-copy-target="key-0"]')).opacity === "0");
|
||||
await page.waitForFunction(() => getComputedStyle(document.querySelector('[data-copy-target="command-install"]')).opacity === "0");
|
||||
}
|
||||
await keyButton.focus();
|
||||
await page.waitForFunction(() => getComputedStyle(document.querySelector('[data-copy-target="key-0"]')).opacity === "1");
|
||||
await commandButton.focus();
|
||||
await page.waitForFunction(() => getComputedStyle(document.querySelector('[data-copy-target="command-install"]')).opacity === "1");
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForFunction(() => document.querySelector('[data-copy-target="key-0"]').dataset.state === "success");
|
||||
assert.equal(await page.evaluate(() => window.__clipboardTest.values[0]), fixture.snapshots.repository.keys[0].text);
|
||||
assert.match(await keyButton.getAttribute("aria-label"), /已复制/);
|
||||
await page.waitForFunction(() => document.querySelector('[data-copy-target="command-install"]').dataset.state === "success");
|
||||
assert.equal(await page.evaluate(() => window.__clipboardTest.values[0]), install);
|
||||
assert.match(await commandButton.getAttribute("aria-label"), /已复制/);
|
||||
await page.waitForFunction(() => document.getElementById("copy-status").textContent.includes("已复制"));
|
||||
|
||||
await page.locator("#copy-all-keys").click();
|
||||
const allKeys = fixture.snapshots.repository.keys.map((key) => key.text).join("\n") + "\n";
|
||||
assert.equal(await page.evaluate(() => window.__clipboardTest.values[1]), allKeys);
|
||||
assert.equal(await page.locator("#copy-all-commands").count(), 0);
|
||||
await page.locator("details summary").click();
|
||||
await page.locator('[data-copy-target="command-uninstall"]').focus();
|
||||
await page.keyboard.press("Enter");
|
||||
const uninstall = fixture.snapshots.repository.commands.find((command) => command.id === "uninstall").command;
|
||||
assert.equal(await page.evaluate(() => window.__clipboardTest.values.at(-1)), uninstall);
|
||||
for (const command of fixture.snapshots.repository.commands.filter((entry) => entry.id !== "install")) {
|
||||
const button = page.locator(`[data-copy-target="command-${command.id}"]`);
|
||||
await button.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForFunction((id) => document.querySelector(`[data-copy-target="command-${id}"]`).dataset.state === "success", command.id);
|
||||
assert.equal(await page.evaluate(() => window.__clipboardTest.values.at(-1)), command.command);
|
||||
}
|
||||
await noPageOverflow(page, width);
|
||||
|
||||
await page.evaluate(() => { window.__clipboardTest.reject = true; });
|
||||
await keyButton.focus();
|
||||
await commandButton.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForSelector("#copy-fallback[open]");
|
||||
assert.equal(await page.locator("#fallback-text").inputValue(), fixture.snapshots.repository.keys[0].text);
|
||||
assert.equal(await page.locator("#fallback-text").inputValue(), install);
|
||||
assert.deepEqual(await page.locator("#fallback-text").evaluate((textarea) => ({
|
||||
start: textarea.selectionStart, end: textarea.selectionEnd, focused: document.activeElement === textarea,
|
||||
})), { start: 0, end: fixture.snapshots.repository.keys[0].text.length, focused: true });
|
||||
assert.equal(await keyButton.getAttribute("data-state"), null, "failed copy must not show success");
|
||||
})), { start: 0, end: install.length, focused: true });
|
||||
assert.equal(await commandButton.getAttribute("data-state"), null, "failed copy must not show success");
|
||||
await capture(page, `homepage-${width}-manual-copy`);
|
||||
await page.keyboard.press("Escape");
|
||||
assert.equal(await page.locator("#copy-fallback").evaluate((dialog) => dialog.open), false);
|
||||
assert.equal(await keyButton.evaluate((button) => document.activeElement === button), true);
|
||||
assert.equal(await commandButton.evaluate((button) => document.activeElement === button), true);
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForSelector("#copy-fallback[open]");
|
||||
await page.locator("#close-copy-fallback").click();
|
||||
assert.equal(await keyButton.evaluate((button) => document.activeElement === button), true);
|
||||
assert.equal(await commandButton.evaluate((button) => document.activeElement === button), true);
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(externalRequests, []);
|
||||
} finally { await context.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
await t.test("all source states render the same snapshot into metadata, keys and copy data", async () => {
|
||||
await t.test("source states expose metadata only and never fetch key or certificate APIs", async () => {
|
||||
const { context, externalRequests } = await guardedContext(browser, fixture, { viewport: { width: 390, height: 844 } });
|
||||
try {
|
||||
await installClipboardMock(context);
|
||||
const page = await context.newPage();
|
||||
for (const [state, sourceText] of [["repository", "可用"], ["fallback", "备用源可用"], ["unavailable", "暂不可用"], ["pending", "暂不可用"], ["explicit-empty", "明确撤销"]]) {
|
||||
for (const [state, sourceText] of [["repository", "可用"], ["gist", "可用"], ["fallback", "备用源可用"], ["unavailable", "暂不可用"], ["pending", "待获取"], ["explicit-empty", "明确撤销"]]) {
|
||||
const response = await page.goto(fixture.origin + "/" + state);
|
||||
assert.equal(response.status(), state === "unavailable" ? 503 : 200);
|
||||
await page.waitForSelector("html.enhanced");
|
||||
assert.equal(await page.locator(".source-status").textContent(), sourceText);
|
||||
const snapshot = fixture.snapshots[state];
|
||||
assert.equal(await page.locator(".key-count").textContent(), String(snapshot.keys.length));
|
||||
assert.equal(await page.locator("#copy-all-keys").isDisabled(), snapshot.keys.length === 0);
|
||||
assert.equal(await page.locator(".key-count").textContent(), String(snapshot.keyCount));
|
||||
assert.equal(await page.locator(".key-card, #copy-all-keys, [data-copy-target^='key-']").count(), 0);
|
||||
const embedded = await page.locator("#release-data").evaluate((element) => JSON.parse(element.textContent));
|
||||
assert.equal(embedded.source.type, state);
|
||||
assert.deepEqual(embedded.keys, snapshot.keys);
|
||||
assert.equal(embedded.keyRevision, snapshot.sha256);
|
||||
assert.equal(embedded.keys, undefined);
|
||||
assert.equal(embedded.keyRevision, snapshot.keyRevision || snapshot.sha256);
|
||||
assert.equal(embedded.signaturePresent, Boolean(snapshot.signature && snapshot.signedDocument === snapshot.keysText));
|
||||
assert.equal(embedded.certificatePresent, Boolean(snapshot.publicCertificate));
|
||||
assert.equal(await page.locator(".signature-status").textContent(), snapshot.signature ? "签名包已提供(待客户端验证)" : "未提供签名包");
|
||||
assert.equal(await page.locator(".certificate-status").textContent(), snapshot.publicCertificate ? "公开证书已提供(安装时核验)" : "未提供公开证书");
|
||||
assert.equal(await page.locator('a[href="/public-key.gpg"]').count(), snapshot.publicCertificate ? 1 : 0);
|
||||
assert.equal(await page.locator(".release-revision").first().textContent(), snapshot.revision.slice(0, 12));
|
||||
assert.equal(await page.locator(".release-revision").nth(1).textContent(), snapshot.sha256?.slice(0, 12) || "—");
|
||||
assert.equal(await page.locator(".release-revision").nth(1).textContent(), (snapshot.keyRevision || snapshot.sha256)?.slice(0, 12) || "—");
|
||||
const content = await page.content();
|
||||
assert.doesNotMatch(content, /HOMEPAGE-MUST-NOT-LEAK|ssh-ed25519|SHA256:|\"keys\"\s*:/);
|
||||
if (state === "fallback") {
|
||||
assert.equal(await page.locator(".source-label").textContent(), "备用源:" + snapshot.source.url);
|
||||
assert.equal(await page.locator(".source-label script").count(), 0);
|
||||
await page.locator("#copy-all-keys").click();
|
||||
assert.equal(await page.evaluate(() => window.__clipboardTest.values[0]), snapshot.keys.map((key) => key.text).join("\n") + "\n");
|
||||
}
|
||||
if (state === "unavailable" || state === "pending") {
|
||||
assert.match(await page.locator(".keys-notice").textContent(), /未发布撤销指令.*保留上次成功密钥/);
|
||||
assert.equal(await page.locator(".key-card").count(), 0);
|
||||
}
|
||||
if (state === "gist") assert.equal(await page.locator(".source-label").textContent(), "Gist:" + snapshot.source.url);
|
||||
if (state === "unavailable" || state === "pending") assert.match(await page.locator(".keys-notice").textContent(), /未发布撤销指令.*保留上次成功密钥/);
|
||||
if (state === "explicit-empty") assert.match(await page.locator(".keys-notice").textContent(), /明确撤销全部受管理公钥/);
|
||||
assert.equal(await page.evaluate(() => window.__injected), undefined);
|
||||
await noPageOverflow(page, 390);
|
||||
await capture(page, "source-" + state);
|
||||
// Repository appearance is already captured in both viewports above: 12 screenshots in total.
|
||||
if (state !== "repository") await capture(page, "source-" + state);
|
||||
}
|
||||
assert.deepEqual(externalRequests, []);
|
||||
} finally { await context.close(); }
|
||||
});
|
||||
|
||||
await t.test("no-JavaScript page retains keys, commands, source states and system theme", async () => {
|
||||
await t.test("no-JavaScript page retains metadata, commands, source states and system theme", async () => {
|
||||
const { context, externalRequests } = await guardedContext(browser, fixture, {
|
||||
viewport: { width: 390, height: 844 }, javaScriptEnabled: false, colorScheme: "dark",
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
await page.goto(fixture.origin + "/repository");
|
||||
assert.equal(await page.locator("#key-0").isVisible(), true);
|
||||
assert.equal(await page.locator("#key-status").isVisible(), true);
|
||||
assert.equal(await page.locator(".key-card, [data-copy-target^='key-'], #copy-all-keys").count(), 0);
|
||||
assert.equal(await page.locator("#command-install").isVisible(), true);
|
||||
assert.equal(await page.locator("#copy-all-keys").isVisible(), false);
|
||||
assert.equal(await page.locator('[data-copy-target="command-install"]').isVisible(), false);
|
||||
assert.equal(await page.locator("#theme-picker").isVisible(), false);
|
||||
assert.match(await page.locator("noscript").textContent(), /手动选择复制/);
|
||||
await page.locator("details summary").click();
|
||||
@@ -217,6 +227,7 @@ test("real browser homepage integration", { skip: process.env.BROWSER_TESTS !==
|
||||
assert.deepEqual(externalRequests, []);
|
||||
} finally { await context.close(); }
|
||||
});
|
||||
assert.ok(fixture.requests.every((path) => !["/ssh.keys", "/ssh.keys.sig", "/public-key.gpg", "/key-release", "/release.json"].includes(path)), "homepage must not fetch public-key or certificate material");
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await fixture.close();
|
||||
|
||||
+15
-11
@@ -4,9 +4,10 @@ import {readFile,mkdtemp,cp,writeFile,rm,mkdir} from 'node:fs/promises';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
import {renderPage} from '../cf-worker/src/view.js';
|
||||
import {validateConfig,parseKeys,makeKeysText,commandsFor,renderKeys,build,root,digest} from '../scripts/build.mjs';
|
||||
import {validateConfig,parseKeys,makeKeysText,commandsFor,build,root,digest} from '../scripts/build.mjs';
|
||||
const config = {baseUrl:'https://ssh.example.com',repositoryUrl:'https://github.com/example/keys',defaultCron:'*/15 * * * *',allowEmpty:false};
|
||||
const fixture = await readFile(new URL('./fixtures/keys.pub',import.meta.url),'utf8');
|
||||
const canonicalFixture=fixture.trim().split(/ +/).slice(0,2).join(' ')+'\n';
|
||||
|
||||
test('deployment configuration rejects shell injection and ambiguous origins',()=>{
|
||||
assert.deepEqual(validateConfig(config),config);
|
||||
@@ -30,15 +31,18 @@ test('empty revocation must be explicit; protocol is deterministic',async()=>{
|
||||
const empty = makeKeysText([], 'a'.repeat(40),true);
|
||||
assert.match(empty.text,/# key-count 0\n# allow-empty yes\n/);assert.equal(empty.sha256,digest(''));
|
||||
const keys=await parseKeys(fixture);const bundle=makeKeysText(keys,'b'.repeat(40),false);
|
||||
assert.ok(bundle.text.endsWith(fixture));assert.equal(bundle.sha256,digest(fixture));
|
||||
assert.ok(bundle.text.endsWith(canonicalFixture));assert.equal(bundle.sha256,digest(canonicalFixture));
|
||||
assert.throws(()=>makeKeysText(keys,'main',false),/commit SHA/);
|
||||
});
|
||||
test('HTML escapes comments; commands use explicit script endpoint and separate actions',async()=>{
|
||||
const keys=await parseKeys(fixture.trim()+' <script>alert(1)</script>');
|
||||
const html=renderKeys(keys);assert.ok(html.includes('<script>'));assert.ok(!html.includes('<script>'));
|
||||
const commands=commandsFor(config);assert.ok(commands.find(x=>x.id==='install').command.endsWith(' -c'));
|
||||
assert.ok(commands.find(x=>x.id==='root-on').advanced);assert.ok(commandsFor({...config,defaultCron:'0 */6 * * *'})[0].description.includes('0 */6 * * *'));assert.ok(commands.find(x=>x.id==='uninstall').advanced);
|
||||
assert.ok(commands.every(x=>!x.command.includes('github.io')&&!x.command.includes('.keys')));
|
||||
test('homepage excludes keys and commands use explicit install and fixed local sync',async()=>{
|
||||
const commands=commandsFor(config);
|
||||
assert.ok(commands.find(x=>x.id==='install').command.includes('--install'));
|
||||
assert.ok(commands.find(x=>x.id==='root-on').advanced);
|
||||
assert.ok(commandsFor({...config,defaultCron:'0 */6 * * *'})[0].description.includes('0 */6 * * *'));
|
||||
assert.ok(commands.find(x=>x.id==='uninstall').advanced);
|
||||
assert.ok(commands.every(x=>!x.command.includes('github.io')));
|
||||
assert.ok(!commands.some(x=>/ -u$/.test(x.command)));
|
||||
assert.ok(commands.find(x=>x.id==='once').command.includes('$HOME/.conf-sshd/conf-sshd.sh'));
|
||||
});
|
||||
test('build creates consistent assets/release; production empty is blocked',async()=>{
|
||||
const directory=await mkdtemp(join(tmpdir(),'conf-sshd-build-'));
|
||||
@@ -73,12 +77,12 @@ test('fallback configuration allows an empty primary only with explicit bounded
|
||||
} finally {await rm(directory,{recursive:true,force:true});}
|
||||
});
|
||||
|
||||
test('template rendering preserves literal dollar replacements and token-like key comments',async()=>{
|
||||
test('template rendering excludes even adversarial key comments from HTML and JSON',async()=>{
|
||||
const tricky=fixture.trim()+" $& $` $' $$ @@REVISION@@ @@KEYS_HTML@@";
|
||||
const keys=await parseKeys(tricky);
|
||||
const template='<div>@@KEYS_HTML@@</div><script type="application/json">@@RELEASE_JSON@@</script>';
|
||||
const template='<div>@@SOURCE_LABEL@@</div><script type="application/json">@@RELEASE_JSON@@</script>';
|
||||
const html=renderPage(template,{revision:'e'.repeat(40),publishedAt:'2026-10-12',keys,commands:[],repositoryUrl:config.repositoryUrl,sha256:'f'.repeat(64)});
|
||||
const json=JSON.parse(html.match(/<script type="application\/json">([\s\S]*?)<\/script>/)[1]);
|
||||
assert.equal(json.keys[0].text,tricky);assert.ok(html.includes('@@KEYS_HTML@@'));assert.ok(html.includes('$&'));
|
||||
assert.equal(json.keys,undefined);assert.ok(!html.includes(keys[0].text));assert.ok(!html.includes(keys[0].fingerprint));
|
||||
assert.throws(()=>renderPage('@@UNKNOWN@@',{revision:'e'.repeat(40),keys:[]}),/Unresolved/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
import {readFile, mkdtemp, mkdir, rm} from 'node:fs/promises';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
import {verifyCommitSignature, enabledFlag, parseFingerprints, verifySignatureStatus} from '../scripts/verify-commit-signature.mjs';
|
||||
import {notifyDeployment, deploymentPayload} from '../scripts/notify-deployment.mjs';
|
||||
import {openPgpFixture} from './helpers/openpgp-fixture.mjs';
|
||||
|
||||
const primary = 'A'.repeat(40), subkey = 'B'.repeat(40);
|
||||
const validStatus = (fingerprint = primary, signer = fingerprint) => `[GNUPG:] GOODSIG 1234567890ABCDEF Test fixture\n[GNUPG:] VALIDSIG ${signer} 2026-01-01 1767225600 0 4 0 22 8 00 ${fingerprint}\n`;
|
||||
|
||||
test('GPG opt-in is strict and only accepts complete primary fingerprint pins', () => {
|
||||
for (const value of [undefined,'','false']) assert.equal(enabledFlag(value),false);
|
||||
assert.equal(enabledFlag('true'),true);
|
||||
for (const value of ['TRUE','yes','1',' false ','False',null]) assert.throws(() => enabledFlag(value));
|
||||
assert.deepEqual(parseFingerprints(primary.toLowerCase()+',\n'+subkey),new Set([primary,subkey]));
|
||||
for (const value of ['',undefined,'1234ABCD','A'.repeat(64),primary+' invalid']) assert.throws(() => parseFingerprints(value));
|
||||
});
|
||||
|
||||
test('machine status requires a pinned primary and rejects bad, expired and revoked signatures', () => {
|
||||
const pins = new Set([primary]);
|
||||
assert.deepEqual(verifySignatureStatus(validStatus(primary,subkey),pins),{signerFingerprint:subkey,primaryFingerprint:primary});
|
||||
assert.throws(() => verifySignatureStatus(validStatus(subkey),pins),/not pinned/);
|
||||
assert.throws(() => verifySignatureStatus('[GNUPG:] GOODSIG 123 Test fixture\n',pins),/exactly one/);
|
||||
assert.throws(() => verifySignatureStatus(validStatus()+validStatus(),pins),/exactly one/);
|
||||
for (const code of ['BADSIG','ERRSIG','NO_PUBKEY','EXPSIG','EXPKEYSIG','REVKEYSIG','KEYEXPIRED','SIGEXPIRED','KEYREVOKED','FAILURE','ERROR','NODATA','BADARMOR']) {
|
||||
assert.throws(() => verifySignatureStatus(validStatus()+`[GNUPG:] ${code} rejected\n`,pins),/invalid, expired, revoked/);
|
||||
}
|
||||
assert.throws(() => verifySignatureStatus(validStatus().replace('1767225600 0 4','1767225600 1767225601 4'),pins,1767225602),/expired/);
|
||||
assert.throws(() => verifySignatureStatus(validStatus().replace('22 8 00','22 2 00'),pins),/SHA-256/);
|
||||
assert.throws(() => verifySignatureStatus(validStatus().replace(' 4 0 22',' 5 0 22'),pins),/v4/);
|
||||
assert.throws(() => verifySignatureStatus(validStatus().replace(` 00 ${primary}`,' 00'),pins),/primary fingerprint/);
|
||||
});
|
||||
|
||||
function command(bin,args,options = {}) {
|
||||
const result = spawnSync(bin,args,{encoding:'utf8',timeout:30_000,...options});
|
||||
assert.equal(result.error,undefined,`${bin} failed to execute`);
|
||||
assert.equal(result.status,0,`${bin} failed: ${result.stderr}`);
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
test('GPG gate verifies real ephemeral Git commits in an isolated keyring', {timeout:120_000}, async t => {
|
||||
const directory = await mkdtemp(join(tmpdir(),'conf-sshd-signature-test-'));
|
||||
const repo = join(directory,'repo');await mkdir(repo);
|
||||
const env = {...process.env,GIT_CONFIG_NOSYSTEM:'1',GIT_CONFIG_GLOBAL:'/dev/null'};
|
||||
const git = (args,options = {}) => command('git',args,{cwd:repo,env,...options});
|
||||
const commit = (name,fixture) => {
|
||||
const tree = git(['hash-object','-t','tree','-w','--stdin'],{input:''}).trim();
|
||||
const headers = `tree ${tree}\nauthor Security fixture <[email protected]> 1767225600 +0000\ncommitter Security fixture <[email protected]> 1767225600 +0000`;
|
||||
const body = `${headers}\n\n${name}\n`;
|
||||
const signed = fixture ? `${headers}\ngpgsig ${fixture.sign(body).trimEnd().replaceAll('\n','\n ')}\n\n${name}\n` : body;
|
||||
const sha = git(['hash-object','-t','commit','-w','--stdin'],{input:signed}).trim();
|
||||
git(['update-ref','HEAD',sha]);return sha;
|
||||
};
|
||||
const gateEnv = (sha,pin,key) => ({...env,GITHUB_SHA:sha,REQUIRE_GPG_SIGNATURE:'true',GPG_ALLOWED_PRIMARY_FINGERPRINTS:pin,GPG_PUBLIC_KEYS:key});
|
||||
try {
|
||||
git(['init','-q']);
|
||||
const good = openPgpFixture(), other = openPgpFixture(), sub = openPgpFixture({subkeys:2});
|
||||
let sha = commit('unsigned fixture');
|
||||
await t.test('disabled still pins HEAD; enabled unsigned/missing config/incorrect SHA fail',async () => {
|
||||
assert.equal((await verifyCommitSignature({env:{...env,GITHUB_SHA:sha},directory:repo})).required,false);
|
||||
await assert.rejects(verifyCommitSignature({env:{...env,GITHUB_SHA:'0'.repeat(40)},directory:repo}),/does not match/);
|
||||
await assert.rejects(verifyCommitSignature({env:{...env,GITHUB_SHA:sha,REQUIRE_GPG_SIGNATURE:'invalid'},directory:repo}),/exactly true/);
|
||||
await assert.rejects(verifyCommitSignature({env:{...env,GITHUB_SHA:sha,REQUIRE_GPG_SIGNATURE:'true'},directory:repo}),/FINGERPRINTS/);
|
||||
await assert.rejects(verifyCommitSignature({env:gateEnv(sha,good.primaryFingerprint,''),directory:repo}),/public keys/);
|
||||
await assert.rejects(verifyCommitSignature({env:gateEnv(sha,good.primaryFingerprint,good.publicKey),directory:repo}),/Git verification command failed/);
|
||||
});
|
||||
sha = commit('approved signature fixture',good);
|
||||
await t.test('approved primary passes; wrong pin, missing public signer and Git nonzero fail',async () => {
|
||||
assert.equal((await verifyCommitSignature({env:gateEnv(sha,good.primaryFingerprint,good.publicKey),directory:repo})).primaryFingerprint,good.primaryFingerprint);
|
||||
assert.equal((await verifyCommitSignature({env:gateEnv(sha,`${other.primaryFingerprint},${good.primaryFingerprint}`,other.publicKey+good.publicKey),directory:repo})).primaryFingerprint,good.primaryFingerprint);
|
||||
await assert.rejects(verifyCommitSignature({env:gateEnv(sha,other.primaryFingerprint,good.publicKey),directory:repo}),/not pinned/);
|
||||
await assert.rejects(verifyCommitSignature({env:gateEnv(sha,good.primaryFingerprint,other.publicKey),directory:repo}),/Git verification command failed/);
|
||||
await assert.rejects(verifyCommitSignature({env:gateEnv(sha,good.primaryFingerprint,good.publicKey),directory}),/Git verification command failed/);
|
||||
});
|
||||
sha = commit('tampered commit fixture',{sign:data=>good.sign(data.replace('tampered commit fixture','original commit fixture'))});
|
||||
await t.test('a valid signature on different commit bytes is rejected',async () => {
|
||||
await assert.rejects(verifyCommitSignature({env:gateEnv(sha,good.primaryFingerprint,good.publicKey),directory:repo}),/Git verification command failed/);
|
||||
});
|
||||
sha = commit('signing subkey fixture',sub);
|
||||
await t.test('signing subkey is accepted only through the pinned primary fingerprint',async () => {
|
||||
const result = await verifyCommitSignature({env:gateEnv(sha,sub.primaryFingerprint,sub.publicKey),directory:repo});
|
||||
assert.equal(result.primaryFingerprint,sub.primaryFingerprint);assert.equal(result.signerFingerprint,sub.signerFingerprint);assert.notEqual(result.signerFingerprint,sub.primaryFingerprint);
|
||||
await assert.rejects(verifyCommitSignature({env:gateEnv(sha,sub.signerFingerprint,sub.publicKey),directory:repo}),/not pinned/);
|
||||
const secondSha = commit('second signing subkey fixture',{sign:data=>sub.sign(data,1)});
|
||||
const second = await verifyCommitSignature({env:gateEnv(secondSha,sub.primaryFingerprint,sub.publicKey),directory:repo});
|
||||
assert.equal(second.primaryFingerprint,sub.primaryFingerprint);assert.equal(second.signerFingerprint,sub.signerFingerprints[1]);
|
||||
});
|
||||
const revoked = openPgpFixture({revoked:true});sha = commit('revoked fixture',revoked);
|
||||
await t.test('real primary revocation rejects a previously created signature',async () => {
|
||||
await assert.rejects(verifyCommitSignature({env:gateEnv(sha,revoked.primaryFingerprint,revoked.publicKey),directory:repo}),/Git verification command failed|revoked/);
|
||||
});
|
||||
const expired = openPgpFixture({expired:true});sha = commit('expired key fixture',expired);
|
||||
await t.test('expired signing key is rejected using current verifier time',async () => {
|
||||
await assert.rejects(verifyCommitSignature({env:gateEnv(sha,expired.primaryFingerprint,expired.publicKey),directory:repo}),/Git verification command failed|expired/);
|
||||
});
|
||||
sha = commit('forced verifier fixture',good);
|
||||
await t.test('Git configuration cannot substitute another signature format or verifier',async () => {
|
||||
git(['config','gpg.format','ssh']);git(['config','gpg.openpgp.program','/bin/true']);git(['config','gpg.program','/bin/true']);
|
||||
assert.equal((await verifyCommitSignature({env:gateEnv(sha,good.primaryFingerprint,good.publicKey),directory:repo})).primaryFingerprint,good.primaryFingerprint);
|
||||
});
|
||||
} finally {await rm(directory,{recursive:true,force:true});}
|
||||
});
|
||||
|
||||
const notificationEnv = {
|
||||
ENABLE_MAIL_NOTIFICATION:'true',CI:'true',GITHUB_ACTIONS:'true',
|
||||
MAIL_NOTIFICATION_WEBHOOK:'https://mailer.example.invalid/hook?token=DO_NOT_LOG',
|
||||
MAIL_NOTIFICATION_TO:'[email protected]',DEPLOYMENT_STATUS:'success',DEPLOYMENT_PHASE:'complete',
|
||||
GITHUB_SHA:'c'.repeat(40),GITHUB_REPOSITORY:'example/server-keys',GITHUB_RUN_ID:'123',
|
||||
};
|
||||
const deploymentConfig = {
|
||||
baseUrl:'https://ssh.example.invalid',keySource:{type:'gist'},
|
||||
signedRelease:{listId:'server-keys-production'},fallbackSources:['https://backup.example.invalid/server.keys'],
|
||||
};
|
||||
|
||||
test('notification stays network-free unless explicitly enabled in CI with complete configuration',async () => {
|
||||
let calls = 0;const fetchImpl = async () => {calls++;return {ok:true,status:200};};
|
||||
for (const ENABLE_MAIL_NOTIFICATION of [undefined,'','false']) {
|
||||
assert.deepEqual(await notifyDeployment({env:{ENABLE_MAIL_NOTIFICATION},fetchImpl}),{sent:false,reason:'disabled'});
|
||||
}
|
||||
for (const value of ['yes','TRUE','1']) await assert.rejects(notifyDeployment({env:{...notificationEnv,ENABLE_MAIL_NOTIFICATION:value},config:deploymentConfig,fetchImpl}),/exactly true/);
|
||||
await assert.rejects(notifyDeployment({env:{...notificationEnv,CI:'false'},config:deploymentConfig,fetchImpl}),/only in GitHub Actions/);
|
||||
await assert.rejects(notifyDeployment({env:{...notificationEnv,MAIL_NOTIFICATION_WEBHOOK:''},config:deploymentConfig,fetchImpl}),/required/);
|
||||
for (const endpoint of ['http://mailer.example.invalid','https://user:[email protected]','https://mailer.example.invalid/#secret']) {
|
||||
await assert.rejects(notifyDeployment({env:{...notificationEnv,MAIL_NOTIFICATION_WEBHOOK:endpoint},config:deploymentConfig,fetchImpl}),/HTTPS/);
|
||||
}
|
||||
assert.equal(calls,0);
|
||||
});
|
||||
|
||||
test('notification JSON identifies deployment, configured mode/list/fallbacks and uses a bounded nonredirecting POST',async () => {
|
||||
for (const [status,phase] of [['success','complete'],['failure','deployment'],['failure','remote-verification']]) {
|
||||
const result = await notifyDeployment({env:{...notificationEnv,DEPLOYMENT_STATUS:status,DEPLOYMENT_PHASE:phase},config:deploymentConfig,fetchImpl:async (url,options) => {
|
||||
assert.equal(url,notificationEnv.MAIL_NOTIFICATION_WEBHOOK);assert.equal(options.method,'POST');assert.equal(options.redirect,'error');
|
||||
assert.equal(options.headers['content-type'],'application/json');assert.ok(options.signal instanceof AbortSignal);
|
||||
const payload = JSON.parse(options.body);
|
||||
assert.equal(payload.status,status);assert.equal(payload.phase,phase);assert.equal(payload.revision,notificationEnv.GITHUB_SHA);
|
||||
assert.equal(payload.configuration.mode,'gist');assert.equal(payload.configuration.listId,'server-keys-production');
|
||||
assert.deepEqual(payload.configuration.fallbackSources,deploymentConfig.fallbackSources);assert.match(payload.configurationScope,/not measured server state/);
|
||||
assert.equal(payload.to,'[email protected]');assert.ok(!options.body.includes('DO_NOT_LOG'));
|
||||
return {ok:true,status:202};
|
||||
}});
|
||||
assert.deepEqual(result,{sent:true});
|
||||
}
|
||||
const defaults = deploymentPayload({baseUrl:'https://ssh.example.invalid'}, {...notificationEnv,MAIL_NOTIFICATION_TO:''});
|
||||
assert.equal(defaults.configuration.mode,'git');assert.equal(defaults.configuration.listId,null);assert.equal(defaults.to,null);
|
||||
assert.deepEqual(defaults.configuration.fallbackSources,[]);
|
||||
});
|
||||
|
||||
test('notification rejects ambiguous metadata and never exposes webhook tokens or retries failures',async () => {
|
||||
for (const patch of [{DEPLOYMENT_STATUS:'unknown'},{DEPLOYMENT_PHASE:'unknown'},{GITHUB_SHA:'main'},{GITHUB_REPOSITORY:'evil\nrepo'},{GITHUB_RUN_ID:'$((1))'},{MAIL_NOTIFICATION_TO:'[email protected]\nBcc:[email protected]'}]) {
|
||||
assert.throws(() => deploymentPayload(deploymentConfig,{...notificationEnv,...patch}));
|
||||
}
|
||||
let calls = 0;
|
||||
await assert.rejects(notifyDeployment({env:notificationEnv,config:deploymentConfig,fetchImpl:async () => {calls++;throw Error(notificationEnv.MAIL_NOTIFICATION_WEBHOOK);}}),error=>!error.message.includes('DO_NOT_LOG') && /request failed/.test(error.message));
|
||||
assert.equal(calls,1);
|
||||
await assert.rejects(notifyDeployment({env:notificationEnv,config:deploymentConfig,fetchImpl:async () => ({ok:false,status:503})}),/HTTP 503/);
|
||||
});
|
||||
|
||||
test('deployment workflow gates both entry points before repository execution and separates secret scopes',async () => {
|
||||
const workflow = await readFile(new URL('../.github/workflows/deploy.yml',import.meta.url),'utf8');
|
||||
const verify = workflow.split('\n verify:\n')[1].split('\n deploy:\n')[0];
|
||||
const deploy = workflow.split('\n deploy:\n')[1].split('\n notify:\n')[0];
|
||||
const notify = workflow.split('\n notify:\n')[1];
|
||||
assert.match(workflow,/workflow_dispatch:/);assert.match(verify,/refs\/heads\/main/);assert.match(verify,/verify-commit-signature\.mjs/);
|
||||
assert.ok(!verify.includes('${{ secrets.'));assert.ok(!verify.includes('environment: production'));assert.ok(!verify.includes('npm '));
|
||||
assert.match(deploy,/needs: verify/);assert.match(deploy,/environment: production/);
|
||||
assert.equal((workflow.match(/ref: \$\{\{ github.sha \}\}/g)||[]).length,3);
|
||||
assert.match(notify,/needs\.deploy\.outputs\.attempted == 'true'/);assert.ok(!notify.includes('CLOUDFLARE_'));
|
||||
assert.match(notify,/DEPLOYMENT_PHASE:/);assert.match(notify,/MAIL_NOTIFICATION_WEBHOOK/);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
// Test-only OpenPGP v4 fixture encoder. Private keys are generated in memory,
|
||||
// never saved; production verification uses GnuPG, never this implementation.
|
||||
// This keeps integration tests runnable when gpg-agent sockets are unavailable.
|
||||
import {generateKeyPairSync, createHash, sign} from 'node:crypto';
|
||||
|
||||
const bytes = (...values) => Buffer.concat(values.map(value => Buffer.isBuffer(value) ? value : Buffer.from(value)));
|
||||
const u16 = value => {const out = Buffer.alloc(2);out.writeUInt16BE(value);return out;};
|
||||
const u32 = value => {const out = Buffer.alloc(4);out.writeUInt32BE(value);return out;};
|
||||
const sha = (algorithm,value) => createHash(algorithm).update(value).digest();
|
||||
function mpi(input) {
|
||||
const first = input.findIndex(value => value !== 0), data = first < 0 ? Buffer.alloc(0) : input.subarray(first);
|
||||
return bytes(u16(data.length ? (data.length-1)*8 + 32-Math.clz32(data[0]) : 0),data);
|
||||
}
|
||||
function length(size) {
|
||||
if (size < 192) return Buffer.from([size]);
|
||||
if (size <= 8383) return Buffer.from([((size-192)>>8)+192,(size-192)&255]);
|
||||
return bytes([255],u32(size));
|
||||
}
|
||||
const packet = (type,body) => bytes([0xc0|type],length(body.length),body);
|
||||
const subpacket = (type,body) => bytes(length(body.length+1),[type],body);
|
||||
const keyPrefix = body => bytes([0x99],u16(body.length),body);
|
||||
function armor(type,data) {
|
||||
let crc = 0xb704ce;
|
||||
for (const byte of data) {crc ^= byte<<16;for(let bit=0;bit<8;bit++) {crc <<= 1;if(crc&0x1000000) crc ^= 0x1864cfb;}}
|
||||
const checksum = Buffer.from([(crc>>16)&255,(crc>>8)&255,crc&255]).toString('base64');
|
||||
return `-----BEGIN PGP ${type}-----\n\n${data.toString('base64').match(/.{1,64}/g).join('\n')}\n=${checksum}\n-----END PGP ${type}-----\n`;
|
||||
}
|
||||
function key(created) {
|
||||
const {publicKey,privateKey} = generateKeyPairSync('rsa',{modulusLength:2048});
|
||||
const jwk = publicKey.export({format:'jwk'});
|
||||
const body = bytes([4],u32(created),[1],mpi(Buffer.from(jwk.n,'base64url')),mpi(Buffer.from(jwk.e,'base64url')));
|
||||
return {body,privateKey,fingerprint:sha('sha1',keyPrefix(body)),created};
|
||||
}
|
||||
function signature(signer,type,data,{created = signer.created,extra = []} = {}) {
|
||||
const hashed = bytes(subpacket(2,u32(created)),subpacket(33,bytes([4],signer.fingerprint)),...extra);
|
||||
const head = bytes([4,type,1,8],u16(hashed.length),hashed);
|
||||
const toSign = bytes(data,head,[4,255],u32(head.length));
|
||||
const unhashed = subpacket(16,signer.fingerprint.subarray(-8));
|
||||
return bytes(head,u16(unhashed.length),unhashed,sha('sha256',toSign).subarray(0,2),mpi(sign('RSA-SHA256',toSign,signer.privateKey)));
|
||||
}
|
||||
|
||||
export function openPgpFixture({subkey = false,subkeys = subkey ? 1 : 0,expired = false,revoked = false} = {}) {
|
||||
if (!Number.isInteger(subkeys) || subkeys < 0 || subkeys > 4) throw Error('Fixture supports zero through four signing subkeys.');
|
||||
const now = Math.floor(Date.now()/1000), created = now - (expired ? 5*86400 : 60);
|
||||
const primary = key(created);
|
||||
const uid = Buffer.from('Ephemeral security fixture <[email protected]>');
|
||||
const certification = signature(primary,0x13,bytes(keyPrefix(primary.body),[0xb4],u32(uid.length),uid),{
|
||||
extra:[subpacket(27,[subkeys ? 1 : 3]),...(expired ? [subpacket(9,u32(86400))] : [])],
|
||||
});
|
||||
const revocation = revoked ? packet(2,signature(primary,0x20,keyPrefix(primary.body),{created:created+10})) : Buffer.alloc(0);
|
||||
let publicPackets = bytes(packet(6,primary.body),revocation,packet(13,uid),packet(2,certification));
|
||||
const signers = [];
|
||||
for (let index = 0; index < subkeys; index++) {
|
||||
const signingKey = key(created);signers.push(signingKey);
|
||||
const bound = bytes(keyPrefix(primary.body),keyPrefix(signingKey.body));
|
||||
const crossCertification = signature(signingKey,0x19,bound);
|
||||
const binding = signature(primary,0x18,bound,{extra:[subpacket(27,[2]),subpacket(32,crossCertification)]});
|
||||
publicPackets = bytes(publicPackets,packet(14,signingKey.body),packet(2,binding));
|
||||
}
|
||||
if (!signers.length) signers.push(primary);
|
||||
const signData = (signer,data) => armor('SIGNATURE',packet(2,signature(signer,0x00,Buffer.from(data),{created:created+5})));
|
||||
return {
|
||||
primaryFingerprint:primary.fingerprint.toString('hex').toUpperCase(),
|
||||
signerFingerprint:signers[0].fingerprint.toString('hex').toUpperCase(),
|
||||
signerFingerprints:signers.map(signer=>signer.fingerprint.toString('hex').toUpperCase()),
|
||||
publicKey:armor('PUBLIC KEY BLOCK',publicPackets),
|
||||
// Sign in the key's valid past lifetime, so verification must notice expiry
|
||||
// or revocation now rather than merely reject an impossible creation date.
|
||||
sign:(data,index = 0) => {if(!signers[index]) throw Error('Unknown fixture signer.');return signData(signers[index],data);},
|
||||
// Deliberately valid text-mode signature for exact-byte policy rejection tests.
|
||||
signText:(data,index = 0) => {if(!signers[index]) throw Error('Unknown fixture signer.');return armor('SIGNATURE',packet(2,signature(signers[index],0x01,Buffer.from(String(data).replace(/\r?\n/g,'\r\n')),{created:created+5})));},
|
||||
signWithPrimary:data => signData(primary,data),
|
||||
};
|
||||
}
|
||||
+181
-11
@@ -3,8 +3,10 @@ import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import {createHash, generateKeyPairSync} from 'node:crypto';
|
||||
import {FallbackUnavailableError, KEY_SOURCE_LIMITS, makeKeyBundle, parsePublicKeys, resolveKeys, validateFallbackSources} from '../cf-worker/src/key-source.js';
|
||||
import {SIGNED_BUNDLE_MAX_BYTES, makeSignedDocument, SIGNATURE_SEPARATOR} from '../cf-worker/src/signed-release.js';
|
||||
|
||||
const fixture = await readFile(new URL('./fixtures/keys.pub', import.meta.url), 'utf8');
|
||||
const canonical = fixture.trim().split(/\s+/).slice(0, 2).join(' ') + '\n';
|
||||
const revision = 'a'.repeat(40);
|
||||
const urls = ['https://keys.example.org/first.keys', 'https://keys.example.org/second.keys', 'https://keys.example.org/third.keys'];
|
||||
const emptyRelease = {keys: [], allowEmpty: false, revision, fallbackSources: urls};
|
||||
@@ -33,9 +35,9 @@ test('repository keys win without any upstream request and have complete protoco
|
||||
assert.deepEqual(result.source, {type: 'repository'});
|
||||
assert.equal(result.revision, revision);
|
||||
assert.equal(result.keyCount, 1);
|
||||
assert.equal(result.sha256, hash(fixture));
|
||||
assert.equal(result.sha256, hash(canonical));
|
||||
assert.equal(result.keyRevision, result.sha256);
|
||||
assert.equal(result.keysText, `# conf-sshd-v1\n# revision ${revision}\n# key-count 1\n# allow-empty no\n# sha256 ${hash(fixture)}\n# end-metadata\n${fixture}`);
|
||||
assert.equal(result.keysText, `# conf-sshd-v1\n# revision ${revision}\n# key-count 1\n# allow-empty no\n# sha256 ${hash(canonical)}\n# end-metadata\n${canonical}`);
|
||||
assert.ok(Number.isFinite(Date.parse(result.fetchedAt)));
|
||||
});
|
||||
|
||||
@@ -61,7 +63,7 @@ test('first valid nonempty source wins in order, without aggregation or redirect
|
||||
assert.deepEqual(visited, [urls[0]]);
|
||||
assert.deepEqual(result.source, {type: 'fallback', url: urls[0]});
|
||||
assert.equal(result.keyCount, 1);
|
||||
assert.equal(result.sha256, hash(fixture));
|
||||
assert.equal(result.sha256, hash(canonical));
|
||||
});
|
||||
|
||||
test('malformed and empty sources are skipped before the next valid source', async () => {
|
||||
@@ -91,7 +93,7 @@ test('a response reported as redirected is rejected even when status is 200', as
|
||||
Object.defineProperty(redirected, 'redirected', {value: true});
|
||||
const result = await resolveKeys(emptyRelease, async url => url === urls[0] ? redirected : response(secondFixture));
|
||||
assert.equal(result.source.url, urls[1]);
|
||||
assert.equal(result.sha256, hash(secondFixture));
|
||||
assert.equal(result.sha256, hash(secondFixture.trim().split(/\s+/).slice(0, 2).join(' ') + '\n'));
|
||||
});
|
||||
|
||||
test('fetch timeout is bounded even when an upstream ignores AbortSignal', async () => {
|
||||
@@ -139,7 +141,7 @@ test('streamed size is enforced even without or with a misleading Content-Length
|
||||
});
|
||||
|
||||
test('oversized declared length is rejected without reading the body', async () => {
|
||||
const error = await unavailable(async () => new Response(fixture, {headers: {'Content-Length': String(KEY_SOURCE_LIMITS.maxBytes + 1)}}), {}, {...emptyRelease, fallbackSources: [urls[0]]});
|
||||
const error = await unavailable(async () => new Response(fixture, {headers: {'Content-Length': String(SIGNED_BUNDLE_MAX_BYTES + 1)}}), {}, {...emptyRelease, fallbackSources: [urls[0]]});
|
||||
assert.equal(error.diagnostics[0].code, 'too-large');
|
||||
});
|
||||
|
||||
@@ -179,7 +181,7 @@ test('Unicode comments and CRLF are canonicalized, with a known SHA256 fingerpri
|
||||
assert.equal(key.fingerprint, 'SHA256:bbXpuKG6zhzdmnxq256TlqzFBzRl2f6OOg722cYNbU8');
|
||||
assert.equal(key.text, `${fixture.trim().split(/\s+/).slice(0, 2).join(' ')} 家用服务器 🔐 张三`);
|
||||
const bundle = await makeKeyBundle([key], revision, false);
|
||||
assert.equal(bundle.sha256, hash(key.text + '\n'));
|
||||
assert.equal(bundle.sha256, hash(canonical));
|
||||
});
|
||||
|
||||
test('duplicates, options, certificates, private keys, invalid controls, and junk are rejected', async () => {
|
||||
@@ -276,7 +278,7 @@ test('whole serialized protocol, including metadata, is limited to the client re
|
||||
assert.ok(Buffer.byteLength(bundle.text) <= KEY_SOURCE_LIMITS.maxBytes);
|
||||
});
|
||||
|
||||
test('an upstream fitting the input cap but exceeding the serialized output cap is skipped', async () => {
|
||||
test('large upstream comments are stripped before publication without leaking labels', async () => {
|
||||
const publicBytes = keyData.subarray(keyData.length - 32);
|
||||
const lines = Array.from({length: 128}, (_, index) => {
|
||||
const bytes = Buffer.from(publicBytes); bytes[31] ^= index;
|
||||
@@ -284,11 +286,13 @@ test('an upstream fitting the input cap but exceeding the serialized output cap
|
||||
return prefix + 'x'.repeat(8190 - prefix.length) + '\n';
|
||||
}).join('');
|
||||
assert.ok(Buffer.byteLength(lines) <= KEY_SOURCE_LIMITS.maxBytes);
|
||||
const error = await unavailable(async () => response(lines), {}, {...emptyRelease, fallbackSources: [urls[0]]});
|
||||
assert.equal(error.diagnostics[0].code, 'too-large');
|
||||
const output = await resolveKeys({...emptyRelease, fallbackSources: [urls[0]]}, async () => response(lines));
|
||||
assert.equal(output.keyCount, 128);
|
||||
assert.ok(output.keysText.length < 20000);
|
||||
assert.equal(output.keys[0].label, 'Server key 1');
|
||||
const result = await resolveKeys(emptyRelease, async url => response(url === urls[0] ? lines : fixture));
|
||||
assert.equal(result.source.url, urls[1]);
|
||||
assert.equal(result.sha256, hash(fixture));
|
||||
assert.equal(result.source.url, urls[0]);
|
||||
assert.equal(result.keyCount, 128);
|
||||
});
|
||||
|
||||
test('ordinary private-key advice in comments is allowed, while real private formats are rejected anywhere', async () => {
|
||||
@@ -301,3 +305,169 @@ test('ordinary private-key advice in comments is allowed, while real private for
|
||||
await assert.rejects(parsePublicKeys(publicWithComment.trim() + ' ' + marker + '\n'), /Private keys are never accepted/);
|
||||
}
|
||||
});
|
||||
|
||||
const gistSource = {type:'gist',id:'a'.repeat(32),ownerId:12345,ownerLogin:'original-login',file:'ssh.keys',signatureFile:'ssh.keys.sig',bundleFile:'release.bundle'};
|
||||
const gistRevision = 'b'.repeat(40);
|
||||
const gistRelease = {...emptyRelease,keySource:gistSource};
|
||||
const signedPolicy = {listId:'production',sequence:9,issuedAt:100,expiresAt:300};
|
||||
const signedDocument = await makeSignedDocument(fixture,signedPolicy);
|
||||
// Syntactic fixture only, deliberately NOT a valid cryptographic signature. Worker never makes that claim.
|
||||
const signatureFixture = '-----BEGIN PGP SIGNATURE-----\n\nYWJj\n-----END PGP SIGNATURE-----\n';
|
||||
const signedBundleFixture = signedDocument + SIGNATURE_SEPARATOR + signatureFixture;
|
||||
const gistFile = (filename,content) => ({filename,content,size:Buffer.byteLength(content),truncated:false,encoding:'utf-8',raw_url:'https://untrusted.invalid/ignored'});
|
||||
const gistData = (files={}) => ({id:gistSource.id,owner:{id:gistSource.ownerId,login:'renamed-login'},public:true,truncated:false,history:[{version:gistRevision}],files});
|
||||
const jsonResponse = data => response(JSON.stringify(data));
|
||||
const gistUrl = `https://api.github.com/gists/${gistSource.id}`;
|
||||
|
||||
for (const status of [201,204,206]) test(`non-200 success HTTP ${status} is not a key release`, async () => {
|
||||
const result=await resolveKeys(emptyRelease,async url=>url===urls[0]?new Response(status===204?null:fixture,{status}):response(fixture));
|
||||
assert.equal(result.source.url,urls[1]);
|
||||
});
|
||||
|
||||
test('Git signed document is authoritative and exact, while missing/bad signature does not block OFF', async () => {
|
||||
for (const signature of [null,'bad armor',signatureFixture]) {
|
||||
const result=await resolveKeys({...emptyRelease,keys:fixtureKeys,signedDocument,signature},()=>assert.fail('No network'));
|
||||
assert.equal(result.keysText,signedDocument); assert.equal(result.signedDocument,signedDocument);
|
||||
assert.equal(result.signature,signature===signatureFixture?signatureFixture:null);
|
||||
assert.equal(result.keyRevision,hash(signedDocument)); assert.equal(result.sha256,hash(canonical));
|
||||
assert.equal(result.signedMetadata.sequence,9);
|
||||
}
|
||||
});
|
||||
|
||||
test('signed empty revocation suppresses fallback even when legacy allowEmpty is false', async () => {
|
||||
const document=await makeSignedDocument('',{...signedPolicy,revoke:true});
|
||||
const result=await resolveKeys({...emptyRelease,signedDocument:document,signature:signatureFixture},()=>assert.fail('Revocation must not fetch'));
|
||||
assert.equal(result.keyCount,0);assert.equal(result.allowEmpty,true);assert.equal(result.source.type,'explicit-empty');
|
||||
assert.equal(result.keysText,document);
|
||||
});
|
||||
|
||||
test('strict sources never downgrade to raw keys; ordered signed fallback chooses one entire bundle', async () => {
|
||||
const visited=[];
|
||||
const result=await resolveKeys({...emptyRelease,keys:fixtureKeys},async url=>{visited.push(url);return response(url===urls[0]?fixture:signedBundleFixture);},{requireSigned:true,now:200});
|
||||
assert.deepEqual(visited,urls.slice(0,2));assert.equal(result.source.url,urls[1]);assert.equal(result.signature,signatureFixture);
|
||||
assert.equal(result.keysText,signedDocument);assert.equal(result.signedBundle,signedBundleFixture);
|
||||
});
|
||||
|
||||
test('fallback standalone signed document gets only its same-source detached signature', async () => {
|
||||
const visited=[];
|
||||
const result=await resolveKeys(emptyRelease,async url=>{visited.push(url);return response(url.endsWith('.sig')?signatureFixture:signedDocument);},{requireSigned:true,now:200});
|
||||
assert.deepEqual(visited,[urls[0],urls[0]+'.sig']);assert.equal(result.signature,signatureFixture);assert.equal(result.keysText,signedDocument);
|
||||
});
|
||||
|
||||
test('missing, non200, malformed or hung optional signature still serves the valid OFF document', async () => {
|
||||
for (const mode of ['missing','non200','bad','fetch-timeout','body-timeout']) {
|
||||
let cancelled=false;
|
||||
const body=new ReadableStream({start(c){c.enqueue(new TextEncoder().encode('part'));},cancel(){cancelled=true;}});
|
||||
const fetcher=async url=> {
|
||||
if (!url.endsWith('.sig')) return response(signedDocument);
|
||||
if(mode==='missing')return new Response('missing',{status:404});
|
||||
if(mode==='non200')return new Response(signatureFixture,{status:206});
|
||||
if(mode==='bad')return response('bad');
|
||||
if(mode==='fetch-timeout')return new Promise(()=>{});
|
||||
return new Response(body);
|
||||
};
|
||||
const result=await resolveKeys(emptyRelease,fetcher,{timeoutMs:20});
|
||||
assert.equal(result.keysText,signedDocument,mode);assert.equal(result.signature,null,mode);
|
||||
if(mode==='body-timeout')assert.equal(cancelled,true);
|
||||
}
|
||||
});
|
||||
|
||||
test('strict signature failure stays with its source before trying next whole signed release', async () => {
|
||||
const result=await resolveKeys(emptyRelease,async url=>url===urls[0]?response(signedDocument):url===urls[0]+'.sig'?new Response('',{status:404}):response(signedBundleFixture),{requireSigned:true,now:200});
|
||||
assert.equal(result.source.url,urls[1]);assert.equal(result.signedBundle,signedBundleFixture);
|
||||
});
|
||||
|
||||
test('Gist fetch uses numeric-owner pin and immutable revision, never latest contents or raw_url', async () => {
|
||||
const visited=[];
|
||||
const result=await resolveKeys(gistRelease,async (url,init)=>{
|
||||
visited.push(url);assert.equal(init.headers['User-Agent'],'conf-sshd-key-source');assert.equal(init.redirect,'manual');assert.equal(init.cache,'no-store');
|
||||
if(url===gistUrl)return jsonResponse(gistData({'ssh.keys':gistFile('ssh.keys',secondFixture)}));
|
||||
assert.equal(url,gistUrl+'/'+gistRevision);
|
||||
return jsonResponse(gistData({'ssh.keys':gistFile('ssh.keys',fixture)}));
|
||||
});
|
||||
assert.deepEqual(visited,[gistUrl,gistUrl+'/'+gistRevision]);
|
||||
assert.equal(result.sha256,hash(canonical));assert.equal(result.source.type,'gist');assert.equal(result.source.ownerId,12345);
|
||||
assert.equal(result.source.ownerLogin,'original-login');assert.equal(result.source.gistRevision,gistRevision);
|
||||
assert.equal(result.signature,null);
|
||||
});
|
||||
|
||||
test('Gist signed document and signature are extracted from one pinned revision without transforms', async () => {
|
||||
let calls=0;
|
||||
const result=await resolveKeys(gistRelease,async url=>{calls++;return jsonResponse(gistData(url===gistUrl?{}:{'ssh.keys':gistFile('ssh.keys',signedDocument),'ssh.keys.sig':gistFile('ssh.keys.sig',signatureFixture)}));},{requireSigned:true,now:200});
|
||||
assert.equal(calls,2);assert.equal(result.keysText,signedDocument);assert.equal(result.signature,signatureFixture);assert.equal(result.keyRevision,hash(signedDocument));
|
||||
});
|
||||
|
||||
test('Gist bundle has one authoritative document and explicit revocation cannot be refilled', async () => {
|
||||
const revoke=await makeSignedDocument('',{...signedPolicy,revoke:true});
|
||||
const bundle=revoke+SIGNATURE_SEPARATOR+signatureFixture;
|
||||
const result=await resolveKeys(gistRelease,async url=>{assert.ok(url.startsWith(gistUrl));return jsonResponse(gistData({'release.bundle':gistFile('release.bundle',bundle),'ssh.keys':gistFile('ssh.keys',fixture)}));});
|
||||
assert.equal(result.keyCount,0);assert.equal(result.allowEmpty,true);assert.equal(result.keysText,revoke);assert.equal(result.source.file,'release.bundle');
|
||||
});
|
||||
|
||||
test('missing/truncated/malformed Gist signature does not prevent an OFF document response', async () => {
|
||||
for(const optional of [null,gistFile('ssh.keys.sig','bad'),{...gistFile('ssh.keys.sig',signatureFixture),truncated:true}]) {
|
||||
const result=await resolveKeys(gistRelease,async()=>jsonResponse(gistData({'ssh.keys':gistFile('ssh.keys',signedDocument),...(optional?{'ssh.keys.sig':optional}:{})})));
|
||||
assert.equal(result.keysText,signedDocument);assert.equal(result.signature,null);
|
||||
}
|
||||
});
|
||||
|
||||
for (const when of ['latest','revision']) test(`Gist owner mismatch in ${when} fails closed before ordered fallback`, async () => {
|
||||
const visited=[];
|
||||
const result=await resolveKeys(gistRelease,async url=>{
|
||||
visited.push(url);
|
||||
if(url===urls[0])return response(fixture);
|
||||
const data=gistData({'ssh.keys':gistFile('ssh.keys',fixture)});
|
||||
if((when==='latest')===(url===gistUrl))data.owner.id=99999;
|
||||
return jsonResponse(data);
|
||||
});
|
||||
assert.equal(result.source.type,'fallback');assert.equal(visited.length,when==='latest'?2:3);
|
||||
});
|
||||
|
||||
test('Gist rejects top-level truncation, private sources, invalid revision, selected-file truncation and byte mismatches', async () => {
|
||||
const mutations=[data=>{data.truncated=true;},data=>{data.public=false;},data=>{data.history[0].version='../latest';},data=>{data.files['ssh.keys'].truncated=true;},data=>{data.files['ssh.keys'].size++;},data=>{data.files['ssh.keys'].filename='different';},data=>{data.files['ssh.keys'].encoding='base64';},data=>{data.id='b'.repeat(32);}];
|
||||
for(const mutate of mutations){
|
||||
const result=await resolveKeys(gistRelease,async url=>{if(url===urls[0])return response(fixture);const data=gistData({'ssh.keys':gistFile('ssh.keys',fixture)});mutate(data);return jsonResponse(data);});
|
||||
assert.equal(result.source.type,'fallback');
|
||||
}
|
||||
});
|
||||
|
||||
test('Gist combined metadata/revision HTTP/body timeout is bounded and falls back without stale keys', async () => {
|
||||
for(const mode of ['http','json','fetch','body']) {
|
||||
let cancelled=false;
|
||||
const body=new ReadableStream({start(c){c.enqueue(new TextEncoder().encode('{'));},cancel(){cancelled=true;}});
|
||||
const result=await resolveKeys(gistRelease,async url=>{
|
||||
if(url===urls[0])return response(fixture);
|
||||
if(url===gistUrl)return jsonResponse(gistData());
|
||||
if(mode==='http')return new Response('',{status:503});
|
||||
if(mode==='json')return response('{');
|
||||
if(mode==='fetch')return new Promise(()=>{});
|
||||
return new Response(body);
|
||||
},{timeoutMs:20});
|
||||
assert.equal(result.source.type,'fallback');if(mode==='body')assert.equal(cancelled,true);
|
||||
}
|
||||
});
|
||||
|
||||
test('Gist dynamic revision changes content digest while code revision stays fixed', async () => {
|
||||
let current=gistRevision, text=fixture;
|
||||
const fetcher=async url=>{const data=gistData({'ssh.keys':gistFile('ssh.keys',text)});data.history[0].version=current;assert.ok(url===gistUrl||url===gistUrl+'/'+current);return jsonResponse(data);};
|
||||
const first=await resolveKeys(gistRelease,fetcher);
|
||||
current='c'.repeat(40);text=secondFixture;
|
||||
const second=await resolveKeys(gistRelease,fetcher);
|
||||
assert.notEqual(first.sha256,second.sha256);assert.notEqual(first.source.gistRevision,second.source.gistRevision);assert.equal(first.revision,second.revision);
|
||||
});
|
||||
|
||||
test('invalid Gist and signing config is rejected before any network request', async () => {
|
||||
for(const keySource of [null,{type:'other'},{type:'git',url:'https://evil.example'}, {...gistSource,ownerId:'12345'}, {...gistSource,ownerId:0}, {...gistSource,id:'../escape'}, {...gistSource,ownerLogin:'bad/login'}, {...gistSource,file:'../key'}, {...gistSource,signatureFile:'ssh.keys'}, {...gistSource,token:'secret'}]) await assert.rejects(resolveKeys({...gistRelease,keySource},()=>assert.fail('Invalid config must not fetch')));
|
||||
});
|
||||
|
||||
test('Gist response bytes are bounded and no fallback response inherits upstream text or private data', async () => {
|
||||
const result=await resolveKeys(gistRelease,async url=>url===urls[0]?response(fixture):new Response('{}',{headers:{'Content-Length':String(2*SIGNED_BUNDLE_MAX_BYTES+1)}}));
|
||||
assert.equal(result.source.type,'fallback');assert.doesNotMatch(JSON.stringify(result),/TEST-ONLY/);
|
||||
});
|
||||
|
||||
test('named Gist bundles cannot silently downgrade to raw key files', async () => {
|
||||
for(const contents of [fixture,signedDocument]) {
|
||||
const result=await resolveKeys(gistRelease,async url=>url===urls[0]?response(fixture):jsonResponse(gistData({'release.bundle':gistFile('release.bundle',contents)})));
|
||||
assert.equal(result.source.type,'fallback');
|
||||
}
|
||||
});
|
||||
|
||||
+589
-10
@@ -15,12 +15,16 @@ import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
import json
|
||||
import pty
|
||||
import select
|
||||
import errno
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE = (ROOT / 'conf-sshd.sh').read_text()
|
||||
SHELL = shlex.split(os.environ.get('CONF_SSHD_TEST_SHELL', '/bin/sh'))
|
||||
ORIGIN = 'https://keys.example.test'
|
||||
SCRIPT = SOURCE.replace('@@BASE_URL@@', ORIGIN).replace('@@DEFAULT_CRON@@', '*/15 * * * *')
|
||||
SCRIPT = SOURCE.replace('@@BASE_URL@@', ORIGIN).replace('@@DEFAULT_CRON@@', '*/15 * * * *').replace('@@DEFAULT_GPG_VERIFY@@', 'off').replace('@@DEFAULT_GPG_FINGERPRINT@@', '').replace('@@DEFAULT_GPG_PIN_MODE@@', 'primary').replace('@@DEFAULT_LIST_ID@@', '').replace('@@DEFAULT_GPG_EMAIL@@', '')
|
||||
KEY = (ROOT / 'tests/fixtures/keys.pub').read_bytes().strip() + b'\n'
|
||||
BEGIN = b'# --- BEGIN MANAGED BY CONF-SSHD SCRIPT ---\n'
|
||||
END = b'# --- END MANAGED BY CONF-SSHD SCRIPT ---\n'
|
||||
@@ -72,7 +76,17 @@ while [ "$#" -gt 0 ]; do
|
||||
case "$1" in --output) shift; out=$1 ;; https://*) url=$1 ;; esac
|
||||
shift
|
||||
done
|
||||
case $url in */ssh.keys) cp "$TEST_RESPONSE" "$out" ;; */script.sh) cp "$TEST_UPDATE" "$out" ;; *) exit 99 ;; esac
|
||||
status=${TEST_HTTP_STATUS:-200}
|
||||
case $url in
|
||||
*/script.sh) cp "$TEST_UPDATE" "$out" ;;
|
||||
*/public-key.gpg) cp "${TEST_CERTIFICATE:-/nonexistent}" "$out" || exit 22; status=${TEST_CERT_STATUS:-$status} ;;
|
||||
*/mirror/ssh.keys.sig) cp "${TEST_FALLBACK_SIGNATURE:-$TEST_SIGNATURE}" "$out"; status=${TEST_FALLBACK_SIG_STATUS:-$status} ;;
|
||||
*/mirror/ssh.keys) cp "${TEST_FALLBACK_RESPONSE:-$TEST_RESPONSE}" "$out"; status=${TEST_FALLBACK_KEY_STATUS:-$status} ;;
|
||||
*/ssh.keys.sig) cp "${TEST_SIGNATURE:-/nonexistent}" "$out" || exit 22; status=${TEST_SIG_STATUS:-$status} ;;
|
||||
*/ssh.keys) cp "$TEST_RESPONSE" "$out"; status=${TEST_KEY_STATUS:-$status} ;;
|
||||
*) exit 99 ;;
|
||||
esac
|
||||
printf '%s' "$status"
|
||||
''')
|
||||
self.mock('crontab', '''#!/bin/sh
|
||||
printf 'crontab %s\\n' "$*" >> "$TEST_CALLS"
|
||||
@@ -233,7 +247,7 @@ fi
|
||||
self.assertEqual(self.auth.read_bytes(), original)
|
||||
self.assert_clean()
|
||||
calls = self.calls.read_text()
|
||||
for option in ('--fail', '--proto =https', '--connect-timeout 10', '--max-time 30', '--retry 2'):
|
||||
for option in ('--disable', '--fail', '--proto =https', '--connect-timeout 10', '--max-time 30', '--retry 0'):
|
||||
self.assertIn(option, calls)
|
||||
self.assertNotIn('--location', calls)
|
||||
|
||||
@@ -361,7 +375,7 @@ fi
|
||||
self.assertTrue(text.startswith(unrelated))
|
||||
self.assertIn("*/15 * * * * /bin/sh '", text)
|
||||
self.assertIn("'\\''quote/.conf-sshd/conf-sshd.sh' -o", text)
|
||||
self.assertNotIn('-u', text)
|
||||
self.assertNotRegex(text, r'(?:^|\s)-u(?:\s|$)')
|
||||
self.assertEqual(self.installed.stat().st_mode & 0o777, 0o700)
|
||||
text = text.replace('*/15 * * * *', '7 1 * * 2')
|
||||
self.cron.write_text(text)
|
||||
@@ -476,12 +490,12 @@ fi
|
||||
for text in cases:
|
||||
with self.subTest(text=text[:40]):
|
||||
self.update.write_text(text)
|
||||
self.run_script('-u', ok=False)
|
||||
self.run_script('--install', '--download-script', ok=False)
|
||||
self.assertEqual(self.installed.read_bytes(), original)
|
||||
self.assert_clean()
|
||||
self.run_script('-u', ok=False, env={'TEST_CURL_ERROR': '22'})
|
||||
self.run_script('--install', '--download-script', ok=False, env={'TEST_CURL_ERROR': '22'})
|
||||
self.update.write_text(SCRIPT + '\n# new release\n')
|
||||
self.run_script('-u')
|
||||
self.run_script('--install', '--download-script')
|
||||
self.assertEqual(self.installed.read_bytes(), self.update.read_bytes())
|
||||
self.assertEqual(Path(str(self.installed) + '.conf-sshd.bak').read_bytes(), original)
|
||||
self.assertEqual(self.auth.read_bytes(), keys)
|
||||
@@ -668,9 +682,9 @@ fi
|
||||
self.run_script('-c')
|
||||
self.assertIn('8 3 * * * /bin/sh', self.cron.read_text())
|
||||
self.update.write_text(custom + '\n# updated\n')
|
||||
self.run_script('-u')
|
||||
self.run_script('--install', '--download-script')
|
||||
self.update.write_text(SCRIPT)
|
||||
self.run_script('-u', ok=False)
|
||||
self.run_script('--install', '--download-script', ok=False)
|
||||
|
||||
def test_signal_removes_own_lock_and_temporary_files(self):
|
||||
first = subprocess.Popen(self.script_command('-o'),
|
||||
@@ -721,5 +735,570 @@ fi
|
||||
self.assertFalse(self.auth.exists())
|
||||
|
||||
|
||||
class SignatureTests(ShellTests):
|
||||
"""Pure sandbox OpenPGP/TTY fixtures; all network paths intercepted."""
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.fixture_dir = tempfile.TemporaryDirectory(prefix='conf-sshd-gpg-fixtures-')
|
||||
js = r"""
|
||||
import {openPgpFixture} from './tests/helpers/openpgp-fixture.mjs';
|
||||
import {readFileSync} from 'node:fs';
|
||||
const f = openPgpFixture({subkeys:2}), other = openPgpFixture(), expired = openPgpFixture({expired:true}), revoked = openPgpFixture({revoked:true});
|
||||
const key=readFileSync('tests/fixtures/keys.pub','utf8').trim().split(/\s+/).slice(0,2).join(' ')+'\n';
|
||||
const now=Math.floor(Date.now()/1000);
|
||||
function doc({seq=5,list='fixture-list',issued=now-10,expires=now+3600,payload=key,revoke='no'}={}) {
|
||||
return `# conf-sshd-signed-v1\n# list-id ${list}\n# sequence ${seq}\n# issued-at ${issued}\n# expires-at ${expires}\n# revoke ${revoke}\n# key-count ${payload ? payload.trim().split('\n').length : 0}\n# end-metadata\n${payload}`;
|
||||
}
|
||||
const specs={good:{},next:{seq:6},later:{seq:7,expires:now+7200},old:{seq:4},equivocation:{issued:now-9},expired:{seq:6,issued:now-3600,expires:now-1},future:{seq:6,issued:now+3600,expires:now+7200},wronglist:{seq:6,list:'another'},revoke:{seq:6,payload:'',revoke:'yes'},badrevoke:{seq:6,revoke:'yes'},comment:{seq:6,payload:key.trim()+' comment\n'}};
|
||||
const releases={}; for(const [name,spec] of Object.entries(specs)){const data=doc(spec);releases[name]={data,sig:f.sign(data)}}
|
||||
releases.sibling={data:doc({seq:6}),sig:f.sign(doc({seq:6}),1)};
|
||||
releases.textmode={data:doc({seq:6}),sig:f.signText(doc({seq:6}))};
|
||||
releases.wrongsigner={data:doc({seq:6}),sig:other.sign(doc({seq:6}))};
|
||||
console.log(JSON.stringify({publicKey:f.publicKey,primary:f.primaryFingerprint,subkeys:f.signerFingerprints,otherKey:other.publicKey,otherPin:other.primaryFingerprint,otherSig:other.sign(doc({seq:6})),expiredKey:expired.publicKey,expiredPin:expired.primaryFingerprint,expiredSig:expired.sign(doc()),revokedKey:revoked.publicKey,revokedPin:revoked.primaryFingerprint,revokedSig:revoked.sign(doc()),releases}));
|
||||
"""
|
||||
generated = subprocess.run(['node', '--input-type=module', '-e', js], cwd=ROOT,
|
||||
check=True, capture_output=True, text=True)
|
||||
cls.fixtures = json.loads(generated.stdout)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.fixture_dir.cleanup()
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.cert = self.root / 'public-key.gpg'
|
||||
self.cert.write_text(self.fixtures['publicKey'])
|
||||
self.signature = self.root / 'ssh.keys.sig'
|
||||
self.env.update(TEST_CERTIFICATE=str(self.cert), TEST_SIGNATURE=str(self.signature),
|
||||
GNUPGHOME=str(self.root / 'empty-ambient-home'))
|
||||
self.set_release('good')
|
||||
real_gpg = shutil.which('gpg')
|
||||
self.assertIsNotNone(real_gpg)
|
||||
# GnuPG cryptographic work is real. Remote GnuPG discovery never is.
|
||||
self.mock('gpg', f'''#!/bin/sh
|
||||
printf 'gpg %s\\n' "$*" >> "$TEST_CALLS"
|
||||
home=; server=; action=
|
||||
prev=
|
||||
for arg do
|
||||
case $prev in --homedir) home=$arg ;; --keyserver) server=$arg ;; esac
|
||||
case $arg in --recv-keys|--locate-external-keys) action=$arg ;; esac
|
||||
prev=$arg
|
||||
done
|
||||
if [ -n "$action" ]; then
|
||||
if [ "${{TEST_DISCOVERY_SUCCESS:-}}" = "$server" ] || {{ [ "$action" = --locate-external-keys ] && [ "${{TEST_DISCOVERY_SUCCESS:-}}" = wkd ]; }}; then
|
||||
"{real_gpg}" --batch --no-options --no-autostart --yes --dearmor --output "$home/pubring.gpg" "$TEST_CERTIFICATE" 2>/dev/null
|
||||
exit $?
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
exec "{real_gpg}" "$@"
|
||||
''')
|
||||
|
||||
def set_release(self, name):
|
||||
value = self.fixtures['releases'][name]
|
||||
self.payload.write_text(value['data'])
|
||||
self.signature.write_text(value['sig'])
|
||||
|
||||
def run_tty(self, *args, answer=None, ok=True, env=None, script=None):
|
||||
command = self.script_command(*args, script=script)
|
||||
pid, fd = pty.fork()
|
||||
if pid == 0:
|
||||
os.execvpe(command[0], command, self.env | (env or {}))
|
||||
output = bytearray()
|
||||
sent = False
|
||||
deadline = time.monotonic() + 20
|
||||
code = None
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
ready, _, _ = select.select([fd], [], [], 0.05)
|
||||
if ready:
|
||||
try:
|
||||
data = os.read(fd, 65536)
|
||||
except OSError as e:
|
||||
if e.errno != errno.EIO:
|
||||
raise
|
||||
data = b''
|
||||
output.extend(data)
|
||||
if not sent and b'To confirm ' in output:
|
||||
os.write(fd, ((answer if answer is not None else self.fixtures['primary']) + '\n').encode())
|
||||
sent = True
|
||||
result, status = os.waitpid(pid, os.WNOHANG)
|
||||
if result:
|
||||
code = os.waitstatus_to_exitcode(status)
|
||||
break
|
||||
if code is None:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
os.waitpid(pid, 0)
|
||||
self.fail('TTY fixture timed out: ' + output.decode(errors='replace'))
|
||||
finally:
|
||||
os.close(fd)
|
||||
result = output.decode(errors='replace')
|
||||
if ok:
|
||||
self.assertEqual(code, 0, result)
|
||||
else:
|
||||
self.assertNotEqual(code, 0, result)
|
||||
self.assertNotIn('UNEXPECTED', self.calls.read_text())
|
||||
return result
|
||||
|
||||
def enroll(self, pin=None, mode='primary', extra=(), answer=None, ok=True):
|
||||
pin = pin or self.fixtures['primary']
|
||||
return self.run_tty('--install', '--gpg-verification', 'on', '--gpg-fingerprint', pin,
|
||||
'--gpg-pin-mode', mode, '--list-id', 'fixture-list', *extra,
|
||||
answer=answer or pin, ok=ok)
|
||||
|
||||
@property
|
||||
def local_config(self):
|
||||
return self.home / '.conf-sshd/local.conf'
|
||||
|
||||
@property
|
||||
def replay(self):
|
||||
return self.home / '.conf-sshd/replay.state'
|
||||
|
||||
def test_strict_primary_accepts_bound_subkeys_and_sync_is_offline_for_certificate(self):
|
||||
self.enroll()
|
||||
self.assertIn('policy required\n', self.local_config.read_text())
|
||||
self.assertIn('sequence 5\n', self.replay.read_text())
|
||||
self.assertEqual(self.auth.read_bytes(), BEGIN + b' '.join(KEY.split()[:2]) + b'\n' + END)
|
||||
self.calls.write_text('')
|
||||
self.set_release('sibling')
|
||||
self.run_script('-o')
|
||||
calls = self.calls.read_text()
|
||||
self.assertIn('/ssh.keys.sig', calls)
|
||||
self.assertNotIn('/public-key.gpg', calls)
|
||||
self.assertNotIn('--recv-keys', calls)
|
||||
self.assertNotIn('--locate-external-keys', calls)
|
||||
self.assert_clean()
|
||||
|
||||
def test_strict_exact_subkey_rejects_sibling_and_other_signer(self):
|
||||
self.enroll(pin=self.fixtures['subkeys'][0], mode='subkey')
|
||||
original = self.auth.read_bytes()
|
||||
for name in ('sibling', 'wrongsigner'):
|
||||
self.set_release(name)
|
||||
self.run_script('-o', ok=False)
|
||||
self.assertEqual(self.auth.read_bytes(), original)
|
||||
self.assert_clean()
|
||||
|
||||
def test_strict_rejects_rollback_equivocation_expiry_future_wronglist_and_unsigned(self):
|
||||
self.enroll()
|
||||
original = self.auth.read_bytes()
|
||||
original_state = self.replay.read_bytes()
|
||||
for name in ('old', 'equivocation', 'expired', 'future', 'wronglist', 'badrevoke', 'comment', 'wrongsigner', 'textmode'):
|
||||
with self.subTest(name=name):
|
||||
self.set_release(name)
|
||||
self.run_script('-o', ok=False)
|
||||
self.assertEqual(self.auth.read_bytes(), original)
|
||||
self.assertEqual(self.replay.read_bytes(), original_state)
|
||||
self.assert_clean()
|
||||
self.payload.write_bytes(response())
|
||||
self.signature.write_text('')
|
||||
self.run_script('-o', ok=False)
|
||||
self.assertEqual(self.auth.read_bytes(), original)
|
||||
|
||||
def test_strict_idempotent_replay_and_explicit_empty_revocation(self):
|
||||
original = self.old_auth(b'# manual recovery key\n')
|
||||
self.enroll()
|
||||
self.run_script('-o')
|
||||
self.set_release('revoke')
|
||||
self.run_script('-o')
|
||||
self.assertEqual(self.auth.read_bytes(), original + BEGIN + END)
|
||||
self.assertIn('sequence 6\n', self.replay.read_text())
|
||||
self.set_release('good')
|
||||
self.run_script('-o', ok=False)
|
||||
self.assertEqual(self.auth.read_bytes(), original + BEGIN + END)
|
||||
|
||||
def test_off_never_requests_signature_or_certificate_even_when_present(self):
|
||||
self.signature.write_text('definitely invalid')
|
||||
self.run_script('-o')
|
||||
self.assertNotIn('/ssh.keys.sig', self.calls.read_text())
|
||||
self.assertNotIn('/public-key.gpg', self.calls.read_text())
|
||||
self.assertNotIn('gpg ', self.calls.read_text())
|
||||
self.assertFalse(self.replay.exists())
|
||||
|
||||
def test_signature_requires_exact_http_200_and_exact_bytes(self):
|
||||
self.enroll()
|
||||
original = self.auth.read_bytes()
|
||||
for status in ('201', '204', '206', '301', '404', '503'):
|
||||
for key in ('TEST_KEY_STATUS', 'TEST_SIG_STATUS'):
|
||||
with self.subTest(status=status, part=key):
|
||||
self.run_script('-o', ok=False, env={key:status})
|
||||
self.assertEqual(self.auth.read_bytes(), original)
|
||||
self.payload.write_text(self.payload.read_text().replace('sequence 5', 'sequence 6'))
|
||||
self.run_script('-o', ok=False)
|
||||
self.assertEqual(self.auth.read_bytes(), original)
|
||||
|
||||
def test_cancel_no_tty_and_removed_bypass_preserve_targets_without_network(self):
|
||||
original = self.old_auth()
|
||||
self.enroll(answer='cancel', ok=False)
|
||||
self.assertFalse(self.local_config.exists())
|
||||
self.assertFalse(self.installed.exists())
|
||||
self.assertEqual(self.auth.read_bytes(), original)
|
||||
self.assertEqual(self.calls.read_text(), '')
|
||||
self.run_script('--install', '--gpg-verification', 'on', '--gpg-fingerprint', self.fixtures['primary'], '--list-id', 'fixture-list', ok=False)
|
||||
self.assertEqual(self.calls.read_text(), '')
|
||||
self.run_script('--install', '--confirm-trust', self.fixtures['primary'], ok=False)
|
||||
self.assertEqual(self.calls.read_text(), '')
|
||||
self.assert_clean()
|
||||
|
||||
def test_bad_initial_signature_never_commits_script_config_certificate_or_state(self):
|
||||
original = self.old_auth()
|
||||
self.signature.write_text(self.fixtures['releases']['wrongsigner']['sig'])
|
||||
self.enroll(ok=False)
|
||||
self.assertEqual(self.auth.read_bytes(), original)
|
||||
for path in (self.installed, self.local_config, self.replay):
|
||||
self.assertFalse(path.exists())
|
||||
self.assertEqual(list((self.home / '.conf-sshd').glob('trust-*.asc')), [])
|
||||
self.assert_clean()
|
||||
|
||||
def test_missing_replay_or_pinned_certificate_fails_closed(self):
|
||||
self.enroll()
|
||||
original = self.auth.read_bytes()
|
||||
saved = self.replay.read_bytes()
|
||||
self.replay.unlink()
|
||||
self.run_script('-o', ok=False)
|
||||
self.assertEqual(self.auth.read_bytes(), original)
|
||||
self.replay.write_bytes(saved)
|
||||
next((self.home / '.conf-sshd').glob('trust-*.asc')).unlink()
|
||||
self.run_script('-o', ok=False)
|
||||
self.assertEqual(self.auth.read_bytes(), original)
|
||||
|
||||
def test_local_urls_fallback_order_and_signature_policy_never_downgrade(self):
|
||||
fallbacks = self.root / 'fallbacks'
|
||||
fallbacks.write_text(ORIGIN + '/mirror/ssh.keys\n')
|
||||
self.enroll(extra=('--fallback-urls-file', str(fallbacks)))
|
||||
self.calls.write_text('')
|
||||
self.run_script('-o', env={'TEST_KEY_STATUS':'503'})
|
||||
calls = self.calls.read_text()
|
||||
self.assertLess(calls.index(ORIGIN+'/ssh.keys'), calls.index(ORIGIN+'/mirror/ssh.keys'))
|
||||
self.assertIn('/mirror/ssh.keys.sig', calls)
|
||||
original = self.auth.read_bytes()
|
||||
self.run_script('-o', ok=False, env={'TEST_SIG_STATUS':'404', 'TEST_FALLBACK_SIG_STATUS':'404'})
|
||||
self.assertEqual(self.auth.read_bytes(), original)
|
||||
|
||||
def test_persisted_config_is_strict_data_never_executed(self):
|
||||
self.enroll()
|
||||
original = self.auth.read_bytes()
|
||||
self.local_config.write_text(self.local_config.read_text() + 'touch ' + str(self.root/'PWNED') + '\n')
|
||||
self.run_script('-o', ok=False)
|
||||
self.assertFalse((self.root/'PWNED').exists())
|
||||
self.assertEqual(self.auth.read_bytes(), original)
|
||||
|
||||
def test_floor_and_repository_defaults_require_consent(self):
|
||||
self.enroll(extra=('--min-sequence','6'), ok=False)
|
||||
self.assertFalse(self.replay.exists())
|
||||
defaults = SCRIPT.replace("DEFAULT_GPG_VERIFY='off'", "DEFAULT_GPG_VERIFY='on'").replace("DEFAULT_GPG_FINGERPRINT=''", f"DEFAULT_GPG_FINGERPRINT='{self.fixtures['primary']}'").replace("DEFAULT_LIST_ID=''", "DEFAULT_LIST_ID='fixture-list'")
|
||||
self.script.write_text(defaults)
|
||||
self.run_script('-o', ok=False)
|
||||
self.run_tty('-o', answer=self.fixtures['primary'])
|
||||
self.assertFalse(self.installed.exists())
|
||||
self.assertIn('policy required\n', self.local_config.read_text())
|
||||
self.script.write_text(SCRIPT)
|
||||
self.calls.write_text('')
|
||||
self.run_script('-o')
|
||||
self.assertIn('/ssh.keys.sig', self.calls.read_text())
|
||||
|
||||
def test_disable_requires_terminal_and_preserves_reenable_rollback_floor(self):
|
||||
self.enroll()
|
||||
original_config = self.local_config.read_bytes()
|
||||
self.run_script('-o','--gpg-verification','off',ok=False)
|
||||
self.assertEqual(self.local_config.read_bytes(), original_config)
|
||||
self.run_tty('-o','--gpg-verification','off')
|
||||
self.assertIn('policy off\n', self.local_config.read_text())
|
||||
self.assertIn('sequence 5\n', self.replay.read_text())
|
||||
self.set_release('old')
|
||||
self.run_tty('-o','--gpg-verification','on',ok=False)
|
||||
self.assertIn('policy off\n', self.local_config.read_text())
|
||||
|
||||
def test_explicit_reinstall_preserves_urls_pin_and_schedule(self):
|
||||
self.enroll(extra=('-c','7 1 * * 2'))
|
||||
before_config = self.local_config.read_bytes()
|
||||
before_cron = self.cron.read_bytes()
|
||||
old_script = self.installed.read_bytes()
|
||||
self.script.write_text(SCRIPT + '\n# local verified release\n')
|
||||
self.run_tty('--install')
|
||||
self.assertEqual(self.local_config.read_bytes(), before_config)
|
||||
self.assertEqual(self.cron.read_bytes(), before_cron)
|
||||
self.assertEqual(self.installed.read_bytes(), self.script.read_bytes())
|
||||
self.assertEqual(Path(str(self.installed)+'.conf-sshd.bak').read_bytes(), old_script)
|
||||
self.run_script('-u',ok=False)
|
||||
self.run_script('--install','--download-script',ok=False)
|
||||
self.assert_clean()
|
||||
|
||||
def test_wrong_certificate_then_ordered_keyserver_fallback(self):
|
||||
self.enroll(extra=('--gpg-email','[email protected]'), ok=True)
|
||||
# A separate installation uses real cert parsing but all discovery mocked.
|
||||
self.run_script('--uninstall')
|
||||
self.local_config.unlink(); self.replay.unlink()
|
||||
for path in (self.home/'.conf-sshd').glob('trust-*.asc'): path.unlink()
|
||||
self.calls.write_text('')
|
||||
self.run_tty('--install','--gpg-verification','on','--gpg-fingerprint',self.fixtures['primary'],'--list-id','fixture-list','--gpg-email','[email protected]', env={'TEST_CERT_STATUS':'404','TEST_DISCOVERY_SUCCESS':'hkps://keyserver.ubuntu.com'})
|
||||
calls=self.calls.read_text()
|
||||
self.assertLess(calls.index('/public-key.gpg'),calls.index('--locate-external-keys'))
|
||||
self.assertLess(calls.index('--locate-external-keys'),calls.index('hkps://keys.openpgp.org'))
|
||||
self.assertLess(calls.index('hkps://keys.openpgp.org'),calls.index('hkps://keyserver.ubuntu.com'))
|
||||
self.assert_clean()
|
||||
|
||||
def test_pending_journal_recovers_same_release_after_auth_replace_failure(self):
|
||||
self.enroll()
|
||||
self.set_release('next')
|
||||
real_mv=shutil.which('mv')
|
||||
self.mock('mv',f'''#!/bin/sh
|
||||
last=
|
||||
for arg do last=$arg; done
|
||||
if [ "$last" = "$HOME/.ssh/authorized_keys" ]; then exit 74; fi
|
||||
exec "{real_mv}" "$@"
|
||||
''')
|
||||
# Force a changed authorized_keys body, while retaining its manual lines.
|
||||
self.old_auth(b'# existing manual\n')
|
||||
self.run_script('-o',ok=False)
|
||||
self.assertTrue((self.home/'.conf-sshd/pending.state').exists())
|
||||
self.assertTrue((self.home/'.conf-sshd/pending.bundle').exists())
|
||||
(self.bin/'mv').unlink()
|
||||
self.set_release('old')
|
||||
self.calls.write_text('')
|
||||
self.run_script('-o')
|
||||
self.assertIn('sequence 6\n',self.replay.read_text())
|
||||
self.assertNotIn('curl ',self.calls.read_text())
|
||||
self.assertFalse((self.home/'.conf-sshd/pending.state').exists())
|
||||
self.assertFalse((self.home/'.conf-sshd/pending.bundle').exists())
|
||||
self.assert_clean()
|
||||
|
||||
def test_wrong_expired_revoked_and_multiple_certificate_rejected(self):
|
||||
for name in ('other','expired','revoked'):
|
||||
with self.subTest(name=name):
|
||||
self.cert.write_text(self.fixtures[name+'Key'])
|
||||
pin=self.fixtures[name+'Pin'] if name!='other' else self.fixtures['primary']
|
||||
self.enroll(pin=pin,ok=False)
|
||||
self.assertFalse(self.installed.exists())
|
||||
self.assertFalse(self.local_config.exists())
|
||||
self.assert_clean()
|
||||
self.cert.write_text(self.fixtures['publicKey']+self.fixtures['otherKey'])
|
||||
self.enroll(ok=False)
|
||||
self.assertFalse(self.installed.exists())
|
||||
|
||||
|
||||
def test_signed_pipe_install_requires_tty_then_installs_downloaded_release(self):
|
||||
defaults = SCRIPT.replace("DEFAULT_GPG_VERIFY='off'", "DEFAULT_GPG_VERIFY='on'").replace("DEFAULT_GPG_FINGERPRINT=''", f"DEFAULT_GPG_FINGERPRINT='{self.fixtures['primary']}'").replace("DEFAULT_LIST_ID=''", "DEFAULT_LIST_ID='fixture-list'")
|
||||
self.update.write_text(defaults)
|
||||
wrapper = self.root/'pipe-bootstrap.sh'
|
||||
prelude = shlex.quote(self.isolation_prelude())
|
||||
wrapper.write_text(f"{{ printf '%s' {prelude}; cat {shlex.quote(str(self.update))}; }} | {shlex.join(SHELL)} -s -- --install --download-script -c\n")
|
||||
self.run_script(script=wrapper,ok=False)
|
||||
self.assertFalse(self.installed.exists())
|
||||
self.assertNotIn('curl ',self.calls.read_text())
|
||||
self.run_tty(script=wrapper)
|
||||
self.assertEqual(self.installed.read_text(),defaults)
|
||||
self.assertIn('policy required\n',self.local_config.read_text())
|
||||
self.assertIn('/ssh.keys.sig',self.calls.read_text())
|
||||
self.assertIn('/script.sh',self.calls.read_text())
|
||||
self.assert_clean()
|
||||
|
||||
def test_machine_status_rejects_weak_digest_text_signature_wrong_primary_and_errors(self):
|
||||
self.enroll()
|
||||
original=self.auth.read_bytes()
|
||||
real_gpg=shutil.which('gpg')
|
||||
self.mock('gpg',f'''#!/bin/sh
|
||||
for arg do
|
||||
if [ "$arg" = --verify ]; then cat "$TEST_FAKE_STATUS"; exit 0; fi
|
||||
done
|
||||
exec "{real_gpg}" "$@"
|
||||
''')
|
||||
path=self.root/'status-fixture'
|
||||
signer=self.fixtures['subkeys'][0]
|
||||
primary=self.fixtures['primary']
|
||||
good=f'[GNUPG:] VALIDSIG {signer} 2026-01-01 {int(time.time())-10} 0 4 0 1 8 00 {primary}\n'
|
||||
cases=[good.replace(' 8 00 ',' 11 00 '),good.replace(' 8 00 ',' 8 01 '),
|
||||
good.replace(' 0 4 0 ',' 1 4 0 '),good.replace(' 0 4 0 ',' 0 5 0 '),
|
||||
good.rstrip()+' extra\n',good.rsplit(' ',1)[0]+'\n',
|
||||
good+good,good+'[GNUPG:] KEYREVOKED\n',good+'[GNUPG:] BADARMOR\n',
|
||||
f'[GNUPG:] GOODSIG {signer[-16:]} fixture\n']
|
||||
for status in cases:
|
||||
with self.subTest(status=status):
|
||||
path.write_text(status)
|
||||
self.run_script('-o',ok=False,env={'TEST_FAKE_STATUS':str(path)})
|
||||
self.assertEqual(self.auth.read_bytes(),original)
|
||||
self.assert_clean()
|
||||
|
||||
def test_rotation_preserves_sequence_and_explicit_floor_cannot_lower_it(self):
|
||||
self.enroll()
|
||||
original=self.local_config.read_bytes()
|
||||
self.cert.write_text(self.fixtures['otherKey'])
|
||||
self.payload.write_text(self.fixtures['releases']['next']['data'])
|
||||
self.signature.write_text(self.fixtures['otherSig'])
|
||||
self.enroll(pin=self.fixtures['otherPin'])
|
||||
self.assertNotEqual(self.local_config.read_bytes(),original)
|
||||
self.assertIn('sequence 6\n',self.replay.read_text())
|
||||
self.enroll(pin=self.fixtures['otherPin'],extra=('--min-sequence','5'),ok=False)
|
||||
self.assertIn('sequence 6\n',self.replay.read_text())
|
||||
self.enroll(pin=self.fixtures['otherPin'],extra=('--min-sequence','7'),ok=False)
|
||||
self.assertIn('sequence 6\n',self.replay.read_text())
|
||||
self.assert_clean()
|
||||
|
||||
def test_missing_policy_does_not_reset_security_to_off(self):
|
||||
self.enroll()
|
||||
original=self.auth.read_bytes()
|
||||
self.local_config.unlink()
|
||||
self.run_script('-o',ok=False)
|
||||
self.assertEqual(self.auth.read_bytes(),original)
|
||||
|
||||
def test_worker_certificate_failure_can_use_pinned_local_public_ring_without_mutation(self):
|
||||
ambient=self.root/'local-public-ring'
|
||||
ambient.mkdir(mode=0o700)
|
||||
subprocess.run(['gpg','--batch','--no-options','--no-autostart','--yes','--dearmor','--output',str(ambient/'pubring.gpg'),str(self.cert)],check=True,capture_output=True)
|
||||
before={x.name:x.read_bytes() for x in ambient.iterdir()}
|
||||
self.run_tty('--install','--gpg-verification','on','--gpg-fingerprint',self.fixtures['primary'],'--list-id','fixture-list',env={'GNUPGHOME':str(ambient),'TEST_CERT_STATUS':'404'})
|
||||
self.assertNotIn('/public-key.gpg',self.calls.read_text())
|
||||
self.assertEqual({x.name:x.read_bytes() for x in ambient.iterdir()},before)
|
||||
self.assert_clean()
|
||||
|
||||
def test_corrupt_pending_package_blocks_reset_and_preserves_keys(self):
|
||||
self.enroll()
|
||||
original=self.auth.read_bytes()
|
||||
pending=self.home/'.conf-sshd/pending.bundle'
|
||||
pending.write_text('invalid\n')
|
||||
self.run_script('-o',ok=False)
|
||||
self.assertEqual(self.auth.read_bytes(),original)
|
||||
self.assertTrue(pending.exists())
|
||||
self.assertIn('sequence 5\n',self.replay.read_text())
|
||||
|
||||
|
||||
def test_expired_pending_is_not_applied_but_higher_fresh_release_can_progress(self):
|
||||
self.enroll()
|
||||
self.set_release('next')
|
||||
real_mv=shutil.which('mv')
|
||||
self.mock('mv',f'''#!/bin/sh
|
||||
last=
|
||||
for arg do last=$arg; done
|
||||
if [ "$last" = "$HOME/.ssh/authorized_keys" ]; then exit 74; fi
|
||||
exec "{real_mv}" "$@"
|
||||
''')
|
||||
self.old_auth(b'# retained manual key\n')
|
||||
self.run_script('-o',ok=False)
|
||||
original=self.auth.read_bytes()
|
||||
(self.bin/'mv').unlink()
|
||||
now=int(time.time())+3700
|
||||
self.mock('date',f'#!/bin/sh\nprintf "%s\\n" "{now}"\n')
|
||||
self.set_release('old')
|
||||
self.run_script('-o',ok=False)
|
||||
self.assertEqual(self.auth.read_bytes(),original)
|
||||
self.assertTrue((self.home/'.conf-sshd/pending.state').exists())
|
||||
self.set_release('later')
|
||||
self.run_script('-o')
|
||||
self.assertIn('sequence 7\n',self.replay.read_text())
|
||||
self.assertFalse((self.home/'.conf-sshd/pending.state').exists())
|
||||
self.assertTrue(self.auth.read_bytes().startswith(original))
|
||||
self.assert_clean()
|
||||
|
||||
def test_missing_security_state_does_not_block_stop_cron_or_uninstall(self):
|
||||
self.enroll(extra=('-c',))
|
||||
original=self.auth.read_bytes()
|
||||
self.replay.unlink()
|
||||
self.run_script('-c','false')
|
||||
self.assertNotIn(CB,self.cron.read_text())
|
||||
self.local_config.write_text('malformed but cleanup should work\n')
|
||||
self.run_script('--uninstall')
|
||||
self.assertFalse(self.installed.exists())
|
||||
self.assertEqual(self.auth.read_bytes(),original)
|
||||
|
||||
def test_reinstall_reuses_dedicated_certificate_before_worker_and_keyservers(self):
|
||||
self.enroll()
|
||||
self.calls.write_text('')
|
||||
self.run_tty('--install',env={'TEST_CERT_STATUS':'404'})
|
||||
calls=self.calls.read_text()
|
||||
self.assertNotIn('/public-key.gpg',calls)
|
||||
self.assertNotIn('--recv-keys',calls)
|
||||
self.assertNotIn('--locate-external-keys',calls)
|
||||
self.assert_clean()
|
||||
|
||||
def test_wkd_lookup_is_optional_bounded_and_stops_before_keyservers(self):
|
||||
self.run_tty('--install','--gpg-verification','on','--gpg-fingerprint',self.fixtures['primary'],'--list-id','fixture-list','--gpg-email','[email protected]',env={'TEST_CERT_STATUS':'404','TEST_DISCOVERY_SUCCESS':'wkd'})
|
||||
calls=self.calls.read_text()
|
||||
self.assertIn('--locate-external-keys -- [email protected]',calls)
|
||||
self.assertNotIn('--recv-keys',calls)
|
||||
self.assert_clean()
|
||||
|
||||
def test_keyserver_override_replaces_builtins_and_remains_after_reinstall(self):
|
||||
server='hkps://keys.fixture.invalid'
|
||||
self.run_tty('--install','--gpg-verification','on','--gpg-fingerprint',self.fixtures['primary'],'--list-id','fixture-list','--keyserver',server,env={'TEST_CERT_STATUS':'404','TEST_DISCOVERY_SUCCESS':server})
|
||||
calls=self.calls.read_text()
|
||||
self.assertIn('--keyserver '+server,calls)
|
||||
self.assertNotIn('--keyserver hkps://keys.openpgp.org',calls)
|
||||
self.assertNotIn('--keyserver hkps://keyserver.ubuntu.com',calls)
|
||||
self.assertIn('keyserver '+server+'\n',self.local_config.read_text())
|
||||
self.assertNotIn('keys.openpgp.org',self.local_config.read_text())
|
||||
|
||||
|
||||
def test_failed_script_upgrade_never_activates_on_policy_over_legacy_script(self):
|
||||
self.installed.parent.mkdir(mode=0o700)
|
||||
self.installed.write_text('#!/bin/sh\n# legacy client without verification\n')
|
||||
old_script=self.installed.read_bytes()
|
||||
old_keys=self.old_auth()
|
||||
real_mv=shutil.which('mv')
|
||||
self.mock('mv',f'''#!/bin/sh
|
||||
last=
|
||||
for arg do last=$arg; done
|
||||
if [ "$last" = "$HOME/.conf-sshd/conf-sshd.sh" ]; then exit 74; fi
|
||||
exec "{real_mv}" "$@"
|
||||
''')
|
||||
self.enroll(ok=False)
|
||||
self.assertEqual(self.installed.read_bytes(),old_script)
|
||||
self.assertEqual(self.auth.read_bytes(),old_keys)
|
||||
self.assertFalse(self.local_config.exists())
|
||||
self.assertFalse(self.replay.exists())
|
||||
self.assertEqual(list(self.installed.parent.glob('trust-*.asc')),[])
|
||||
|
||||
def test_keys_only_enrollment_refuses_to_leave_legacy_scheduled_executable(self):
|
||||
self.installed.parent.mkdir(mode=0o700)
|
||||
self.installed.write_text('#!/bin/sh\n# old script\n')
|
||||
self.run_tty('-o','--gpg-verification','on','--gpg-fingerprint',self.fixtures['primary'],'--list-id','fixture-list',ok=False)
|
||||
self.assertFalse(self.local_config.exists())
|
||||
self.assertNotIn('curl ',self.calls.read_text())
|
||||
|
||||
def test_install_cron_false_preserves_policy_and_never_uses_cleanup_bypass(self):
|
||||
self.enroll(extra=('-c',))
|
||||
original=self.local_config.read_bytes()
|
||||
self.run_tty('--install','-c','false')
|
||||
self.assertEqual(self.local_config.read_bytes(),original)
|
||||
self.assertNotIn(CB,self.cron.read_text())
|
||||
fresh=self.root/'other-home'
|
||||
fresh.mkdir(mode=0o700)
|
||||
self.run_script('--install','-c','false',env={'HOME':str(fresh)})
|
||||
self.assertIn('policy off\n',(fresh/'.conf-sshd/local.conf').read_text())
|
||||
|
||||
def test_missing_dedicated_certificate_can_be_recovered_by_explicit_install(self):
|
||||
self.enroll()
|
||||
next((self.home/'.conf-sshd').glob('trust-*.asc')).unlink()
|
||||
self.run_tty('--install')
|
||||
self.assertTrue(next((self.home/'.conf-sshd').glob('trust-*.asc')).exists())
|
||||
self.assertIn('sequence 5\n',self.replay.read_text())
|
||||
|
||||
|
||||
def test_certificate_export_failure_continues_to_next_discovery_source(self):
|
||||
wrapper=self.bin/'gpg'
|
||||
text=wrapper.read_text()
|
||||
needle='home=; server=; action=\n'
|
||||
injected="""if [ \"${TEST_FAIL_FIRST_EXPORT:-0}\" = 1 ]; then
|
||||
for arg do
|
||||
if [ \"$arg\" = --export ] && [ ! -f \"$TEST_ROOT/export-failed-once\" ]; then
|
||||
: > \"$TEST_ROOT/export-failed-once\"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
"""
|
||||
self.assertIn(needle,text)
|
||||
wrapper.write_text(text.replace(needle,injected+needle,1))
|
||||
self.run_tty('--install','--gpg-verification','on','--gpg-fingerprint',self.fixtures['primary'],'--list-id','fixture-list',env={'TEST_FAIL_FIRST_EXPORT':'1','TEST_DISCOVERY_SUCCESS':'hkps://keys.openpgp.org'})
|
||||
calls=self.calls.read_text()
|
||||
self.assertIn('/public-key.gpg',calls)
|
||||
self.assertIn('--keyserver hkps://keys.openpgp.org --recv-keys',calls)
|
||||
self.assertTrue((self.root/'export-failed-once').exists())
|
||||
self.assertTrue(self.installed.exists())
|
||||
self.assert_clean()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
suite=unittest.TestSuite()
|
||||
suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(ShellTests))
|
||||
suite.addTests(SignatureTests(name) for name in SignatureTests.__dict__ if name.startswith('test_'))
|
||||
result=unittest.TextTestRunner(verbosity=2).run(suite)
|
||||
raise SystemExit(not result.wasSuccessful())
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile, mkdtemp, writeFile, rm, cp, mkdir} from 'node:fs/promises';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {execFileSync} from 'node:child_process';
|
||||
import {makeSignedDocument, parseSignedDocument, parseSignedBundle, parsePublishedSignedRelease, SIGNATURE_SEPARATOR, SIGNED_RELEASE_LIMITS, validateSignature} from '../cf-worker/src/signed-release.js';
|
||||
import {prepare} from '../scripts/prepare-key-release.mjs';
|
||||
import {openPgpFixture} from './helpers/openpgp-fixture.mjs';
|
||||
import {verifyRemote} from '../scripts/verify-release.mjs';
|
||||
import {validateConfig, validateClientDefaults, build, root} from '../scripts/build.mjs';
|
||||
import {validatePublicCertificate, checkSigningCertificate, validatePayloadSignatureStatus} from '../scripts/signature-tools.mjs';
|
||||
|
||||
const fixture = await readFile(new URL('./fixtures/keys.pub', import.meta.url), 'utf8');
|
||||
const canonical = fixture.trim().split(/\s+/).slice(0, 2).join(' ') + '\n';
|
||||
const policy = {listId: 'production', sequence: 1, issuedAt: 100, expiresAt: 300};
|
||||
const document = await makeSignedDocument(fixture, policy);
|
||||
// Structural fixture only: this armor is NOT a cryptographic signature and must never be trusted as one.
|
||||
const signature = '-----BEGIN PGP SIGNATURE-----\n\nYWJj\n-----END PGP SIGNATURE-----\n';
|
||||
|
||||
test('preparation strips all key comments BEFORE signing; document bytes and metadata are exact', async () => {
|
||||
assert.equal(document, '# conf-sshd-signed-v1\n# list-id production\n# sequence 1\n# issued-at 100\n# expires-at 300\n# revoke no\n# key-count 1\n# end-metadata\n' + canonical);
|
||||
const parsed = await parseSignedDocument(document, {now: 200, listId: 'production'});
|
||||
assert.equal(parsed.document, document);
|
||||
assert.equal(parsed.keys[0].label, 'Server key 1');
|
||||
assert.equal(parsed.metadata.documentSha256, createHash('sha256').update(document).digest('hex'));
|
||||
});
|
||||
|
||||
test('bundle parsing returns exact document and signature without implying cryptographic verification', async () => {
|
||||
const bundle = document + SIGNATURE_SEPARATOR + signature;
|
||||
const parsed = await parseSignedBundle(bundle, {now: 200});
|
||||
assert.equal(parsed.bundle, bundle); assert.equal(parsed.document, document); assert.equal(parsed.signature, signature);
|
||||
assert.equal(parsed.verified, undefined);
|
||||
assert.equal(parsed.metadata.sequence, 1);
|
||||
});
|
||||
|
||||
test('signed documents reject comments, CRLF, normalization, duplicate keys, padding changes and trailing bytes', async () => {
|
||||
for (const changed of [document.replace(canonical, fixture), document.replaceAll('\n','\r\n'), document.replace(canonical, canonical.replace(' ', '\t')), document.replace(canonical, canonical + canonical), document + '\n', document.slice(0, -1), document.replace(canonical, canonical + '# extra\n'), document.replace('key-count 1','key-count 2')]) await assert.rejects(parseSignedDocument(changed, {now:200}));
|
||||
});
|
||||
|
||||
test('metadata is fixed-order, single-instance, canonical and bounded', async () => {
|
||||
for (const [from,to] of [['sequence 1','sequence 0'], ['sequence 1','sequence 01'], ['sequence 1','sequence 2147483648'], ['issued-at 100','issued-at 0100'], ['expires-at 300','expires-at 100'], ['expires-at 300','expires-at 10000000000'], ['list-id production','list-id has space'], ['revoke no','revoke yes'], ['key-count 1','key-count 1001'], ['# end-metadata','# unknown value\n# end-metadata']]) await assert.rejects(parseSignedDocument(document.replace(from,to), {now:200}));
|
||||
await assert.rejects(parseSignedDocument(document, {now:200,listId:'different'}));
|
||||
await assert.rejects(parseSignedDocument(document + 'x'.repeat(SIGNED_RELEASE_LIMITS.documentBytes), {now:200}));
|
||||
});
|
||||
|
||||
test('freshness rejects expiration and future issue, optional transport parsing does not enforce client freshness', async () => {
|
||||
await assert.rejects(parseSignedDocument(document, {now:300}), /expired/);
|
||||
const future = await makeSignedDocument(fixture, {...policy,issuedAt:501,expiresAt:800});
|
||||
await assert.rejects(parseSignedDocument(future, {now:200}), /not yet/);
|
||||
assert.equal((await parseSignedDocument(future, {now:201})).metadata.issuedAt, 501);
|
||||
assert.equal((await parseSignedDocument(document, {now:999,checkFreshness:false})).metadata.sequence, 1);
|
||||
});
|
||||
|
||||
test('empty revocation requires signed explicit revoke, no nonempty revoke accepted', async () => {
|
||||
const empty = await makeSignedDocument('', {...policy,revoke:true});
|
||||
assert.equal((await parseSignedDocument(empty, {now:200})).metadata.revoke, true);
|
||||
for (const args of [['',policy], [fixture,{...policy,revoke:true}]]) await assert.rejects(makeSignedDocument(...args));
|
||||
});
|
||||
|
||||
test('malformed or missing signatures do not block OFF documents but fail strict shape policy', async () => {
|
||||
for (const invalid of [null, '', signature.replace('PGP SIGNATURE','PGP PRIVATE KEY BLOCK'), signature.slice(0,-1), signature + signature, 'x'.repeat(SIGNED_RELEASE_LIMITS.signatureBytes+1)]) {
|
||||
const off = await parsePublishedSignedRelease(document, invalid, {checkFreshness:false});
|
||||
assert.equal(off.document, document); assert.equal(off.signature,null); assert.equal(off.bundle,null);
|
||||
await assert.rejects(parsePublishedSignedRelease(document, invalid, {now:200,requireSigned:true}));
|
||||
}
|
||||
assert.equal(validateSignature(signature), signature);
|
||||
await assert.rejects(parseSignedBundle(document + SIGNATURE_SEPARATOR + signature + SIGNATURE_SEPARATOR, {now:200}));
|
||||
});
|
||||
|
||||
test('preparation CLI never overwrites source/output and packs without altering signed bytes', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(),'conf-prep-'));
|
||||
try {
|
||||
const input=join(dir,'keys'), output=join(dir,'document'), sig=join(dir,'signature'), bundle=join(dir,'bundle');
|
||||
await writeFile(input, fixture); await writeFile(sig, signature);
|
||||
const args=['--input',input,'--output',output,'--list-id','production','--sequence','1','--issued-at','100','--expires-at','300'];
|
||||
await prepare(args); assert.equal(await readFile(output,'utf8'),document);
|
||||
await assert.rejects(prepare(args),/EEXIST/);
|
||||
await prepare(['--input',output,'--signature',sig,'--bundle',bundle]);
|
||||
assert.equal(await readFile(bundle,'utf8'),document+SIGNATURE_SEPARATOR+signature);
|
||||
await assert.rejects(prepare([...args,'--sequence','2']));
|
||||
await assert.rejects(prepare(args.map(value=>value==='100'?'0100':value)));
|
||||
} finally { await rm(dir,{recursive:true,force:true}); }
|
||||
});
|
||||
|
||||
test('deployment client defaults are OFF and reject unsafe templates or incomplete ON pins', () => {
|
||||
assert.deepEqual(validateClientDefaults(),{gpgVerification:'off',fingerprint:'',pinMode:'primary',listId:'',email:''});
|
||||
assert.equal(validateClientDefaults({gpgVerification:'on',fingerprint:'a'.repeat(40),listId:'prod',email:'[email protected]'}).fingerprint,'A'.repeat(40));
|
||||
for (const value of [null,{gpgVerification:'yes'},{gpgVerification:'on'},{fingerprint:'short'},{listId:'bad space'},{listId:'-'},{pinMode:'any'},{email:"a'@example.com"},{email:'[email protected]\n'},{unknown:1}]) assert.throws(()=>validateClientDefaults(value));
|
||||
const base={baseUrl:'https://ssh.example.com',repositoryUrl:'https://github.com/example/repo',defaultCron:'*/15 * * * *',allowEmpty:false};
|
||||
assert.doesNotThrow(()=>validateConfig({...base,signedRelease:{listId:'prod',publicKeyFile:'keys/certificate.asc',fingerprint:'A'.repeat(40)}}));
|
||||
for (const signedRelease of [{listId:'prod',publicKeyFile:'../secret'}, {listId:'prod',fingerprint:'A'.repeat(40)}, {listId:'bad id'}, {listId:'prod',publicKeyFile:'keys/key.asc',fingerprint:'123'}]) assert.throws(()=>validateConfig({...base,signedRelease}));
|
||||
});
|
||||
|
||||
test('public certificate envelope rejects private material and unbounded input', () => {
|
||||
for (const text of ['-----BEGIN PGP PRIVATE KEY BLOCK-----\nabc\n-----END PGP PRIVATE KEY BLOCK-----\n', 'x'.repeat(SIGNED_RELEASE_LIMITS.publicKeyBytes+1), '-----BEGIN PGP PUBLIC KEY BLOCK-----\nabc\n-----END PGP PUBLIC KEY BLOCK-----\n-----BEGIN PGP PUBLIC KEY BLOCK-----\nx\n-----END PGP PUBLIC KEY BLOCK-----\n']) assert.throws(()=>validatePublicCertificate(text));
|
||||
});
|
||||
|
||||
async function buildFixture(config, action) {
|
||||
const directory=await mkdtemp(join(tmpdir(),'conf-signed-build-'));
|
||||
try {
|
||||
await cp(join(root,'site'),join(directory,'site'),{recursive:true});await mkdir(join(directory,'keys'));
|
||||
await cp(join(root,'conf-sshd.sh'),join(directory,'conf-sshd.sh'));
|
||||
await writeFile(join(directory,'deployment.json'),JSON.stringify({baseUrl:'https://ssh.example.com',repositoryUrl:'https://github.com/example/repo',defaultCron:'*/15 * * * *',allowEmpty:false,...config}));
|
||||
return await action(directory,()=>build({directory,revision:'d'.repeat(40),publishedAt:'2026-10-13T00:00:00.000Z'}));
|
||||
} finally {await rm(directory,{recursive:true,force:true});}
|
||||
}
|
||||
|
||||
test('Git build publishes exact detached files as Worker Static Assets and generated bytes', async () => {
|
||||
await buildFixture({},async (dir,buildNow)=>{
|
||||
await writeFile(join(dir,'keys/ssh.keys'),document);await writeFile(join(dir,'keys/ssh.keys.sig'),signature);
|
||||
await writeFile(join(dir,'keys/authorized_keys'),'# ordinary repository file deliberately empty\n');
|
||||
const result=await buildNow();assert.equal(result.keyCount,1);assert.equal(result.signedDocument,document);
|
||||
assert.equal(result.signature,signature);assert.equal(result.signatureAvailable,true);
|
||||
assert.equal(await readFile(join(dir,'cf-worker/public/ssh.keys'),'utf8'),document);
|
||||
assert.equal(await readFile(join(dir,'cf-worker/public/ssh.keys.sig'),'utf8'),signature);
|
||||
assert.equal(result.sha256,createHash('sha256').update(canonical).digest('hex'));
|
||||
const manifest=JSON.parse(await readFile(join(dir,'cf-worker/generated/manifest.json'),'utf8'));
|
||||
assert.equal(manifest.signedMetadata.sequence,1);assert.equal(manifest.signedDocument,undefined);
|
||||
assert.equal(manifest.signature,undefined);assert.equal(manifest.publicCertificate,undefined);
|
||||
});
|
||||
});
|
||||
|
||||
test('Git missing/malformed signature is usable with OFF; bundle and detached files cannot conflict', async () => {
|
||||
await buildFixture({},async (dir,buildNow)=>{
|
||||
await writeFile(join(dir,'keys/ssh.keys'),document);
|
||||
for(const content of [null,'bad']){
|
||||
if(content!==null)await writeFile(join(dir,'keys/ssh.keys.sig'),content);
|
||||
const result=await buildNow();assert.equal(result.signedDocument,document);assert.equal(result.signature,null);
|
||||
await assert.rejects(readFile(join(dir,'cf-worker/public/ssh.keys.sig')),{code:'ENOENT'});
|
||||
}
|
||||
await writeFile(join(dir,'keys/release.bundle'),document+SIGNATURE_SEPARATOR+signature);
|
||||
await assert.rejects(buildNow(),/not both/);
|
||||
});
|
||||
});
|
||||
|
||||
test('Git revocation bundle overrides legacy empty policy without falling back', async () => {
|
||||
await buildFixture({fallbackSources:['https://keys.example.com/fallback']},async (dir,buildNow)=>{
|
||||
const revoke=await makeSignedDocument('',{...policy,revoke:true});
|
||||
await writeFile(join(dir,'keys/release.bundle'),revoke+SIGNATURE_SEPARATOR+signature);
|
||||
const result=await buildNow();assert.equal(result.allowEmpty,true);assert.equal(result.keyCount,0);assert.equal(result.needsFallback,false);
|
||||
assert.equal(await readFile(join(dir,'cf-worker/public/ssh.keys'),'utf8'),revoke);
|
||||
});
|
||||
});
|
||||
|
||||
test('dynamic Gist builds need no local keys and must not publish stale static key placeholders', async () => {
|
||||
await buildFixture({keySource:{type:'gist',id:'a'.repeat(32),ownerId:7,ownerLogin:'example'}},async (dir,buildNow)=>{
|
||||
await writeFile(join(dir,'keys/authorized_keys'),fixture);
|
||||
const result=await buildNow();assert.equal(result.keys.length,0);assert.equal(result.needsSource,true);assert.equal(result.sha256,null);
|
||||
await assert.rejects(readFile(join(dir,'cf-worker/public/ssh.keys')),{code:'ENOENT'});
|
||||
assert.doesNotMatch(await readFile(join(dir,'cf-worker/generated/release.js'),'utf8'),/TEST-ONLY/);
|
||||
});
|
||||
});
|
||||
|
||||
test('ON static defaults fail known-missing signatures and mismatched list IDs', async () => {
|
||||
await buildFixture({clientDefaults:{gpgVerification:'on',fingerprint:'A'.repeat(40),listId:'production'}},async (dir,buildNow)=>{
|
||||
await writeFile(join(dir,'keys/authorized_keys'),fixture);
|
||||
await assert.rejects(buildNow(),/require a signed document/);
|
||||
await writeFile(join(dir,'keys/ssh.keys'),document);await writeFile(join(dir,'keys/ssh.keys.sig'),signature);
|
||||
assert.equal((await buildNow()).clientDefaults.gpgVerification,'on');
|
||||
await writeFile(join(dir,'keys/ssh.keys'),document.replace('list-id production','list-id wrong'));
|
||||
await assert.rejects(buildNow(),/list ID differs/);
|
||||
});
|
||||
});
|
||||
|
||||
test('real detached signatures verify with a public-only isolated GPG keyring and reject tampering/pin mismatch', async () => {
|
||||
const fixture = openPgpFixture({subkey:true});
|
||||
const actualSignature=fixture.sign(document), bundle=document+SIGNATURE_SEPARATOR+actualSignature;
|
||||
assert.equal(await checkSigningCertificate(fixture.publicKey,fixture.primaryFingerprint,bundle),fixture.primaryFingerprint);
|
||||
await assert.rejects(checkSigningCertificate(fixture.publicKey,'A'.repeat(40),bundle),/fingerprint mismatch/);
|
||||
await assert.rejects(checkSigningCertificate(fixture.publicKey,fixture.primaryFingerprint,document.replace('sequence 1','sequence 2')+SIGNATURE_SEPARATOR+actualSignature),/verification failed/);
|
||||
await assert.rejects(checkSigningCertificate(fixture.publicKey,fixture.primaryFingerprint,document+SIGNATURE_SEPARATOR+signature),/verification failed/);
|
||||
});
|
||||
|
||||
test('real revoked and expired signing identities fail even for formerly valid payload signatures', async () => {
|
||||
for(const options of [{expired:true},{revoked:true}]) {
|
||||
const fixture=openPgpFixture(options);
|
||||
await assert.rejects(checkSigningCertificate(fixture.publicKey,fixture.primaryFingerprint,document+SIGNATURE_SEPARATOR+fixture.sign(document)));
|
||||
}
|
||||
});
|
||||
|
||||
test('strict GPG status requires binary v4 strong hash and rejects every failure marker', () => {
|
||||
const fp='A'.repeat(40), status=`[GNUPG:] VALIDSIG ${fp} 2026-01-01 100 0 4 0 1 8 00 ${fp}\n`;
|
||||
assert.equal(validatePayloadSignatureStatus(status,fp).primaryFingerprint,fp);
|
||||
for(const token of ['BADSIG','ERRSIG','NO_PUBKEY','EXPSIG','EXPKEYSIG','REVKEYSIG','KEYEXPIRED','SIGEXPIRED','KEYREVOKED','FAILURE','ERROR','NODATA','BADARMOR']) assert.throws(()=>validatePayloadSignatureStatus(status+`[GNUPG:] ${token} value\n`,fp));
|
||||
for(const invalid of [status+status,status.replace('1 8 00','1 11 00'),status.replace('1 8 00','1 2 00'),status.replace('1 8 00','1 8 01'),status.replace('0 4 0','0 5 0'),status.trim().split(' ').slice(0,-1).join(' '),status.replace('100 0 4','100 101 4')]) assert.throws(()=>validatePayloadSignatureStatus(invalid,fp));
|
||||
});
|
||||
|
||||
test('build publishes only a validated public certificate and verifies exact signed bytes against its primary pin', async () => {
|
||||
const fixture=openPgpFixture({subkey:true});
|
||||
await buildFixture({signedRelease:{listId:'production',publicKeyFile:'keys/certificate.asc',fingerprint:fixture.primaryFingerprint}},async (dir,buildNow)=>{
|
||||
await writeFile(join(dir,'keys/certificate.asc'),fixture.publicKey);
|
||||
await writeFile(join(dir,'keys/ssh.keys'),document);await writeFile(join(dir,'keys/ssh.keys.sig'),fixture.sign(document));
|
||||
const release=await buildNow();assert.equal(release.publicCertificate,fixture.publicKey);
|
||||
assert.equal(await readFile(join(dir,'cf-worker/public/public-key.gpg'),'utf8'),fixture.publicKey);
|
||||
await writeFile(join(dir,'keys/ssh.keys'),document.replace('sequence 1','sequence 2'));
|
||||
await assert.rejects(buildNow(),/verification failed/);
|
||||
});
|
||||
});
|
||||
|
||||
test('remote verification ON cannot pass a dynamic Gist with a missing detached signature', async () => {
|
||||
const revision='a'.repeat(40), gist={type:'gist',id:'b'.repeat(32),ownerId:1,ownerLogin:'user'}, sha256=createHash('sha256').update(canonical).digest('hex');
|
||||
const manifest={revision,baseUrl:'https://ssh.example.com',keySource:gist,needsSource:true,fallbackSources:[],clientDefaults:{gpgVerification:'on'}};
|
||||
const fetcher=async url=>new Response(url.endsWith('/release.json')?JSON.stringify({revision,sha256,keyCount:1,source:{...gist,gistRevision:'c'.repeat(40)}}):url.endsWith('/ssh.keys')?document:'',{status:url.endsWith('/ssh.keys.sig')?404:200});
|
||||
await assert.rejects(verifyRemote(manifest,fetcher),/signature is missing/);
|
||||
const text=await verifyRemote({...manifest,clientDefaults:{gpgVerification:'off'}},fetcher);
|
||||
assert.match(text,/OpenPGP not checked/);
|
||||
});
|
||||
|
||||
|
||||
test('cryptographically valid text-mode signatures are rejected by exact-byte payload policy',async()=>{
|
||||
const fixture=openPgpFixture();
|
||||
const textSignature=fixture.signText(document);
|
||||
const dir=await mkdtemp(join(tmpdir(),'conf-text-signature-'));
|
||||
try {
|
||||
const cert=join(dir,'public.asc'),ring=join(dir,'public.gpg'),doc=join(dir,'document'),sig=join(dir,'document.sig');
|
||||
await writeFile(cert,fixture.publicKey,{mode:0o600});
|
||||
await writeFile(doc,document,{mode:0o600});
|
||||
await writeFile(sig,textSignature,{mode:0o600});
|
||||
const args=['--no-options','--homedir',dir,'--batch','--no-tty','--no-autostart','--no-auto-key-retrieve','--auto-key-locate','clear'];
|
||||
execFileSync('gpg',[...args,'--output',ring,'--dearmor',cert],{stdio:'pipe'});
|
||||
const status=execFileSync('gpg',[...args,'--no-default-keyring','--keyring',ring,'--status-fd','1','--verify',sig,doc],{encoding:'utf8',stdio:['ignore','pipe','pipe']});
|
||||
assert.match(status,/\[GNUPG:\] VALIDSIG .* 01 /);
|
||||
await assert.rejects(checkSigningCertificate(fixture.publicKey,fixture.primaryFingerprint,document+SIGNATURE_SEPARATOR+textSignature),/binary/);
|
||||
} finally {await rm(dir,{recursive:true,force:true});}
|
||||
});
|
||||
|
||||
|
||||
test('list identity dash is reserved consistently with local enrollment state',async()=>{
|
||||
await assert.rejects(makeSignedDocument(fixture,{...policy,listId:'-'}),/list ID/);
|
||||
await assert.rejects(parseSignedDocument(document.replace('# list-id production','# list-id -'),{checkFreshness:false}),/list ID/);
|
||||
assert.throws(()=>validateConfig({baseUrl:'https://ssh.example.com',repositoryUrl:'https://github.com/test/repo',defaultCron:'*/15 * * * *',allowEmpty:false,signedRelease:{listId:'-'}}),/list ID/);
|
||||
});
|
||||
+162
-66
@@ -2,10 +2,14 @@ import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
import { commandsFor, renderPage } from "../cf-worker/src/view.js";
|
||||
import { makeSignedDocument, parsePublishedSignedRelease } from "../cf-worker/src/signed-release.js";
|
||||
|
||||
const script = await readFile(new URL("../site/assets/app.js", import.meta.url), "utf8");
|
||||
const html = await readFile(new URL("../site/index.html", import.meta.url), "utf8");
|
||||
const css = await readFile(new URL("../site/assets/style.css", import.meta.url), "utf8");
|
||||
const config = { baseUrl: "https://example.test", repositoryUrl: "https://github.com/example/repo", defaultCron: "*/15 * * * *" };
|
||||
const commands = commandsFor(config);
|
||||
|
||||
class Element {
|
||||
constructor(id, text = "") {
|
||||
@@ -32,12 +36,11 @@ class Element {
|
||||
close() { this.open = false; this.dispatch("close"); }
|
||||
}
|
||||
|
||||
function setup({ keys = ["ssh-ed25519 AAAA exact comment"], savedTheme, brokenStorage = false, clipboard = async () => {}, rawRelease } = {}) {
|
||||
function setup({ commandTexts = commands.map(({command}) => command), savedTheme, brokenStorage = false, clipboard = async () => {}, missingTarget = false } = {}) {
|
||||
const root = new Element("root");
|
||||
const ids = ["theme-picker", "copy-status", "copy-fallback", "fallback-text", "close-copy-fallback", "copy-all-keys", "release-data"];
|
||||
const ids = ["theme-picker", "copy-status", "copy-fallback", "fallback-text", "close-copy-fallback"];
|
||||
const elements = new Map(ids.map((id) => [id, new Element(id)]));
|
||||
const keyElements = keys.map((text, index) => new Element(`key-${index}`, text));
|
||||
const codeElements = [...keyElements, new Element("command-install", "curl -fsSL https://example.test/script.sh | sh -s -- -c")];
|
||||
const codeElements = commandTexts.map((text, index) => new Element(`command-${index}`, text));
|
||||
const pres = codeElements.map((code) => new Element(`pre-${code.id}`));
|
||||
const buttons = codeElements.map((code) => {
|
||||
const button = new Element(`copy-${code.id}`);
|
||||
@@ -46,16 +49,12 @@ function setup({ keys = ["ssh-ed25519 AAAA exact comment"], savedTheme, brokenSt
|
||||
return button;
|
||||
});
|
||||
for (const element of codeElements) elements.set(element.id, element);
|
||||
elements.get("release-data").textContent = rawRelease ?? JSON.stringify({ keys: keys.map((text) => ({ text })) });
|
||||
elements.get("copy-all-keys").setAttribute("aria-label", "复制全部公钥");
|
||||
if (missingTarget) elements.delete(codeElements[0].id);
|
||||
const requestedIds = [];
|
||||
const document = {
|
||||
documentElement: root,
|
||||
getElementById: (id) => elements.get(id) || null,
|
||||
querySelectorAll: (selector) => ({
|
||||
".code-block pre": pres,
|
||||
"[data-copy-target]": buttons,
|
||||
".key-card pre code": keyElements,
|
||||
})[selector] || [],
|
||||
getElementById: (id) => { requestedIds.push(id); return elements.get(id) || null; },
|
||||
querySelectorAll: (selector) => ({ ".code-block pre": pres, "[data-copy-target]": buttons })[selector] || [],
|
||||
};
|
||||
const stored = new Map(savedTheme ? [["quickly-conf-sshd-theme", savedTheme]] : []);
|
||||
const timers = new Map();
|
||||
@@ -71,15 +70,22 @@ function setup({ keys = ["ssh-ed25519 AAAA exact comment"], savedTheme, brokenSt
|
||||
clearTimeout: (id) => timers.delete(id),
|
||||
});
|
||||
vm.runInContext(script, context);
|
||||
return { root, elements, keyElements, codeElements, buttons, pres, stored, flushTimers: () => {
|
||||
return { root, elements, codeElements, buttons, pres, stored, requestedIds, flushTimers: () => {
|
||||
const pending = [...timers.values()];
|
||||
timers.clear();
|
||||
pending.forEach((callback) => callback());
|
||||
} };
|
||||
}
|
||||
|
||||
test("homepage prerenders content, release metadata, and independent local assets", () => {
|
||||
for (const token of ["RELEASE_JSON", "KEYS_HTML", "COMMANDS_HTML", "REVISION", "PUBLISHED_AT", "KEY_COUNT", "REPOSITORY_URL", "RELEASE_LABEL", "PREVIEW_NOTICE", "SOURCE_LABEL", "SOURCE_STATUS", "CONTENT_REVISION", "KEYS_NOTICE"]) {
|
||||
function snapshot(overrides = {}) {
|
||||
return {revision: "a".repeat(40), publishedAt: "2026-10-12T00:00:00Z", keys: [], keyCount: 1, sha256: "b".repeat(64), keyRevision: "c".repeat(64), commands, ...config, ...overrides};
|
||||
}
|
||||
function embeddedMetadata(page) {
|
||||
return JSON.parse(page.match(/<script id="release-data" type="application\/json">([\s\S]*?)<\/script>/)[1]);
|
||||
}
|
||||
|
||||
test("homepage prerenders metadata and commands, never a public-key listing", () => {
|
||||
for (const token of ["RELEASE_JSON", "COMMANDS_HTML", "REVISION", "PUBLISHED_AT", "KEY_COUNT", "REPOSITORY_URL", "RELEASE_LABEL", "PREVIEW_NOTICE", "SOURCE_LABEL", "SOURCE_STATUS", "SIGNATURE_STATUS", "CERTIFICATE_STATUS", "CERTIFICATE_LINK", "CONTENT_REVISION", "KEYS_NOTICE"]) {
|
||||
assert.ok(html.includes(`@@${token}@@`), `${token} placeholder is required`);
|
||||
}
|
||||
assert.match(html, /<html lang="zh-CN">/);
|
||||
@@ -90,8 +96,124 @@ test("homepage prerenders content, release metadata, and independent local asset
|
||||
assert.match(html, /<noscript>/);
|
||||
assert.match(html, /不代表所有服务器均已同步/);
|
||||
assert.match(html, /不会要求上传或输入私钥/);
|
||||
assert.doesNotMatch(html, /github\.io|https?:[^"']+\.(?:css|js)["']/);
|
||||
assert.doesNotMatch(html, /copy-all-commands/);
|
||||
assert.match(html, /公钥不是秘密/);
|
||||
assert.match(html, /原始 API 与公开 Git \/ Gist/);
|
||||
assert.doesNotMatch(html, /KEYS_HTML|key-list|key-card|copy-all|github\.io|https?:[^"']+\.(?:css|js)["']/);
|
||||
});
|
||||
|
||||
test("homepage projection excludes raw keys, fingerprints, comments and signed document content", () => {
|
||||
const secretMarker = "must-not-appear-in-homepage";
|
||||
const key = {text: `ssh-ed25519 ${secretMarker} comment`, fingerprint: `SHA256:${secretMarker}`, label: `${secretMarker}<script>`, type: "ssh-ed25519"};
|
||||
const page = renderPage(html, snapshot({keys: [key], keysText: key.text, signedDocument: key.text, signature: `-----BEGIN PGP SIGNATURE-----\nComment: ${secretMarker}\n\nAQID\n-----END PGP SIGNATURE-----\n`, publicCertificate: secretMarker, signedBundle: secretMarker, signedMetadata: {listId: secretMarker}, source: {type: "gist", url: "https://gist.github.com/example/example", keys: [key], fingerprint: key.fingerprint}}));
|
||||
assert.doesNotMatch(page, new RegExp(secretMarker));
|
||||
assert.doesNotMatch(page, /ssh-ed25519|SHA256:|data-copy-target="key-|id="key-\d|class="key-card"/);
|
||||
const embedded = embeddedMetadata(page);
|
||||
assert.deepEqual(Object.keys(embedded).sort(), ["schema", "revision", "publishedAt", "keyCount", "sha256", "keyRevision", "source", "sourceStatus", "signaturePresent", "signatureStatus", "certificatePresent", "certificateStatus", "repositoryUrl", "testFixture"].sort());
|
||||
assert.deepEqual(embedded.source, {type: "gist", url: "https://gist.github.com/example/example"});
|
||||
assert.equal(embedded.keyCount, 1);
|
||||
assert.equal(embedded.keyRevision, "c".repeat(64));
|
||||
assert.equal(embedded.signaturePresent, true);
|
||||
assert.match(embedded.signatureStatus, /待客户端验证/);
|
||||
assert.doesNotMatch(embedded.signatureStatus, /已验签|已验证签名|verified/i);
|
||||
assert.match(page, /不代表 Worker 已验证签名/);
|
||||
});
|
||||
|
||||
test("source states and signature presence describe one snapshot without claiming verification", () => {
|
||||
for (const [type, status] of [["repository", "可用"], ["git", "可用"], ["gist", "可用"], ["fallback", "备用源可用"], ["unavailable", "暂不可用"], ["pending", "待获取"], ["explicit-empty", "明确撤销"]]) {
|
||||
const unavailable = ["unavailable", "pending"].includes(type);
|
||||
const empty = unavailable || type === "explicit-empty";
|
||||
const state = snapshot({source: {type, url: 'https://example.test/list?text=<script>window.__injected=true</script>'}, keyCount: empty ? 0 : 1, allowEmpty: type === "explicit-empty", sha256: empty ? null : "b".repeat(64), keyRevision: empty ? null : "c".repeat(64)});
|
||||
const page = renderPage(html, state);
|
||||
const embedded = embeddedMetadata(page);
|
||||
assert.equal(embedded.sourceStatus, status);
|
||||
assert.equal(embedded.keyCount, empty ? 0 : 1);
|
||||
assert.equal(embedded.signaturePresent, false);
|
||||
assert.equal(embedded.signatureStatus, "未提供签名包");
|
||||
assert.doesNotMatch(page, /<script>window\.__injected/);
|
||||
if (unavailable) assert.match(page, /未发布撤销指令.*保留上次成功密钥/);
|
||||
if (type === "explicit-empty") assert.match(page, /明确撤销全部受管理公钥/);
|
||||
}
|
||||
assert.equal(embeddedMetadata(renderPage(html, snapshot({signature: "unvalidated signature alone"}))).signaturePresent, false);
|
||||
assert.match(renderPage(html, snapshot({source: {type: "gist"}, keyCount: 0, allowEmpty: true})), /class="source-status">明确撤销/);
|
||||
});
|
||||
|
||||
test("a parsed valid document with missing or malformed signature stays unsigned on the homepage", async () => {
|
||||
const fixture = await readFile(new URL("./fixtures/keys.pub", import.meta.url), "utf8");
|
||||
const document = await makeSignedDocument(fixture, {listId: "homepage-regression", sequence: 1, issuedAt: 1, expiresAt: 2});
|
||||
for (const signature of [null, "malformed-detached-signature"]) {
|
||||
const parsed = await parsePublishedSignedRelease(document, signature, {checkFreshness: false});
|
||||
assert.ok(parsed.metadata);
|
||||
assert.equal(parsed.signature, null);
|
||||
const page = renderPage(html, snapshot({keys: parsed.keys, keysText: parsed.document, signedDocument: parsed.document, signedMetadata: parsed.metadata, signature: parsed.signature, signedBundle: parsed.bundle}));
|
||||
const metadata = embeddedMetadata(page);
|
||||
assert.equal(metadata.signaturePresent, false);
|
||||
assert.equal(metadata.signatureStatus, "未提供签名包");
|
||||
assert.equal(metadata.certificatePresent, false);
|
||||
assert.ok(!page.includes(parsed.keys[0].text));
|
||||
assert.ok(!page.includes(document));
|
||||
assert.doesNotMatch(page, /malformed-detached-signature|conf-sshd-signed-v1/);
|
||||
}
|
||||
});
|
||||
|
||||
test("signature and certificate availability are independent and never expose their bytes", () => {
|
||||
const document = "document-bytes-must-not-appear";
|
||||
const signature = "-----BEGIN PGP SIGNATURE-----\nComment: signature-bytes-must-not-appear\n\nAQID\n-----END PGP SIGNATURE-----\n";
|
||||
const certificate = "-----BEGIN PGP PUBLIC KEY BLOCK-----\ncertificate-bytes-must-not-appear\n-----END PGP PUBLIC KEY BLOCK-----\n";
|
||||
const paired = {keysText: document, signedDocument: document, signedMetadata: {listId: "test-list"}};
|
||||
const cases = [
|
||||
{name: "document alone", data: paired, signature: false, certificate: false},
|
||||
{name: "malformed detached signature", data: {...paired, signature: "malformed-signature-must-not-appear"}, signature: false, certificate: false},
|
||||
{name: "valid pair without certificate", data: {...paired, signature}, signature: true, certificate: false},
|
||||
{name: "certificate without signature", data: {...paired, publicCertificate: certificate}, signature: false, certificate: true},
|
||||
{name: "valid pair with certificate", data: {...paired, signature, publicCertificate: certificate}, signature: true, certificate: true},
|
||||
{name: "wrong document pair", data: {...paired, keysText: "different-document", signature}, signature: false, certificate: false},
|
||||
{name: "explicit unavailable flags", data: {...paired, signature, publicCertificate: certificate, signatureAvailable: false, publicCertificateAvailable: false}, signature: false, certificate: false},
|
||||
{name: "explicit available flags", data: {signatureAvailable: true, publicCertificateAvailable: true}, signature: true, certificate: true},
|
||||
{name: "unavailable keys retain independent certificate", data: {...paired, source: {type: "unavailable"}, signatureAvailable: true, publicCertificate: certificate}, signature: false, certificate: true},
|
||||
];
|
||||
for (const entry of cases) {
|
||||
const page = renderPage(html, snapshot(entry.data));
|
||||
const metadata = embeddedMetadata(page);
|
||||
assert.equal(metadata.signaturePresent, entry.signature, entry.name);
|
||||
assert.equal(metadata.certificatePresent, entry.certificate, entry.name);
|
||||
assert.equal(metadata.signatureStatus, entry.signature ? "签名包已提供(待客户端验证)" : "未提供签名包", entry.name);
|
||||
assert.equal(metadata.certificateStatus, entry.certificate ? "公开证书已提供(安装时核验)" : "未提供公开证书", entry.name);
|
||||
assert.equal(page.includes('href="/public-key.gpg"'), entry.certificate, entry.name);
|
||||
assert.doesNotMatch(page, /must-not-appear|BEGIN PGP|AQID/, entry.name);
|
||||
assert.equal(metadata.signedMetadata, undefined);
|
||||
assert.equal(metadata.signature, undefined);
|
||||
assert.equal(metadata.publicCertificate, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("commands use an explicit installer and fixed installed client; no self-update command", () => {
|
||||
const byId = Object.fromEntries(commands.map((command) => [command.id, command]));
|
||||
assert.equal(byId.install.command, "curl -fsSL 'https://example.test/script.sh' | sh -s -- --install --download-script -c");
|
||||
assert.equal(byId.once.command, 'sh "$HOME/.conf-sshd/conf-sshd.sh" -o');
|
||||
assert.equal(byId.preview.command, 'sh "$HOME/.conf-sshd/conf-sshd.sh" -o --dry-run');
|
||||
assert.equal(byId.help.command, "curl -fsSL 'https://example.test/script.sh' | sh -s -- -h");
|
||||
assert.match(byId.help.description, /不建立信任/);
|
||||
assert.match(byId.install.description, /备份.*保留本地配置/);
|
||||
assert.match(byId.install.description, /首次安装或明确重装都会信任你执行的脚本/);
|
||||
assert.match(byId.install.description, /推荐先独立核验安装脚本/);
|
||||
assert.doesNotMatch(byId.install.description, /重装须使用.*本地脚本/);
|
||||
assert.equal(byId.update, undefined);
|
||||
for (const id of ["root-off", "root-on", "uninstall"]) {
|
||||
assert.equal(byId[id].advanced, true);
|
||||
assert.ok(byId[id].command.startsWith('sh "$HOME/.conf-sshd/conf-sshd.sh"'));
|
||||
}
|
||||
assert.ok(commands.every(({command}) => !/(?:^|\s)-u(?:\s|$)|--update/.test(command)));
|
||||
assert.match(html, /预期 SHA-256.*pin/);
|
||||
assert.match(html, /验签默认关闭/);
|
||||
assert.match(html, /--install --gpg-verification on/);
|
||||
assert.match(html, /远端发布内容不能关闭本地验签/);
|
||||
assert.match(html, /本地终端确认完整指纹,不能跳过确认/);
|
||||
assert.match(html, /日常同步不联网刷新证书/);
|
||||
assert.doesNotMatch(html, /--confirm-trust/);
|
||||
assert.match(html, /不请求签名/);
|
||||
assert.match(html, /两者均返回 200/);
|
||||
assert.match(html, /href="\/ssh\.keys\.sig"/);
|
||||
assert.doesNotMatch(html, /\/key-release/);
|
||||
});
|
||||
|
||||
test("default follows system and explicit light/dark/system choices persist", () => {
|
||||
@@ -119,13 +241,13 @@ test("storage restrictions do not prevent theme selection or enhancement", () =>
|
||||
for (const pre of page.pres) assert.equal(pre.getAttribute("tabindex"), "0");
|
||||
});
|
||||
|
||||
test("individual key and command copies retain every byte, including comments and punctuation", async () => {
|
||||
const exact = `ssh-ed25519 ${"A".repeat(2000)} user <script> & \"quoted\" 中文`;
|
||||
test("individual command copies retain every byte, including shell quotes and punctuation", async () => {
|
||||
const exact = `sh "$HOME/${"long-path-".repeat(250)}/conf-sshd.sh" -o --dry-run`;
|
||||
const copied = [];
|
||||
const page = setup({ keys: [exact], clipboard: async (text) => copied.push(text) });
|
||||
const page = setup({ commandTexts: [commands[0].command, exact], clipboard: async (text) => copied.push(text) });
|
||||
await page.buttons[0].dispatch("click");
|
||||
await page.buttons[1].dispatch("click");
|
||||
assert.deepEqual(copied, [exact, page.codeElements[1].textContent]);
|
||||
assert.deepEqual(copied, [commands[0].command, exact]);
|
||||
assert.equal(page.buttons[0].dataset.state, "success");
|
||||
assert.match(page.buttons[0].getAttribute("aria-label"), /已复制/);
|
||||
assert.equal(page.buttons[0].disabled, false);
|
||||
@@ -133,32 +255,29 @@ test("individual key and command copies retain every byte, including comments an
|
||||
page.flushTimers();
|
||||
assert.equal(page.elements.get("copy-status").textContent, "已复制完整内容。");
|
||||
assert.equal(page.buttons[0].dataset.state, undefined);
|
||||
assert.equal(page.buttons[0].getAttribute("aria-label"), "复制 key-0");
|
||||
assert.equal(page.buttons[0].getAttribute("aria-label"), "复制 command-0");
|
||||
});
|
||||
|
||||
test("copy all contains only raw public keys and a final LF, never commands", async () => {
|
||||
const keys = ["ssh-ed25519 AAAA key one", "ssh-rsa BBBB key two"];
|
||||
test("page enhancement neither requests key data nor reads embedded release metadata", () => {
|
||||
const page = setup();
|
||||
assert.ok(!page.requestedIds.includes("release-data"));
|
||||
assert.ok(!page.requestedIds.includes("copy-all-keys"));
|
||||
assert.doesNotMatch(script, /release-data|release\.keys|key-card|allKeysButton|JSON\.parse|\bfetch\s*\(|XMLHttpRequest|WebSocket|EventSource/);
|
||||
});
|
||||
|
||||
test("missing command targets disable only their own copy buttons", async () => {
|
||||
const copied = [];
|
||||
const page = setup({ keys, clipboard: async (text) => copied.push(text) });
|
||||
await page.elements.get("copy-all-keys").dispatch("click");
|
||||
assert.deepEqual(copied, [`${keys.join("\n")}\n`]);
|
||||
assert.ok(!copied[0].includes("curl"));
|
||||
const page = setup({missingTarget: true, clipboard: async (text) => copied.push(text)});
|
||||
assert.equal(page.buttons[0].disabled, true);
|
||||
await page.buttons[0].dispatch("click");
|
||||
await page.buttons[1].dispatch("click");
|
||||
assert.deepEqual(copied, [page.codeElements[1].textContent]);
|
||||
});
|
||||
|
||||
test("missing or malformed release metadata falls back to prerendered keys", async () => {
|
||||
for (const rawRelease of ["not json", "{}", '{"keys":[null]}']) {
|
||||
const copied = [];
|
||||
const page = setup({ keys: ["ssh-ed25519 AAAA fallback"], rawRelease, clipboard: async (text) => copied.push(text) });
|
||||
await page.elements.get("copy-all-keys").dispatch("click");
|
||||
assert.deepEqual(copied, ["ssh-ed25519 AAAA fallback\n"]);
|
||||
}
|
||||
assert.equal(setup({ keys: [] }).elements.get("copy-all-keys").disabled, true);
|
||||
});
|
||||
|
||||
test("denied or unavailable clipboard offers exact text for manual copy and returns focus", async () => {
|
||||
test("denied or unavailable clipboard offers exact command for manual copy and returns focus", async () => {
|
||||
for (const clipboard of [null, async () => { throw new Error("NotAllowedError"); }]) {
|
||||
const exact = "ssh-ed25519 AAAA comment <not markup>";
|
||||
const page = setup({ keys: [exact], clipboard });
|
||||
const exact = commands[0].command;
|
||||
const page = setup({ commandTexts: [exact], clipboard });
|
||||
await page.buttons[0].dispatch("click");
|
||||
const dialog = page.elements.get("copy-fallback");
|
||||
const textarea = page.elements.get("fallback-text");
|
||||
@@ -197,34 +316,11 @@ test("repeated clicks while clipboard is pending make a single request and retri
|
||||
assert.equal(page.buttons[0].dataset.state, "success");
|
||||
});
|
||||
|
||||
test("unavailable or explicitly revoked key sets leave copy-all disabled and make no clipboard request", async () => {
|
||||
for (const sourceStatus of ["暂不可用", "明确撤销"]) {
|
||||
const copied = [];
|
||||
const page = setup({ keys: [], rawRelease: JSON.stringify({ keys: [], sourceStatus }), clipboard: async (text) => copied.push(text) });
|
||||
assert.equal(page.elements.get("copy-all-keys").disabled, true);
|
||||
await page.elements.get("copy-all-keys").dispatch("click");
|
||||
assert.deepEqual(copied, []);
|
||||
}
|
||||
assert.match(html, /公钥来源/);
|
||||
assert.match(html, /代码版本/);
|
||||
assert.match(html, /公钥版本/);
|
||||
assert.match(html, /class="keys-notice" role="note"/);
|
||||
});
|
||||
|
||||
test("CSS covers system theme, narrow viewports, touch copies, focus, and reduced motion", () => {
|
||||
assert.match(css, /prefers-color-scheme: dark/);
|
||||
assert.match(css, /:root\[data-theme="dark"\]/);
|
||||
assert.match(css, /max-width: 680px/);
|
||||
assert.match(css, /hover: hover/);
|
||||
assert.match(css, /pointer: fine/);
|
||||
assert.match(css, /focus-within/);
|
||||
assert.match(css, /focus-visible/);
|
||||
assert.match(css, /overflow-x: auto/);
|
||||
assert.match(css, /prefers-reduced-motion/);
|
||||
assert.doesNotMatch(script, /innerHTML|insertAdjacentHTML|\beval\s*\(|\bfetch\s*\(/);
|
||||
for (const pattern of [/prefers-color-scheme: dark/, /:root\[data-theme="dark"\]/, /max-width: 680px/, /hover: hover/, /pointer: fine/, /focus-within/, /focus-visible/, /overflow-x: auto/, /prefers-reduced-motion/]) assert.match(css, pattern);
|
||||
assert.doesNotMatch(script, /innerHTML|insertAdjacentHTML|\beval\s*\(/);
|
||||
});
|
||||
|
||||
|
||||
test("touch and narrow layouts explicitly override desktop copy-button hiding", () => {
|
||||
const override = css.match(/@media \(hover: none\), \(pointer: coarse\), \(any-pointer: coarse\), \(max-width: 680px\) \{[\s\S]*?\n\}/);
|
||||
assert.ok(override, "touch, hybrid, and narrow layouts need an explicit visibility rule");
|
||||
|
||||
+48
-3
@@ -19,7 +19,7 @@ test('explicit endpoints ignore user agent and are never cached',async()=>{
|
||||
}
|
||||
});
|
||||
test('root serves browser HTML and legacy curl script without content mixing',async()=>{
|
||||
for(const agent of ['curl/8.1.0','libcurl/8.1.0']){
|
||||
for(const agent of ['curl/8.1.0','libcurl/8.1.0','Wget/1.21.3','HTTPie/3.2.4']){
|
||||
const response=await fetch(request('/',{headers:{'User-Agent':agent,Accept:'*/*'}}),env);
|
||||
assert.equal(await response.text(),release.script);assert.equal(response.headers.get('Vary'),'Accept, User-Agent');
|
||||
}
|
||||
@@ -48,9 +48,9 @@ test('fallback page and key metadata represent one effective snapshot; unavailab
|
||||
const fallback={...release,keys:[],keyCount:0,sha256:null,keysText:null,fallbackSources:['https://keys.example.com/server.keys']};
|
||||
const handler=createHandler(fallback,{fetchImpl:async()=>new Response(fixture)});
|
||||
const html=await (await handler(request('/'),env)).text();
|
||||
assert.ok(html.includes('备用源可用'));assert.ok(html.includes('https://keys.example.com/server.keys'));assert.ok(html.includes(keys[0].fingerprint));
|
||||
assert.ok(html.includes('备用源可用'));assert.ok(html.includes('https://keys.example.com/server.keys'));assert.ok(!html.includes(keys[0].fingerprint));assert.ok(!html.includes(keys[0].text));
|
||||
const match=html.match(/<script id="release-data" type="application\/json">([\s\S]*?)<\/script>/);
|
||||
const embedded=JSON.parse(match[1]);assert.equal(embedded.sha256,bundle.sha256);assert.equal(embedded.keyCount,1);assert.equal(embedded.keys[0].text,keys[0].text);
|
||||
const embedded=JSON.parse(match[1]);assert.equal(embedded.sha256,bundle.sha256);assert.equal(embedded.keyCount,1);assert.equal(embedded.keys,undefined);
|
||||
const metadata=await (await handler(request('/release.json'),env)).json();assert.equal(metadata.keyRevision,bundle.sha256);assert.equal(metadata.source.type,'fallback');
|
||||
assert.equal(await (await handler(request('/ssh.keys'),env)).text(),bundle.text);
|
||||
const unavailable=createHandler(fallback,{fetchImpl:async()=>new Response('',{status:503})});
|
||||
@@ -64,3 +64,48 @@ test('explicit empty revocation suppresses fallback in page, metadata and keys',
|
||||
const html=await (await handler(request('/'),env)).text();assert.ok(html.includes('明确撤销'));assert.ok(!html.includes('未发布撤销指令'));
|
||||
const keysText=await (await handler(request('/ssh.keys'),env)).text();assert.match(keysText,/# key-count 0\n# allow-empty yes/);
|
||||
});
|
||||
|
||||
test('signature endpoint never invents a signature for unsigned sources',async()=>{
|
||||
for(const method of ['GET','HEAD']) {
|
||||
const response=await fetch(request('/ssh.keys.sig',{method}),env);
|
||||
assert.equal(response.status,404);
|
||||
assert.equal(response.headers.get('Cache-Control'),'no-store');
|
||||
if(method==='HEAD') assert.equal(await response.text(),'');
|
||||
}
|
||||
const metadata=await(await fetch(request('/release.json'),env)).json();
|
||||
assert.equal(metadata.signatureAvailable,false);
|
||||
assert.equal(metadata.signatureVerifiedByWorker,false);
|
||||
assert.equal(metadata.signedMetadata,null);
|
||||
});
|
||||
|
||||
test('public certificate is independent of unavailable key sources and never cached',async()=>{
|
||||
assert.equal((await fetch(request('/public-key.gpg'),env)).status,404);
|
||||
const certificate='-----BEGIN PGP PUBLIC KEY BLOCK-----\nfixture-public-only\n-----END PGP PUBLIC KEY BLOCK-----\n';
|
||||
const handler=createHandler({...release,publicCertificate:certificate,keys:[],allowEmpty:false,fallbackSources:['https://bad.example.com/keys']},{fetchImpl:async()=>{throw Error('certificate must not resolve keys')}});
|
||||
const response=await handler(request('/public-key.gpg'),env);
|
||||
assert.equal(response.status,200);assert.equal(await response.text(),certificate);
|
||||
assert.equal(response.headers.get('Cache-Control'),'no-store');
|
||||
assert.equal(response.headers.get('Content-Type'),'application/pgp-keys');
|
||||
assert.equal(await(await handler(request('/public-key.gpg',{method:'HEAD'}),env)).text(),'');
|
||||
});
|
||||
|
||||
test('signature endpoint pairs with exact signed document, never the legacy wrapper',async()=>{
|
||||
const {makeSignedDocument}=await import('../cf-worker/src/signed-release.js');
|
||||
const document=await makeSignedDocument(fixture,{listId:'test-list',sequence:1,issuedAt:1700000000,expiresAt:1900000000});
|
||||
// Structurally valid, deliberately non-cryptographic armor tests routing only.
|
||||
const signature='-----BEGIN PGP SIGNATURE-----\n\nYWJj\n-----END PGP SIGNATURE-----\n';
|
||||
const handler=createHandler({...release,signedDocument:document,signature});
|
||||
const keysResponse=await handler(request('/ssh.keys'),env);
|
||||
assert.equal(keysResponse.status,200);assert.equal(await keysResponse.text(),document);
|
||||
const sigResponse=await handler(request('/ssh.keys.sig'),env);
|
||||
assert.equal(sigResponse.status,200);assert.equal(await sigResponse.text(),signature);
|
||||
assert.equal(sigResponse.headers.get('Content-Type'),'application/pgp-signature');
|
||||
assert.equal(sigResponse.headers.get('Cache-Control'),'no-store');
|
||||
const metadata=await(await handler(request('/release.json'),env)).json();
|
||||
assert.equal(metadata.signatureAvailable,true);assert.equal(metadata.signatureVerifiedByWorker,false);
|
||||
assert.equal(metadata.signedMetadata.sequence,1);
|
||||
assert.equal(metadata.keyRevision,metadata.signedMetadata.documentSha256);
|
||||
const unsigned=createHandler({...release,signedDocument:document,signature:null});
|
||||
assert.equal(await(await unsigned(request('/ssh.keys'),env)).text(),document);
|
||||
assert.equal((await unsigned(request('/ssh.keys.sig'),env)).status,404);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user