Skip to content

Commit

Permalink
feat: Add functionality to create a "signtool.exe" (#7)
Browse files Browse the repository at this point in the history
This PR adds functionality to have `@electron/windows-sign` create a
`signtool.exe` that signs any given file with the same options passed to
`@electron/windows-sign` in the first place. That's helpful for tools
like Squirrel - which will _only_ sign with `signtool.exe`. It's a bit
of a clever hack to make `@electron/windows-sign` compatible with any
tool that'll let users call `signtool.exe`.

Here's how it works:
- On the user's machine, we use Node's "single executable binary"
feature to create a custom version of `node.exe` that contains signing
options. We'll call that newly minted binary `signtool.exe`.
- When our `signtool.exe` is executed, it spawns user's `node.exe`,
requires `@electron/windows-sign`, and signs the file originally passed
to it with the options already embedded in the binary.
- Since we _create_ the `signtool.exe` on the local machine, users can
use this tool with confidence - there is no mysterious "signtool.exe"
that'd be able to exfiltrate secrets.

I don't expect users to actually use this feature directly. It'll be
consumed by `@electron/windows-installer`, for which I'll open another
PR.
  • Loading branch information
felixrieseberg authored Feb 4, 2024
1 parent efbe255 commit 8b23eaa
Show file tree
Hide file tree
Showing 11 changed files with 752 additions and 432 deletions.
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@

/dist/

yarn-error.log
yarn-error.log

tmp
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ await sign({

```ps1
electron-windows-sign $PATH_TO_APP_DIRECTORY --certificate-file=$PATH_TO_CERT --certificate-password=$CERT-PASSWORD
```
```

### Full configuration
```ts
Expand Down Expand Up @@ -138,8 +138,9 @@ export default async function (filePath) {

// Bad:
module.exports = {
function (filePath) {
console.log(`Path to file to sign: ${filePath}`)
myCustomHookName: function (filePath) {
console.log(`Path to file to sign: ${filePath}`)
}
}

// Bad:
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,11 @@
},
"homepage": "https://github.com/electron/windows-sign",
"dependencies": {
"cross-dirname": "^0.1.0",
"debug": "^4.3.4",
"fs-extra": "^11.1.1",
"minimist": "^1.2.8"
"minimist": "^1.2.8",
"postject": "^1.0.0-alpha.6"
},
"devDependencies": {
"@types/debug": "^4.1.10",
Expand Down
1 change: 1 addition & 0 deletions src/ambient.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
declare module 'postject'
10 changes: 9 additions & 1 deletion src/files.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import path from 'path';
import fs from 'fs-extra';

import { SignOptions } from './types';
import { SignOptions, SignOptionsForFiles } from './types';

const IS_PE_REGEX = /\.(exe|dll|sys|efi|scr|node)$/i;
const IS_MSI_REGEX = /\.msi$/i;
Expand All @@ -26,6 +26,10 @@ const IS_JS_REGEX = /\.js$/i;
* - JavaScript files (.js)
*/
export function getFilesToSign(options: SignOptions, dir?: string): Array<string> {
if (isSignOptionsForFiles(options)) {
return options.files;
}

dir = dir || options.appDirectory;

// Array of file paths to sign
Expand Down Expand Up @@ -61,3 +65,7 @@ export function getFilesToSign(options: SignOptions, dir?: string): Array<string

return result;
}

function isSignOptionsForFiles(input: SignOptions): input is SignOptionsForFiles {
return !!(input as SignOptionsForFiles).files;
}
5 changes: 3 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { sign } from './sign';
import { OptionalHookOptions, OptionalSignToolOptions, SignOptions } from './types';
import { createSeaSignTool } from './sea';
import { OptionalHookOptions, OptionalSignToolOptions, SignOptions, SignToolOptions } from './types';

export { sign, SignOptions, OptionalSignToolOptions, OptionalHookOptions };
export { sign, SignOptions, SignToolOptions, OptionalSignToolOptions, OptionalHookOptions, createSeaSignTool };
264 changes: 264 additions & 0 deletions src/sea.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
import { getDirname } from 'cross-dirname';
import path from 'path';
import os from 'os';
import fs from 'fs-extra';
import postject from 'postject';

import { spawnPromise } from './spawn';
import { log } from './utils/log';
import { SignToolOptions } from './types';

interface SeaOptions {
// Full path to the sea. Needs to end with .exe
path: string
// Optional: A binary to use. Will use the current executable
// (process.execPath) by default.
bin?: string;
// Sign options
windowsSign: SignToolOptions
}

interface InternalSeaOptions extends Required<SeaOptions> {
dir: string
filename: string
}

/**
* cross-dir uses new Error() stacks
* to figure out our directory in a way
* that's somewhat cross-compatible.
*
* We can't just use __dirname because it's
* undefined in ESM - and we can't use import.meta.url
* because TypeScript won't allow usage unless you're
* _only_ compiling for ESM.
*/
export const DIRNAME = getDirname();

const FILENAMES = {
SEA_CONFIG: 'sea-config.json',
SEA_MAIN: 'sea.js',
SEA_BLOB: 'sea.blob',
SEA_RECEIVER: 'receiver.mjs'
};

const SEA_MAIN_SCRIPT = `
const bin = "%PATH_TO_BIN%";
const script = "%PATH_TO_SCRIPT%";
const options = %WINDOWS_SIGN_OPTIONS%
const { spawnSync } = require('child_process');
function main() {
console.log("@electron/windows-sign sea");
console.log({ bin, script });
try {
const spawn = spawnSync(
bin,
[ script, JSON.stringify(options), JSON.stringify(process.argv.slice(1)) ],
{ stdio: ['inherit', 'inherit', 'pipe'] }
);
const stderr = spawn.stderr.toString().trim();
if (stderr) {
throw new Error(stderr);
}
} catch (error) {
process.exitCode = 1;
throw new Error(error);
}
}
main();
`;

const SEA_RECEIVER_SCRIPT = `
import { sign } from '@electron/windows-sign';
import fs from 'fs-extra';
import path from 'path';
const logPath = path.join('electron-windows-sign.log');
const options = JSON.parse(process.argv[2]);
const signArgv = JSON.parse(process.argv[3]);
const files = signArgv.slice(-1);
fs.appendFileSync(logPath, \`\\n\${files}\`);
sign({ ...options, files })
.then((result) => {
fs.appendFileSync(logPath, \`\\n\${result}\`);
console.log(\`Successfully signed \${files}\`, result);
})
.catch((error) => {
fs.appendFileSync(logPath, \`\\n\${error}\`);
throw new Error(error);
});
`;

/**
* Uses Node's "Single Executable App" functionality
* to create a Node-driven signtool.exe that calls this
* module.
*
* This is useful with other tooling that _always_ calls
* a signtool.exe to sign. Some of those tools cannot be
* easily configured, but we _can_ override their signtool.exe.
*/
export async function createSeaSignTool(options: Partial<SeaOptions> = {}): Promise<InternalSeaOptions> {
checkCompatibility();

const requiredOptions = await getOptions(options);
await createFiles(requiredOptions);
await createBlob(requiredOptions);
await createBinary(requiredOptions);
await createSeaReceiver(requiredOptions);
await cleanup(requiredOptions);

return requiredOptions;
}

async function createSeaReceiver(options: InternalSeaOptions) {
const receiverPath = path.join(options.dir, FILENAMES.SEA_RECEIVER);

await fs.ensureFile(receiverPath);
await fs.writeFile(receiverPath, SEA_RECEIVER_SCRIPT);
}

async function createFiles(options: InternalSeaOptions) {
const { dir, bin } = options;
const receiverPath = path.join(options.dir, FILENAMES.SEA_RECEIVER);

// sea-config.json
await fs.outputJSON(path.join(dir, FILENAMES.SEA_CONFIG), {
main: FILENAMES.SEA_MAIN,
output: FILENAMES.SEA_BLOB,
disableExperimentalSEAWarning: true
}, {
spaces: 2
});

// signtool.js
const binPath = bin || process.execPath;
const script = SEA_MAIN_SCRIPT
.replace('%PATH_TO_BIN%', escapeMaybe(binPath))
.replace('%PATH_TO_SCRIPT%', escapeMaybe(receiverPath))
.replace('%WINDOWS_SIGN_OPTIONS%', JSON.stringify(options.windowsSign));

await fs.outputFile(path.join(dir, FILENAMES.SEA_MAIN), script);
}

async function createBlob(options: InternalSeaOptions) {
const args = ['--experimental-sea-config', 'sea-config.json'];
const bin = process.execPath;
const cwd = options.dir;

log(`Calling ${bin} with options:`, args);

return spawnPromise(bin, args, {
cwd
});
}

async function createBinary(options: InternalSeaOptions) {
const { dir, filename } = options;

log(`Creating ${filename} in ${dir}`);

// Copy Node over
const seaPath = path.join(dir, filename);
await fs.copyFile(process.execPath, seaPath);

// Remove the Node signature
const signtool = path.join(DIRNAME, '../../vendor/signtool.exe');
await spawnPromise(signtool, [
'remove',
'/s',
seaPath
]);

// Inject the blob
const blob = await fs.readFile(path.join(dir, FILENAMES.SEA_BLOB));
await postject.inject(seaPath, 'NODE_SEA_BLOB', blob, {
sentinelFuse: 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2'
});
}

async function cleanup(options: InternalSeaOptions) {
const { dir } = options;
const toRemove = [
FILENAMES.SEA_BLOB,
FILENAMES.SEA_MAIN,
FILENAMES.SEA_CONFIG
];

for (const file of toRemove) {
try {
await fs.remove(path.join(dir, file));
} catch (error) {
console.warn(`Tried and failed to remove ${file}. Continuing.`, error);
}
}
}

async function getOptions(options: Partial<SeaOptions>): Promise<InternalSeaOptions> {
const cloned = { ...options };

if (!cloned.path) {
cloned.path = path.join(os.homedir(), '.electron', 'windows-sign', 'sea.exe');
await fs.ensureFile(cloned.path);
}

if (!cloned.bin) {
cloned.bin = process.execPath;
}

if (!cloned.windowsSign) {
throw new Error('Did not find windowsSign options, which are required');
}

return {
path: cloned.path,
dir: path.dirname(cloned.path),
filename: path.basename(cloned.path),
bin: cloned.bin,
windowsSign: cloned.windowsSign
};
}

/**
* Ensures that the current Node.js version supports SEA app generation and errors if not.
*/
function checkCompatibility() {
const version = process.versions.node;
const split = version.split('.');
const major = parseInt(split[0], 10);
const minor = parseInt(split[1], 10);

if (major >= 20) {
return true;
}

if (major === 19 && minor >= 7) {
return true;
}

if (major === 18 && minor >= 16) {
return true;
}

throw new Error(`Your Node.js version (${process.version}) does not support Single Executable Applications. Please upgrade your version of Node.js.`);
}

/** Make sure that the input string has escaped backwards slashes
* - but never double-escaped backwards slashes.
*/
function escapeMaybe(input: string): string {
const result = input.split(path.sep).join('\\\\');

if (result.includes('\\\\\\\\')) {
throw new Error(`Your passed input ${input} contains escaped slashes. Please do not escape them`);
}

return result;
}
5 changes: 4 additions & 1 deletion src/sign-with-signtool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import path from 'path';
import { log } from './utils/log';
import { spawnPromise } from './spawn';
import { HASHES, InternalSignOptions, InternalSignToolOptions } from './types';
import { getDirname } from 'cross-dirname';

const DIRNAME = getDirname();

function getSigntoolArgs(options: InternalSignToolOptions) {
// See the following url for docs
Expand Down Expand Up @@ -93,7 +96,7 @@ export async function signWithSignTool(options: InternalSignOptions) {
const certificateFile = options.certificateFile || process.env.WINDOWS_CERTIFICATE_FILE;
const signWithParams = options.signWithParams || process.env.WINDOWS_SIGN_WITH_PARAMS;
const timestampServer = options.timestampServer || process.env.WINDOWS_TIMESTAMP_SERVER || 'http://timestamp.digicert.com';
const signToolPath = options.signToolPath || process.env.WINDOWS_SIGNTOOL_PATH || path.join(__dirname, '../../vendor/signtool.exe');
const signToolPath = options.signToolPath || process.env.WINDOWS_SIGNTOOL_PATH || path.join(DIRNAME, '../../vendor/signtool.exe');
const description = options.description || process.env.WINDOWS_SIGN_DESCRIPTION;
const website = options.website || process.env.WINDOWS_SIGN_WEBSITE;

Expand Down
13 changes: 11 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,26 @@ export const enum HASHES {
sha256 = 'sha256',
}

export interface SignOptions extends OptionalSignToolOptions, OptionalHookOptions {
export type SignOptions = SignOptionsForDirectory | SignOptionsForFiles

export interface SignOptionsForDirectory extends SignToolOptions {
// Path to the application directory. We will scan this
// directory for any .dll, .exe, .msi, or .node files and
// codesign them with signtool.exe
appDirectory: string;
}

export interface InternalSignOptions extends SignOptions {
export interface SignOptionsForFiles extends SignToolOptions {
// Path to files to be signed.
files: Array<string>;
}

export interface SignToolOptions extends OptionalSignToolOptions, OptionalHookOptions {

}

export interface InternalSignOptions extends SignOptionsForFiles {}

export interface InternalSignToolOptions extends OptionalSignToolOptions, OptionalHookOptions {
certificateFile?: string;
certificatePassword?: string;
Expand Down
Loading

0 comments on commit 8b23eaa

Please sign in to comment.