diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 7855650e7a..cfe04bff7f 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -128,10 +128,10 @@ jobs: if ([string]::IsNullOrEmpty($mode)) { $mode = "product" } Write-Host "Using SSL.com mode: $mode" $propFile = "C:\CodeSignTool\conf\code_sign_tool.properties" - $sandbox = "C:\CodeSignTool\conf\code_sign_tool_demo.properties" + $sandbox = Join-Path $env:GITHUB_WORKSPACE "packages/neuron-wallet/scripts/sslcom-sandbox.properties" if ($mode -eq "sandbox") { if (-not (Test-Path $sandbox)) { - Write-Host "::error::sandbox mode requested but code_sign_tool_demo.properties was not found." + Write-Host "::error::sandbox mode requested but sslcom-sandbox.properties was not found." exit 1 } Copy-Item $sandbox $propFile -Force @@ -168,7 +168,6 @@ jobs: SSL_COM_PASSWORD: ${{ secrets.SSL_COM_PASSWORD }} SSL_COM_TOTP_SECRET: ${{ secrets.SSL_COM_TOTP_SECRET }} SSL_COM_CREDENTIAL_ID: ${{ secrets.SSL_COM_CREDENTIAL_ID }} - SSL_COM_MODE: ${{ secrets.SSL_COM_MODE }} CODE_SIGN_TOOL_PATH: C:\CodeSignTool - name: Verify Windows signature diff --git a/_typos.toml b/_typos.toml index 7a852c75b4..a2b0317e7d 100644 --- a/_typos.toml +++ b/_typos.toml @@ -1,3 +1,7 @@ +[default] +# OAuth client IDs are opaque identifiers, not prose. +extend-ignore-re = ["(?m)^CLIENT_ID=[A-Za-z0-9_-]+$"] + [default.extend-words] thur = "thur" numer = "numer" diff --git a/packages/neuron-wallet/scripts/customSign.js b/packages/neuron-wallet/scripts/customSign.js index 94be035601..de9ef9ad85 100644 --- a/packages/neuron-wallet/scripts/customSign.js +++ b/packages/neuron-wallet/scripts/customSign.js @@ -1,4 +1,4 @@ -const { execSync } = require('node:child_process') +const { execFileSync } = require('node:child_process') const path = require('node:path') /** @@ -8,7 +8,6 @@ const path = require('node:path') * SSL_COM_USERNAME - SSL.com account username * SSL_COM_PASSWORD - SSL.com account password * SSL_COM_TOTP_SECRET - eSigner TOTP secret (OAuth secret) - * SSL_COM_MODE - "sandbox" or "product". Defaults to "product". * SSL_COM_CREDENTIAL_ID - optional, only needed when the account has multiple certificates * CODE_SIGN_TOOL_PATH - directory where CodeSignTool is extracted, e.g. C:\\CodeSignTool * @@ -18,14 +17,8 @@ const path = require('node:path') * build can never be silently left unsigned. */ exports.default = async configuration => { - const { - SSL_COM_USERNAME, - SSL_COM_PASSWORD, - SSL_COM_TOTP_SECRET, - SSL_COM_CREDENTIAL_ID, - SSL_COM_MODE, - CODE_SIGN_TOOL_PATH, - } = process.env + const { SSL_COM_USERNAME, SSL_COM_PASSWORD, SSL_COM_TOTP_SECRET, SSL_COM_CREDENTIAL_ID, CODE_SIGN_TOOL_PATH } = + process.env if (!SSL_COM_USERNAME || !SSL_COM_PASSWORD || !SSL_COM_TOTP_SECRET) { console.info('Skip signing because SSL.com credentials are not configured') @@ -37,27 +30,32 @@ exports.default = async configuration => { } const toolDir = CODE_SIGN_TOOL_PATH || 'C:\\CodeSignTool' - const toolCmd = path.join(toolDir, 'CodeSignTool.bat') + // Use the runtime bundled with the pinned CodeSignTool v1.3.2 archive. + const java = path.join(toolDir, 'jdk-11.0.2', 'bin', 'java.exe') + const jar = path.join(toolDir, 'jar', 'code_sign_tool-1.3.2.jar') const inputPath = path.resolve(String(configuration.path)) const args = [ + '-jar', + jar, 'sign', - `-username="${SSL_COM_USERNAME}"`, - `-password="${SSL_COM_PASSWORD}"`, - `-totp_secret="${SSL_COM_TOTP_SECRET}"`, - `-input_file_path="${inputPath}"`, + `-username=${SSL_COM_USERNAME}`, + `-password=${SSL_COM_PASSWORD}`, + `-totp_secret=${SSL_COM_TOTP_SECRET}`, + `-input_file_path=${inputPath}`, '-override=true', ] if (SSL_COM_CREDENTIAL_ID) { - args.push(`-credential_id="${SSL_COM_CREDENTIAL_ID}"`) + args.push(`-credential_id=${SSL_COM_CREDENTIAL_ID}`) } console.info(`Signing ${inputPath} with SSL.com CodeSignTool`) - execSync(`"${toolCmd}" ${args.join(' ')}`, { + // Credentials must bypass cmd.exe expansion, including percent signs and quotes. + execFileSync(java, args, { cwd: toolDir, stdio: 'inherit', - env: { ...process.env, MODE: SSL_COM_MODE || 'product' }, + shell: false, }) } diff --git a/packages/neuron-wallet/scripts/sslcom-sandbox.properties b/packages/neuron-wallet/scripts/sslcom-sandbox.properties new file mode 100644 index 0000000000..1e9f7eac6b --- /dev/null +++ b/packages/neuron-wallet/scripts/sslcom-sandbox.properties @@ -0,0 +1,7 @@ +# Public sandbox configuration from SSL.com's official action: +# https://github.com/SSLcom/esigner-codesign/blob/b7f8ff36fc0de8690fbbab8e5b4421d29802f747/src/config.ts +CLIENT_ID=qOUeZCCzSqgA93acB3LYq6lBNjgZdiOxQc-KayC3UMw +OAUTH2_ENDPOINT=https://oauth-sandbox.ssl.com/oauth2/token +CSC_API_ENDPOINT=https://cs-try.ssl.com +TSA_URL=http://ts.ssl.com +TSA_LEGACY_URL=http://ts.ssl.com/legacy diff --git a/packages/neuron-wallet/tests/scripts/customSign.test.js b/packages/neuron-wallet/tests/scripts/customSign.test.js new file mode 100644 index 0000000000..5cc943047f --- /dev/null +++ b/packages/neuron-wallet/tests/scripts/customSign.test.js @@ -0,0 +1,74 @@ +jest.mock('node:child_process', () => ({ execFileSync: jest.fn(), execSync: jest.fn() })) + +const path = require('node:path') +const { execFileSync, execSync } = require('node:child_process') +const sign = require('../../scripts/customSign').default + +const originalEnv = process.env +const credentials = { + SSL_COM_USERNAME: 'signer@example.test', + SSL_COM_PASSWORD: 'before%USERNAME%after" & | ^ ! \\ end', + SSL_COM_TOTP_SECRET: 'test-totp', + SSL_COM_CREDENTIAL_ID: 'test-certificate', + CODE_SIGN_TOOL_PATH: path.resolve('tool directory'), +} + +beforeEach(() => { + process.env = { ...credentials } + jest.clearAllMocks() + jest.spyOn(console, 'info').mockImplementation(() => {}) +}) + +afterEach(() => { + process.env = originalEnv + jest.restoreAllMocks() +}) + +test('passes credentials and paths literally without a shell', async () => { + const inputPath = path.resolve('build directory', 'Neuron.exe') + await sign({ path: inputPath }) + expect(execSync).not.toHaveBeenCalled() + expect(execFileSync).toHaveBeenCalledWith( + path.join(credentials.CODE_SIGN_TOOL_PATH, 'jdk-11.0.2', 'bin', 'java.exe'), + [ + '-jar', + path.join(credentials.CODE_SIGN_TOOL_PATH, 'jar', 'code_sign_tool-1.3.2.jar'), + 'sign', + `-username=${credentials.SSL_COM_USERNAME}`, + `-password=${credentials.SSL_COM_PASSWORD}`, + `-totp_secret=${credentials.SSL_COM_TOTP_SECRET}`, + `-input_file_path=${inputPath}`, + '-override=true', + `-credential_id=${credentials.SSL_COM_CREDENTIAL_ID}`, + ], + { cwd: credentials.CODE_SIGN_TOOL_PATH, stdio: 'inherit', shell: false } + ) +}) + +test.each(['SSL_COM_USERNAME', 'SSL_COM_PASSWORD', 'SSL_COM_TOTP_SECRET'])( + 'skips unsigned test builds when %s is missing', + async key => { + delete process.env[key] + await sign({ path: 'Neuron.exe' }) + expect(execFileSync).not.toHaveBeenCalled() + expect(execSync).not.toHaveBeenCalled() + } +) + +test('allows accounts with only one certificate', async () => { + delete process.env.SSL_COM_CREDENTIAL_ID + await sign({ path: 'Neuron.exe' }) + expect(execFileSync.mock.calls[0][1].some(arg => arg.startsWith('-credential_id='))).toBe(false) +}) + +test('rejects a missing application path', async () => { + await expect(sign({})).rejects.toThrow('Path of application is not found') + expect(execFileSync).not.toHaveBeenCalled() +}) + +test('propagates signing failures to electron-builder', async () => { + execFileSync.mockImplementationOnce(() => { + throw new Error('signing failed') + }) + await expect(sign({ path: 'Neuron.exe' })).rejects.toThrow('signing failed') +})