Skip to content

Commit 380ceed

Browse files
authored
Merge pull request #69 from TrentBrown/tb-portreeve-preview-signature
2 parents c1f53e7 + fa20562 commit 380ceed

6 files changed

Lines changed: 163 additions & 3 deletions

File tree

docs/installation.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
> configured. Product maturity (`alpha`), release channel (`preview`), and macOS trust
1010
> (`unsigned`) are separate facts.
1111
12+
The preview is ad-hoc signed so macOS can verify that the assembled application bundle
13+
and its nested executables have not changed. An ad-hoc signature carries no verified
14+
developer identity and is not notarization, so Gatekeeper may still require the scoped
15+
**Open Anyway** procedure below.
16+
1217
Use [GitHub Releases](https://github.com/TrentBrown/portreeve/releases) to identify the
1318
newest preview and verify its assets. The commands below install the currently published
1419
Homebrew, DMG, and direct-download artifacts.
@@ -174,7 +179,7 @@ appropriate.
174179

175180
## Build from source
176181

177-
Before the first public preview, macOS users can build the current source with the
182+
Contributors and users testing unreleased changes can build the current source with the
178183
pinned Bun 1.3.14 toolchain:
179184

180185
```sh

docs/releasing.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ real Developer ID signing, hardened runtime, secure timestamps, notarization, st
4444
Gatekeeper acceptance, and native ARM64/x64 evidence. The current workflow deliberately
4545
has no way to substitute synthetic evidence for those requirements.
4646

47+
Preview Desktop bundles are ad-hoc signed and structurally verified before packaging.
48+
That integrity seal has no developer identity and confers no Gatekeeper trust; the
49+
release record therefore continues to describe preview Desktop trust as `unsigned`.
50+
4751
## Prerequisites
4852

4953
All release work requires a clean checkout of the source commit to be released and the

scripts/desktop-package-lib.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ export function assertPackagedDesktopContents(options) {
148148
* @param {{applicationPath: string, controllerVersion: string, architecture: 'arm64'|'x64'}} options
149149
*/
150150
export async function verifyPackagedDesktop(options) {
151+
await verifyPackagedDesktopSignature(options.applicationPath);
151152
const resourcesRoot = resolve(options.applicationPath, 'Contents', 'Resources');
152153
const executableFormat = await inspectExecutable(
153154
resolve(options.applicationPath, 'Contents', 'MacOS', 'PortReeve'),
@@ -206,6 +207,31 @@ export async function verifyPackagedDesktop(options) {
206207
return artifact;
207208
}
208209

210+
/**
211+
* Reject a bundle whose nested code or sealed resources do not match its signature.
212+
* An ad-hoc preview passes this structural check but remains untrusted by Gatekeeper.
213+
*
214+
* @param {string} applicationPath
215+
* @param {(executable: string, arguments_: string[]) => Promise<{stdout: string, stderr: string, exitCode: number}>} [run]
216+
*/
217+
export async function verifyPackagedDesktopSignature(
218+
applicationPath,
219+
run = runCommand,
220+
) {
221+
const result = await run('codesign', [
222+
'--verify',
223+
'--deep',
224+
'--strict',
225+
'--verbose=4',
226+
applicationPath,
227+
]);
228+
if (result.exitCode !== 0) {
229+
throw new Error(
230+
`Packaged Desktop bundle signature is invalid: ${result.stderr.trim() || result.stdout.trim()}`,
231+
);
232+
}
233+
}
234+
209235
/**
210236
* Launch the assembled application through its real Electron executable. The
211237
* smoke branch is read-only and receives isolated lifecycle and Desktop paths.
@@ -316,3 +342,27 @@ function runBounded(executable, environment, timeoutMilliseconds) {
316342
});
317343
});
318344
}
345+
346+
/** @param {string} executable @param {string[]} arguments_ @returns {Promise<{stdout: string, stderr: string, exitCode: number}>} */
347+
function runCommand(executable, arguments_) {
348+
return new Promise((resolvePromise, reject) => {
349+
const child = spawn(executable, arguments_, {
350+
shell: false,
351+
stdio: ['ignore', 'pipe', 'pipe'],
352+
});
353+
let stdout = '';
354+
let stderr = '';
355+
child.stdout.setEncoding('utf8');
356+
child.stderr.setEncoding('utf8');
357+
child.stdout.on('data', (chunk) => {
358+
stdout = (stdout + chunk).slice(0, OUTPUT_LIMIT);
359+
});
360+
child.stderr.on('data', (chunk) => {
361+
stderr = (stderr + chunk).slice(0, OUTPUT_LIMIT);
362+
});
363+
child.once('error', reject);
364+
child.once('close', (exitCode) => {
365+
resolvePromise({ stdout, stderr, exitCode: exitCode ?? 70 });
366+
});
367+
});
368+
}

scripts/package-desktop.js

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export async function packageDesktop(options = {}) {
3333
options.outputRoot ?? resolve(workspaceRoot, 'dist', 'desktop'),
3434
);
3535
const desktopRoot = resolve(workspaceRoot, 'apps', 'desktop');
36+
const releaseChannel = options.releaseChannel ?? 'preview';
37+
const osxSign = createDesktopSignOptions(releaseChannel);
3638
const stage = resolve(outputRoot, `stage-${architecture}`);
3739
const output = resolve(outputRoot, architecture);
3840
const resources = resolve(stage, 'release-input', 'portreeve');
@@ -94,7 +96,7 @@ export async function packageDesktop(options = {}) {
9496
private: true,
9597
type: 'module',
9698
main: 'main/index.js',
97-
portreeveReleaseChannel: options.releaseChannel ?? 'preview',
99+
portreeveReleaseChannel: releaseChannel,
98100
},
99101
null,
100102
2,
@@ -109,7 +111,7 @@ export async function packageDesktop(options = {}) {
109111
artifactVersion: artifact.version,
110112
artifactSha256: artifact.sha256,
111113
architecture,
112-
releaseChannel: options.releaseChannel ?? 'preview',
114+
releaseChannel,
113115
moduleGraph: {
114116
directLifecycleController: true,
115117
verifiedArtifactResolver: true,
@@ -142,6 +144,7 @@ export async function packageDesktop(options = {}) {
142144
prune: false,
143145
ignore: /^\/release-input(?:\/|$)/,
144146
extraResource: [resolve(stage, 'release-input', 'portreeve')],
147+
osxSign,
145148
});
146149
if (paths.length !== 1) {
147150
throw new Error(`Desktop packager returned ${paths.length} output paths.`);
@@ -171,6 +174,43 @@ export async function packageDesktop(options = {}) {
171174
};
172175
}
173176

177+
/**
178+
* Preview bundles use an ad-hoc identity so every nested executable and the final
179+
* application bundle are sealed consistently. This proves bundle integrity without
180+
* claiming a Developer ID identity or Gatekeeper trust. Stable packaging remains
181+
* unavailable until the separate Developer ID and notarization path is configured.
182+
*
183+
* @param {'preview'|'stable'} releaseChannel
184+
*/
185+
export function createDesktopSignOptions(releaseChannel) {
186+
if (releaseChannel !== 'preview') {
187+
throw new Error(
188+
'Stable Desktop packaging requires configured Developer ID signing and notarization.',
189+
);
190+
}
191+
return {
192+
identity: '-',
193+
identityValidation: false,
194+
continueOnError: false,
195+
preAutoEntitlements: false,
196+
preEmbedProvisioningProfile: false,
197+
ignore: isPromotedCliResource,
198+
optionsForFile: () => ({ hardenedRuntime: false, timestamp: 'none' }),
199+
};
200+
}
201+
202+
/**
203+
* The promoted CLI must remain byte-for-byte identical to its release manifest.
204+
* The application signature seals that exact resource without re-signing it.
205+
*
206+
* @param {string} filePath
207+
*/
208+
export function isPromotedCliResource(filePath) {
209+
return /\/Contents\/Resources\/portreeve\/portreeve-v[^/]+$/u.test(
210+
filePath.replaceAll('\\', '/'),
211+
);
212+
}
213+
174214
/** @returns {'arm64'|'x64'} */
175215
function nativeArchitecture() {
176216
if (process.arch === 'arm64' || process.arch === 'x64') return process.arch;

test/desktop/package-verification.test.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ import {
55
assertDesktopModuleGraph,
66
assertDesktopPackageIdentity,
77
assertPackagedDesktopContents,
8+
verifyPackagedDesktopSignature,
89
} from '../../scripts/desktop-package-lib.js';
10+
import {
11+
createDesktopSignOptions,
12+
isPromotedCliResource,
13+
} from '../../scripts/package-desktop.js';
914
import { verifyDesktopRuntimeContract } from './runtime-contract.js';
1015

1116
test('requires exact controller and artifact identity before packaging', () => {
@@ -15,6 +20,59 @@ test('requires exact controller and artifact identity before packaging', () => {
1520
);
1621
});
1722

23+
test('ad-hoc signs preview bundles without implying Developer ID trust', () => {
24+
const options = createDesktopSignOptions('preview');
25+
expect(options).toMatchObject({
26+
identity: '-',
27+
identityValidation: false,
28+
continueOnError: false,
29+
preAutoEntitlements: false,
30+
preEmbedProvisioningProfile: false,
31+
});
32+
expect(options.optionsForFile()).toEqual({
33+
hardenedRuntime: false,
34+
timestamp: 'none',
35+
});
36+
expect(
37+
isPromotedCliResource(
38+
'/tmp/PortReeve.app/Contents/Resources/portreeve/portreeve-v0.1.0-macos-arm64',
39+
),
40+
).toBe(true);
41+
expect(
42+
isPromotedCliResource(
43+
'/tmp/PortReeve.app/Contents/Frameworks/PortReeve Helper.app',
44+
),
45+
).toBe(false);
46+
expect(() => createDesktopSignOptions('stable')).toThrow(
47+
'requires configured Developer ID signing and notarization',
48+
);
49+
});
50+
51+
test('rejects a structurally invalid packaged application signature', async () => {
52+
/** @type {[string, string[]][]} */
53+
const calls = [];
54+
await verifyPackagedDesktopSignature(
55+
'/tmp/PortReeve.app',
56+
async (executable, arguments_) => {
57+
calls.push([executable, arguments_]);
58+
return { stdout: '', stderr: '', exitCode: 0 };
59+
},
60+
);
61+
expect(calls).toEqual([
62+
[
63+
'codesign',
64+
['--verify', '--deep', '--strict', '--verbose=4', '/tmp/PortReeve.app'],
65+
],
66+
]);
67+
await expect(
68+
verifyPackagedDesktopSignature('/tmp/PortReeve.app', async () => ({
69+
stdout: '',
70+
stderr: 'code has no resources',
71+
exitCode: 1,
72+
})),
73+
).rejects.toThrow('code has no resources');
74+
});
75+
1876
test('requires the direct lifecycle module graph and excludes the retired CLI adapter', () => {
1977
const inputs = [
2078
'apps/desktop/main/artifact.js',

test/release/documentation.test.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,9 +177,12 @@ test('preview installation guidance is safe, scoped, and lifecycle explicit', as
177177
);
178178
expect(installation).toContain('brew trust --formula trentbrown/portreeve/portreeve');
179179
expect(installation).toContain('PortReeve-VERSION-macos-arm64.dmg');
180+
expect(installation).toContain('ad-hoc signed');
181+
expect(installation).toMatch(/carries no verified\s+developer identity/u);
180182
expect(installation).toContain('System Settings');
181183
expect(installation).toContain('Privacy & Security');
182184
expect(installation).toContain('Open Anyway');
185+
expect(installation).not.toContain('Before the first public preview');
183186
expect(installation).toContain('https://support.apple.com/102445');
184187
expect(installation).toContain('https://docs.brew.sh/Installation');
185188
expect(installation).toContain('portreeve uninstall');

0 commit comments

Comments
 (0)