Skip to content

feat(INFRA-7979): sign windows binary#82

Open
wnoonan wants to merge 9 commits into
criblio:masterfrom
wnoonan:wnoonan/infra-7979-sign-windows-binary
Open

feat(INFRA-7979): sign windows binary#82
wnoonan wants to merge 9 commits into
criblio:masterfrom
wnoonan:wnoonan/infra-7979-sign-windows-binary

Conversation

@wnoonan
Copy link
Copy Markdown

@wnoonan wnoonan commented May 11, 2026

Summary

Adds two opt-in, Windows-only flags to js2bin:

  • --verify-signature (for --build): runs signtool verify /pa on the downloaded cached node binary between download and modify. Fails the build if the binary is unsigned or has an invalid Authenticode signature.
  • --sign (for --ci): Authenticode-signs the built node binary using DigiCert KeyLocker (smctl + signtool) between the build and the upload/cache steps. Enables downstream consumers to verify the upstream binary's origin before using it.

Both flags are no-ops on non-Windows platforms. Both require signtool.exe on PATH (ships with the Windows SDK). --sign additionally requires smctl and DigiCert KeyLocker environment variables (SM_HOST, SM_API_KEY, SM_CLIENT_CERT_FILE, SM_CLIENT_CERT_PASSWORD, SHA1, KEY_ALIAS).

Motivation

The Cribl Edge build wraps a prebuilt node.exe that js2bin downloads from this repo's GitHub releases. For chain-of-custody compliance (INFRA-7979, Build Process Security doc), we need two seams:

  1. Producer side (--sign): the js2bin --ci Jenkins job must sign the built node binary before it gets uploaded to GitHub releases. Without this, there is no way to assert the binary was produced by Cribl's CI pipeline.
  2. Consumer side (--verify-signature): cribl's buildit.sh must verify that signature before js2bin's modify logic runs on the downloaded bytes. Without this, the modify step trusts arbitrary bytes from GitHub.

Together, the two flags close the chain: signed at production, verified at consumption.

Changes

  • src/NodeBuilder.js:
    • verifyWindowsSignature(cachedFile): runs signtool verify /pa.
    • signWindowsBinary(filePath): runs the smctl + signtool cycle chained in a single cmd.exe shell (splitting breaks with NTE_PERM 0x8009002d because smctl's KSP session state does not survive across processes).
    • buildFromCached takes a verifySignature boolean; runs verify between download and modify.
    • buildFromSource takes a sign boolean; runs sign between build and upload (so both the cached copy and the uploaded asset carry the signature).
  • js2bin.js: --verify-signature and --sign flags, help text, threaded through.

Test plan

--verify-signature (consumer side)

  • Run js2bin --build --verify-signature on Windows against a signed release asset -> verify passes, build proceeds, output is correct. BLOCKED by this PR — can only run after --sign produces a signed asset post-merge. Verifiable immediately after first post-merge js2bin Jenkins run publishes a signed asset.
  • Run against an unsigned asset -> verify fails, build aborts before modify.
  • Run without the flag on Windows -> behavior unchanged.
  • Run with the flag on Linux/darwin -> flag silently ignored, behavior unchanged.

--sign (producer side)

  • Run js2bin --ci --sign on Windows via the js2bin Jenkins pipeline (wrapped in auth.withWinSigning) -> signtool verify /pa on the uploaded GitHub release asset returns Successfully verified. Ran this pointing at my own fork & branch.
  • Run with --sign on Linux/darwin -> flag silently ignored, behavior unchanged. (Quick local test.)

Test Results (Consumer)

Jenkins & Windows, --verify-signature against a signed asset

BLOCKED by this PR — verifiable post-release once the js2bin Jenkins pipeline runs with --sign and publishes a signed release asset.

Jenkins & Windows, --verify-signature against an unsigned asset

The asset windows-ptrc-x64-22.22.2-v1-6MB on the criblio/js2bin v1.0.9 release was built and uploaded by the existing js2bin Windows Jenkins pipeline before anyone added a signing step to it.

11:01:50  + JS2BIN_VERIFY_FLAG=--verify-signature
11:01:50  + ./node_modules/.bin/js2bin --build --cache --verify-signature --node=22.22.2 --platform=windows-ptrc --app=/c/home/jenkins/agent/workspace/dist/sluice-prod/bin/cribl.bundle.js --name=cribl --arch=x64 --build-version=v1
11:01:50  2026-05-11T16:01:40.441Z - building for version=22.22.2, plat=windows-ptrc app=C:/home/jenkins/agent/workspace/dist/sluice-prod/bin/cribl.bundle.js} arch=x64
11:02:17  2026-05-11T16:02:13.659Z - main app file content size = 4889641, place holder size MB = 6
11:02:17  2026-05-11T16:02:13.659Z - downloading https://github.com/criblio/js2bin/releases/download/v1.0.9/windows-ptrc-x64-22.22.2-v1-6MB to C:\home\jenkins\agent\workspace\cache\windows-ptrc-x64-22.22.2-v1-6MB ...
11:02:17  2026-05-11T16:02:14.070Z - following redirect ...
11:02:17  2026-05-11T16:02:14.070Z - downloading https://release-assets.githubusercontent.com/github-production-release-asset/193164769/497116f4-4c7a-4ed5-9c56-bb27d78976e8?[...redacted...] to C:\home\jenkins\agent\workspace\cache\windows-ptrc-x64-22.22.2-v1-6MB ...
11:02:18  2026-05-11T16:02:17.879Z - verifying Authenticode signature: C:\home\jenkins\agent\workspace\cache\windows-ptrc-x64-22.22.2-v1-6MB
11:02:18  2026-05-11T16:02:17.879Z - running: signtool verify /pa C:\home\jenkins\agent\workspace\cache\windows-ptrc-x64-22.22.2-v1-6MB ...
11:02:18  File: C:\home\jenkins\agent\workspace\cache\windows-ptrc-x64-22.22.2-v1-6MB
11:02:18  Index  Algorithm  Timestamp
11:02:18  ========================================
11:02:18
11:02:18  Number of errors: 1
11:02:18
11:02:18  SignTool Error: No signature found.
11:02:18  2026-05-11T16:02:17.919Z - Error: signtool verify /pa C:\home\jenkins\agent\workspace\cache\windows-ptrc-x64-22.22.2-v1-6MB exited with code: 1
11:02:18      at ChildProcess.<anonymous> (C:\home\jenkins\agent\workspace\node_modules\js2bin\src\util.js:76:18)
11:02:18      at Object.onceWrapper (node:events:634:26)
11:02:18      at ChildProcess.emit (node:events:519:28)
11:02:18      at maybeClose (node:internal/child_process:1101:16)
11:02:18      at ChildProcess._handle.onexit (node:internal/child_process:304:5)
script returned exit code 1

No flag on non-Windows

Existing behavior preserved.

Local darwin run: no verify line, 117MB output produced

--verify-signature on non-Windows

No-op - guard short-circuits before signtool is invoked.

Local darwin run with flag: no verify line, no signtool call, output produced

Test Results (Producer)

Run with --sign on Linux/darwin -> flag silently ignored, behavior unchanged

  • 1a (--help): flag parses cleanly, help text renders, no crash.
  • 1b (--ci --sign --node=22.14.0): js2bin ran the full node.js build (got to the OpenSSL compile stage before we killed it at 8s). No signing attempt in the log. The isWindows guard short-circuits signWindowsBinary before it tries to invoke smctl/signtool. Behavior identical to a run without the flag.

Signing

I ran the existing Jenkins job that publishes js2bin artifacts pointed against this branch and my forked repository.

@wnoonan wnoonan marked this pull request as ready for review May 11, 2026 16:22
@wnoonan wnoonan marked this pull request as draft May 11, 2026 16:52
  Paired with --verify-signature (consumer side) for Authenticode
  chain-of-custody on Windows binaries. Signs the built node.exe between
  build and upload using DigiCert KeyLocker (smctl + signtool).

  Windows-only; no-op elsewhere.
@wnoonan wnoonan force-pushed the wnoonan/infra-7979-sign-windows-binary branch from 0c6fb65 to 4c7eaa2 Compare May 11, 2026 18:50
@wnoonan wnoonan marked this pull request as ready for review May 11, 2026 18:51
wnoonan added 5 commits May 11, 2026 13:58
  runCommand's spawn-without-shell mangled the inner quotes on Windows,
  causing signtool to split 'Cribl Node' into two tokens. Switching to
  spawn with { shell: true } lets cmd.exe parse the command string
  intact — same pattern cribl's scripts/sign.js uses successfully.
  KeyLocker's KSP is cwd-sensitive; signtool fails with
  NTE_PERM 0x8009002d when invoked from a cwd other than
  where the .p12 was decoded. Same workaround cribl's
  scripts/sign.js uses.
  bleat-msi's working signtool invocation uses a relative cwd (inherits
  from Node process). Pinning to dirname(SM_CLIENT_CERT_FILE) produced
  MSYS-style paths that Node's spawn on Windows couldn't resolve (ENOENT
  on cmd.exe). Matching bleat-msi's pattern.
Comment thread src/NodeBuilder.js Outdated
buildFromCached(platform = 'linux', arch = 'x64', outFile = undefined, cache = false, size, customDownloadUrl) {
// Windows-only. Requires signtool.exe on PATH (Windows SDK).
verifyWindowsSignature(cachedFile) {
if (!isWindows) return Promise.resolve();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

In this case I would return/throw an error. If for some reason we get here, things have gone sideways, and the user has supplied an inconsistent set of parameters. Not throwing could imply that the signature was verified.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

that seems reasonable to me i'll make the changes.

Comment thread src/NodeBuilder.js Outdated
// Chained in one shell; splitting breaks with NTE_PERM 0x8009002d.
// shell: true so cmd.exe preserves the quoted /d argument.
signWindowsBinary(filePath) {
if (!isWindows) return Promise.resolve();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Same idea here; i think this implies the signature was properly applied, yes?

  Silent no-op could imply verification/signing succeeded when it was
  actually skipped. Throwing makes the platform requirement explicit,
  especially important for external consumers of this public repo.
Comment thread src/NodeBuilder.js
const cmd =
'smctl windows ksp register && ' +
'smctl windows certsync --keypair-alias=%key_alias% && ' +
`signtool sign /d "Cribl Node" /tr http://timestamp.digicert.com ` +
Copy link
Copy Markdown
Collaborator

@nromito nromito May 12, 2026

Choose a reason for hiding this comment

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

Baking cribl specific content into this seems like the wrong approach as this is a public, open source project - what other options do we have?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

i could do something like require an envar e.g.

JS2BIN_SIGN_CMD that i use in conjunction with --sign

I need the node.exe to be signed at the time of creation, and the issue with this library is that it creates/uploads in the same command without a way to do signing in-between.

open to suggestion

Copy link
Copy Markdown
Author

@wnoonan wnoonan May 12, 2026

Choose a reason for hiding this comment

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

off the top of my head another option could be:

separate building & uploading from --ci but this would be a breaking change

OR possibly:

create separate commands that effectively break --ci up, e.g. --ci-build --ci-upload this way in the pipeline that builds node.exe i could run js2bin with --ci-build

then, do something like:

  node js2bin.js --ci-build ${opts}        # new subcommand: builds to cache/, no upload
  auth.withWinSigning {
    smctl + signtool cache/windows-*       # sign however we want
  }
  node js2bin.js --ci-upload ${same opts}  # new subcommand: upload cached files

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.

I agree with @nromito that hardcoding cribl-specific signing command is a security risk.

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.

In terms of how to tackle it, I think @wnoonan has proposed decent solutions.
In the env-var approach, js2bin is responsible for invoking the sign command, handling its exit code, and sequencing it correctly. That means js2bin still "owns" signing at runtime, it just delegates the specific command. If something goes wrong in the signing step, the failure surface is still inside js2bin.
With --ci-build / --ci-upload, signing is genuinely external — it never touches js2bin's code path at all. The library does what it's good at (building and publishing nodejs binaries) and nothing else. This might be a better composable solution.

Comment thread src/NodeBuilder.js
const cmd =
'smctl windows ksp register && ' +
'smctl windows certsync --keypair-alias=%key_alias% && ' +
`signtool sign /d "Cribl Node" /tr http://timestamp.digicert.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.

I agree with @nromito that hardcoding cribl-specific signing command is a security risk.

Comment thread src/NodeBuilder.js
const cmd =
'smctl windows ksp register && ' +
'smctl windows certsync --keypair-alias=%key_alias% && ' +
`signtool sign /d "Cribl Node" /tr http://timestamp.digicert.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.

In terms of how to tackle it, I think @wnoonan has proposed decent solutions.
In the env-var approach, js2bin is responsible for invoking the sign command, handling its exit code, and sequencing it correctly. That means js2bin still "owns" signing at runtime, it just delegates the specific command. If something goes wrong in the signing step, the failure surface is still inside js2bin.
With --ci-build / --ci-upload, signing is genuinely external — it never touches js2bin's code path at all. The library does what it's good at (building and publishing nodejs binaries) and nothing else. This might be a better composable solution.

Comment thread src/NodeBuilder.js
// Chained in one shell; splitting breaks with NTE_PERM 0x8009002d.
// shell: true so cmd.exe preserves the quoted /d argument.
signWindowsBinary(filePath) {
if (!isWindows) {
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.

In the js2bin.js is mentioned that this is a no-op for non-Windows OS where it's throwing here. The wording needs to be fixed.

Comment thread src/NodeBuilder.js
log(`signing Authenticode signature: ${filePath}`);
log(`running: ${cmd} ...`);
return new Promise((resolve, reject) => {
spawn(cmd, { shell: true, stdio: 'inherit' })
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.

filePath is interpolated directly into a shell command string with { shell: true }. If filePath ever contains shell-special characters (spaces, quotes, &, |, etc.) this can break the command or, in adversarial contexts, enable command injection. You may want to sanitize it or something like that.
If you decide to use --ci-build and --ci-upload then this is a no-op.

Comment thread src/NodeBuilder.js
throw new Error('--verify-signature is Windows-only (requires signtool.exe)');
}
log(`verifying Authenticode signature: ${cachedFile}`);
return runCommand('signtool', ['verify', '/pa', cachedFile]);
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.

signtool verify /pa checks against the default Authenticode policy (any trusted CA). It does NOT pin to Cribl's specific certificate. An attacker with any valid code-signing cert could produce a binary that passes this check. For a stronger chain of custody, you may want the verify step to pin to Cribl's expected subject/thumbprint/cert chain.

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.

4 participants