release: the Linux / Windows updater signatures are made on the VDS (desktop-release add-remote, vds-key; tools/vds-sign.ts); the Mac no longer downloads those installers, verifies each returned .sig over its BLAKE2b-512 digest before publishing; release.ts --sign-local keeps the old path

This commit is contained in:
CRTkafa 2026-10-21 03:14:53 +07:00
parent d94bf67340
commit ef3c66e8f0
6 changed files with 444 additions and 51 deletions

View file

@ -2,6 +2,12 @@
## Unreleased
- **Tooling: the desktop updater signatures of the Linux and Windows installers are made on the VDS.** The release
no longer downloads them to the Mac: the VDS fetches them from the Forgejo release, checks their SHA-256 against the
candidate, signs them with its copy of the updater key and sends back only the .sig texts, which the Mac verifies
against the app's public key before publishing. `desktop-release.ts vds-key` installs or rotates that key;
`release.ts --sign-local` keeps the old path.
## 0.8.19 · 2026-10-20
- **`shots capture` / `render` find Playwright's chromium in a project's CommonJS playwright** whether it comes on the

View file

@ -95,10 +95,10 @@ Run it by hand from Actions > release with a `tag` (dry by default: only the pla
3. **Fast gate, commit, push:** `bun run ci:quick`, the commit as CRT, push to main, push the tag.
4. **In parallel:** `desktop-release.ts mac` (universal build, sign; Tauri notarizes the app while the boot check runs;
the DMG is notarized while the app and the updater bundle are checked, scanned and signed), Forgejo's ci run of the
bump commit (installers only) and release.yml's promotion. Meanwhile the candidate's Linux / Windows installers
and acceptance runs are downloaded.
5. **add, matrix, verify:** updater signatures of the Linux / Windows files, the 9-run acceptance matrix (strict) to
the release, `verify`.
bump commit (installers only) and release.yml's promotion. The Linux / Windows installers are not downloaded to
the Mac (only with `--sign-local`).
5. **add-remote, verify:** the VDS signs the Linux / Windows files (see "Updater signatures on the VDS" below); the
acceptance matrix only when the release's `TESTS` is on; `verify`.
6. **publish** with `--forgejo-tag`: the VDS fetches the Linux / Windows files from the Forgejo release over its own
network, rsync sends only the Mac's DMG and updater bundle, the VDS attaches those to the Forgejo release from its
copy, then the publish script swaps latest.json. The owner's uplink (about 0.5 to 1 MB/s) carries each Mac file
@ -129,8 +129,10 @@ the local folders, and prints the times.
- `bun tools/desktop-version.ts --set 0.8.5` and the CHANGELOG section, `bun run ci:quick`, commit, push, tag, push
the tag (release.yml promotes the candidate).
- `bun tools/desktop-release.ts mac --out ~/.storebound-releases/desktop/0.8.5`.
- `bun tools/release-candidate.ts fetch --sha <sha> --dir <dir>`, then `bun tools/desktop-release.ts add --out ...
--commit <sha> --run <run> <AppImage> <deb> <setup.exe>`.
- `bun tools/desktop-release.ts add-remote --out ... --host root@<vds> --assets <json> --commit <sha> --run <run>`
(the json: `[{name, url, sha256, size}]` of the release's installers, the SHA-256 from the candidate's
green.json). Fallback: `bun tools/release-candidate.ts fetch --sha <sha> --dir <dir>`, then
`bun tools/desktop-release.ts add --out ... --commit <sha> --run <run> <AppImage> <deb> <setup.exe>`.
- `bun tools/desktop-release.ts publish --out ... --forgejo-tag v0.8.5 --yes` (or `upload-forgejo`, then `publish`).
- `npm publish` of the same version: the owner's step (CONTRIBUTING.md).
@ -155,7 +157,30 @@ a new one under CRT's Settings > Applications with that scope, put it into the s
**Back up `updater.key`** (password manager or an offline copy). If it is lost, installed apps can never update
again: they only accept bundles signed with this key, so everyone would have to reinstall by hand. Never commit
it, never copy it to a runner. The Forgejo workflow builds unsigned installers, and the Mac signs them.
it, never copy it to a runner. The Forgejo workflow builds unsigned installers; the VDS signs the Linux / Windows
ones and the Mac its own.
### Updater signatures on the VDS (since 21 Oct 2026)
The Mac's line is too slow to download the ~120 MB AppImage, the deb and the exe just to sign them, so the
updater key has a second home on the VDS: `/root/.storebound-keys/updater.key` (dir 0700, file 0600, root; an
optional `updater.key.password` next to it, 0600, when the key has a password; today it has none).
- **Install or rotate:** `bun tools/desktop-release.ts vds-key` (host from `~/.storebound-keys/publish-host`, one ssh
connection). It sends the Mac's `updater.key` (and `updater.key.password`, if present) over ssh stdin, never
printed or on a command line, sets the modes, signs a test string on the VDS with the tauri signer and verifies it
here against tauri.conf.json's public key. Rotation: make the new key on the Mac (as in the table), update the
public key in tauri.conf.json and cli/lib/desktop-dist.ts, run `vds-key`.
- **In a release** (`tools/release.ts` step 5, `desktop-release.ts add-remote`, code in `tools/vds-sign.ts`): one ssh
script fetches the promoted release's installers into `/var/lib/storebound-desktop/incoming/<version>/`, checks
each SHA-256 against the candidate's green.json, signs with `bunx @tauri-apps/cli@<pinned> signer sign` (bun is on
the VDS; no minisign needed) and prints only `SIG <file> <blake2b-512> <size> <.sig>` lines. The Mac verifies every
signature over that digest with the public key (and that its trusted comment names the file) before it writes
anything, then writes the .sig files, latest.json, SHA256SUMS and `.remote.json` (the files that live only on the
VDS). `verify` and `publish` use `.remote.json`; publish re-checks those files' SHA-256 on the VDS and never
rsyncs them. The installers' privacy scan is the one of their build jobs.
- **Fallback:** `bun tools/release.ts <version> --resume --sign-local --yes` (or without `--resume`): the old path,
the installers downloaded to the Mac and signed there with `~/.storebound-keys/updater.key`.
## Privacy gate

85
tests/vds-sign.test.ts Normal file
View file

@ -0,0 +1,85 @@
// 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/);
});
});

View file

@ -16,6 +16,12 @@
// folder: privacy scan, minisign signature here (the key never goes to a runner), latest.json, SHA256SUMS and
// evidence-<linux|windows>-<version>.json (the run and commit they came from; a commit other than the one of the
// folder's other evidence is refused: every artifact of a version comes from one commit).
// bun tools/desktop-release.ts add-remote --out <dir> --host <user@host> --assets <json> [--commit <sha>] [--run <url>] [--code-signed]
// The default since 21 Oct 2026: the VDS fetches the Linux / Windows files of the Forgejo release into its incoming
// folder, checks their SHA-256, signs them with its copy of the updater key (tools/vds-sign.ts); only the .sig
// texts come back and are verified here. The files stay on the VDS (.remote.json); publish checks them there.
// bun tools/desktop-release.ts vds-key [--host root@<host>]
// Installs or rotates the updater key in /root/.storebound-keys on the VDS and verifies a test signature here.
// bun tools/desktop-release.ts publish --out <dir> --host <user@host> [--channel stable|beta|rehearsal]
// [--forgejo-tag v<version>] [--yes]
// Checks the folder again, uploads it to the server's incoming folder (rsync) and runs the root-owned publish
@ -45,6 +51,7 @@ import { buildOne, OUT_DIR as SIDECAR_DIR } from './build-sidecars';
import { versions } from './desktop-version';
import { listReleases, upsert as forgejoUpsert } from './forgejo-release';
import { isMacArtifact, scanSignedMac } from './mac-signature-scan';
import { parseSigLines, REMOTE_KEY, REMOTE_KEY_PASSWORD, REMOTE_KEYS, type RemoteItem, remoteSigProblems, remoteSignScript, signCommand, sshScript, verifyDigest } from './vds-sign';
import { allTerms, DEFAULT_TERMS, type Report, scan, TERMS_ENV } from './privacy-scan';
export const ROOT = resolve(import.meta.dir, '..');
@ -248,6 +255,15 @@ function privacyGate(paths: string[]): { files: number; bytes: number; hits: num
return { files: r.files, bytes: r.bytes, hits: 0 };
}
/** Files of the version folder that live only on the VDS (signed there, tools/vds-sign.ts): `.remote.json`, never
* uploaded. Their SHA-256 was checked on the VDS against the candidate's record; BLAKE2b-512 is what was signed. */
export interface RemoteFile { file: string; sha256: string; size: number; blake2b: string }
const REMOTE_JSON = '.remote.json';
function readRemote(out: string): RemoteFile[] {
const p = join(out, REMOTE_JSON);
return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) as RemoteFile[] : [];
}
function readManifest(out: string): Manifest | null {
const p = join(out, 'latest.json');
return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) as Manifest : null;
@ -258,8 +274,9 @@ function writeVersionFiles(out: string, manifest: Manifest): void {
if (problems.length) fail(`latest.json: ${problems.join('; ')}`);
writeFileSync(join(out, 'latest.json.part'), `${JSON.stringify(manifest, null, 2)}\n`);
renameSync(join(out, 'latest.json.part'), join(out, 'latest.json'));
const files = readdirSync(out).filter(n => uploadable(n) && n !== 'SHA256SUMS');
writeFileSync(join(out, 'SHA256SUMS'), sumsText(files.map(file => ({ file, sha256: sha256(join(out, file)) }))));
const remote = readRemote(out);
const files = readdirSync(out).filter(n => uploadable(n) && n !== 'SHA256SUMS' && !remote.some(r => r.file === n));
writeFileSync(join(out, 'SHA256SUMS'), sumsText([...files.map(file => ({ file, sha256: sha256(join(out, file)) })), ...remote.map(r => ({ file: r.file, sha256: r.sha256 }))]));
}
function gitState(): { commit: string; dirty: boolean } {
@ -643,90 +660,187 @@ export function commitProblems(commit: string | undefined, evidence: Record<stri
return Object.entries(evidence).filter(([, c]) => c && c !== commit).map(([f, c]) => `${f} says commit ${c!.slice(0, 10)}, these files are from ${commit.slice(0, 10)}`);
}
function add(argv: string[]): void {
/** The Linux / Windows artifact name of a workflow file (`_<version>_` in it, kind from the extension). */
function addedName(n: string, version: string, codeSigned: boolean): string {
if (!n.includes(`_${version}_`)) fail(`${n} is not version ${version}`);
const kind = /-setup\.exe$/i.test(n) ? 'nsis' : /\.AppImage$/.test(n) ? 'appimage' : /\.deb$/.test(n) ? 'deb' : null;
if (!kind) fail(`${n}: expected an NSIS setup .exe, an .AppImage or a .deb`);
return artifactName(version, kind, kind === 'nsis' ? codeSigned : true);
}
function addPrelude(argv: string[]) {
const a = args(argv);
const { version, drift } = versions();
if (!version || drift.length) fail('desktop version drift (bun tools/desktop-version.ts)');
const out = resolve(a.get('out') ?? join(homedir(), '.storebound-releases', 'desktop', version));
const files = a.rest.slice(1);
if (!files.length) fail('usage: add --out <dir> <setup.exe | .AppImage | .deb ...>');
mkdirSync(out, { recursive: true });
const commit = a.get('commit');
const otherEvidence = Object.fromEntries(readdirSync(out).filter(n => /^evidence-.*\.json$/.test(n))
.map(n => [n, (JSON.parse(readFileSync(join(out, n), 'utf8')) as { commit?: string }).commit]));
const commitBad = commitProblems(commit, otherEvidence);
if (commitBad.length) fail(`not one commit: ${commitBad.join('; ')}`);
const codeSigned = a.has('code-signed');
return { a, version, out, commit, codeSigned: a.has('code-signed') };
}
interface Added { name: string; from: string; sha256: string; size: number; sig: string }
/** latest.json, SHA256SUMS and evidence-<platform>-<version>.json for added Linux / Windows files. */
function recordAdded(p: ReturnType<typeof addPrelude>, added: Added[], checks: Record<string, unknown>): void {
const { a, version, out, commit, codeSigned } = p;
const platforms: Manifest['platforms'] = {};
const downloads: Partial<Record<DownloadKey, Download>> = {};
const added: string[] = [];
const sources: Record<string, string> = {};
for (const f of files.map(x => resolve(x))) {
const n = basename(f);
if (!n.includes(`_${version}_`)) fail(`${n} is not version ${version}`);
const kind = /-setup\.exe$/i.test(n) ? 'nsis' : /\.AppImage$/.test(n) ? 'appimage' : /\.deb$/.test(n) ? 'deb' : null;
if (!kind) fail(`${n}: expected an NSIS setup .exe, an .AppImage or a .deb`);
const name = artifactName(version, kind, kind === 'nsis' ? codeSigned : true);
const dest = join(out, name);
copyFileSync(f, dest);
added.push(dest);
sources[name] = n;
}
const privacy = privacyGate(added);
for (const dest of added) {
const name = basename(dest);
const sig = signFile(dest);
const d: Download = { file: name, url: releaseUrl(version, name), sha256: sha256(dest), size: statSync(dest).size, signature: sig, codeSigned: name.endsWith('.exe') ? codeSigned : false };
if (name.endsWith('.exe')) { downloads['windows-x86_64-nsis'] = d; platforms['windows-x86_64'] = { signature: sig, url: d.url }; }
else if (name.endsWith('.AppImage')) { downloads['linux-x86_64-appimage'] = d; platforms['linux-x86_64'] = { signature: sig, url: d.url }; }
for (const x of added) {
const d: Download = { file: x.name, url: releaseUrl(version, x.name), sha256: x.sha256, size: x.size, signature: x.sig, codeSigned: x.name.endsWith('.exe') ? codeSigned : false };
if (x.name.endsWith('.exe')) { downloads['windows-x86_64-nsis'] = d; platforms['windows-x86_64'] = { signature: x.sig, url: d.url }; }
else if (x.name.endsWith('.AppImage')) { downloads['linux-x86_64-appimage'] = d; platforms['linux-x86_64'] = { signature: x.sig, url: d.url }; }
else downloads['linux-x86_64-deb'] = d;
say(`added ${name} (${mb(d.size)})`);
say(`added ${x.name} (${mb(x.size)})`);
}
const existing = readManifest(out);
const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
writeVersionFiles(out, mergeManifest(existing, version, a.get('notes') ?? existing?.notes ?? `storebound desktop ${version}`, now, { platforms, downloads }));
// evidence per platform (stays local, like the macOS one): where the files came from and what was checked here
for (const platform of ['linux', 'windows'] as const) {
const mine = added.filter(d => (platform === 'windows') === d.endsWith('.exe'));
const mine = added.filter(d => (platform === 'windows') === d.name.endsWith('.exe'));
if (!mine.length) continue;
const evidence = {
product: 'storebound desktop', version, platform: `${platform}-x86_64`, addedAt: now, commit: commit ?? null, run: a.get('run') ?? null,
checks: { privacy, updaterSignature: 'minisign, verified against tauri.conf.json', codeSigned: platform === 'windows' ? codeSigned : false },
artifacts: mine.map(d => ({ file: basename(d), from: sources[basename(d)], size: statSync(d).size, sha256: sha256(d) })),
checks: { ...checks, codeSigned: platform === 'windows' ? codeSigned : false },
artifacts: mine.map(d => ({ file: d.name, from: d.from, size: d.size, sha256: d.sha256 })),
};
writeFileSync(join(out, `evidence-${platform}-${version}.json`), `${JSON.stringify(evidence, null, 2)}\n`);
}
say(`latest.json now has ${Object.keys(readManifest(out)!.platforms).join(', ')}${commit ? `; evidence: commit ${commit.slice(0, 10)}` : ''}`);
}
/** The fallback (--sign-local in tools/release.ts): the files are on this Mac and signed with ~/.storebound-keys. */
function add(argv: string[]): void {
const p = addPrelude(argv);
const files = p.a.rest.slice(1);
if (!files.length) fail('usage: add --out <dir> <setup.exe | .AppImage | .deb ...>');
const copied: { dest: string; from: string }[] = [];
for (const f of files.map(x => resolve(x))) {
const dest = join(p.out, addedName(basename(f), p.version, p.codeSigned));
copyFileSync(f, dest);
copied.push({ dest, from: basename(f) });
}
const privacy = privacyGate(copied.map(c => c.dest));
const added = copied.map(c => ({ name: basename(c.dest), from: c.from, sha256: sha256(c.dest), size: statSync(c.dest).size, sig: signFile(c.dest) }));
recordAdded(p, added, { privacy, updaterSignature: 'minisign on the Mac, verified against tauri.conf.json' });
}
/** The VDS signs (owner's decision 21 Oct 2026): `add-remote --out <dir> --host <user@host> --assets <json>` with
* [{name, url, sha256, size}] of the Forgejo release. The VDS fetches each file into its incoming folder, checks
* the SHA-256 and signs it; only the .sig texts come back, each verified here before anything is written. */
async function addRemote(argv: string[]): Promise<void> {
const p = addPrelude(argv);
const host = p.a.get('host') ?? fail('--host <user@host> is required');
if (!/^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$/.test(host)) fail(`--host: user@host, got ${host}`);
const assets = JSON.parse(readFileSync(p.a.get('assets') ?? fail('--assets <json file> is required'), 'utf8')) as { name: string; url: string; sha256: string; size: number }[];
if (!assets.length) fail('--assets: no files');
const items: (RemoteItem & { from: string; size: number })[] = assets.map(x => ({ file: addedName(x.name, p.version, p.codeSigned), url: x.url, sha256: x.sha256, from: x.name, size: x.size }));
const dir = `${REMOTE_INCOMING}/${p.version}`;
say(`VDS ${host.split('@')[1]}: fetch ${items.length} files from Forgejo into ${dir}, check SHA-256, sign with ${REMOTE_KEY}`);
const outText = await sshScript(host, remoteSignScript(dir, items), { timeoutMs: 30 * 60_000, what: 'VDS fetch + sign', log: say }).catch((e: Error) => fail(e.message));
const got = parseSigLines(outText);
const problems = remoteSigProblems(items.map(i => i.file), got, confPubkey());
if (problems.length) fail(`signatures from the VDS refused, nothing written: ${problems.join('; ')}`);
const remote: RemoteFile[] = [];
const added: Added[] = items.map(i => {
const g = got.find(x => x.file === i.file)!;
if (i.size && g.size !== i.size) fail(`${i.file}: ${g.size} bytes on the VDS, the release says ${i.size}`);
remote.push({ file: i.file, sha256: i.sha256, size: g.size, blake2b: g.blake2b });
writeFileSync(join(p.out, `${i.file}.sig`), g.signature);
rmSync(join(p.out, i.file), { force: true }); // a stale local copy from a --sign-local run never wins
return { name: i.file, from: i.from, sha256: i.sha256, size: g.size, sig: g.signature };
});
const prev = readRemote(p.out).filter(r => !remote.some(x => x.file === r.file));
writeFileSync(join(p.out, REMOTE_JSON), `${JSON.stringify([...prev, ...remote], null, 2)}\n`);
say(`${added.length} signatures from the VDS verified against tauri.conf.json's key`);
recordAdded(p, added, { privacy: 'scanned in the build jobs (ci.yml, private terms)', updaterSignature: 'minisign on the VDS, verified on the Mac against tauri.conf.json (BLAKE2b-512 digest)', sha256: 'checked on the VDS against the candidate record' });
}
/** `vds-key --host <user@host>`: installs or updates the updater key (and its password file, if
* ~/.storebound-keys/updater.key.password exists) in /root/.storebound-keys on the VDS (dir 0700, files 0600, root),
* over ssh stdin (never printed, never on a command line), then signs a test string there and verifies it here. */
async function vdsKey(argv: string[]): Promise<void> {
const a = args(argv);
const host = a.get('host') ?? process.env.STOREBOUND_PUBLISH_HOST ?? (existsSync(join(KEYS_DIR, 'publish-host')) ? readFileSync(join(KEYS_DIR, 'publish-host'), 'utf8').trim() : '');
if (!/^root@[A-Za-z0-9.-]+$/.test(host)) fail(`the VDS host must be root@<host> (--host, STOREBOUND_PUBLISH_HOST or ~/.storebound-keys/publish-host), got ${host || 'nothing'}`);
const key = updaterKey();
const keyB64 = readFileSync(key).toString('base64');
const pwFile = `${key}.password`;
const pwB64 = existsSync(pwFile) ? readFileSync(pwFile).toString('base64') : '';
const test = `storebound-vds-key-check-${Date.now()}`;
const t = `/tmp/sb-key-check-${process.pid}`;
const script = [
'set -euo pipefail', 'umask 077',
`install -d -m 700 -o root -g root ${REMOTE_KEYS}`,
`base64 -d > ${REMOTE_KEY}.new <<'K'`, keyB64, 'K',
`chmod 600 ${REMOTE_KEY}.new && chown root:root ${REMOTE_KEY}.new && mv -f ${REMOTE_KEY}.new ${REMOTE_KEY}`,
pwB64 ? `base64 -d > ${REMOTE_KEY_PASSWORD}.new <<'K'
${pwB64}
K
chmod 600 ${REMOTE_KEY_PASSWORD}.new && mv -f ${REMOTE_KEY_PASSWORD}.new ${REMOTE_KEY_PASSWORD}` : `rm -f ${REMOTE_KEY_PASSWORD}`,
`chmod 700 ${REMOTE_KEYS}`,
`mkdir -p ${t} && printf '%s' '${test}' > ${t}/check.txt`,
'echo "signing a test string (the first run downloads the tauri CLI)"',
`${signCommand(`${t}/check.txt`)}`,
`printf 'SIG %s %s %s %s\n' check.txt "$(b2sum -l 512 ${t}/check.txt | cut -d' ' -f1)" "$(stat -c %s ${t}/check.txt)" "$(tr -d '\n' < ${t}/check.txt.sig)"`,
`rm -rf ${t}`,
`stat -c 'MODE %a %U %n' ${REMOTE_KEYS} ${REMOTE_KEYS}/*`,
].join('\n');
say(`installing the updater key on ${host.split('@')[1]}:${REMOTE_KEYS} (one ssh connection)`);
const out = await sshScript(host, `${script}\n`, { timeoutMs: 10 * 60_000, what: 'vds-key', log: say }).catch((e: Error) => fail(e.message));
const modes = out.split('\n').filter(l => l.startsWith('MODE '));
const bad = modes.filter(l => !/^MODE (700|600) root /.test(l));
if (!modes.length || bad.length) fail(`unexpected modes on the VDS: ${bad.join('; ') || 'none read'}`);
const [sig] = parseSigLines(out);
const blake = createHash('blake2b512').update(test).digest('hex');
if (!sig || sig.blake2b !== blake) fail('the VDS did not sign the test string');
const v = verifyDigest(blake, sig.signature, confPubkey(), 'check.txt');
if (!v.ok) fail(`the VDS's test signature does not verify against tauri.conf.json: ${v.reason}`);
say(`ok: ${modes.length} paths 0700/0600 root; the VDS signs with the key of tauri.conf.json (test string verified here)`);
}
// ---------------------------------------------------------------------------------------------------------------------
// verify / publish
function verifyFolder(out: string): { manifest: Manifest; files: string[] } {
function verifyFolder(out: string): { manifest: Manifest; files: string[]; remote: RemoteFile[] } {
if (!existsSync(join(out, 'latest.json')) || !existsSync(join(out, 'SHA256SUMS'))) fail(`${out}: no latest.json / SHA256SUMS`);
const manifest = readManifest(out)!;
const problems = manifestProblems(manifest);
if (problems.length) fail(`latest.json: ${problems.join('; ')}`);
const sums = readFileSync(join(out, 'SHA256SUMS'), 'utf8').trim().split('\n').map(l => { const [h, f] = l.split(/\s+/); return { h: h!, f: f! }; });
for (const { h, f } of sums) if (!existsSync(join(out, f)) || sha256(join(out, f)) !== h) fail(`SHA256SUMS: ${f} does not match`);
const files = readdirSync(out).filter(uploadable);
const remote = readRemote(out);
const remoteOf = (f: string) => remote.find(r => r.file === f);
for (const { h, f } of sums) {
const r = remoteOf(f);
if (r ? r.sha256 !== h : (!existsSync(join(out, f)) || sha256(join(out, f)) !== h)) fail(`SHA256SUMS: ${f} does not match`);
}
const files = [...new Set([...readdirSync(out).filter(uploadable), ...remote.map(r => r.file)])].sort();
for (const f of files) if (f !== 'SHA256SUMS' && !sums.some(s => s.f === f)) fail(`${f} is not in SHA256SUMS`);
const pub = confPubkey();
const check = (url: string, signature: string, what: string) => {
const file = url.split('/').pop()!;
if (!url.startsWith(releaseUrl(manifest.version, ''))) fail(`${what}: ${url} is not in /desktop/dl/${manifest.version}/`);
if (!files.includes(file)) fail(`${what}: ${file} is not in the folder`);
const v = verifySig(readFileSync(join(out, file)), signature, pub);
const r = remoteOf(file);
const v = r ? verifyDigest(r.blake2b, signature, pub, file) : verifySig(readFileSync(join(out, file)), signature, pub);
if (!v.ok) fail(`${what}: ${file}: ${v.reason}`);
};
for (const [k, p] of Object.entries(manifest.platforms)) check(p.url, p.signature, `platforms.${k}`);
for (const [k, d] of Object.entries(manifest.downloads ?? {})) {
check(d!.url, d!.signature, `downloads.${k}`);
if (sha256(join(out, d!.file)) !== d!.sha256 || statSync(join(out, d!.file)).size !== d!.size) fail(`downloads.${k}: size or SHA-256 differ from the file`);
const r = remoteOf(d!.file);
const [h, size] = r ? [r.sha256, r.size] : [sha256(join(out, d!.file)), statSync(join(out, d!.file)).size];
if (h !== d!.sha256 || size !== d!.size) fail(`downloads.${k}: size or SHA-256 differ from the file`);
}
privacyGate(files.filter(f => !/\.(sig|json)$|^SHA256SUMS$/.test(f)).map(f => join(out, f)));
// the VDS-signed files were scanned in their build jobs (ci.yml, private terms) and are not on this Mac
privacyGate(files.filter(f => !remoteOf(f) && !/\.(sig|json)$|^SHA256SUMS$/.test(f)).map(f => join(out, f)));
if (remote.length) say(`on the VDS only (signed there, SHA-256 checked there against the candidate): ${remote.map(r => r.file).join(', ')}`);
say(`verified ${manifest.version}: ${files.length} files, platforms ${Object.keys(manifest.platforms).join(', ')}, downloads ${Object.keys(manifest.downloads ?? {}).join(', ')}`);
return { manifest, files };
return { manifest, files, remote };
}
/** The npm CLI and the desktop app are one release: the folder must be the version of this checkout's package.json
@ -781,7 +895,9 @@ async function publish(argv: string[]): Promise<void> {
if (!/^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$/.test(host)) fail(`--host: user@host, got ${host}`);
const channel = a.get('channel') ?? 'stable';
if (!(CHANNELS as readonly string[]).includes(channel)) fail(`--channel ${CHANNELS.join(', ')}`);
const { manifest, files } = verifyFolder(out);
const { manifest, files: all, remote } = verifyFolder(out);
// files signed on the VDS are already in its incoming folder: never rsynced or seeded, only checked there
const files = all.filter(f => !remote.some(r => r.file === f));
const v = manifest.version;
const together = releaseTogetherProblems(v, versions().version);
if (together.length) fail(together.join('; '));
@ -803,6 +919,7 @@ async function publish(argv: string[]): Promise<void> {
if (unsigned.length) console.log(` note: unsigned preview builds (${unsigned.join(', ')}); the downloads page says so.`);
console.log('\nsteps:');
for (const s of steps) console.log(` ${s[0]} ${s.slice(1).map(x => (x.includes(' ') ? `"${x}"` : x.replace(out, '<out>'))).join(' ')}`);
if (remote.length) console.log(` already on the server (signed there), SHA-256 checked again: ${remote.map(r => r.file).join(', ')}`);
if (seed.length) console.log(` the server fetches from Forgejo ${forgejoTag}: ${seed.map(x => x.file).join(', ')}`);
if (macFiles.length) console.log(` then attaches to Forgejo ${forgejoTag} from its copy: ${macFiles.join(', ')}`);
if (!a.has('yes')) { console.log('\ndry run: nothing uploaded. Every installed app on this channel offers the update once latest.json is live; run again with --yes to publish.'); return; }
@ -816,6 +933,12 @@ async function publish(argv: string[]): Promise<void> {
// the script goes to bash on stdin: the server's login shell may be zsh, which does not split "$pids"
run(['ssh', host, 'bash', '-s'], { input: `pids=''\n${lines.map(l => `(${l}) & pids="$pids $!"`).join('\n')}\nfor p in $pids; do wait $p || exit 1; done\n`, stdio: ['pipe', 'inherit', 'inherit'] });
}
if (remote.length) {
const dir = `${REMOTE_INCOMING}/${v}`;
say(`server: SHA-256 of the ${remote.length} files signed there`);
await sshScript(host, `set -euo pipefail\n${remote.map(r => `echo '${r.sha256} ${dir}/${r.file}' | sha256sum -c --quiet || { echo "${r.file} on the server is not the signed file"; exit 1; }`).join('\n')}\n`, { timeoutMs: 5 * 60_000, what: 'remote SHA-256 check', log: say })
.catch((e: Error) => fail(e.message));
}
say(`rsync (only what the server does not have yet) ...`);
run(rsync, { stdio: ['ignore', 'inherit', 'inherit'] });
if (macFiles.length) {
@ -860,6 +983,8 @@ if (import.meta.main) {
const cmd = argv[0];
if (cmd === 'mac') await mac(argv.slice(1));
else if (cmd === 'add') add(argv);
else if (cmd === 'add-remote') await addRemote(argv);
else if (cmd === 'vds-key') await vdsKey(argv);
else if (cmd === 'verify') verifyFolder(resolve(args(argv).get('out') ?? fail('--out <version folder>')));
else if (cmd === 'publish') await publish(argv.slice(1));
else if (cmd === 'upload-forgejo') await uploadForgejo(argv.slice(1));
@ -870,6 +995,6 @@ if (import.meta.main) {
const r = spawnSync(cmdline[0]!, cmdline.slice(1), { stdio: 'inherit', env: { ...process.env, ...pathFreeEnv(ROOT, process.env) }, shell: process.platform === 'win32' });
process.exit(r.status ?? 1);
}
else { console.log(readFileSync(import.meta.path, 'utf8').split('\n').filter(l => l.startsWith('//')).slice(0, 30).map(l => l.slice(3)).join('\n')); process.exit(cmd ? 2 : 0); }
else { console.log(readFileSync(import.meta.path, 'utf8').split('\n').filter(l => l.startsWith('//')).slice(0, 40).map(l => l.slice(3)).join('\n')); process.exit(cmd ? 2 : 0); }
}

View file

@ -7,6 +7,7 @@
// rehearsal/<n>: a prerelease on git.crt.fyi and the rehearsal
// channel of storebound.dev (no app reads it), never npm and never
// /Applications; everything it made is deleted at the end
// bun tools/release.ts <version> [--sign-local] fallback: download the Linux / Windows installers and sign here
// bun tools/release.ts <version> --resume --yes after a stop past the push: HEAD is origin/main, tagged v<version>,
// with that version; skips the bump and continues from step 4
// bun tools/release.ts npm-token once: the npm granular token from the clipboard to ~/.storebound-keys
@ -22,8 +23,10 @@
// 4. at once, in parallel: the Mac build (tools/desktop-release.ts mac: universal app, sign, notarize app and DMG,
// every check), Forgejo's ci run of the bump commit (version-only: the installers only, the parent's CI and
// matrix count) and the promotion of the tag (release.yml); this waits for the candidate (heartbeat, timeout)
// and downloads its Linux / Windows installers while the Mac builds;
// 5. add (updater signatures of the Linux / Windows files), the full acceptance matrix (9 runs), verify;
// (with --sign-local also downloads its Linux / Windows installers while the Mac builds);
// 5. add-remote: the VDS fetches the Linux / Windows files of the release, checks their SHA-256 and signs them with
// its copy of the updater key; only the .sig texts come back and are verified here (--sign-local: the old path,
// the files downloaded to this Mac in step 4 and signed here); the acceptance matrix (TESTS), verify;
// 6. publish: the VDS fetches the Linux / Windows files from Forgejo itself, rsync sends only the Mac's files,
// the VDS attaches them to the Forgejo release from its copy, then storebound.dev's latest.json;
// 7. /Applications/storebound.app: the running app is quit (gracefully, then its exact PID), the new one copied
@ -55,6 +58,18 @@ const TESTS = false;
// ---------------------------------------------------------------------------------------------------------------------
// Pure parts (tests/release.test.ts)
/** The Linux / Windows assets of the promoted release that the VDS signs, with the SHA-256 of the candidate's record
* (green.json). An installer without a recorded hash is refused: the VDS checks every byte against it. */
export function vdsSignAssets(assets: { name: string; size: number; browser_download_url: string }[], recorded: Record<string, string>): { name: string; url: string; sha256: string; size: number }[] {
const mine = assets.filter(a => /\.(AppImage|deb|exe)$/.test(a.name));
if (!mine.length) throw new Error('the release has no Linux / Windows installers');
return mine.map(a => {
const sha256 = recorded[a.name];
if (!sha256 || !/^[0-9a-f]{64}$/.test(sha256)) throw new Error(`${a.name}: no SHA-256 in the candidate's record`);
return { name: a.name, url: a.browser_download_url, sha256, size: a.size };
}).sort((x, y) => x.name.localeCompare(y.name));
}
/** The CHANGELOG with its Unreleased section turned into the version's section and a new empty Unreleased on top. */
export function cutChangelog(text: string, version: string, date: string): string {
const lines = text.split('\n');
@ -234,6 +249,7 @@ async function main(): Promise<void> {
if (argv[0] === 'mac-acceptance') { const key = cliKey(ROOT); console.log(await macAcceptance(key, join(RELEASES, 'logs', `acceptance-${key}`))); return; }
const rehearsal = has('--rehearsal');
const resume = has('--resume');
const signLocal = has('--sign-local');
if (resume && rehearsal) fail('--resume is for a real version');
const positional = argv.filter(a => !a.startsWith('--'));
const pkgVersion = COPIES[0]!.read(readFileSync(join(ROOT, 'package.json'), 'utf8'))!;
@ -342,15 +358,28 @@ async function main(): Promise<void> {
const s = await waitGreen(sha, 45);
return s;
});
const fetchP = candP.then(s => phase('download Linux / Windows installers + acceptance runs', () => fetchCandidate(s, dl)));
// the Linux / Windows installers stay off this Mac: the VDS fetches and signs them (owner, 21 Oct 2026); only
// --sign-local (the fallback) downloads them here to sign with ~/.storebound-keys
const fetchP = signLocal ? candP.then(s => phase('download Linux / Windows installers + acceptance runs', () => fetchCandidate(s, dl)))
: TESTS ? candP.then(s => phase('download acceptance runs', () => fetchCandidate(s, dl, /^acceptance-.*\.json$/))) : Promise.resolve([]);
const relP = candP.then(() => phase('Forgejo: release.yml promotes the tag', () => waitRelease(tag, 15)));
await Promise.all([macP, fetchP, relP, accP]);
const cand = await candP;
// ---------------------------------------------------------------- 5. add, acceptance matrix, verify
await phase('add Linux / Windows + acceptance matrix + verify', async () => {
const files = readdirSync(dl).filter(f => /\.(AppImage|deb|exe)$/.test(f)).map(f => join(dl, f));
sh(['bun', 'tools/desktop-release.ts', 'add', '--out', out, '--commit', sha, '--run', cand.record.run, ...files]);
if (signLocal) {
const files = readdirSync(dl).filter(f => /\.(AppImage|deb|exe)$/.test(f)).map(f => join(dl, f));
sh(['bun', 'tools/desktop-release.ts', 'add', '--out', out, '--commit', sha, '--run', cand.record.run, ...files]);
} else {
const rel = (await listReleases()).find(r => r.tag_name === tag) ?? fail(`no Forgejo release ${tag}`);
const plan = vdsSignAssets(rel.assets, cand.record.files);
const assetsFile = join(logDir, 'vds-sign-assets.json');
mkdirSync(logDir, { recursive: true });
writeFileSync(assetsFile, `${JSON.stringify(plan, null, 2)}\n`);
const code = await bg('VDS sign', ['bun', 'tools/desktop-release.ts', 'add-remote', '--out', out, '--host', host, '--assets', assetsFile, '--commit', sha, '--run', cand.record.run], join(logDir, 'add-remote.log'), { timeoutMs: 35 * 60_000 });
if (code !== 0) fail(`VDS signing failed: ${join(logDir, 'add-remote.log').replace(HOME, '~')} (fallback: --resume --sign-local)`);
}
if (TESTS) {
const accDir = mkdtempSync(join(tmpdir(), 'sb-acc-'));
for (const f of readdirSync(dl).filter(f => /^acceptance-.*\.json$/.test(f))) writeFileSync(join(accDir, f), readFileSync(join(dl, f)));

123
tools/vds-sign.ts Normal file
View file

@ -0,0 +1,123 @@
// Updater signatures of the Linux / Windows installers made on the VDS (the owner's decision, 21 Oct 2026): the files
// are already there (the VDS fetches them from the Forgejo release over its own network), so the Mac never downloads
// them just to sign. Only the small .sig texts come back; each is checked here against tauri.conf.json's key over the
// BLAKE2b-512 digest the VDS reports, and the file's SHA-256 is checked on the VDS against the candidate's record.
// The key lives on the VDS in /root/.storebound-keys/ (dir 0700, files 0600): `bun tools/desktop-release.ts vds-key`.
// Pure parts are tested in tests/vds-sign.test.ts; the runner spawns ssh with a timeout and a heartbeat.
import { spawn } from 'node:child_process';
import { createPublicKey, verify as edVerify } from 'node:crypto';
import { parsePublicKey, parseSignature, type VerifyResult } from '../cli/lib/minisign';
export const REMOTE_KEYS = '/root/.storebound-keys';
export const REMOTE_KEY = `${REMOTE_KEYS}/updater.key`;
/** Optional: the key's password (one line). Without the file the password is empty (how updater.key was made). */
export const REMOTE_KEY_PASSWORD = `${REMOTE_KEYS}/updater.key.password`;
/** The tauri signer the VDS runs (bun is on the host): pinned to the CLI version of apps/desktop. */
export const TAURI_CLI = '@tauri-apps/cli@2.12.1';
export interface RemoteItem { file: string; url: string; sha256: string }
export interface RemoteSig { file: string; blake2b: string; size: number; signature: string }
const SAFE_FILE = /^[A-Za-z0-9._-]+$/;
const SAFE_DIR = /^\/[A-Za-z0-9._/-]+$/;
const SAFE_URL = /^https:\/\/[A-Za-z0-9./_%:-]+$/;
/** The sign command of one file on the VDS (pure): password from the optional file, never on the command line;
* stdin from /dev/null so the signer can never wait for a prompt. */
export function signCommand(path: string): string {
if (!SAFE_DIR.test(path)) throw new Error(`unsafe path ${path}`);
return `TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$(cat ${REMOTE_KEY_PASSWORD} 2>/dev/null || true)" timeout 300 bunx ${TAURI_CLI} signer sign -f ${REMOTE_KEY} '${path}' </dev/null >/dev/null`;
}
/** One SIG line per file: name, BLAKE2b-512 hex, size, the .sig text (tauri's one-line base64). */
const sigLine = (path: string, file: string) =>
`printf 'SIG %s %s %s %s\\n' '${file}' "$(b2sum -l 512 '${path}' | cut -d' ' -f1)" "$(stat -c %s '${path}')" "$(tr -d '\\n' < '${path}.sig')"`;
/**
* The bash script for `ssh host bash -s` (pure): fetch every file into `dir` in parallel (skipped when it is already
* there with the right SHA-256), check its SHA-256, sign it with the VDS's key and print SIG lines. Any failure exits
* non-zero with a line that says which file.
*/
export function remoteSignScript(dir: string, items: RemoteItem[]): string {
if (!SAFE_DIR.test(dir)) throw new Error(`unsafe dir ${dir}`);
if (!items.length) throw new Error('nothing to sign');
for (const x of items) {
if (!SAFE_FILE.test(x.file)) throw new Error(`unsafe file name ${x.file}`);
if (!SAFE_URL.test(x.url)) throw new Error(`unexpected url ${x.url}`);
if (!/^[0-9a-f]{64}$/.test(x.sha256)) throw new Error(`${x.file}: not a SHA-256`);
}
const lines = ['set -euo pipefail', `test -s ${REMOTE_KEY} || { echo "no updater key at ${REMOTE_KEY} (bun tools/desktop-release.ts vds-key)"; exit 3; }`,
`mkdir -p '${dir}'`, `cd '${dir}'`, "pids=''"];
for (const x of items) {
const p = `${dir}/${x.file}`;
lines.push(`( if ! echo '${x.sha256} ${p}' | sha256sum -c --quiet 2>/dev/null; then curl -fsSL --retry 2 --connect-timeout 20 --max-time 900 -o '${p}.part' '${x.url}' && mv -f '${p}.part' '${p}'; fi; echo '${x.sha256} ${p}' | sha256sum -c --quiet || { echo "FAIL ${x.file}: SHA-256 differs from the candidate's record"; exit 1; }; echo "fetched ${x.file}" ) & pids="$pids $!"`);
}
lines.push('for p in $pids; do wait $p || { echo "FAIL a download"; exit 1; }; done');
for (const x of items) {
const p = `${dir}/${x.file}`;
lines.push(`echo "signing ${x.file}"`, `rm -f '${p}.sig'`, `${signCommand(p)} || { echo "FAIL ${x.file}: the signer failed"; exit 1; }`, sigLine(p, x.file));
}
return `${lines.join('\n')}\n`;
}
/** The SIG lines of the script's output (pure). Other lines are progress. */
export function parseSigLines(out: string): RemoteSig[] {
const res: RemoteSig[] = [];
for (const l of out.split('\n')) {
const m = /^SIG (\S+) ([0-9a-f]{128}) (\d+) ([A-Za-z0-9+/]+={0,2})$/.exec(l.trim());
if (m) res.push({ file: m[1]!, blake2b: m[2]!, size: Number(m[3]), signature: m[4]! });
}
return res;
}
/** A minisign "ED" signature over a BLAKE2b-512 digest (pure): the same check as cli/lib/minisign verify, without the
* file's bytes. The trusted comment must name the file (tauri writes `file:<name>`). */
export function verifyDigest(blake2bHex: string, signature: string, publicKey: string, file?: string): VerifyResult {
try {
const pk = parsePublicKey(publicKey); const s = parseSignature(signature);
if (s.alg !== 'ED') return { ok: false, reason: `algorithm ${s.alg}, expected ED (prehashed)` };
if (pk.keyId !== s.keyId) return { ok: false, reason: `signed with key ${s.keyId}, expected ${pk.keyId}` };
if (!/^[0-9a-f]{128}$/.test(blake2bHex)) return { ok: false, reason: 'not a BLAKE2b-512 digest' };
const key = createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: pk.key.toString('base64url') }, format: 'jwk' });
if (!edVerify(null, Buffer.from(blake2bHex, 'hex'), key, s.sig)) return { ok: false, reason: 'the signature does not match the file' };
if (!edVerify(null, Buffer.concat([s.sig, Buffer.from(s.trustedComment, 'utf8')]), key, s.globalSig)) return { ok: false, reason: 'the trusted comment was changed' };
if (file && !s.trustedComment.split(/\s+/).includes(`file:${file}`)) return { ok: false, reason: `the trusted comment does not name ${file}` };
return { ok: true, trustedComment: s.trustedComment };
} catch (e) { return { ok: false, reason: (e as Error).message }; }
}
/** Every expected file came back once, with a signature that verifies (pure). Problems, empty when all is well. */
export function remoteSigProblems(expected: string[], got: RemoteSig[], publicKey: string): string[] {
const problems: string[] = [];
for (const f of expected) {
const s = got.filter(g => g.file === f);
if (s.length !== 1) { problems.push(`${f}: ${s.length ? 'more than one' : 'no'} signature came back`); continue; }
const v = verifyDigest(s[0]!.blake2b, s[0]!.signature, publicKey, f);
if (!v.ok) problems.push(`${f}: ${v.reason}`);
}
for (const g of got) if (!expected.includes(g.file)) problems.push(`${g.file}: not asked for`);
return problems;
}
/** ssh with a script on stdin, a timeout, and a heartbeat with the last output line. Rejects with the tail. */
export function sshScript(host: string, script: string, o: { timeoutMs: number; what: string; heartbeatMs?: number; log?: (s: string) => void }): Promise<string> {
const log = o.log ?? ((s: string) => console.log(s));
return new Promise((res, rej) => {
const c = spawn('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=20', '-o', 'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=4', host, 'bash', '-s'], { stdio: ['pipe', 'pipe', 'pipe'] });
let out = ''; let err = ''; let last = '(no output yet)';
const t0 = Date.now();
c.stdout.on('data', (d: Buffer) => { out += d; const t = d.toString().trim().split('\n').filter(l => !l.startsWith('SIG ')).at(-1); if (t) last = t; });
c.stderr.on('data', (d: Buffer) => { err += d; });
const beat = setInterval(() => log(` ${o.what}: ${Math.round((Date.now() - t0) / 1000)} s, ${last.slice(0, 150)}`), o.heartbeatMs ?? 30_000);
const timer = setTimeout(() => { c.kill('SIGTERM'); }, o.timeoutMs);
c.on('error', e => { clearInterval(beat); clearTimeout(timer); rej(new Error(`${o.what}: ssh did not start (${e.message})`)); });
c.on('close', (code, sig) => {
clearInterval(beat); clearTimeout(timer);
const tail = `${out}\n${err}`.trim().split('\n').filter(l => !l.startsWith('SIG ')).slice(-8).join('\n');
if (sig === 'SIGTERM') rej(new Error(`${o.what}: timeout after ${Math.round(o.timeoutMs / 60_000)} min on ${host}; last output:\n${tail}`));
else if (code !== 0) rej(new Error(`${o.what}: exit ${code} on ${host}${code === 255 ? ' (ssh: connection or login failed; not retried)' : ''}:\n${tail}`));
else res(out);
});
c.stdin.end(script);
});
}