85 lines
5.6 KiB
TypeScript
85 lines
5.6 KiB
TypeScript
// tools/vds-sign.ts and the VDS-signing parts of tools/release.ts: building the remote command, parsing the returned
|
|
// signatures, verifying them over the digest and refusing the ones that do not verify. No ssh here.
|
|
import { describe, expect, test } from 'bun:test';
|
|
import { createHash, generateKeyPairSync, sign } from 'node:crypto';
|
|
import { vdsSignAssets } from '../tools/release';
|
|
import { parseSigLines, remoteSigProblems, remoteSignScript, signCommand, TAURI_CLI, verifyDigest } from '../tools/vds-sign';
|
|
|
|
/** A throwaway minisign key pair and tauri-style (base64-wrapped) "ED" signatures. */
|
|
function keys() {
|
|
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
const raw = Buffer.from(publicKey.export({ format: 'jwk' }).x!, 'base64url');
|
|
const id = Buffer.from('0102030405060708', 'hex');
|
|
const pub = Buffer.from(`untrusted comment: test key\n${Buffer.concat([Buffer.from('Ed'), id, raw]).toString('base64')}\n`).toString('base64');
|
|
const sigOf = (data: string | Buffer, file: string, comment = `timestamp:1\tfile:${file}`) => {
|
|
const digest = createHash('blake2b512').update(data).digest();
|
|
const s = sign(null, digest, privateKey);
|
|
const g = sign(null, Buffer.concat([s, Buffer.from(comment)]), privateKey);
|
|
return Buffer.from(`untrusted comment: sig\n${Buffer.concat([Buffer.from('ED'), id, s]).toString('base64')}\ntrusted comment: ${comment}\n${g.toString('base64')}\n`).toString('base64');
|
|
};
|
|
return { pub, sigOf };
|
|
}
|
|
const b2 = (d: string) => createHash('blake2b512').update(d).digest('hex');
|
|
const SHA = 'a'.repeat(64);
|
|
|
|
describe('VDS signing', () => {
|
|
test('the remote script fetches, checks the SHA-256, signs with the VDS key and prints SIG lines', () => {
|
|
const s = remoteSignScript('/var/lib/storebound-desktop/incoming/1.2.3', [
|
|
{ file: 'storebound_1.2.3_amd64.AppImage', url: 'https://git.example.com/o/r/releases/download/v1.2.3/storebound_1.2.3_amd64.AppImage', sha256: SHA },
|
|
]);
|
|
expect(s).toContain('set -euo pipefail');
|
|
expect(s).toContain(`echo '${SHA} /var/lib/storebound-desktop/incoming/1.2.3/storebound_1.2.3_amd64.AppImage' | sha256sum -c --quiet`);
|
|
expect(s).toContain('--max-time 900');
|
|
expect(s).toContain(`bunx ${TAURI_CLI} signer sign -f /root/.storebound-keys/updater.key`);
|
|
expect(s).toContain("printf 'SIG %s");
|
|
expect(s).toContain('b2sum -l 512');
|
|
expect(signCommand('/x/y')).toContain('</dev/null');
|
|
expect(signCommand('/x/y')).toContain('timeout 300');
|
|
});
|
|
test('unsafe names, urls and hashes are refused before anything runs', () => {
|
|
expect(() => remoteSignScript('/d', [{ file: "a'; rm -rf /", url: 'https://x/a', sha256: SHA }])).toThrow(/unsafe/);
|
|
expect(() => remoteSignScript('/d', [{ file: 'a', url: 'http://x/a', sha256: SHA }])).toThrow(/url/);
|
|
expect(() => remoteSignScript('/d', [{ file: 'a', url: 'https://x/a', sha256: 'abc' }])).toThrow(/SHA-256/);
|
|
expect(() => remoteSignScript('/d', [])).toThrow();
|
|
});
|
|
test('SIG lines are parsed; progress lines are not', () => {
|
|
const { sigOf } = keys();
|
|
const sig = sigOf('hello', 'f.deb');
|
|
const got = parseSigLines(`fetched f.deb\nsigning f.deb\nSIG f.deb ${b2('hello')} 5 ${sig}\nnoise\n`);
|
|
expect(got).toEqual([{ file: 'f.deb', blake2b: b2('hello'), size: 5, signature: sig }]);
|
|
expect(parseSigLines('SIG f.deb short 5 abc')).toEqual([]);
|
|
});
|
|
test('a returned signature verifies over the digest against the public key', () => {
|
|
const { pub, sigOf } = keys();
|
|
expect(verifyDigest(b2('hello'), sigOf('hello', 'f.deb'), pub, 'f.deb').ok).toBe(true);
|
|
});
|
|
test('a signature that does not verify is refused', () => {
|
|
const { pub, sigOf } = keys();
|
|
const other = keys();
|
|
const r = (x: ReturnType<typeof verifyDigest>) => (x.ok ? 'ok' : x.reason);
|
|
expect(r(verifyDigest(b2('other bytes'), sigOf('hello', 'f.deb'), pub, 'f.deb'))).toMatch(/does not match/);
|
|
expect(r(verifyDigest(b2('hello'), other.sigOf('hello', 'f.deb'), pub, 'f.deb'))).toMatch(/does not match/);
|
|
expect(r(verifyDigest(b2('hello'), sigOf('hello', 'g.deb'), pub, 'f.deb'))).toMatch(/does not name f\.deb/);
|
|
expect(r(verifyDigest(b2('hello'), 'garbage', pub))).not.toBe('ok');
|
|
});
|
|
test('every expected file must come back once, verified; extras are refused', () => {
|
|
const { pub, sigOf } = keys();
|
|
const ok = { file: 'a.deb', blake2b: b2('a'), size: 1, signature: sigOf('a', 'a.deb') };
|
|
expect(remoteSigProblems(['a.deb'], [ok], pub)).toEqual([]);
|
|
expect(remoteSigProblems(['a.deb', 'b.exe'], [ok], pub)).toEqual(['b.exe: no signature came back']);
|
|
expect(remoteSigProblems(['a.deb'], [ok, ok], pub)[0]).toMatch(/more than one/);
|
|
expect(remoteSigProblems(['a.deb'], [{ ...ok, blake2b: b2('b') }], pub)[0]).toMatch(/does not match/);
|
|
expect(remoteSigProblems(['a.deb'], [ok, { ...ok, file: 'x' }], pub)).toEqual(['x: not asked for']);
|
|
});
|
|
test('the release signs only installers with a recorded SHA-256', () => {
|
|
const assets = [
|
|
{ name: 'storebound_1.2.3_x64-setup.exe', size: 9, browser_download_url: 'https://g/x.exe' },
|
|
{ name: 'SHA256SUMS', size: 1, browser_download_url: 'https://g/s' },
|
|
{ name: 'storebound_1.2.3_amd64.deb', size: 8, browser_download_url: 'https://g/x.deb' },
|
|
];
|
|
const rec = { 'storebound_1.2.3_x64-setup.exe': SHA, 'storebound_1.2.3_amd64.deb': SHA };
|
|
expect(vdsSignAssets(assets, rec).map(a => a.name)).toEqual(['storebound_1.2.3_amd64.deb', 'storebound_1.2.3_x64-setup.exe']);
|
|
expect(() => vdsSignAssets(assets, { 'storebound_1.2.3_amd64.deb': SHA })).toThrow(/no SHA-256/);
|
|
expect(() => vdsSignAssets([assets[1]!], rec)).toThrow(/no Linux/);
|
|
});
|
|
});
|