diff --git a/.buildkite/commands/build-for-windows.ps1 b/.buildkite/commands/build-for-windows.ps1 index 82eff5b681..a8580a3863 100644 --- a/.buildkite/commands/build-for-windows.ps1 +++ b/.buildkite/commands/build-for-windows.ps1 @@ -28,9 +28,16 @@ if ($Architecture -notin $VALID_ARCHITECTURES) { Exit 1 } -# setup_windows_code_signing.ps1 comes from CI Toolkit Plugin -& "setup_windows_code_signing.ps1" -If ($LastExitCode -ne 0) { Exit $LastExitCode } +If ($env:USE_AZURE_TRUSTED_SIGNING) { + Write-Host "--- :lock: Setting up Azure Trusted Signing" + $setupScript = (Get-Command setup_azure_trusted_signing.ps1 -ErrorAction Stop).Source + & $setupScript + If ($LastExitCode -ne 0) { Exit $LastExitCode } +} Else { + # setup_windows_code_signing.ps1 comes from CI Toolkit Plugin + & "setup_windows_code_signing.ps1" + If ($LastExitCode -ne 0) { Exit $LastExitCode } +} Write-Host "--- :npm: Installing Node dependencies" bash .buildkite/commands/install-node-dependencies.sh diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index a29981dc81..208e733425 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -138,6 +138,8 @@ steps: - label: 🔨 Windows Dev Build - {{matrix}} agents: queue: windows + env: + USE_AZURE_TRUSTED_SIGNING: 1 command: powershell -File .buildkite/commands/build-for-windows.ps1 -BuildType dev -Architecture {{matrix}} artifact_paths: - apps\studio\out\**\studio-setup.exe diff --git a/.buildkite/release-build-and-distribute.yml b/.buildkite/release-build-and-distribute.yml index f6fb6fde30..d31eecbacb 100644 --- a/.buildkite/release-build-and-distribute.yml +++ b/.buildkite/release-build-and-distribute.yml @@ -68,6 +68,8 @@ steps: - label: 🔨 Windows Release Build - {{matrix}} agents: queue: windows + env: + USE_AZURE_TRUSTED_SIGNING: 1 command: | bash .buildkite/commands/checkout-release-branch.sh "${RELEASE_VERSION}" powershell -File .buildkite/commands/build-for-windows.ps1 -BuildType release -Architecture {{matrix}} diff --git a/.buildkite/shared-pipeline-vars b/.buildkite/shared-pipeline-vars index a7a9319a1a..cb6d57c015 100755 --- a/.buildkite/shared-pipeline-vars +++ b/.buildkite/shared-pipeline-vars @@ -5,7 +5,7 @@ # The ~> modifier is not currently used, but we check for it just in case XCODE_VERSION=$(sed -E -n 's/^(~> )?(.*)/xcode-\2/p' .xcode-version) -CI_TOOLKIT_VERSION='6.0.1' +CI_TOOLKIT_VERSION='4411dd924c08dea251702db5760e741c9d81eff2' NVM_PLUGIN_VERSION='0.6.0' export IMAGE_ID="$XCODE_VERSION" diff --git a/apps/studio/forge.config.ts b/apps/studio/forge.config.ts index 8905b00211..3e5319275f 100644 --- a/apps/studio/forge.config.ts +++ b/apps/studio/forge.config.ts @@ -9,6 +9,7 @@ import { isErrnoException } from '@studio/common/lib/is-errno-exception'; import { exec } from 'child_process'; import { exec as pkgExec } from '@yao-pkg/pkg'; import type { ForgeConfig } from '@electron-forge/shared-types'; +import { windowsSign } from './windowsSign'; const repoRoot = path.resolve( __dirname, '../..' ); @@ -22,6 +23,7 @@ const config: ForgeConfig = { ], executableName: process.platform === 'linux' ? 'studio' : undefined, icon: path.join( __dirname, 'assets', 'studio-app-icon' ), + windowsSign, osxSign: { optionsForFile: ( filePath ) => { // The bundled Node binary requires specific entitlements for V8 JIT compilation. @@ -95,9 +97,16 @@ const config: ForgeConfig = { setupExe: 'studio-setup.exe', - // CI code-signing setup writes certificate.pfx at the repository root. - certificateFile: path.join( repoRoot, 'certificate.pfx' ), - certificatePassword: process.env.WINDOWS_CODE_SIGNING_CERT_PASSWORD, + // Azure mode: use the custom signing hook that calls signtool + // with Azure Trusted Signing parameters. + // PFX mode: use the local certificate file and password. + ...( windowsSign + ? { windowsSign } + : { + certificateFile: path.join( repoRoot, 'certificate.pfx' ), + certificatePassword: process.env.WINDOWS_CODE_SIGNING_CERT_PASSWORD, + } + ), }, [ 'win32' ] ), diff --git a/apps/studio/windowsSign.ts b/apps/studio/windowsSign.ts new file mode 100644 index 0000000000..7620c7944b --- /dev/null +++ b/apps/studio/windowsSign.ts @@ -0,0 +1,32 @@ +import path from 'path'; +import type { WindowsSignOptions } from '@electron/packager'; + +// Azure Trusted Signing configuration for Windows code signing. +// +// Uses a custom hook module because the default @electron/windows-sign +// dual-signs (SHA1 + SHA256), but Azure only supports SHA256. +// The hook calls signtool directly with SHA256-only parameters. +// +// Controlled by the USE_AZURE_TRUSTED_SIGNING env var: +// - Unset/falsy: returns undefined, letting Forge use PFX certificate signing. +// - Set: returns the Azure signing hook config, or throws if the +// required Azure env vars are missing. +function getWindowsSign(): WindowsSignOptions | undefined { + if ( ! process.env.USE_AZURE_TRUSTED_SIGNING ) { + return undefined; + } + + if ( ! process.env.AZURE_CODE_SIGNING_DLIB || ! process.env.AZURE_METADATA_JSON ) { + throw new Error( + 'USE_AZURE_TRUSTED_SIGNING is set but Azure signing env vars ' + + '(AZURE_CODE_SIGNING_DLIB, AZURE_METADATA_JSON) are missing. ' + + 'Did setup_azure_trusted_signing.ps1 run?' + ); + } + + return { + hookModulePath: path.resolve( __dirname, '..', '..', 'scripts', 'azure-sign-hook.js' ), + }; +} + +export const windowsSign = getWindowsSign(); diff --git a/scripts/azure-sign-hook.js b/scripts/azure-sign-hook.js new file mode 100644 index 0000000000..2e1ce9620f --- /dev/null +++ b/scripts/azure-sign-hook.js @@ -0,0 +1,28 @@ +// Custom signing hook for @electron/windows-sign. +// Azure Trusted Signing only supports SHA256; the default dual-sign +// (sha1 + sha256) fails because there's no local cert for sha1. +// This hook calls signtool directly with SHA256-only parameters. + +/* eslint-disable @typescript-eslint/no-require-imports */ + +const { execFileSync } = require( 'child_process' ); +const path = require( 'path' ); +const { + assertAzureSigningEnv, + getAzureSignArgs, + getAzureSigningConfig, +} = require( './azure-signing.cjs' ); + +// @electron/windows-sign includes .ps1 shim scripts from node_modules/.bin +// in its file list. These are text files that don't need code signing. +const SKIP_EXTENSIONS = new Set( [ '.ps1', '.vbs', '.wsf' ] ); + +module.exports = function ( fileToSign ) { + if ( SKIP_EXTENSIONS.has( path.extname( fileToSign ).toLowerCase() ) ) { + return; + } + assertAzureSigningEnv(); + const { signtoolPath } = getAzureSigningConfig(); + + execFileSync( signtoolPath, getAzureSignArgs( fileToSign ), { stdio: 'inherit' } ); +}; diff --git a/scripts/azure-signing.cjs b/scripts/azure-signing.cjs new file mode 100644 index 0000000000..4476e79bba --- /dev/null +++ b/scripts/azure-signing.cjs @@ -0,0 +1,59 @@ +const REQUIRED_ENV_VARS = [ + 'AZURE_CODE_SIGNING_DLIB', + 'AZURE_METADATA_JSON', + 'SIGNTOOL_PATH' +]; + +const DEFAULTS = { + fileDigestAlgorithm: 'SHA256', + timestampDigestAlgorithm: 'SHA256', + timestampServer: 'http://timestamp.acs.microsoft.com', +}; + +function getAzureSigningConfig() { + return { + signtoolPath: process.env.SIGNTOOL_PATH, + dlib: process.env.AZURE_CODE_SIGNING_DLIB, + metadata: process.env.AZURE_METADATA_JSON, + fileDigestAlgorithm: process.env.AZURE_FILE_DIGEST || DEFAULTS.fileDigestAlgorithm, + timestampDigestAlgorithm: + process.env.AZURE_TIMESTAMP_DIGEST || DEFAULTS.timestampDigestAlgorithm, + timestampServer: process.env.AZURE_TIMESTAMP_SERVER || DEFAULTS.timestampServer, + }; +} + +function assertAzureSigningEnv() { + for ( const envVar of REQUIRED_ENV_VARS ) { + if ( ! process.env[ envVar ] ) { + throw new Error( `Required env var ${ envVar } is not set!` ); + } + } +} + +function getAzureSignArgs( fileToSign, extraArgs = [] ) { + const { dlib, metadata, fileDigestAlgorithm, timestampDigestAlgorithm, timestampServer } = + getAzureSigningConfig(); + + return [ + 'sign', + '/v', + ...extraArgs, + '/fd', + fileDigestAlgorithm, + '/tr', + timestampServer, + '/td', + timestampDigestAlgorithm, + '/dlib', + dlib, + '/dmdf', + metadata, + fileToSign, + ]; +} + +module.exports = { + assertAzureSigningEnv, + getAzureSignArgs, + getAzureSigningConfig, +}; diff --git a/scripts/package-appx.mjs b/scripts/package-appx.mjs index 0c6cd38ad1..9cff147372 100644 --- a/scripts/package-appx.mjs +++ b/scripts/package-appx.mjs @@ -1,15 +1,32 @@ +import { execFileSync } from 'child_process'; import fs from 'fs/promises'; import path from 'path'; import { fileURLToPath } from 'url'; import convertToWindowsStore from 'electron2appx'; import packageJson from '../apps/studio/package.json' with { type: 'json' }; +const useAzureSigning = [ '1', 'true' ].includes( + process.env.USE_AZURE_TRUSTED_SIGNING?.trim().toLowerCase() +); + console.log( '--- :electron: Packaging AppX' ); -console.log( '~~~ Verifying WINDOWS_CODE_SIGNING_CERT_PASSWORD env var...' ); -if ( ! process.env.WINDOWS_CODE_SIGNING_CERT_PASSWORD ) { - console.error( 'Required env var WINDOWS_CODE_SIGNING_CERT_PASSWORD is not set!' ); - process.exit( 1 ); +if ( useAzureSigning ) { + const azureSigning = await import( './azure-signing.cjs' ); + const { assertAzureSigningEnv } = azureSigning.default; + console.log( '~~~ Verifying Azure Trusted Signing env vars...' ); + try { + assertAzureSigningEnv(); + } catch ( error ) { + console.error( error instanceof Error ? error.message : error ); + process.exit( 1 ); + } +} else { + console.log( '~~~ Verifying WINDOWS_CODE_SIGNING_CERT_PASSWORD env var...' ); + if ( ! process.env.WINDOWS_CODE_SIGNING_CERT_PASSWORD ) { + console.error( 'Required env var WINDOWS_CODE_SIGNING_CERT_PASSWORD is not set!' ); + process.exit( 1 ); + } } const __dirname = path.dirname( fileURLToPath( import.meta.url ) ); @@ -167,14 +184,58 @@ await convertToWindowsStore( { outputDirectory: appxOutputPathUnsigned, } ); -// Create signed AppX +// Create signed AppX (used for local testing via sideloading) const appxOutputPathSigned = path.resolve( outPath, `${ appxName }-${ architecture }-signed` ); console.log( `~~~ Creating signed .appx for local testing at ${ appxOutputPathSigned }...` ); -await convertToWindowsStore( { - ...sharedOptions, - publisher: 'CN="Automattic, Inc.", O="Automattic, Inc.", S=California, C=US', - devCert: 'certificate.pfx', - certPass: process.env.WINDOWS_CODE_SIGNING_CERT_PASSWORD, - outputDirectory: appxOutputPathSigned, -} ); +if ( useAzureSigning ) { + const sideloadPublisher = + 'CN=Automattic Inc., O=Automattic Inc., L=San Francisco, S=California, C=US'; + + // Build unsigned, then sign with Azure Trusted Signing via signtool. + await convertToWindowsStore( { + ...sharedOptions, + publisher: sideloadPublisher, + devCert: 'nil', + outputDirectory: appxOutputPathSigned, + } ); + + console.log( '~~~ Signing sideload .appx with Azure Trusted Signing...' ); + const azureSigning = await import( './azure-signing.cjs' ); + const { getAzureSignArgs, getAzureSigningConfig } = azureSigning.default; + const appxFiles = ( await fs.readdir( appxOutputPathSigned ) ).filter( ( f ) => + f.endsWith( '.appx' ) + ); + if ( appxFiles.length === 0 ) { + console.error( 'No .appx file found to sign!' ); + process.exit( 1 ); + } + + for ( const appxFile of appxFiles ) { + const appxPath = path.join( appxOutputPathSigned, appxFile ); + console.log( `Signing ${ appxPath }...` ); + const { signtoolPath } = getAzureSigningConfig(); + execFileSync( signtoolPath, getAzureSignArgs( appxPath, [ '/debug' ] ), { + stdio: 'inherit', + } ); + console.log( `Signed ${ appxFile } successfully.` ); + + // Rename to remove misleading "unsigned" from the filename + const renamedFile = appxFile.replace( ' unsigned', '' ); + if ( renamedFile !== appxFile ) { + const renamedPath = path.join( appxOutputPathSigned, renamedFile ); + await fs.rename( appxPath, renamedPath ); + console.log( `Renamed to ${ renamedFile }` ); + } + } +} else { + // PFX certificate signing + await convertToWindowsStore( { + ...sharedOptions, + publisher: + 'CN="Automattic, Inc.", O="Automattic, Inc.", S=California, C=US', + devCert: 'certificate.pfx', + certPass: process.env.WINDOWS_CODE_SIGNING_CERT_PASSWORD, + outputDirectory: appxOutputPathSigned, + } ); +}