Skip to content

Commit

Permalink
CI: Test macOS signing during CI
Browse files Browse the repository at this point in the history
This enables running a version of the signing script during CI, so that we
can check it's not catastrophically broken.  Note that this cannot test
everything (because CI doesn't have access to production certificates), so
we will still need some manual testing.

- Add macOS signing to the package step, where we currently test Windows
  signing.
- Add option to skip launch constraints embedding for mac, because CI runs
  macOS 11/12, and they do not support it (we need at least 13)
- Update macOS signing to never publish.
- Use a custom packager factory on macOS to avoid generating blockmap files
  as that take too long on CI for some reason (even though it takes less
  than a minute locally).
- Use electron-builder logging for more uniform output.

Signed-off-by: Mark Yen <mark.yen@suse.com>
  • Loading branch information
mook-as committed Jan 10, 2024
1 parent 5b58b12 commit 19d237a
Show file tree
Hide file tree
Showing 5 changed files with 185 additions and 36 deletions.
4 changes: 4 additions & 0 deletions .github/actions/spelling/expect.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ abbrv
ACTIONSTART
activedirectory
addexclusion
addext
addgroup
addlabel
addrepo
Expand Down Expand Up @@ -214,6 +215,7 @@ einfo
electronjs
elko
elog
endgroup
endscript
engineimage
epinio
Expand Down Expand Up @@ -364,6 +366,8 @@ KDM
keychain
keycloak
keycloakoidc
keyform
keyout
Kgm
kiali
kib
Expand Down
103 changes: 90 additions & 13 deletions .github/workflows/package.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -137,14 +137,14 @@ jobs:
env:
OBS_WEBHOOK_TOKEN: ${{ secrets.OBS_WEBHOOK_TOKEN }}

sign:
name: Test Signing
sign-win:
name: Test Signing (Windows)
needs: package
runs-on: windows-2022
if: >-
(github.event_name == 'push' && github.ref == 'refs/heads/main') ||
(github.event_name == 'push' && startsWith(github.ref, 'refs/heads/release-')) ||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags')) ||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) ||
(github.event_name == 'workflow_dispatch')
permissions:
contents: read
Expand All @@ -153,7 +153,6 @@ jobs:
with:
persist-credentials: false
- name: Install Windows dependencies
if: runner.os == 'Windows'
shell: powershell
run: .\scripts\windows-setup.ps1 -SkipVisualStudio -SkipTools
- uses: actions/setup-go@v5
Expand All @@ -167,13 +166,12 @@ jobs:
# Needs a network timeout for macos & windows. See https://github.com/yarnpkg/yarn/issues/8242 for more info
- run: yarn install --frozen-lockfile --network-timeout 1000000
- uses: actions/download-artifact@v4
if: runner.os == 'Windows'
name: Download artifact
with:
name: Rancher Desktop-win.zip
- if: runner.os == 'Windows'
- name: Generate test signing certificate
shell: powershell
run: |
# Generate a test signing certificate
$cert = New-SelfSignedCertificate `
-Type Custom `
-Subject "CN=Rancher-Sandbox, C=CA" `
Expand All @@ -182,13 +180,92 @@ jobs:
-FriendlyName "Rancher-Sandbox Code Signing" `
-TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.3", "2.5.29.19={text}")
Write-Output $cert
$env:CSC_FINGERPRINT = $cert.Thumbprint
# Run the signing script
yarn sign -- (Get-Item "Rancher Desktop*-win.zip")
# Check that the msi file was signed by the expected cert
Write-Output "CSC_FINGERPRINT=$($cert.Thumbprint)" `
| Out-File -Append -Encoding ASCII "${env:GITHUB_ENV}"
timeout-minutes: 1
- name: Sign artifact
shell: powershell
run: yarn sign (Get-Item "Rancher Desktop*-win.zip")
timeout-minutes: 10
- name: Verify installer signature
shell: powershell
run: |
$usedCert = (Get-AuthenticodeSignature -FilePath 'dist\Rancher Desktop Setup*.msi').SignerCertificate
Write-Output $usedCert
if ($cert -ne $usedCert) {
Write-Output "Expected Certificate" $cert "Actual Certificate" $usedCert
if ($usedCert.Thumbprint -ne $env:CSC_FINGERPRINT) {
Throw "Installer signed with wrong certificate"
}
timeout-minutes: 1

sign-mac:
name: Test Signing (macOS)
needs: package
strategy:
matrix:
include:
- arch: aarch64
# skip x86_64, we don't need to duplicate the testing for now.
runs-on: macos-12
if: >-
(github.event_name == 'push' && github.ref == 'refs/heads/main') ||
(github.event_name == 'push' && startsWith(github.ref, 'refs/heads/release-')) ||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) ||
(github.event_name == 'workflow_dispatch')
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-go@v5
with:
go-version: '^1.21'
cache-dependency-path: src/go/**/go.sum
- uses: actions/setup-node@v4
with:
node-version: '18.16.x'
cache: yarn
# Needs a network timeout for macos & windows. See https://github.com/yarnpkg/yarn/issues/8242 for more info
- run: yarn install --frozen-lockfile --network-timeout 1000000
- uses: actions/download-artifact@v4
name: Download artifact
with:
name: Rancher Desktop-mac.${{ matrix.arch }}.zip
- name: Generate test signing certificate
run: |
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem \
-keyform pem -sha256 -days 3650 -nodes -subj \
"/C=CA/CN=RD Test Signing Key" \
-addext keyUsage=critical,digitalSignature \
-addext extendedKeyUsage=critical,codeSigning
# Create a custom keychain so we can unlock it properly.
security create-keychain -p "" tmp.keychain
security default-keychain -d user -s tmp.keychain
security unlock-keychain -p "" tmp.keychain
security import key.pem -k tmp.keychain -t priv -A
security import cert.pem -k tmp.keychain -t cert -A
security set-key-partition-list -S apple-tool:,apple:,codesign: -s \
-k "" tmp.keychain
# Print out the valid certificates for debugging.
security find-identity
# Determine the key fingerprint.
awk_expr='/)/ { print $2 ; exit }'
hash="$(security find-identity | awk "$awk_expr")"
echo "CSC_FINGERPRINT=${hash}" >> "$GITHUB_ENV"
timeout-minutes: 1
- name: Flag build for M1
if: matrix.arch == 'aarch64'
run: echo "M1=1" >> "${GITHUB_ENV}"
- name: Sign artifact
run: |
for zip in Rancher\ Desktop-*mac*.zip; do
echo "::group::Signing ${zip}"
yarn sign --skip-notarize --skip-constraints "${zip}"
echo "::endgroup::"
done
timeout-minutes: 15
- name: Verify signature
run: |
codesign --verify --deep --strict --verbose=2 dist/*.dmg
codesign --verify --deep --strict --verbose=2 dist/*.zip
timeout-minutes: 5
33 changes: 32 additions & 1 deletion docs/development/signing.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,36 @@ _Mac Development_ certificate is insufficient for notarization; it must be a
_Developer ID Application_ certificate. This will be reflected in the Common
Name of the certificate.

Launch constraints require macOS Ventura (macOS 13) or newer. This is therefore
needed for production signing.

[Apple Documentation]: https://developer.apple.com/help/account/create-certificates/create-developer-id-certificates

### Generate a test certificate

If a real certificate from Apple is unavailable, it is possible to generate a
self-signed test certificate; however, note that this wouldn't properly exercise
all of the signing code.
```sh
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem \
-keyform pem -sha256 -days 3650 -nodes -subj \
"/C=XX/ST=NA/L=Some Town/O=No Org/OU=No Unit/CN=RD Test Signing Key" \
-addext keyUsage=critical,digitalSignature \
-addext extendedKeyUsage=critical,codeSigning
security import key.pem -t priv -A
security import cert.pem -t cert -A
security set-key-partition-list -S apple-tool:,apple:,codesign: -s
security add-trusted-cert -p codeSign cert.pem
```
### Configuring Access
- Import your signing certificate into your macOS Keychain.
- Run `security find-identity -v` to locate the fingerprint of the key to use.
Export the long hex string as the `CSC_FINGERPRINT` environment variable.
- For a test certificate, use `security find-identity` without `-v`; the
certificate to use isn't valid.

For notarization, the following environment variables are also needed:

Expand All @@ -98,11 +121,19 @@ For notarization, the following environment variables are also needed:

### Performing signing

When signing for M1/aarch64, please set the `M1` environment variable ahead of
time as usual.

If notarization is not required, append `--skip-notarize` to the command:

```sh
yarn sign --skip-notarize path/to/archive.zip
```

This is necessary to test the signing flow (since there's no way to notarize
without the production certificate).
without the production certificate). This is also necessary to use a test
certificate (since Apple will reject it).
When using an older version of macOS (12/Monterey or older),
`--skip-constraints` is also needed to skip assigning launch constraints, as
that requires Ventura or later. This is inappropriate for the actual release.
77 changes: 57 additions & 20 deletions scripts/lib/sign-macos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import path from 'path';

import { notarize } from '@electron/notarize';
import { build, Arch, Configuration, Platform } from 'app-builder-lib';
import MacPackager from 'app-builder-lib/out/macPackager';
import { AsyncTaskManager, log } from 'builder-util';
import { Target } from 'electron-builder';
import _ from 'lodash';
import plist from 'plist';
import yaml from 'yaml';
Expand Down Expand Up @@ -51,13 +54,14 @@ export async function sign(workDir: string): Promise<string[]> {
const signingConfig: SigningConfig = yaml.parse(signingConfigText, { merge: true });
const plistsDir = path.join(workDir, 'plists');
let wroteDefaultEntitlements = false;
let constraintSkipped = false;

console.log('Removing excess files...');
log.info('Removing excess files...');
await Promise.all(signingConfig.remove.map(async(relpath) => {
await fs.promises.rm(path.join(appDir, relpath), { recursive: true });
}));

console.log('Signing application...');
log.info('Signing application...');
// We're not using @electron/osx-sign because it doesn't allow --launch-constraint-*
await fs.promises.mkdir(plistsDir, { recursive: true });
for await (const filePath of findFilesToSign(appDir)) {
Expand All @@ -84,31 +88,38 @@ export async function sign(workDir: string): Promise<string[]> {
args.push('--entitlements', entitlementFile);

// Determine the launch constraints
const launchConstraints = signingConfig.constraints.find(c => c.paths.includes(relPath));
const constraintTypes = ['self', 'parent', 'responsible'] as const;
if (process.argv.includes('--skip-constraints')) {
if (!constraintSkipped) {
log.warn('Skipping --launch-constraint-...: --skip-constraints given.');
constraintSkipped = true;
}
} else {
const launchConstraints = signingConfig.constraints.find(c => c.paths.includes(relPath));
const constraintTypes = ['self', 'parent', 'responsible'] as const;

for (const constraintType of constraintTypes) {
const constraint = launchConstraints?.[constraintType];
for (const constraintType of constraintTypes) {
const constraint = launchConstraints?.[constraintType];

if (constraint) {
const constraintsFile = path.join(plistsDir, `${ fileHash }-constraint-${ constraintType }.plist`);
if (constraint) {
const constraintsFile = path.join(plistsDir, `${ fileHash }-constraint-${ constraintType }.plist`);

await fs.promises.writeFile(constraintsFile, plist.build(evaluateConstraints(constraint)));
args.push(`--launch-constraint-${ constraintType }`, constraintsFile);
await fs.promises.writeFile(constraintsFile, plist.build(evaluateConstraints(constraint)));
args.push(`--launch-constraint-${ constraintType }`, constraintsFile);
}
}
}

await spawnFile('codesign', [...args, filePath], { stdio: 'inherit' });
}

console.log('Verifying application signature...');
log.info('Verifying application signature...');
await spawnFile('codesign', ['--verify', '--deep', '--strict', '--verbose=2', appDir], { stdio: 'inherit' });
await spawnFile('codesign', ['--display', '--entitlements', '-', appDir], { stdio: 'inherit' });

if (process.argv.includes('--skip-notarize')) {
console.warn('Skipping notarization: --skip-notarize given.');
log.warn('Skipping notarization: --skip-notarize given.');
} else if (appleId && appleIdPassword && teamId) {
console.log('Notarizing application...');
log.info('Notarizing application...');
await notarize({
appBundleId: config.appId as string,
appPath: appDir,
Expand All @@ -125,7 +136,7 @@ export async function sign(workDir: string): Promise<string[]> {
throw new Error(message.join('\n'));
}

console.log('Building disk image and update archive...');
log.info('Building disk image and update archive...');
const arch = process.env.M1 ? Arch.arm64 : Arch.x64;
const productFileName = config.productName?.replace(/\s+/g, '.');
const productArch = process.env.M1 ? 'aarch64' : 'x86_64';
Expand All @@ -135,9 +146,20 @@ export async function sign(workDir: string): Promise<string[]> {
// Build the dmg, explicitly _not_ using an identity; we just signed
// everything as we wanted already.
const results = await build({
targets: new Map([[Platform.MAC, new Map([[arch, formats]])]]),
config: _.merge<Configuration, Configuration>(config, { mac: { artifactName, identity: null } }),
prepackaged: appDir,
publish: 'never',
targets: new Map([[Platform.MAC, new Map([[arch, formats]])]]),
config: _.merge<Configuration, Configuration>(config,
{
dmg: { writeUpdateInfo: false },
mac: { artifactName, identity: null },
}),
prepackaged: appDir,
// Provide a custom packager factory so that we can override the packager
// to skip generating blockmap files. Generating the blockmap hangs on CI
// for some reason.
platformPackagerFactory: (info) => {
return new CustomPackager(info);
},
});

const filesToSign = results.filter(f => !f.endsWith('.blockmap'));
Expand Down Expand Up @@ -204,14 +226,14 @@ async function *findFilesToSign(dir: string): AsyncIterable<string> {
await file.close();
}
} catch {
console.debug(`Failed to read file ${ fullPath }, assuming no need to sign.`);
log.info({ fullPath }, 'Failed to read file, assuming no need to sign.');
continue;
}

// If the file is already signed, don't sign it again.
try {
await spawnFile('codesign', ['--verify', '--strict=all', '--test-requirement=anchor apple', fullPath]);
console.debug(`Skipping signing of already-signed ${ fullPath }`);
log.info({ fullPath }, 'Skipping signing of already-signed directory');
} catch {
yield fullPath;
}
Expand All @@ -221,7 +243,7 @@ async function *findFilesToSign(dir: string): AsyncIterable<string> {
// We need to sign app bundles, if they haven't been signed yet.
try {
await spawnFile('codesign', ['--verify', '--strict=all', '--test-requirement=anchor apple', dir]);
console.debug(`Skipping signing of already-signed ${ dir }`);
log.info({ dir }, 'Skipping signing of already-signed directory');
} catch {
yield dir;
}
Expand Down Expand Up @@ -275,3 +297,18 @@ function evaluateConstraints(constraint: Record<string, any>): Record<string, an
}
});
}

/**
* CustomPackager overrides MacPackager to avoid building blockmap files
*/
class CustomPackager extends MacPackager {
override pack(outDir: string, arch: Arch, targets: Array<Target>, taskManager: AsyncTaskManager): Promise<any> {
for (const target of targets) {
if ('isWriteUpdateInfo' in target) {
(target as any).isWriteUpdateInfo = false;
}
}

return super.pack.call(this, outDir, arch, targets, taskManager);
}
}
4 changes: 2 additions & 2 deletions scripts/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import * as path from 'path';
import { flipFuses, FuseV1Options, FuseVersion } from '@electron/fuses';
import { LinuxPackager } from 'app-builder-lib/out/linuxPackager';
import { LinuxTargetHelper } from 'app-builder-lib/out/targets/LinuxTargetHelper';
import { executeAppBuilder } from 'builder-util';
import { executeAppBuilder, log } from 'builder-util';
import {
AfterPackContext, Arch, build, CliOptions, Configuration, LinuxTargetSpecificOptions,
} from 'electron-builder';
Expand Down Expand Up @@ -103,7 +103,7 @@ class Builder {
}

async package(): Promise<CliOptions> {
console.log('Packaging...');
log.info('Packaging...');

// Build the electron builder configuration to include the version data
const config: ReadWrite<Configuration> = yaml.parse(await fs.promises.readFile('packaging/electron-builder.yml', 'utf-8'));
Expand Down

0 comments on commit 19d237a

Please sign in to comment.