Skip to content

Commit fe53388

Browse files
committed
feat: user-friendly MAS code signing
* `identity` is removed from the `build.mas`. * Env `CSC_INSTALLER_NAME` is removed. * You don't need to specify `CSC_NAME` env or `build.osx.identity`. Valid identity from your keychain will be automatically used. * `CSC_NAME` env or `build.osx.identity` is still not removed because it is required if you have several identities. But now instead of `Developer ID Installer: Your Name (XXXXXXXXXX)`, you should specify only `Your Name` — appropriate certificate will be chosen automatically.
1 parent 67ed60b commit fe53388

File tree

14 files changed

+179
-139
lines changed

14 files changed

+179
-139
lines changed

.idea/dictionaries/develar.xml

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/Code Signing.md

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,14 @@
11
OS X and Windows code signing is supported. Windows is dual code-signed (SHA1 & SHA256 hashing algorithms).
22

3-
On a development machine set environment variable `CSC_NAME` (and `CSC_INSTALLER_NAME` if you build for Mac App Store) to your identity.
3+
On a OS X development machine valid and appropriate identity from your keychain will be automatically used.
44

55
| Env name | Description
66
| -------------- | -----------
77
| `CSC_LINK` | The HTTPS link (or base64-encoded data) to certificate (`*.p12` file).
88
| `CSC_KEY_PASSWORD` | The password to decrypt the certificate given in `CSC_LINK`.
99
| `CSC_INSTALLER_LINK` | *osx-only* The HTTPS link (or base64-encoded data) to certificate to sign Mac App Store build (`*.p12` file).
1010
| `CSC_INSTALLER_KEY_PASSWORD` | *osx-only* The password to decrypt the certificate given in `CSC_INSTALLER_LINK`.
11-
| `CSC_NAME` | *osx-only* Name of certificate (to retrieve from login.keychain). Useful on a development machine (not on CI).
12-
| `CSC_INSTALLER_NAME` | *osx-only* Name of installer certificate (to retrieve from login.keychain). Useful on a development machine (not on CI).
13-
14-
```
15-
export CSC_NAME="Developer ID Application: Your Name (code)"
16-
```
11+
| `CSC_NAME` | *osx-only* Name of certificate (to retrieve from login.keychain). Useful on a development machine (not on CI) if you have several identities (otherwise don't specify it).
1712

1813
## Travis, AppVeyor and other CI Servers
1914
To sign app on build server you need to set `CSC_LINK`, `CSC_KEY_PASSWORD` (and `CSC_INSTALLER_LINK`, `CSC_INSTALLER_KEY_PASSWORD` if you build for Mac App Store):
@@ -29,4 +24,5 @@ To sign app on build server you need to set `CSC_LINK`, `CSC_KEY_PASSWORD` (and
2924
In case of AppVeyor, don't forget to click on lock icon to “Toggle variable encryption”.
3025

3126
# Where to Buy Code Signing Certificate
32-
[StartSSL](https://startssl.com/Support?v=34) is recommended.
27+
[StartSSL](https://startssl.com/Support?v=34) is recommended.
28+
Please note — Gatekeeper only recognises [Apple digital certificates](http://stackoverflow.com/questions/11833481/non-apple-issued-code-signing-certificate-can-it-work-with-mac-os-10-8-gatekeep).

docs/Options.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,6 @@ MAS (Mac Application Store) specific options (in addition to `build.osx`).
8484

8585
| Name | Description
8686
| --- | ---
87-
| identity | <a name="MasBuildOptions-identity"></a>The name of certificate to use when signing. Consider using environment variables [CSC_INSTALLER_LINK or CSC_INSTALLER_NAME](https://github.com/electron-userland/electron-builder/wiki/Code-Signing).
8887
| entitlements | <a name="MasBuildOptions-entitlements"></a><p>The path to entitlements file for signing the app. <code>build/mas.entitlements</code> will be used if exists (it is a recommended way to set). Otherwise [default](https://github.com/electron-userland/electron-osx-sign/blob/master/default.mas.entitlements).</p>
8988
| entitlementsInherit | <a name="MasBuildOptions-entitlementsInherit"></a><p>The path to child entitlements which inherit the security settings for signing frameworks and bundles of a distribution. <code>build/mas.inherit.entitlements</code> will be used if exists (it is a recommended way to set). Otherwise [default](https://github.com/electron-userland/electron-osx-sign/blob/master/default.mas.inherit.entitlements).</p>
9089

@@ -97,6 +96,7 @@ MAS (Mac Application Store) specific options (in addition to `build.osx`).
9796
| msi | <a name="WinBuildOptions-msi"></a>Whether to create an MSI installer. Defaults to `false` (MSI is not created).
9897
| remoteReleases | <a name="WinBuildOptions-remoteReleases"></a>A URL to your existing updates. If given, these will be downloaded to create delta updates.
9998
| remoteToken | <a name="WinBuildOptions-remoteToken"></a>Authentication token for remote updates
99+
| signingHashAlgorithms | <a name="WinBuildOptions-signingHashAlgorithms"></a>Array of signing algorithms used. Defaults to `['sha1', 'sha256']`
100100

101101
<a name="LinuxBuildOptions"></a>
102102
### `.build.linux`

src/codeSign.ts

Lines changed: 57 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,8 @@ export interface CodeSigningInfo {
1818
installerName?: string | null
1919
}
2020

21-
function randomString(): string {
22-
return randomBytes(8).toString("hex")
23-
}
24-
2521
export function generateKeychainName(): string {
26-
return "csc-" + randomString() + ".keychain"
22+
return path.join(tmpdir(), getTempName("csc") + ".keychain")
2723
}
2824

2925
function downloadUrlOrBase64(urlOrBase64: string, destination: string): BluebirdPromise<any> {
@@ -35,48 +31,50 @@ function downloadUrlOrBase64(urlOrBase64: string, destination: string): Bluebird
3531
}
3632
}
3733

38-
let bundledCertKeychainAdded = false
34+
let bundledCertKeychainAdded: Promise<any> | null = null
35+
36+
// "Note that filename will not be searched to resolve the signing identity's certificate chain unless it is also on the user's keychain search list."
37+
// but "security list-keychains" doesn't support add - we should 1) get current list 2) set new list - it is very bad http://stackoverflow.com/questions/10538942/add-a-keychain-to-search-list
38+
// "overly complicated and introduces a race condition."
39+
// https://github.com/electron-userland/electron-builder/issues/398
40+
async function createCustomCertKeychain() {
41+
// copy to temp and then atomic rename to final path
42+
const tmpKeychainPath = path.join(homedir(), ".cache", getTempName("electron_builder_root_certs"))
43+
const keychainPath = path.join(homedir(), ".cache", "electron_builder_root_certs.keychain")
44+
const results = await BluebirdPromise.all<string>([
45+
exec("security", ["list-keychains"]),
46+
copy(path.join(__dirname, "..", "certs", "root_certs.keychain"), tmpKeychainPath)
47+
.then(() => rename(tmpKeychainPath, keychainPath)),
48+
])
49+
const list = results[0]
50+
.split("\n")
51+
.map(it => {
52+
let r = it.trim()
53+
return r.substring(1, r.length - 1)
54+
})
55+
.filter(it => it.length > 0)
56+
57+
if (!list.includes(keychainPath)) {
58+
await exec("security", ["list-keychains", "-d", "user", "-s", keychainPath].concat(list))
59+
}
60+
}
3961

4062
export async function createKeychain(keychainName: string, cscLink: string, cscKeyPassword: string, cscILink?: string | null, cscIKeyPassword?: string | null): Promise<CodeSigningInfo> {
41-
if (!bundledCertKeychainAdded) {
42-
// "Note that filename will not be searched to resolve the signing identity's certificate chain unless it is also on the user's keychain search list."
43-
// but "security list-keychains" doesn't support add - we should 1) get current list 2) set new list - it is very bad http://stackoverflow.com/questions/10538942/add-a-keychain-to-search-list
44-
// "overly complicated and introduces a race condition."
45-
// https://github.com/electron-userland/electron-builder/issues/398
46-
47-
bundledCertKeychainAdded = true
48-
49-
// copy to temp and then atomic rename to final path
50-
const tmpKeychainPath = path.join(homedir(), ".cache", getTempName("electron_builder_root_certs"))
51-
const keychainPath = path.join(homedir(), ".cache", "electron_builder_root_certs.keychain")
52-
const results = await BluebirdPromise.all<Array<string> | string>([
53-
exec("security", ["list-keychains"]),
54-
copy(path.join(__dirname, "..", "certs", "root_certs.keychain"), tmpKeychainPath)
55-
.then(() => rename(tmpKeychainPath, keychainPath)),
56-
])
57-
const list = (<string[]>results[0])[0]
58-
.split("\n")
59-
.map(it => {
60-
let r = it.trim()
61-
return r.substring(1, r.length - 1)
62-
})
63-
.filter(it => it.length > 0)
64-
65-
if (!list.includes(keychainPath)) {
66-
await exec("security", ["list-keychains", "-d", "user", "-s", keychainPath].concat(list))
67-
}
63+
if (bundledCertKeychainAdded == null) {
64+
bundledCertKeychainAdded = createCustomCertKeychain()
6865
}
66+
await bundledCertKeychainAdded
6967

7068
const certLinks = [cscLink]
7169
if (cscILink != null) {
7270
certLinks.push(cscILink)
7371
}
7472

7573
const certPaths = new Array(certLinks.length)
76-
const keychainPassword = randomString()
74+
const keychainPassword = randomBytes(8).toString("hex")
7775
return await executeFinally(BluebirdPromise.all([
7876
BluebirdPromise.map(certLinks, (link, i) => {
79-
const tempFile = path.join(tmpdir(), `${randomString()}.p12`)
77+
const tempFile = path.join(tmpdir(), `${getTempName()}.p12`)
8078
certPaths[i] = tempFile
8179
return downloadUrlOrBase64(link, tempFile)
8280
}),
@@ -117,7 +115,7 @@ async function importCerts(keychainName: string, paths: Array<string>, keyPasswo
117115
function extractCommonName(password: string, certPath: string): BluebirdPromise<string> {
118116
return exec("openssl", ["pkcs12", "-nokeys", "-nodes", "-passin", "pass:" + password, "-nomacver", "-clcerts", "-in", certPath])
119117
.then(result => {
120-
const match = <Array<string | null> | null>(result[0].toString().match(/^subject.*\/CN=([^\/\n]+)/m))
118+
const match = <Array<string | null> | null>(result.match(/^subject.*\/CN=([^\/\n]+)/m))
121119
if (match == null || match[1] == null) {
122120
throw new Error("Cannot extract common name from p12")
123121
}
@@ -136,8 +134,7 @@ export function sign(path: string, options: CodeSigningInfo): BluebirdPromise<an
136134
}
137135

138136
export function deleteKeychain(keychainName: string, ignoreNotFound: boolean = true): BluebirdPromise<any> {
139-
// exec("security", ["delete-keychain", keychainName])
140-
const result = BluebirdPromise.resolve()
137+
const result = exec("security", ["delete-keychain", keychainName])
141138
if (ignoreNotFound) {
142139
return result.catch(error => {
143140
if (!error.message.includes("The specified keychain could not be found.")) {
@@ -151,7 +148,28 @@ export function deleteKeychain(keychainName: string, ignoreNotFound: boolean = t
151148
}
152149

153150
export function downloadCertificate(cscLink: string): Promise<string> {
154-
const certPath = path.join(tmpdir(), randomString() + ".p12")
151+
const certPath = path.join(tmpdir(), `${getTempName()}.p12`)
155152
return downloadUrlOrBase64(cscLink, certPath)
156153
.thenReturn(certPath)
157154
}
155+
156+
let findIdentityRawResult: Promise<string> | null = null
157+
158+
export async function findIdentity(namePrefix: string, qualifier?: string): Promise<string | null> {
159+
if (findIdentityRawResult == null) {
160+
findIdentityRawResult = exec("security", ["find-identity", "-v", "-p", "codesigning"])
161+
}
162+
163+
const lines = (await findIdentityRawResult).split("\n")
164+
for (let line of lines) {
165+
if (qualifier != null && !line.includes(qualifier)) {
166+
continue
167+
}
168+
169+
const location = line.indexOf(namePrefix)
170+
if (location >= 0) {
171+
return line.substring(location, line.lastIndexOf('"'))
172+
}
173+
}
174+
return null
175+
}

src/linuxPackager.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,7 @@ Icon=${this.metadata.name}
126126
}
127127

128128
private async createFromIcns(tempDir: string): Promise<Array<string>> {
129-
const outputs = await exec("icns2png", ["-x", "-o", tempDir, path.join(this.buildResourcesDir, "icon.icns")])
130-
const output = outputs[0].toString()
129+
const output = await exec("icns2png", ["-x", "-o", tempDir, path.join(this.buildResourcesDir, "icon.icns")])
131130
debug(output)
132131

133132
const imagePath = path.join(tempDir, "icon_256x256x32.png")

src/metadata.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -207,11 +207,6 @@ export interface OsXBuildOptions extends PlatformSpecificBuildOptions {
207207
MAS (Mac Application Store) specific options (in addition to `build.osx`).
208208
*/
209209
export interface MasBuildOptions extends OsXBuildOptions {
210-
/*
211-
The name of certificate to use when signing. Consider using environment variables [CSC_INSTALLER_LINK or CSC_INSTALLER_NAME](https://github.com/electron-userland/electron-builder/wiki/Code-Signing).
212-
*/
213-
readonly identity?: string | null
214-
215210
/*
216211
The path to entitlements file for signing the app. `build/mas.entitlements` will be used if exists (it is a recommended way to set).
217212
Otherwise [default](https://github.com/electron-userland/electron-osx-sign/blob/master/default.mas.entitlements).
@@ -263,6 +258,9 @@ export interface WinBuildOptions extends PlatformSpecificBuildOptions {
263258
*/
264259
readonly remoteToken?: string | null
265260

261+
/*
262+
Array of signing algorithms used. Defaults to `['sha1', 'sha256']`
263+
*/
266264
readonly signingHashAlgorithms?: Array<string> | null
267265
readonly signcodePath?: string | null
268266
}

src/osxPackager.ts

Lines changed: 75 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Platform, OsXBuildOptions, MasBuildOptions } from "./metadata"
33
import * as path from "path"
44
import { Promise as BluebirdPromise } from "bluebird"
55
import { log, debug, warn } from "./util"
6-
import { createKeychain, deleteKeychain, CodeSigningInfo, generateKeychainName } from "./codeSign"
6+
import { createKeychain, deleteKeychain, CodeSigningInfo, generateKeychainName, findIdentity } from "./codeSign"
77
import deepAssign = require("deep-assign")
88
import { sign, flat, BaseSignOptions, SignOptions, FlatOptions } from "electron-osx-sign-tf"
99

@@ -16,14 +16,18 @@ export default class OsXPackager extends PlatformPackager<OsXBuildOptions> {
1616
constructor(info: BuildInfo, cleanupTasks: Array<() => Promise<any>>) {
1717
super(info)
1818

19-
if (this.options.cscLink != null && this.options.cscKeyPassword != null) {
19+
if (this.options.cscLink == null) {
20+
this.codeSigningInfo = BluebirdPromise.resolve(null)
21+
}
22+
else {
23+
if (this.options.cscKeyPassword == null) {
24+
throw new Error("cscLink is set, but cscKeyPassword not")
25+
}
26+
2027
const keychainName = generateKeychainName()
2128
cleanupTasks.push(() => deleteKeychain(keychainName))
2229
this.codeSigningInfo = createKeychain(keychainName, this.options.cscLink, this.options.cscKeyPassword, this.options.cscInstallerLink, this.options.cscInstallerKeyPassword)
2330
}
24-
else {
25-
this.codeSigningInfo = BluebirdPromise.resolve(null)
26-
}
2731
}
2832

2933
get platform() {
@@ -59,33 +63,77 @@ export default class OsXPackager extends PlatformPackager<OsXBuildOptions> {
5963
}
6064
}
6165

66+
private static async findIdentity(certType: string, name?: string | null): Promise<string | null> {
67+
let identity = process.env.CSC_NAME || name
68+
if (identity == null || identity.trim().length === 0) {
69+
return await findIdentity(certType)
70+
}
71+
else {
72+
identity = identity.trim()
73+
checkPrefix(identity, "Developer ID Application:")
74+
checkPrefix(identity, "3rd Party Mac Developer Application:")
75+
checkPrefix(identity, "Developer ID Installer:")
76+
checkPrefix(identity, "3rd Party Mac Developer Installer:")
77+
const result = await findIdentity(certType, identity)
78+
if (result == null) {
79+
throw new Error(`Identity name "${identity}" is specified, but no valid identity with this name in the keychain`)
80+
}
81+
return result
82+
}
83+
}
84+
6285
private async sign(appOutDir: string, masOptions: MasBuildOptions | null): Promise<void> {
6386
let codeSigningInfo = await this.codeSigningInfo
6487
if (codeSigningInfo == null) {
65-
codeSigningInfo = {
66-
name: process.env.CSC_NAME || this.customBuildOptions.identity,
67-
installerName: process.env.CSC_INSTALLER_NAME || (masOptions == null ? null : masOptions.identity),
88+
if (process.env.CSC_LINK != null) {
89+
throw new Error("codeSigningInfo is null, but CSC_LINK defined")
90+
}
91+
92+
const identity = await OsXPackager.findIdentity(masOptions == null ? "Developer ID Application" : "3rd Party Mac Developer Application", this.customBuildOptions.identity)
93+
if (identity == null) {
94+
const message = "App is not signed: CSC_LINK or CSC_NAME are not specified, and no valid identity in the keychain, see https://github.com/electron-userland/electron-builder/wiki/Code-Signing"
95+
if (masOptions == null) {
96+
warn(message)
97+
return
98+
}
99+
else {
100+
throw new Error(message)
101+
}
68102
}
69-
}
70103

71-
const identity = codeSigningInfo.name
72-
if (<string | null>identity == null) {
73-
const message = "App is not signed: CSC_LINK or CSC_NAME are not specified, see https://github.com/electron-userland/electron-builder/wiki/Code-Signing"
74104
if (masOptions != null) {
75-
throw new Error(message)
105+
const installerName = masOptions == null ? null : (await OsXPackager.findIdentity("3rd Party Mac Developer Installer", this.customBuildOptions.identity))
106+
if (installerName == null) {
107+
throw new Error("Cannot find valid installer certificate: CSC_LINK or CSC_NAME are not specified, and no valid identity in the keychain, see https://github.com/electron-userland/electron-builder/wiki/Code-Signing")
108+
}
109+
110+
codeSigningInfo = {
111+
name: identity,
112+
installerName: installerName,
113+
}
114+
}
115+
else {
116+
codeSigningInfo = {
117+
name: identity,
118+
}
119+
}
120+
}
121+
else {
122+
if (codeSigningInfo.name == null && masOptions == null) {
123+
throw new Error("codeSigningInfo.name is null, but CSC_LINK defined")
124+
}
125+
if (masOptions != null && codeSigningInfo.installerName == null) {
126+
throw new Error("Signing is required for mas builds but CSC_INSTALLER_LINK is not specified")
76127
}
77-
warn(message)
78-
return
79128
}
80129

130+
const identity = codeSigningInfo.name
81131
log(`Signing app (identity: ${identity})`)
82132

83133
const baseSignOptions: BaseSignOptions = {
84-
app: path.join(appOutDir, this.appName + ".app"),
85-
platform: masOptions == null ? "darwin" : "mas"
86-
}
87-
if (codeSigningInfo.keychainName != null) {
88-
baseSignOptions.keychain = codeSigningInfo.keychainName
134+
app: path.join(appOutDir, `${this.appName}.app`),
135+
platform: masOptions == null ? "darwin" : "mas",
136+
keychain: <any>codeSigningInfo.keychainName,
89137
}
90138

91139
const signOptions = Object.assign({
@@ -118,15 +166,10 @@ export default class OsXPackager extends PlatformPackager<OsXBuildOptions> {
118166
await this.doSign(signOptions)
119167

120168
if (masOptions != null) {
121-
const installerIdentity = codeSigningInfo.installerName
122-
if (installerIdentity == null) {
123-
throw new Error("Signing is required for mas builds but CSC_INSTALLER_LINK or CSC_INSTALLER_NAME are not specified")
124-
}
125-
126169
const pkg = path.join(appOutDir, `${this.appName}-${this.metadata.version}.pkg`)
127170
await this.doFlat(Object.assign({
128171
pkg: pkg,
129-
identity: installerIdentity,
172+
identity: codeSigningInfo.installerName,
130173
}, baseSignOptions))
131174
this.dispatchArtifactCreated(pkg, `${this.metadata.name}-${this.metadata.version}.pkg`)
132175
}
@@ -225,4 +268,10 @@ export default class OsXPackager extends PlatformPackager<OsXBuildOptions> {
225268

226269
this.dispatchArtifactCreated(artifactPath, `${this.metadata.name}-${this.metadata.version}.dmg`)
227270
}
271+
}
272+
273+
function checkPrefix(name: string, prefix: string) {
274+
if (name.startsWith(prefix)) {
275+
throw new Error(`Please remove prefix "${prefix}" from the specified name — appropriate certificate will be chosen automatically`)
276+
}
228277
}

0 commit comments

Comments
 (0)