// 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(' { 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) => (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/); }); });