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:
parent
d94bf67340
commit
ef3c66e8f0
6 changed files with 444 additions and 51 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
85
tests/vds-sign.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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); }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
123
tools/vds-sign.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue