Skip to content

feat(download): verify GPG signature of release binaries on download#87

Open
wnoonan wants to merge 7 commits into
criblio:masterfrom
wnoonan:wnoonan/infra-7979-gpg-verify
Open

feat(download): verify GPG signature of release binaries on download#87
wnoonan wants to merge 7 commits into
criblio:masterfrom
wnoonan:wnoonan/infra-7979-gpg-verify

Conversation

@wnoonan

@wnoonan wnoonan commented Jun 30, 2026

Copy link
Copy Markdown

What

Verify the GPG signature of release binaries on download, before js2bin injects the app bundle into them. This closes the chain-of-custody gap

How

download() in src/util.js gains a verification step. When the URL is a GitHub release download, after fetching the binary it also fetches two sibling assets
from the same release: the detached signature (<asset>.asc) and our public key (cribl-js2bin-public.asc). It imports the key into an ephemeral keyring and
runs gpg --verify.

We shell out to gpg via the existing runCommand helper rather than pulling in an OpenPGP library, keeping js2bin dependency-free.

Behavior

Best-effort by design, so gpg is not a hard build requirement:

  • Skips (warns, continues) when: the URL is not a release download, gpg is not installed, or no signature is published for the asset.
  • Fails closed (deletes the file, throws ERR_GPG_VERIFY) only when a signature IS present but does not verify.

The signature and public-key assets are themselves excluded from verification, so the check never recurses onto its own downloads.

Signing side (for context)

The signing/publishing half lives in the js2bin Jenkins job, not in this repo: it GPG-signs each built binary and uploads the binary, its .asc signature, and
the public key to the release. This PR is only the consumer/verification half.

Tests

test/gpg-verify.test.js:

  • Skip paths (non-release URL, gpg unavailable) run everywhere.
  • Full end-to-end sign / verify / tamper cases run when gpg is available, using a throwaway generated key against a local release server.

Asset layout this expects on the release

For a binary downloaded from .../releases/download/<tag>/<asset>, this code expects two sibling assets on the same release:

Asset Name Purpose
Binary <asset> the node binary being verified
Signature <asset>.asc detached GPG signature of the binary
Public key cribl-js2bin-public.asc the key the signature is verified against

Comment thread src/util.js Outdated
}

// GPG public key asset published on the release, used to verify other assets.
const PUBLIC_KEY_ASSET = 'cribl-js2bin-public.asc';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than fetching the public key from the release at runtime, consider committing it in the repo (e.g., keys/release-signing.pub) and referencing it from disk. This eliminates the co-location attack vector, removes a network dependency, and provides git audit history for key changes.

Comment thread src/util.js Outdated
function verifyGpgSignature(url, file, headers) {
const sigUrl = sigUrlForDownload(url);
if (!sigUrl) return Promise.resolve();
if (!isGpgAvailable()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the Windows pipeline where INFRA-7979 requires chain-of-custody enforcement, there's no way to make verification mandatory. Consider supporting a --require-signature arg with --build flag that turns these skip-paths into hard failures. Without it, a missing gpg public key or absent signature silently degrades, and enforcement depends on the caller inspecting logs

Comment thread src/util.js Outdated
return;
}
const keyUrl = pubKeyUrlForDownload(url);
return download(keyUrl, keyFile, headers)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we commit the key in the repo instead (see earlier comment), this entire download + import becomes a local file read

Comment thread src/util.js Outdated
// GPG public key asset published on the release, used to verify other assets.
const PUBLIC_KEY_ASSET = 'cribl-js2bin-public.asc';

let gpgReleaseHost = 'github.com';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This implementation says "Does this URL look like a GitHub release? If yes, try to verify. If no, skip." and it can be confusing. Let's provide gpg signature url and gpg public key when --require-signature is passed. You can do something like below (possibly with some modifications):

if (!args['require-signature']) return Promise.resolve(); // user didn't ask, skip

// User asked — signature and key must be available
const sigUrl = args['gpg-signature-url']
  || process.env.JS2BIN_GPG_SIGNATURE_URL
  || `${binaryUrl}.asc`;  // convention fallback

const keyPath = args['gpg-key-path']
  || process.env.JS2BIN_GPG_KEY_PATH
  || join(__dirname, '..', 'keys', 'release-signing.asc');  // bundled fallback

// Verify — no skipping, no "best effort", fail hard if anything goes wrong

@wnoonan wnoonan marked this pull request as ready for review July 1, 2026 16:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants