feat(INFRA-7979): sign windows binary#82
Conversation
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.
0c6fb65 to
4c7eaa2
Compare
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.
| 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(); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
that seems reasonable to me i'll make the changes.
| // 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(); |
There was a problem hiding this comment.
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.
| const cmd = | ||
| 'smctl windows ksp register && ' + | ||
| 'smctl windows certsync --keypair-alias=%key_alias% && ' + | ||
| `signtool sign /d "Cribl Node" /tr http://timestamp.digicert.com ` + |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 filesThere was a problem hiding this comment.
I agree with @nromito that hardcoding cribl-specific signing command is a security risk.
There was a problem hiding this comment.
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.
| const cmd = | ||
| 'smctl windows ksp register && ' + | ||
| 'smctl windows certsync --keypair-alias=%key_alias% && ' + | ||
| `signtool sign /d "Cribl Node" /tr http://timestamp.digicert.com ` + |
There was a problem hiding this comment.
I agree with @nromito that hardcoding cribl-specific signing command is a security risk.
| const cmd = | ||
| 'smctl windows ksp register && ' + | ||
| 'smctl windows certsync --keypair-alias=%key_alias% && ' + | ||
| `signtool sign /d "Cribl Node" /tr http://timestamp.digicert.com ` + |
There was a problem hiding this comment.
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.
| // Chained in one shell; splitting breaks with NTE_PERM 0x8009002d. | ||
| // shell: true so cmd.exe preserves the quoted /d argument. | ||
| signWindowsBinary(filePath) { | ||
| if (!isWindows) { |
There was a problem hiding this comment.
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.
| log(`signing Authenticode signature: ${filePath}`); | ||
| log(`running: ${cmd} ...`); | ||
| return new Promise((resolve, reject) => { | ||
| spawn(cmd, { shell: true, stdio: 'inherit' }) |
There was a problem hiding this comment.
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.
| throw new Error('--verify-signature is Windows-only (requires signtool.exe)'); | ||
| } | ||
| log(`verifying Authenticode signature: ${cachedFile}`); | ||
| return runCommand('signtool', ['verify', '/pa', cachedFile]); |
There was a problem hiding this comment.
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.
Summary
Adds two opt-in, Windows-only flags to js2bin:
--verify-signature(for--build): runssigntool verify /paon 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.exeon PATH (ships with the Windows SDK).--signadditionally requiressmctland 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.exethat js2bin downloads from this repo's GitHub releases. For chain-of-custody compliance (INFRA-7979, Build Process Security doc), we need two seams:--sign): the js2bin--ciJenkins 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.--verify-signature): cribl'sbuildit.shmust 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): runssigntool verify /pa.signWindowsBinary(filePath): runs the smctl + signtool cycle chained in a singlecmd.exeshell (splitting breaks with NTE_PERM 0x8009002d because smctl's KSP session state does not survive across processes).buildFromCachedtakes averifySignatureboolean; runs verify between download and modify.buildFromSourcetakes asignboolean; runs sign between build and upload (so both the cached copy and the uploaded asset carry the signature).js2bin.js:--verify-signatureand--signflags, help text, threaded through.Test plan
--verify-signature(consumer side)js2bin --build --verify-signatureon Windows against a signed release asset -> verify passes, build proceeds, output is correct. BLOCKED by this PR — can only run after--signproduces a signed asset post-merge. Verifiable immediately after first post-merge js2bin Jenkins run publishes a signed asset.--sign(producer side)js2bin --ci --signon Windows via the js2bin Jenkins pipeline (wrapped inauth.withWinSigning) ->signtool verify /paon the uploaded GitHub release asset returnsSuccessfully verified. Ran this pointing at my own fork & branch.--signon Linux/darwin -> flag silently ignored, behavior unchanged. (Quick local test.)Test Results (Consumer)
Jenkins & Windows,
--verify-signatureagainst a signed assetBLOCKED by this PR — verifiable post-release once the js2bin Jenkins pipeline runs with
--signand publishes a signed release asset.Jenkins & Windows,
--verify-signatureagainst an unsigned assetThe asset
windows-ptrc-x64-22.22.2-v1-6MBon thecriblio/js2binv1.0.9release was built and uploaded by the existingjs2binWindows Jenkins pipeline before anyone added a signing step to it.No flag on non-Windows
Existing behavior preserved.
--verify-signatureon non-WindowsNo-op - guard short-circuits before signtool is invoked.
Test Results (Producer)
Run with --sign on Linux/darwin -> flag silently ignored, behavior unchanged
--help): flag parses cleanly, help text renders, no crash.--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
js2binartifacts pointed against this branch and my forked repository.