six-degrees 0.8.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +13 -13
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/api/admin-delete/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/admin-update/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/bridges/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/bulk-import/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/company-scores/legacy/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/company-scores/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/connections/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/data/backup/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/data/csv/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/data/export/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/data/import/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/data/restart/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/data/restore/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/data/reveal/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/data/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/delete-cluster/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/ingest/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/network/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/notifications/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/outreach/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/queue/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/scraper/route.js +1 -1
- package/.next/standalone/.next/server/app/api/scraper/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/settings/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/settings/sector-preview/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/settings/sector-suggestions/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/setup-profile/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/social/crm/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/social/messages/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/social/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/unlock/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/update/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/update-images/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/users/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/avatars/[file]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/import/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/import/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/import.html +1 -1
- package/.next/standalone/.next/server/app/import.rsc +16 -16
- package/.next/standalone/.next/server/app/import.segments/_full.segment.rsc +16 -16
- package/.next/standalone/.next/server/app/import.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/import.segments/import/__PAGE__.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +16 -16
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +16 -16
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/insights/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/insights/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/insights.html +1 -1
- package/.next/standalone/.next/server/app/insights.rsc +16 -16
- package/.next/standalone/.next/server/app/insights.segments/_full.segment.rsc +16 -16
- package/.next/standalone/.next/server/app/insights.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/insights.segments/insights/__PAGE__.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/launch/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/launch/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/launch.html +2 -2
- package/.next/standalone/.next/server/app/launch.rsc +16 -16
- package/.next/standalone/.next/server/app/launch.segments/_full.segment.rsc +16 -16
- package/.next/standalone/.next/server/app/launch.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/launch.segments/launch/__PAGE__.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/paths/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/paths/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/paths.html +1 -1
- package/.next/standalone/.next/server/app/paths.rsc +16 -16
- package/.next/standalone/.next/server/app/paths.segments/_full.segment.rsc +16 -16
- package/.next/standalone/.next/server/app/paths.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/paths.segments/paths/__PAGE__.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/profile/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/profile/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/profile.html +1 -1
- package/.next/standalone/.next/server/app/profile.rsc +16 -16
- package/.next/standalone/.next/server/app/profile.segments/_full.segment.rsc +16 -16
- package/.next/standalone/.next/server/app/profile.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/profile.segments/profile/__PAGE__.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/queue/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/queue/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/queue.html +1 -1
- package/.next/standalone/.next/server/app/queue.rsc +16 -16
- package/.next/standalone/.next/server/app/queue.segments/_full.segment.rsc +16 -16
- package/.next/standalone/.next/server/app/queue.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/queue.segments/queue/__PAGE__.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/scores/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/scores/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/scores.html +1 -1
- package/.next/standalone/.next/server/app/scores.rsc +16 -16
- package/.next/standalone/.next/server/app/scores.segments/_full.segment.rsc +16 -16
- package/.next/standalone/.next/server/app/scores.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/scores.segments/scores/__PAGE__.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/settings.html +1 -1
- package/.next/standalone/.next/server/app/settings.rsc +16 -16
- package/.next/standalone/.next/server/app/settings.segments/_full.segment.rsc +16 -16
- package/.next/standalone/.next/server/app/settings.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/setup/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/setup.html +1 -1
- package/.next/standalone/.next/server/app/setup.rsc +17 -17
- package/.next/standalone/.next/server/app/setup.segments/_full.segment.rsc +17 -17
- package/.next/standalone/.next/server/app/setup.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/setup.segments/setup/__PAGE__.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/social/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/social/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/social.html +1 -1
- package/.next/standalone/.next/server/app/social.rsc +16 -16
- package/.next/standalone/.next/server/app/social.segments/_full.segment.rsc +16 -16
- package/.next/standalone/.next/server/app/social.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/social.segments/social/__PAGE__.segment.rsc +15 -15
- package/.next/standalone/.next/server/chunks/_1_fynia._.js +7 -7
- package/.next/standalone/.next/server/chunks/_1itd1o_._.js +1 -1
- package/.next/standalone/.next/server/chunks/lib_02iz_9q._.js +2 -2
- package/.next/standalone/.next/server/chunks/lib_05agvk3._.js +2 -2
- package/.next/standalone/.next/server/chunks/lib_0v84u66._.js +2 -2
- package/.next/standalone/.next/server/chunks/lib_db-client_0_dch35.js +1 -1
- package/.next/standalone/.next/server/chunks/{lib_rpc_0t552z6.js → lib_rpc_1hjjjr4.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0_79idq._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_setup_page_1-282zx.js +4 -4
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/middleware-manifest.json +5 -5
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/static/chunks/0hggugbh84m4t.js +21 -0
- package/.next/standalone/.next/static/chunks/1ods5h86raivz.js +1 -0
- package/.next/standalone/CHANGELOG.md +23 -0
- package/.next/standalone/README.md +15 -13
- package/.next/standalone/SECURITY.md +9 -4
- package/.next/standalone/app/api/scraper/route.js +9 -1
- package/.next/standalone/app/setup/page.js +55 -4
- package/.next/standalone/install.sh +7 -6
- package/.next/standalone/lib/scanner-setup.js +82 -3
- package/.next/standalone/lib/settings.js +3 -0
- package/.next/standalone/package-lock.json +2 -2
- package/.next/standalone/package.json +1 -1
- package/.next/standalone/scripts/build-app.mjs +167 -11
- package/.next/standalone/scripts/build-desktop.mjs +3 -2
- package/.next/standalone/scripts/ci-mac-signing.sh +128 -0
- package/.next/standalone/scripts/entitlements/electron.plist +23 -0
- package/.next/standalone/scripts/entitlements/node.plist +27 -0
- package/.next/standalone/scripts/mac-sign.mjs +563 -0
- package/.next/standalone/scripts/make-dmg-background.mjs +2 -1
- package/.next/standalone/scripts/set-signing-secrets.sh +96 -0
- package/.next/static/chunks/0hggugbh84m4t.js +21 -0
- package/.next/static/chunks/1ods5h86raivz.js +1 -0
- package/CHANGELOG.md +23 -0
- package/README.md +15 -13
- package/SECURITY.md +9 -4
- package/lib/scanner-setup.js +82 -3
- package/lib/settings.js +3 -0
- package/package.json +1 -1
- package/.next/standalone/.next/static/chunks/0838_s6w-i_ik.js +0 -1
- package/.next/standalone/.next/static/chunks/112mv-com-mqa.js +0 -21
- package/.next/static/chunks/0838_s6w-i_ik.js +0 -1
- package/.next/static/chunks/112mv-com-mqa.js +0 -21
- /package/.next/standalone/.next/static/{3NCyJyAPDCEYsYTwF1Ieu → mwCGF3OV3CWV4SjUgg5T8}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{3NCyJyAPDCEYsYTwF1Ieu → mwCGF3OV3CWV4SjUgg5T8}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{3NCyJyAPDCEYsYTwF1Ieu → mwCGF3OV3CWV4SjUgg5T8}/_ssgManifest.js +0 -0
- /package/.next/static/{3NCyJyAPDCEYsYTwF1Ieu → mwCGF3OV3CWV4SjUgg5T8}/_buildManifest.js +0 -0
- /package/.next/static/{3NCyJyAPDCEYsYTwF1Ieu → mwCGF3OV3CWV4SjUgg5T8}/_clientMiddlewareManifest.js +0 -0
- /package/.next/static/{3NCyJyAPDCEYsYTwF1Ieu → mwCGF3OV3CWV4SjUgg5T8}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Signing and notarizing the Mac app (docs/brain/DESKTOP.md D4). Used by
|
|
3
|
+
// scripts/build-app.mjs, and by scripts/ci-mac-signing.sh through the
|
|
4
|
+
// `release-plan` command at the bottom.
|
|
5
|
+
//
|
|
6
|
+
// Two ways, chosen by the environment (signingSetup):
|
|
7
|
+
//
|
|
8
|
+
// ad hoc no identity. Exactly what every build did before D4, and what
|
|
9
|
+
// forks, pull requests and local builds without a certificate
|
|
10
|
+
// still do: build-app.mjs runs its own `codesign --sign -` lines,
|
|
11
|
+
// untouched. Nothing in this file runs for it.
|
|
12
|
+
// Developer ID SIX_DEGREES_SIGN_IDENTITY names a "Developer ID Application"
|
|
13
|
+
// identity (its name or its SHA-1), SIX_DEGREES_SIGN_KEYCHAIN
|
|
14
|
+
// optionally the keychain holding it. Every program and library
|
|
15
|
+
// is signed on its own, inside out, never with --deep: codesign's
|
|
16
|
+
// --deep signs nested bundles but not the loose programs and
|
|
17
|
+
// libraries in Resources (the server's Node, the scanner's
|
|
18
|
+
// Python, native modules), and it gives everything the same
|
|
19
|
+
// entitlements. Each signature has the hardened runtime and a
|
|
20
|
+
// secure timestamp, both of which Apple's notary requires.
|
|
21
|
+
//
|
|
22
|
+
// Notarization, on top of Developer ID, when SIX_DEGREES_NOTARY_KEY (the path to
|
|
23
|
+
// an App Store Connect API key, AuthKey_….p8), SIX_DEGREES_NOTARY_KEY_ID and
|
|
24
|
+
// SIX_DEGREES_NOTARY_ISSUER are all set: notarytool submits, waits for Apple's
|
|
25
|
+
// answer, and on a refusal fetches Apple's log and prints its issues.
|
|
26
|
+
//
|
|
27
|
+
// The decisions are pure functions at the top (tests/mac-sign.test.mjs); the
|
|
28
|
+
// work below them runs codesign, notarytool, stapler and spctl. Nothing here
|
|
29
|
+
// ever prints a credential: notarytool's command line holds the key's id and
|
|
30
|
+
// issuer, so its errors are never shown as Node builds them ("Command failed:
|
|
31
|
+
// xcrun notarytool … --key-id …"), only its own output, with those values
|
|
32
|
+
// replaced by *** (scrub).
|
|
33
|
+
|
|
34
|
+
import { spawnSync, execFileSync } from 'node:child_process';
|
|
35
|
+
import {
|
|
36
|
+
readdirSync, existsSync, openSync, readSync, closeSync, statSync,
|
|
37
|
+
} from 'node:fs';
|
|
38
|
+
import path from 'node:path';
|
|
39
|
+
import { fileURLToPath } from 'node:url';
|
|
40
|
+
|
|
41
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
42
|
+
|
|
43
|
+
/** The GitHub Actions secrets the release workflow signs with (scripts/set-signing-secrets.sh sets them). */
|
|
44
|
+
export const SECRET_NAMES = Object.freeze([
|
|
45
|
+
'MACOS_SIGN_P12_BASE64',
|
|
46
|
+
'MACOS_SIGN_P12_PASSWORD',
|
|
47
|
+
'APPLE_API_KEY_P8_BASE64',
|
|
48
|
+
'APPLE_API_KEY_ID',
|
|
49
|
+
'APPLE_API_ISSUER_ID',
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
const NOTARY_VARS = Object.freeze(['SIX_DEGREES_NOTARY_KEY', 'SIX_DEGREES_NOTARY_KEY_ID', 'SIX_DEGREES_NOTARY_ISSUER']);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The entitlements, by who needs them. Only main executables carry
|
|
56
|
+
* entitlements; libraries get the hardened runtime and nothing else.
|
|
57
|
+
*
|
|
58
|
+
* electron the app and its helper apps: Electron's V8 compiles JavaScript as
|
|
59
|
+
* it runs, which the hardened runtime refuses without allow-jit
|
|
60
|
+
* (and allow-unsigned-executable-memory, which V8 still uses on
|
|
61
|
+
* Intel Macs).
|
|
62
|
+
* node the server's Node, and Playwright's driver when it has a Node of
|
|
63
|
+
* its own: the same V8. Checked here: Node 24 signed with the
|
|
64
|
+
* hardened runtime and no entitlements stops at once with "Failed
|
|
65
|
+
* to reserve virtual memory for CodeRange".
|
|
66
|
+
*
|
|
67
|
+
* Nobody gets com.apple.security.cs.disable-library-validation. Library
|
|
68
|
+
* validation lets a program load only libraries signed by Apple or by the same
|
|
69
|
+
* team, and every library in the app is signed here by the same identity: the
|
|
70
|
+
* Electron framework, the Python's extension modules, sharp's .node and its
|
|
71
|
+
* libvips. The app never loads a library from outside itself: the scanner's
|
|
72
|
+
* own environment (Install, Set up the scanner) is built from this computer's
|
|
73
|
+
* Python or a downloaded one, never from the app's (lib/scanner-python.js
|
|
74
|
+
* installSteps), so no pip-installed module is ever loaded by a program here.
|
|
75
|
+
*
|
|
76
|
+
* The scanner's Python gets none: CPython doesn't compile code at run time, and
|
|
77
|
+
* the scanner and its packages don't make executable memory (no ctypes
|
|
78
|
+
* callbacks). build-app.mjs runs it under the hardened runtime after signing
|
|
79
|
+
* (checkSignedRuntime), so a missing entitlement fails the build, not a scan.
|
|
80
|
+
*/
|
|
81
|
+
export const ENTITLEMENTS = Object.freeze({
|
|
82
|
+
electron: path.join(HERE, 'entitlements', 'electron.plist'),
|
|
83
|
+
node: path.join(HERE, 'entitlements', 'node.plist'),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
export class SigningError extends Error {}
|
|
87
|
+
|
|
88
|
+
const blank = (v) => v === undefined || v === null || String(v).trim() === '';
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* How this build signs, from the environment: { mode: 'ad-hoc' }, or
|
|
92
|
+
* { mode: 'developer-id', identity, keychain, notary } with notary null or
|
|
93
|
+
* { key, keyId, issuer }, or { error } for a setting that can't be right.
|
|
94
|
+
* `exists` checks a path (the key file, the keychain).
|
|
95
|
+
*/
|
|
96
|
+
export function signingSetup(env = {}, { exists = () => true } = {}) {
|
|
97
|
+
const identity = String(env.SIX_DEGREES_SIGN_IDENTITY ?? '').trim();
|
|
98
|
+
const keychain = String(env.SIX_DEGREES_SIGN_KEYCHAIN ?? '').trim();
|
|
99
|
+
const notarySet = NOTARY_VARS.filter((name) => !blank(env[name]));
|
|
100
|
+
if (notarySet.length && notarySet.length < NOTARY_VARS.length) {
|
|
101
|
+
const missing = NOTARY_VARS.filter((name) => blank(env[name]));
|
|
102
|
+
return { error: `Notarization needs ${NOTARY_VARS.join(', ')}; ${missing.join(' and ')} ${missing.length > 1 ? 'are' : 'is'} not set.` };
|
|
103
|
+
}
|
|
104
|
+
if (!identity || identity === '-') {
|
|
105
|
+
if (notarySet.length) {
|
|
106
|
+
return { error: 'Notarization needs a Developer ID signature, and SIX_DEGREES_SIGN_IDENTITY is not set (an ad hoc signature is never notarized).' };
|
|
107
|
+
}
|
|
108
|
+
return { mode: 'ad-hoc' };
|
|
109
|
+
}
|
|
110
|
+
if (keychain && !exists(keychain)) {
|
|
111
|
+
return { error: `SIX_DEGREES_SIGN_KEYCHAIN names a keychain that isn't there (${keychain}).` };
|
|
112
|
+
}
|
|
113
|
+
let notary = null;
|
|
114
|
+
if (notarySet.length) {
|
|
115
|
+
const key = String(env.SIX_DEGREES_NOTARY_KEY).trim();
|
|
116
|
+
// The key's path is said, never its contents: the path is no secret.
|
|
117
|
+
if (!exists(key)) return { error: `SIX_DEGREES_NOTARY_KEY names a file that isn't there (${key}).` };
|
|
118
|
+
notary = { key, keyId: String(env.SIX_DEGREES_NOTARY_KEY_ID).trim(), issuer: String(env.SIX_DEGREES_NOTARY_ISSUER).trim() };
|
|
119
|
+
}
|
|
120
|
+
return { mode: 'developer-id', identity, keychain: keychain || null, notary };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The major version, from "1.2.3" or "v1.2.3-beta.1"; NaN when there isn't one. */
|
|
124
|
+
function majorOf(version) {
|
|
125
|
+
const m = String(version ?? '').trim().replace(/^v/i, '').match(/^(\d+)\./);
|
|
126
|
+
return m ? Number(m[1]) : Number.NaN;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Must this release be signed and notarized? A real release (a tag, not a dry
|
|
131
|
+
* run) of 1.0.0 or later, its betas included: from 1.0 the download pages say
|
|
132
|
+
* it's signed, so a release must never quietly go out without it. Before 1.0,
|
|
133
|
+
* and on any dry run, a build without the secrets is signed ad hoc as before.
|
|
134
|
+
*/
|
|
135
|
+
export function signingRequired({ version, dryRun }) {
|
|
136
|
+
if (dryRun) return false;
|
|
137
|
+
const major = majorOf(version);
|
|
138
|
+
return !Number.isFinite(major) || major >= 1;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* What the release workflow does about signing (scripts/ci-mac-signing.sh):
|
|
143
|
+
* { sign: true } with all five secrets, { sign: false, note } with none when it
|
|
144
|
+
* may build ad hoc, or { error } (names only, never a value): some secrets but
|
|
145
|
+
* not all is always a mistake, and none at all when signing is required.
|
|
146
|
+
* `present` lists the secrets that are set and not empty.
|
|
147
|
+
*/
|
|
148
|
+
export function releaseSigningPlan({ version, dryRun, present = [] }) {
|
|
149
|
+
const have = new Set(present);
|
|
150
|
+
const missing = SECRET_NAMES.filter((name) => !have.has(name));
|
|
151
|
+
const required = signingRequired({ version, dryRun });
|
|
152
|
+
if (!missing.length) return { sign: true, required };
|
|
153
|
+
if (missing.length < SECRET_NAMES.length) {
|
|
154
|
+
return {
|
|
155
|
+
error: `Some of the Mac signing secrets are set and some aren't. Missing: ${missing.join(', ')}. `
|
|
156
|
+
+ 'Set all five (scripts/set-signing-secrets.sh), or none.',
|
|
157
|
+
required,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
if (required) {
|
|
161
|
+
return {
|
|
162
|
+
error: `Version ${version} must be signed and notarized (every real release from 1.0.0 on is), and the signing secrets `
|
|
163
|
+
+ `aren't set: ${SECRET_NAMES.join(', ')}. Set them with scripts/set-signing-secrets.sh, or do a dry run.`,
|
|
164
|
+
required,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
sign: false,
|
|
169
|
+
required,
|
|
170
|
+
note: `No signing secrets, so the Mac app is signed ad hoc, as before (fine for ${dryRun ? 'a dry run' : `${version}, before 1.0`}).`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** codesign's arguments for a Developer ID signature: hardened runtime, secure timestamp, never --deep. */
|
|
175
|
+
export function codesignArgs(setup, { entitlements = null, runtime = true } = {}) {
|
|
176
|
+
return [
|
|
177
|
+
'--force',
|
|
178
|
+
'--sign', setup.identity,
|
|
179
|
+
'--timestamp',
|
|
180
|
+
...(runtime ? ['--options', 'runtime'] : []),
|
|
181
|
+
...(setup.keychain ? ['--keychain', setup.keychain] : []),
|
|
182
|
+
...(entitlements ? ['--entitlements', entitlements] : []),
|
|
183
|
+
];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const BUNDLE_EXTENSIONS = ['.app', '.framework', '.xpc', '.appex'];
|
|
187
|
+
const MH_EXECUTE = 2;
|
|
188
|
+
const MH_DYLIB = 6;
|
|
189
|
+
const MH_BUNDLE = 8;
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The bundle a file is the executable of, relative to the app ('' for the app
|
|
193
|
+
* itself), or null when it isn't one: `X.app/Contents/MacOS/<name>`, or
|
|
194
|
+
* `F.framework/Versions/<v>/F`. codesign treats such a file as its bundle, so it
|
|
195
|
+
* is signed when its bundle is, with the bundle's entitlements.
|
|
196
|
+
*/
|
|
197
|
+
export function bundleOfExecutable(rel) {
|
|
198
|
+
const app = rel.match(/^(?:(.*\.app)\/)?Contents\/MacOS\/[^/]+$/);
|
|
199
|
+
if (app) return app[1] || '';
|
|
200
|
+
const fw = rel.match(/^(.*\/)?(([^/]+)\.framework)\/Versions\/[^/]+\/([^/]+)$/);
|
|
201
|
+
if (fw && fw[3] === fw[4]) return `${fw[1] || ''}${fw[2]}`;
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The order and entitlements for signing an app, from what is in it. `entries`
|
|
207
|
+
* are paths relative to the app: { rel, kind: 'file', fileType } for each
|
|
208
|
+
* Mach-O file (fileType from its header: 2 a program, 6 a library, 8 a
|
|
209
|
+
* loadable module such as a Python extension or a .node), and
|
|
210
|
+
* { rel, kind: 'bundle' } for each code bundle inside (helper apps,
|
|
211
|
+
* frameworks). `mainIsMachO`: the app's own executable is a program (Electron)
|
|
212
|
+
* rather than a script (the classic launcher).
|
|
213
|
+
*
|
|
214
|
+
* Inside out: every loose program and library first (they depend on nothing
|
|
215
|
+
* around them), then the bundles, deepest first, so everything inside a bundle
|
|
216
|
+
* is signed before the bundle seals it, then the app last. A bundle's own
|
|
217
|
+
* executable is signed with its bundle, with the bundle's entitlements.
|
|
218
|
+
* Returns { files: [{ rel, entitlements }], bundles: [{ rel, entitlements }],
|
|
219
|
+
* app: { entitlements } }, entitlements being a key of ENTITLEMENTS or null.
|
|
220
|
+
*/
|
|
221
|
+
export function signingPlan(entries, { mainIsMachO = true } = {}) {
|
|
222
|
+
const depth = (rel) => rel.split('/').length;
|
|
223
|
+
const files = [];
|
|
224
|
+
const bundles = [];
|
|
225
|
+
const bundleSet = new Set(entries.filter((e) => e.kind === 'bundle').map((e) => e.rel));
|
|
226
|
+
const signedWithBundle = (rel) => {
|
|
227
|
+
const root = bundleOfExecutable(rel);
|
|
228
|
+
return root === '' || (root !== null && bundleSet.has(root));
|
|
229
|
+
};
|
|
230
|
+
for (const e of entries) {
|
|
231
|
+
if (e.kind === 'bundle') {
|
|
232
|
+
bundles.push({ rel: e.rel, entitlements: e.rel.endsWith('.app') ? 'electron' : null });
|
|
233
|
+
} else if (e.kind === 'file' && [MH_EXECUTE, MH_DYLIB, MH_BUNDLE].includes(e.fileType) && !signedWithBundle(e.rel)) {
|
|
234
|
+
const program = e.fileType === MH_EXECUTE;
|
|
235
|
+
files.push({ rel: e.rel, entitlements: program && path.posix.basename(e.rel) === 'node' ? 'node' : null });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
bundles.sort((a, b) => depth(b.rel) - depth(a.rel) || a.rel.localeCompare(b.rel));
|
|
239
|
+
files.sort((a, b) => depth(b.rel) - depth(a.rel) || a.rel.localeCompare(b.rel));
|
|
240
|
+
return { files, bundles, app: { entitlements: mainIsMachO ? 'electron' : null } };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* spctl's verdict, from its -vvv output: { accepted, source, origin, disabled }.
|
|
245
|
+
* `disabled`: Gatekeeper's assessments are turned off on this machine
|
|
246
|
+
* ("override=security disabled"), so its "accepted" says nothing about the app.
|
|
247
|
+
*/
|
|
248
|
+
export function parseSpctl(text) {
|
|
249
|
+
const s = String(text || '');
|
|
250
|
+
return {
|
|
251
|
+
accepted: /: accepted\s*$/m.test(s),
|
|
252
|
+
source: (s.match(/^source=(.+)$/m) || [null, null])[1]?.trim() || null,
|
|
253
|
+
origin: (s.match(/^origin=(.+)$/m) || [null, null])[1]?.trim() || null,
|
|
254
|
+
disabled: /^override=security disabled\s*$/m.test(s),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** notarytool's --output-format json answer: { id, status, message }, or null. */
|
|
259
|
+
export function parseNotaryJson(text) {
|
|
260
|
+
const s = String(text || '');
|
|
261
|
+
const start = s.indexOf('{');
|
|
262
|
+
const end = s.lastIndexOf('}');
|
|
263
|
+
if (start < 0 || end <= start) return null;
|
|
264
|
+
try {
|
|
265
|
+
const j = JSON.parse(s.slice(start, end + 1));
|
|
266
|
+
if (!j || typeof j !== 'object') return null;
|
|
267
|
+
return { id: typeof j.id === 'string' ? j.id : null, status: typeof j.status === 'string' ? j.status : null, message: typeof j.message === 'string' ? j.message : null };
|
|
268
|
+
} catch {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The lines worth printing from a notarization log (`notarytool log`, JSON): one per issue, at most `max`. */
|
|
274
|
+
export function notaryIssueLines(log, { max = 40 } = {}) {
|
|
275
|
+
if (!log || typeof log !== 'object') return ['(Apple sent no readable log)'];
|
|
276
|
+
const issues = Array.isArray(log.issues) ? log.issues : [];
|
|
277
|
+
const lines = [];
|
|
278
|
+
if (log.statusSummary) lines.push(String(log.statusSummary));
|
|
279
|
+
for (const issue of issues.slice(0, max)) {
|
|
280
|
+
if (!issue || typeof issue !== 'object') continue;
|
|
281
|
+
const where = [issue.path, issue.architecture ? `(${issue.architecture})` : ''].filter(Boolean).join(' ');
|
|
282
|
+
lines.push(`${issue.severity || 'issue'}: ${where ? `${where}: ` : ''}${issue.message || 'no message'}`);
|
|
283
|
+
}
|
|
284
|
+
if (issues.length > max) lines.push(`… and ${issues.length - max} more`);
|
|
285
|
+
if (!issues.length && !lines.length) lines.push('(no issues listed)');
|
|
286
|
+
return lines;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** `text` with every one of `secrets` (four characters or longer) replaced by ***. */
|
|
290
|
+
export function scrub(text, secrets = []) {
|
|
291
|
+
let s = String(text ?? '');
|
|
292
|
+
for (const secret of secrets) {
|
|
293
|
+
const v = String(secret ?? '');
|
|
294
|
+
if (v.length >= 4) s = s.split(v).join('***');
|
|
295
|
+
}
|
|
296
|
+
return s;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** From `codesign -dv --verbose=2`: { authority, team, runtime, timestamp }. */
|
|
300
|
+
export function parseCodesignInfo(text) {
|
|
301
|
+
const s = String(text || '');
|
|
302
|
+
const team = (s.match(/^TeamIdentifier=(.+)$/m) || [null, null])[1]?.trim() || null;
|
|
303
|
+
return {
|
|
304
|
+
authority: (s.match(/^Authority=(.+)$/m) || [null, null])[1]?.trim() || null,
|
|
305
|
+
team: team && team !== 'not set' ? team : null,
|
|
306
|
+
runtime: /flags=0x[0-9a-f]+\([^)]*runtime[^)]*\)/.test(s),
|
|
307
|
+
timestamp: /^Timestamp=/m.test(s),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** "Developer ID Application: Blake Burford (TEAMID)" → "Blake Burford". */
|
|
312
|
+
export function signerName(authority) {
|
|
313
|
+
return String(authority || '').replace(/^Developer ID Application:\s*/, '').replace(/\s*\([A-Z0-9]+\)\s*$/, '').trim() || null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ── the work ────────────────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
/** A command's output, captured, never thrown: { status, stdout, stderr }. */
|
|
319
|
+
function capture(cmd, args, { env = process.env } = {}) {
|
|
320
|
+
const r = spawnSync(cmd, args, { env, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
321
|
+
return { status: r.error ? null : r.status, stdout: r.stdout || '', stderr: r.error ? String(r.error.message) : (r.stderr || '') };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function pause(seconds) {
|
|
325
|
+
if (seconds > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, seconds * 1000);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const lastLines = (text, n = 6) => String(text || '').trim().split('\n').slice(-n).join(' | ');
|
|
329
|
+
|
|
330
|
+
function readHead(file, offset, length) {
|
|
331
|
+
const fd = openSync(file, 'r');
|
|
332
|
+
try {
|
|
333
|
+
const buf = Buffer.alloc(length);
|
|
334
|
+
const n = readSync(fd, buf, 0, length, offset);
|
|
335
|
+
return buf.subarray(0, n);
|
|
336
|
+
} finally {
|
|
337
|
+
closeSync(fd);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** A Mach-O file's type (2 program, 6 library, 8 loadable module, …), or null when it isn't one. Universal files by their first slice. */
|
|
342
|
+
export function machoFileType(file) {
|
|
343
|
+
let head;
|
|
344
|
+
try {
|
|
345
|
+
if (statSync(file).size < 32) return null;
|
|
346
|
+
head = readHead(file, 0, 32);
|
|
347
|
+
} catch {
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
const le = head.readUInt32LE(0);
|
|
351
|
+
if (le === 0xfeedfacf || le === 0xfeedface) return head.readUInt32LE(12);
|
|
352
|
+
const be = head.readUInt32BE(0);
|
|
353
|
+
if (be !== 0xcafebabe && be !== 0xcafebabf) return null;
|
|
354
|
+
const count = head.readUInt32BE(4);
|
|
355
|
+
if (count < 1 || count > 16) return null; // a Java class file starts with the same bytes
|
|
356
|
+
const offset = be === 0xcafebabf ? Number(head.readBigUInt64BE(16)) : head.readUInt32BE(16);
|
|
357
|
+
try {
|
|
358
|
+
const slice = readHead(file, offset, 16);
|
|
359
|
+
const m = slice.readUInt32LE(0);
|
|
360
|
+
return m === 0xfeedfacf || m === 0xfeedface ? slice.readUInt32LE(12) : null;
|
|
361
|
+
} catch {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Is `dir` a code bundle codesign signs as one (a helper app, a framework)? */
|
|
367
|
+
function isCodeBundle(dir) {
|
|
368
|
+
if (!BUNDLE_EXTENSIONS.some((ext) => dir.endsWith(ext))) return false;
|
|
369
|
+
return [['Contents', 'Info.plist'], ['Resources', 'Info.plist'], ['Versions', 'Current']]
|
|
370
|
+
.some((p) => existsSync(path.join(dir, ...p)));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* What is in the app, for signingPlan: every Mach-O regular file and every code
|
|
375
|
+
* bundle below it. Links aren't followed, so each file is seen once, at its
|
|
376
|
+
* real place (a framework's Versions/Current, Playwright's driver's link to
|
|
377
|
+
* the app's Node).
|
|
378
|
+
*/
|
|
379
|
+
export function findCode(app) {
|
|
380
|
+
const entries = [];
|
|
381
|
+
const visit = (dir) => {
|
|
382
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
383
|
+
const at = path.join(dir, entry.name);
|
|
384
|
+
const rel = path.relative(app, at).split(path.sep).join('/');
|
|
385
|
+
if (entry.isDirectory()) {
|
|
386
|
+
if (isCodeBundle(at)) entries.push({ rel, kind: 'bundle' });
|
|
387
|
+
visit(at);
|
|
388
|
+
} else if (entry.isFile()) {
|
|
389
|
+
const fileType = machoFileType(at);
|
|
390
|
+
if (fileType !== null) entries.push({ rel, kind: 'file', fileType });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
visit(app);
|
|
395
|
+
return entries;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Sign `app` with the Developer ID in `setup`, inside out (signingPlan), then
|
|
400
|
+
* check it the way release.yml and the in-app updater do. `entitlementsFiles`
|
|
401
|
+
* maps signingPlan's keys to plist files (ENTITLEMENTS; a test can pass its
|
|
402
|
+
* own). Prints one line per stage; codesign's own errors stop the build.
|
|
403
|
+
*/
|
|
404
|
+
export function signDeveloperId(app, setup, { entitlementsFiles = ENTITLEMENTS, log = console.log, env = process.env } = {}) {
|
|
405
|
+
const exe = (() => {
|
|
406
|
+
try {
|
|
407
|
+
const name = execFileSync('/usr/bin/plutil', ['-extract', 'CFBundleExecutable', 'raw', '-o', '-', path.join(app, 'Contents', 'Info.plist')]).toString().trim();
|
|
408
|
+
return path.join(app, 'Contents', 'MacOS', name);
|
|
409
|
+
} catch {
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
})();
|
|
413
|
+
const mainIsMachO = Boolean(exe && machoFileType(exe) === MH_EXECUTE);
|
|
414
|
+
const plan = signingPlan(findCode(app), { mainIsMachO });
|
|
415
|
+
const ent = (key) => (key ? entitlementsFiles[key] : null);
|
|
416
|
+
for (const key of new Set([...plan.files, ...plan.bundles, plan.app].map((p) => p.entitlements).filter(Boolean))) {
|
|
417
|
+
if (!existsSync(ent(key))) throw new SigningError(`The ${key} entitlements file is missing (${ent(key)}).`);
|
|
418
|
+
}
|
|
419
|
+
const sign = (paths, entitlements) => {
|
|
420
|
+
// Up to 32 at a time; each still gets its own timestamp from Apple. Its
|
|
421
|
+
// only chatter is "replacing existing signature", once a file: shown only
|
|
422
|
+
// with a failure, without those lines.
|
|
423
|
+
for (let i = 0; i < paths.length; i += 32) {
|
|
424
|
+
const r = capture('codesign', [...codesignArgs(setup, { entitlements: ent(entitlements) }), ...paths.slice(i, i + 32)], { env });
|
|
425
|
+
if (r.status !== 0) {
|
|
426
|
+
const why = r.stderr.split('\n').filter((l) => l.trim() && !/replacing existing signature$/.test(l)).slice(-6).join('\n ');
|
|
427
|
+
throw new SigningError(`codesign failed:\n ${why || `exit ${r.status}`}`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
// Loose programs and libraries, grouped by their entitlements (their order
|
|
432
|
+
// among themselves doesn't matter: none contains another).
|
|
433
|
+
for (const key of [null, 'node', 'electron']) {
|
|
434
|
+
const group = plan.files.filter((f) => f.entitlements === key).map((f) => path.join(app, f.rel));
|
|
435
|
+
if (group.length) sign(group, key);
|
|
436
|
+
}
|
|
437
|
+
log(` signed ${plan.files.length} programs and libraries one by one (${plan.files.filter((f) => f.entitlements).length} with entitlements)`);
|
|
438
|
+
for (const b of plan.bundles) sign([path.join(app, b.rel)], b.entitlements);
|
|
439
|
+
if (plan.bundles.length) log(` then ${plan.bundles.length} nested bundles, deepest first: ${plan.bundles.map((b) => path.basename(b.rel)).join(', ')}`);
|
|
440
|
+
sign([app], plan.app.entitlements);
|
|
441
|
+
const verify = capture('codesign', ['--verify', '--deep', '--strict', app], { env });
|
|
442
|
+
if (verify.status !== 0) throw new SigningError(`The signed app doesn't verify: ${lastLines(verify.stderr)}`);
|
|
443
|
+
const info = parseCodesignInfo(capture('codesign', ['-dv', '--verbose=2', app], { env }).stderr);
|
|
444
|
+
if (!info.runtime) throw new SigningError('The app was signed without the hardened runtime.');
|
|
445
|
+
log(` then the app: ${info.authority || setup.identity}${info.team ? `, team ${info.team}` : ''}, hardened runtime${info.timestamp ? ', timestamped' : ''}; verified`);
|
|
446
|
+
return { plan, info };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** The signer of a signed file or app, from codesign: { authority, team, runtime, timestamp }. */
|
|
450
|
+
export function signatureInfo(file, { env = process.env } = {}) {
|
|
451
|
+
return parseCodesignInfo(capture('codesign', ['-dv', '--verbose=2', file], { env }).stderr);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Sign a disk image with the Developer ID (no runtime: that is for programs). */
|
|
455
|
+
export function signImage(image, setup, { env = process.env } = {}) {
|
|
456
|
+
const r = capture('codesign', [...codesignArgs(setup, { runtime: false }), image], { env });
|
|
457
|
+
if (r.status !== 0) throw new SigningError(`codesign couldn't sign ${path.basename(image)}: ${lastLines(r.stderr)}`);
|
|
458
|
+
const v = capture('codesign', ['--verify', '--strict', image], { env });
|
|
459
|
+
if (v.status !== 0) throw new SigningError(`${path.basename(image)}'s signature doesn't verify: ${lastLines(v.stderr)}`);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Notarize `file` (a zip of the app, or the signed disk image): submit, wait
|
|
464
|
+
* for Apple's answer, and return { id, status } once it is Accepted. On any
|
|
465
|
+
* other answer, Apple's log for the submission is fetched and its issues
|
|
466
|
+
* printed, and a SigningError is thrown. A submission that never got an id
|
|
467
|
+
* (the network, Apple's service) is tried again, `attempts` times in all.
|
|
468
|
+
*/
|
|
469
|
+
export function notarize(file, notary, {
|
|
470
|
+
env = process.env, log = console.log, what = path.basename(file), attempts = 3, retrySeconds = 30, timeout = '1h',
|
|
471
|
+
} = {}) {
|
|
472
|
+
const creds = ['--key', notary.key, '--key-id', notary.keyId, '--issuer', notary.issuer];
|
|
473
|
+
const secrets = [notary.keyId, notary.issuer, notary.key];
|
|
474
|
+
let result = null;
|
|
475
|
+
let problem = '';
|
|
476
|
+
for (let i = 1; i <= attempts; i++) {
|
|
477
|
+
const r = capture('xcrun', ['notarytool', 'submit', file, ...creds, '--wait', '--timeout', timeout, '--output-format', 'json'], { env });
|
|
478
|
+
const parsed = parseNotaryJson(r.stdout);
|
|
479
|
+
if (parsed?.id && parsed.status) {
|
|
480
|
+
result = parsed;
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
483
|
+
problem = scrub(lastLines(r.stderr) || lastLines(r.stdout) || `exit ${r.status}`, secrets);
|
|
484
|
+
log(` notarytool couldn't submit ${what} (try ${i} of ${attempts}): ${problem}`);
|
|
485
|
+
if (i < attempts) pause(retrySeconds);
|
|
486
|
+
}
|
|
487
|
+
if (!result) throw new SigningError(`Apple's notary service didn't take ${what}: ${problem}`);
|
|
488
|
+
log(` ${what}: submission ${result.id}, ${result.status}`);
|
|
489
|
+
if (result.status === 'Accepted') return result;
|
|
490
|
+
const l = capture('xcrun', ['notarytool', 'log', result.id, ...creds], { env });
|
|
491
|
+
let logJson = null;
|
|
492
|
+
try { logJson = JSON.parse(l.stdout); } catch { /* said below */ }
|
|
493
|
+
log(` Apple's log for ${result.id}:`);
|
|
494
|
+
for (const line of notaryIssueLines(logJson)) log(` ${scrub(line, secrets)}`);
|
|
495
|
+
if (!logJson && (l.stderr || l.stdout)) log(` ${scrub(lastLines(l.stderr || l.stdout), secrets)}`);
|
|
496
|
+
throw new SigningError(`Apple didn't notarize ${what} (${result.status}${result.message ? `: ${scrub(result.message, secrets)}` : ''}). Its issues are listed above.`);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Staple the notarization ticket to `file` (the app, or the disk image) and
|
|
501
|
+
* validate it. Right after a submission is accepted, Apple's ticket can take a
|
|
502
|
+
* moment to be served ("Record not found"), so it is tried a few times.
|
|
503
|
+
*/
|
|
504
|
+
export function staple(file, { env = process.env, log = console.log, attempts = 5, retrySeconds = 20 } = {}) {
|
|
505
|
+
let last = '';
|
|
506
|
+
for (let i = 1; i <= attempts; i++) {
|
|
507
|
+
const r = capture('xcrun', ['stapler', 'staple', file], { env });
|
|
508
|
+
if (r.status === 0) {
|
|
509
|
+
const v = capture('xcrun', ['stapler', 'validate', file], { env });
|
|
510
|
+
if (v.status === 0) return;
|
|
511
|
+
last = lastLines(v.stderr || v.stdout);
|
|
512
|
+
} else {
|
|
513
|
+
last = lastLines(r.stderr || r.stdout);
|
|
514
|
+
}
|
|
515
|
+
log(` stapling ${path.basename(file)} didn't work yet (try ${i} of ${attempts}): ${last}`);
|
|
516
|
+
if (i < attempts) pause(retrySeconds);
|
|
517
|
+
}
|
|
518
|
+
throw new SigningError(`Couldn't staple the notarization ticket to ${path.basename(file)}: ${last}`);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Does `file` carry a valid stapled ticket? */
|
|
522
|
+
export function stapleValid(file, { env = process.env } = {}) {
|
|
523
|
+
return capture('xcrun', ['stapler', 'validate', file], { env }).status === 0;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* What Gatekeeper says about `file`: an app ('exec') or a disk image ('open',
|
|
528
|
+
* judged by its own signature). { ok, accepted, source, origin, disabled, text },
|
|
529
|
+
* ok when it is accepted as "Notarized Developer ID". `disabled` when this
|
|
530
|
+
* machine has Gatekeeper's assessments turned off and so can't say: the caller
|
|
531
|
+
* then has only the stapled ticket to go on, and says so.
|
|
532
|
+
*/
|
|
533
|
+
export function assess(file, type, { env = process.env } = {}) {
|
|
534
|
+
const args = type === 'exec'
|
|
535
|
+
? ['-a', '-vvv', '-t', 'exec', file]
|
|
536
|
+
: ['-a', '-vvv', '-t', 'open', '--context', 'context:primary-signature', file];
|
|
537
|
+
const r = capture('spctl', args, { env });
|
|
538
|
+
const text = `${r.stdout}\n${r.stderr}`.trim();
|
|
539
|
+
const v = parseSpctl(text);
|
|
540
|
+
return { ...v, ok: r.status === 0 && v.accepted && v.source === 'Notarized Developer ID', text };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ── release.yml's question: sign this release, or not? ─────────────────────
|
|
544
|
+
//
|
|
545
|
+
// node scripts/mac-sign.mjs release-plan <version> <dry-run: true|false>
|
|
546
|
+
//
|
|
547
|
+
// Reads only whether each of SECRET_NAMES is set (never a value). Prints
|
|
548
|
+
// "developer-id" or "ad-hoc" and exits 0, or says why not and exits 1.
|
|
549
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
550
|
+
const [command, version, dry] = process.argv.slice(2);
|
|
551
|
+
if (command !== 'release-plan' || !version) {
|
|
552
|
+
console.error('usage: node scripts/mac-sign.mjs release-plan <version> <dry-run: true|false>');
|
|
553
|
+
process.exit(2);
|
|
554
|
+
}
|
|
555
|
+
const present = SECRET_NAMES.filter((name) => !blank(process.env[name]));
|
|
556
|
+
const plan = releaseSigningPlan({ version, dryRun: dry === 'true', present });
|
|
557
|
+
if (plan.error) {
|
|
558
|
+
console.error(plan.error);
|
|
559
|
+
process.exit(1);
|
|
560
|
+
}
|
|
561
|
+
if (plan.note) console.error(plan.note);
|
|
562
|
+
console.log(plan.sign ? 'developer-id' : 'ad-hoc');
|
|
563
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Render scripts/dmg/background.html into scripts/dmg/background.tiff — the
|
|
3
|
-
// picture behind the .dmg window: drag across,
|
|
3
|
+
// picture behind the .dmg window: drag across, and that the app is signed and
|
|
4
|
+
// notarized, so it opens like any Mac app (it showed the Open Anyway step until D4).
|
|
4
5
|
//
|
|
5
6
|
// Run by hand when the picture changes, and commit the .tiff. The build only
|
|
6
7
|
// copies it, so packaging never depends on a browser being installed (the
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Put the Mac signing secrets into GitHub, for the release workflow (DESKTOP.md D4).
|
|
3
|
+
# Blake runs this himself, on his own Mac:
|
|
4
|
+
#
|
|
5
|
+
# scripts/set-signing-secrets.sh <cert.p12> <AuthKey_XXXXXXXXXX.p8> <KEY_ID> <ISSUER_ID>
|
|
6
|
+
#
|
|
7
|
+
# cert.p12 the Developer ID Application certificate with its private key
|
|
8
|
+
# (Keychain Access → My Certificates → Export), Apple's intermediate
|
|
9
|
+
# in it too
|
|
10
|
+
# .p8 the App Store Connect API key notarytool uses (Users and Access →
|
|
11
|
+
# Integrations → Team Keys); Apple lets you download it once
|
|
12
|
+
# KEY_ID that key's ID, 10 letters and digits (it's in the .p8's name)
|
|
13
|
+
# ISSUER_ID the issuer ID shown above the keys list (a UUID)
|
|
14
|
+
#
|
|
15
|
+
# It asks for the .p12's password (typed, not shown), checks that it opens the
|
|
16
|
+
# certificate, and sets five secrets on blakeb056/six-degrees with the GitHub CLI:
|
|
17
|
+
# MACOS_SIGN_P12_BASE64, MACOS_SIGN_P12_PASSWORD, APPLE_API_KEY_P8_BASE64,
|
|
18
|
+
# APPLE_API_KEY_ID and APPLE_API_ISSUER_ID. Each value goes to `gh secret set`
|
|
19
|
+
# on its standard input, never as an argument (where `ps` could see it), and is
|
|
20
|
+
# never printed or written to a file: it prints only which secrets it set.
|
|
21
|
+
# Running it again replaces them (a renewed certificate, a new key).
|
|
22
|
+
|
|
23
|
+
set -euo pipefail
|
|
24
|
+
|
|
25
|
+
REPO=blakeb056/six-degrees
|
|
26
|
+
OPENSSL=/usr/bin/openssl # macOS's own (LibreSSL): it reads Keychain Access's .p12 files
|
|
27
|
+
|
|
28
|
+
say() { printf '%s\n' "$*"; }
|
|
29
|
+
fail() { printf '\n ✗ %s\n\n' "$*" >&2; exit 1; }
|
|
30
|
+
usage() {
|
|
31
|
+
printf 'usage: %s <cert.p12> <AuthKey_XXXXXXXXXX.p8> <KEY_ID> <ISSUER_ID>\n' "$(basename "$0")" >&2
|
|
32
|
+
exit 2
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
[ $# -eq 4 ] || usage
|
|
36
|
+
p12="$1" p8="$2" key_id="$3" issuer="$4"
|
|
37
|
+
|
|
38
|
+
# The files first: nothing is asked or sent unless both are there.
|
|
39
|
+
for f in "$p12" "$p8"; do
|
|
40
|
+
[ -f "$f" ] || fail "No file at $f. Nothing was set."
|
|
41
|
+
[ -r "$f" ] || fail "Can't read $f. Nothing was set."
|
|
42
|
+
[ -s "$f" ] || fail "$f is empty. Nothing was set."
|
|
43
|
+
done
|
|
44
|
+
case "$p12" in *.p12|*.pfx) ;; *) fail "The first file should be the certificate's .p12 (got $(basename "$p12")). Nothing was set." ;; esac
|
|
45
|
+
case "$p8" in *.p8) ;; *) fail "The second file should be the API key's .p8 (got $(basename "$p8")). Nothing was set." ;; esac
|
|
46
|
+
grep -q -- '-----BEGIN PRIVATE KEY-----' "$p8" || fail "$(basename "$p8") isn't an App Store Connect API key. Nothing was set."
|
|
47
|
+
|
|
48
|
+
# The two IDs, checked by their shape, never echoed.
|
|
49
|
+
[[ "$key_id" =~ ^[A-Z0-9]{10}$ ]] || fail "KEY_ID should be 10 capital letters and digits (it's in the .p8's name: AuthKey_<KEY_ID>.p8). Nothing was set."
|
|
50
|
+
if [[ "$(basename "$p8")" =~ ^AuthKey_([A-Z0-9]+)\.p8$ ]] && [ "${BASH_REMATCH[1]}" != "$key_id" ]; then
|
|
51
|
+
fail "KEY_ID doesn't match the .p8's name ($(basename "$p8")): use that key's ID. Nothing was set."
|
|
52
|
+
fi
|
|
53
|
+
[[ "$issuer" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]] \
|
|
54
|
+
|| fail "ISSUER_ID should be a UUID (8-4-4-4-12 letters and digits, above the keys list in App Store Connect). Nothing was set."
|
|
55
|
+
|
|
56
|
+
# GitHub, before the password is asked for.
|
|
57
|
+
command -v gh > /dev/null 2>&1 || fail "The GitHub CLI isn't installed (brew install gh). Nothing was set."
|
|
58
|
+
gh auth status > /dev/null 2>&1 || fail "The GitHub CLI isn't signed in: run gh auth login, then this again. Nothing was set."
|
|
59
|
+
|
|
60
|
+
# The password: typed, not shown, kept only in this shell's memory.
|
|
61
|
+
password=""
|
|
62
|
+
if [ -t 0 ]; then
|
|
63
|
+
read -r -s -p "Password for $(basename "$p12"): " password
|
|
64
|
+
printf '\n'
|
|
65
|
+
else
|
|
66
|
+
IFS= read -r password || true
|
|
67
|
+
fi
|
|
68
|
+
[ -n "$password" ] || fail "No password given. Nothing was set."
|
|
69
|
+
|
|
70
|
+
# Does it open the certificate? printf is part of bash, so the password is never
|
|
71
|
+
# a program's argument; openssl reads it from its standard input.
|
|
72
|
+
if ! printf '%s\n' "$password" | "$OPENSSL" pkcs12 -in "$p12" -nokeys -passin stdin -noout > /dev/null 2>&1; then
|
|
73
|
+
answer=""
|
|
74
|
+
if [ -t 0 ]; then
|
|
75
|
+
read -r -p "That password didn't open $(basename "$p12") (or this Mac's openssl can't read it). Set the secrets anyway? [y/N] " answer
|
|
76
|
+
else
|
|
77
|
+
IFS= read -r answer || true
|
|
78
|
+
fi
|
|
79
|
+
case "$answer" in y|Y|yes) ;; *) password=""; fail "Nothing was set." ;; esac
|
|
80
|
+
fi
|
|
81
|
+
|
|
82
|
+
# Each value on gh's standard input. base64 without line breaks, as the release
|
|
83
|
+
# workflow decodes it. (Each `gh secret set` is the last command of its pipe and
|
|
84
|
+
# reads it to the end.)
|
|
85
|
+
done_names=""
|
|
86
|
+
ok() { done_names="${done_names:+$done_names, }$1"; say " set $1"; }
|
|
87
|
+
stop() { password=""; fail "gh couldn't set $1 (does your account have admin rights on $REPO?). Set before it: ${done_names:-none}."; }
|
|
88
|
+
say "Setting the Mac signing secrets on $REPO:"
|
|
89
|
+
if base64 < "$p12" | tr -d '\n' | gh secret set MACOS_SIGN_P12_BASE64 --repo "$REPO" > /dev/null; then ok MACOS_SIGN_P12_BASE64; else stop MACOS_SIGN_P12_BASE64; fi
|
|
90
|
+
if printf '%s' "$password" | gh secret set MACOS_SIGN_P12_PASSWORD --repo "$REPO" > /dev/null; then ok MACOS_SIGN_P12_PASSWORD; else stop MACOS_SIGN_P12_PASSWORD; fi
|
|
91
|
+
password=""
|
|
92
|
+
if base64 < "$p8" | tr -d '\n' | gh secret set APPLE_API_KEY_P8_BASE64 --repo "$REPO" > /dev/null; then ok APPLE_API_KEY_P8_BASE64; else stop APPLE_API_KEY_P8_BASE64; fi
|
|
93
|
+
if printf '%s' "$key_id" | gh secret set APPLE_API_KEY_ID --repo "$REPO" > /dev/null; then ok APPLE_API_KEY_ID; else stop APPLE_API_KEY_ID; fi
|
|
94
|
+
if printf '%s' "$issuer" | gh secret set APPLE_API_ISSUER_ID --repo "$REPO" > /dev/null; then ok APPLE_API_ISSUER_ID; else stop APPLE_API_ISSUER_ID; fi
|
|
95
|
+
say "Done: all five are set. Check them with a dry run (it builds and notarizes, and publishes nothing):"
|
|
96
|
+
say " gh workflow run release.yml --repo $REPO --ref <branch> -f dry_run=true"
|