Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/signed-native-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@selftune/desktop": patch
---

Fix signed macOS Desktop startup by accounting for code-signing changes to the bundled DuckDB native libraries. Keep signed-bundle verification, exact hashes for other runtime files, and byte-identical installed runtime checks.
9 changes: 6 additions & 3 deletions apps/desktop/scripts/build-sidecar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
writeFile,
} from "node:fs/promises";
import { dirname, join, relative, resolve, sep } from "node:path";
import { isSigningMutableRuntimePath } from "../src/main/runtime-integrity";

const desktopRoot = resolve(import.meta.dir, "..");
const selfTuneRoot = resolve(desktopRoot, "../..");
Expand Down Expand Up @@ -50,14 +51,16 @@ async function listFiles(directory: string): Promise<string[]> {
return nested.flat();
}

async function writeRuntimeManifest(executable: string): Promise<void> {
async function writeRuntimeManifest(): Promise<void> {
const files = await Promise.all(
(await listFiles(resourceRoot)).map(async (path) => {
const contents = await readFile(path);
const info = await stat(path);
return {
path: relative(resourceRoot, path).split(sep).join("/"),
signing_mutable: relative(resourceRoot, path).split(sep).join("/") === executable,
signing_mutable: isSigningMutableRuntimePath(
relative(resourceRoot, path).split(sep).join("/"),
),
sha256: createHash("sha256").update(contents).digest("hex"),
size: info.size,
};
Expand Down Expand Up @@ -209,6 +212,6 @@ await cp(
join(selfTuneRoot, "skill/settings_snippet.json"),
join(resourceRoot, "settings_snippet.json"),
);
await writeRuntimeManifest(executable);
await writeRuntimeManifest();

process.stdout.write(`Staged the SelfTune runtime and dashboard at ${resourceRoot}\n`);
49 changes: 46 additions & 3 deletions apps/desktop/src/main/runtime-install.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { afterEach, describe, expect, it } from "bun:test";
import { createHash } from "node:crypto";
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { dirname, join } from "node:path";

import {
parseDeveloperIdSigningIdentity,
Expand All @@ -16,18 +16,32 @@ afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});

function runtimeFixture(): string {
function runtimeFixture(nativePath?: string): string {
const root = mkdtempSync(join(tmpdir(), "selftune-runtime-install-"));
roots.push(root);
const executable = Buffer.from("selftune-runtime");
const settings = Buffer.from('{"version":1}\n');
writeFileSync(join(root, "selftune"), executable, { mode: 0o700 });
writeFileSync(join(root, "settings_snippet.json"), settings);
if (nativePath) {
mkdirSync(dirname(join(root, nativePath)), { recursive: true });
writeFileSync(join(root, nativePath), "unsigned-native-library", { mode: 0o700 });
}
writeFileSync(
join(root, "runtime-manifest.json"),
JSON.stringify({
version: 2,
files: [
...(nativePath
? [
{
path: nativePath,
signing_mutable: true,
size: Buffer.byteLength("unsigned-native-library"),
sha256: createHash("sha256").update("unsigned-native-library").digest("hex"),
},
]
: []),
{
path: "selftune",
signing_mutable: true,
Expand Down Expand Up @@ -156,4 +170,33 @@ describe("stable desktop runtime integrity", () => {
writeFileSync(join(root, "settings_snippet.json"), "tampered");
expect(verifyRuntimeDirectory(root, { allowPlatformSigningMutation: true })).toBeFalse();
});

for (const path of [
"selftune-report-worker",
"selftune-report-worker.exe",
"node_modules/@duckdb/node-api/node_modules/@duckdb/node-bindings/native/duckdb.node",
"node_modules/@duckdb/node-api/node_modules/@duckdb/node-bindings/native/libduckdb.dylib",
]) {
it(`accepts signing changes to ${path} only with verified source trust and identical copies`, () => {
const source = runtimeFixture(path);
const candidate = runtimeFixture(path);
expect(verifyRuntimeDirectory(source)).toBeTrue();
writeFileSync(join(source, path), "developer-id-signed-library");
writeFileSync(join(candidate, path), "developer-id-signed-library");
expect(verifyRuntimeDirectory(source)).toBeFalse();
expect(verifyRuntimeDirectory(source, { allowPlatformSigningMutation: true })).toBeTrue();
expect(
runtimeMatchesSignedSource(source, candidate, { allowPlatformSigningMutation: true }),
).toBeTrue();
writeFileSync(join(candidate, path), "tampered-library");
expect(
runtimeMatchesSignedSource(source, candidate, { allowPlatformSigningMutation: true }),
).toBeFalse();
});
}

it("rejects a manifest that marks ordinary code as signing mutable", () => {
const root = runtimeFixture("node_modules/untrusted/index.js");
expect(verifyRuntimeDirectory(root, { allowPlatformSigningMutation: true })).toBeFalse();
});
});
30 changes: 27 additions & 3 deletions apps/desktop/src/main/runtime-integrity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,25 @@ export interface RuntimeIntegrityOptions {
readonly allowPlatformSigningMutation?: boolean;
}

function isRuntimeExecutable(path: string): boolean {
return [
"selftune",
"selftune.exe",
"selftune-report-worker",
"selftune-report-worker.exe",
].includes(path);
}

export function isSigningMutableRuntimePath(path: string): boolean {
return (
isRuntimeExecutable(path) ||
path ===
"node_modules/@duckdb/node-api/node_modules/@duckdb/node-bindings/native/duckdb.node" ||
path ===
"node_modules/@duckdb/node-api/node_modules/@duckdb/node-bindings/native/libduckdb.dylib"
);
}

export interface DeveloperIdSigningIdentity {
readonly authority: string;
readonly teamIdentifier: string;
Expand Down Expand Up @@ -52,8 +71,9 @@ export function verifyRuntimeDirectory(
if (manifest.files.length === 0) return false;
const signingMutable = manifest.files.filter((entry) => entry.signing_mutable);
if (
signingMutable.length !== 1 ||
!["selftune", "selftune.exe"].includes(signingMutable[0]?.path ?? "")
signingMutable.filter((entry) => ["selftune", "selftune.exe"].includes(entry.path)).length !==
1 ||
signingMutable.some((entry) => !isSigningMutableRuntimePath(entry.path))
) {
return false;
}
Expand All @@ -70,7 +90,11 @@ export function verifyRuntimeDirectory(
if (!existsSync(path)) return false;
const info = statSync(path);
if (!info.isFile()) return false;
if (entry.signing_mutable && process.platform !== "win32" && (info.mode & 0o111) === 0) {
if (
isRuntimeExecutable(entry.path) &&
process.platform !== "win32" &&
(info.mode & 0o111) === 0
) {
return false;
}
const matchesBuildHash = info.size === entry.size && sha256(path) === entry.sha256;
Expand Down
Loading