Add spawn constraint to macOS launch daemon

On some machines, the app bundle can be replaced by any admin user,
so mullvad-daemon can be replaced with a malicious binary that the
launch daemon runs as root.

This adds a spawn constraint that refuses to run any binary that does
not match the expected cdhash of mullvad-daemon. The hash is disabled
for unsigned builds since they don't have this hash embedded.
This commit is contained in:
David Lönnhager committed 2026-10-06 15:04:07 +02:00
1 parent 611273f67f
commit 05ec268c54
2 files changed
+79 -1

No files matched your search

@@ -2,7 +2,7 @@ const path = require('path');
const fs = require('fs');
const builder = require('electron-builder');
const { Arch } = require('electron-builder');
const { execFileSync } = require('child_process');
const { execFileSync, spawnSync } = require('child_process');
const { author } = require('../package.json');
const { signWindows } = require('./sign-windows.cjs');
@@ -354,6 +354,8 @@ async function packMac() {
// them with @electron/universal. Both sub-packs run beforeBuild/beforePack with a concrete
// arch (x64/arm64).
const isUniversal = arch === 'universal';
// Different archs have different cdhashes.
const pkgScriptsDir = buildAssets(`pkg-scripts-${arch ?? 'host'}`);
return {
...config,
mac: {
@@ -364,6 +366,10 @@ async function packMac() {
},
artifactName,
},
pkg: {
...config.pkg,
scripts: pkgScriptsDir,
},
asarUnpack: ['**/*.node'],
beforeBuild: async (options) => {
switch (options.arch) {
@@ -400,6 +406,13 @@ async function packMac() {
await removeNseventforwarderNativeModules();
}
config.beforePack?.(context);
fs.rmSync(pkgScriptsDir, { recursive: true, force: true });
if (process.env.CSC_IDENTITY_AUTO_DISCOVERY !== 'true') {
// afterSign is never called if signing is disabled.
writePkgScripts(pkgScriptsDir, '');
}
},
afterPack: (context) => {
config.afterPack?.(context);
@@ -412,6 +425,11 @@ async function packMac() {
return Promise.resolve();
},
afterAllArtifactBuild: async (_buildResult) => {
const postinstall = fs.readFileSync(path.join(pkgScriptsDir, 'postinstall'), 'utf8');
if (postinstall.includes(DAEMON_SPAWN_CONSTRAINT_PLACEHOLDER)) {
throw new Error('Daemon spawn constraint was not added to postinstall');
}
// Remove the folder that contains the unpacked app. Electron builder cleans up some of
// these directories and it's changed between versions without a mention in the changelog.
for (const dir of appOutDirs) {
@@ -425,6 +443,18 @@ async function packMac() {
afterSign: (context) => {
const appOutDir = context.appOutDir;
appOutDirs.push(appOutDir);
// Only merged app is included in pkg.
if (isUniversal && context.arch !== Arch.universal) {
return;
}
const daemonPath = path.join(
appOutDir,
`${context.packager.appInfo.productFilename}.app`,
'Contents/Resources/mullvad-daemon',
);
writePkgScripts(pkgScriptsDir, daemonSpawnConstraint(daemonPath));
},
};
}
@@ -448,6 +478,49 @@ async function packMac() {
});
}
const DAEMON_SPAWN_CONSTRAINT_PLACEHOLDER = '@DAEMON_SPAWN_CONSTRAINT@';
// Copy the pkg scripts to `outDir`, replacing the spawn constraint placeholder in postinstall with
// `spawnConstraint`. If `spawnConstraint` is null, the placeholder is kept.
function writePkgScripts(outDir, spawnConstraint) {
fs.rmSync(outDir, { recursive: true, force: true });
fs.cpSync(distAssets('pkg-scripts'), outDir, { recursive: true });
const postinstallPath = path.join(outDir, 'postinstall');
const postinstall = fs.readFileSync(postinstallPath, 'utf8');
fs.writeFileSync(
postinstallPath,
postinstall.replace(DAEMON_SPAWN_CONSTRAINT_PLACEHOLDER, () => spawnConstraint),
);
}
// Return a launchd spawn constraint that only allows the daemon at `daemonPath` to be launched,
// identified by its cdhash. A universal binary has one cdhash per architecture.
function daemonSpawnConstraint(daemonPath) {
const archs = execFileSync('lipo', ['-archs', daemonPath], { encoding: 'utf8' })
.trim()
.split(/\s+/);
const cdhashes = archs.map((arch) => {
// codesign prints the signature info to stderr
const { stderr } = spawnSync('codesign', ['--display', '-vvv', '--arch', arch, daemonPath], {
encoding: 'utf8',
});
const match = stderr.match(/^CDHash=([0-9a-f]+)$/m);
if (!match) {
throw new Error(`Failed to get cdhash of ${daemonPath} (${arch})`);
}
return Buffer.from(match[1], 'hex').toString('base64');
});
const value =
cdhashes.length === 1
? `<data>${cdhashes[0]}</data>`
: `<dict><key>\\$in</key><array>${cdhashes.map((cdhash) => `<data>${cdhash}</data>`).join('')}</array></dict>`;
return `<key>SpawnConstraint</key><dict><key>cdhash</key>${value}</dict>`;
}
function packLinux() {
writeRpmChangelog();
+5
View File
@@ -37,6 +37,9 @@ fi
# NOTE: This path must be kept in sync with the path defined
# in mullvad-daemon/src/macos_launch_daemon.rs and preinstall
#
# NOTE: The DAEMON_SPAWN_CONSTRAINT placeholder below is replaced with pinned cdhashes.
# See desktop/packages/mullvad-vpn/tasks/distribution.cjs.
DAEMON_PLIST_PATH="/Library/LaunchDaemons/net.mullvad.daemon.plist"
DAEMON_PLIST=$(cat <<-EOM
@@ -56,6 +59,8 @@ DAEMON_PLIST=$(cat <<-EOM
<key>UserName</key>
<string>root</string>
@DAEMON_SPAWN_CONSTRAINT@
<key>RunAtLoad</key>
<true/>