Skip to content

Commit de01c6d

Browse files
committed
feat: multi-cert p12
Closes #560
1 parent 3fdd1f8 commit de01c6d

File tree

19 files changed

+237
-205
lines changed

19 files changed

+237
-205
lines changed

.idea/dictionaries/develar.xml

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

.idea/runConfigurations/CodeSignTest.xml

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

docs/Code Signing.md

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
1-
MacOS and Windows code signing is supported. Windows is dual code-signed (SHA1 & SHA256 hashing algorithms).
1+
macOS and Windows code signing is supported. Windows is dual code-signed (SHA1 & SHA256 hashing algorithms).
22

3-
On a MacOS development machine valid and appropriate identity from your keychain will be automatically used.
3+
On a macOS development machine valid and appropriate identity from your keychain will be automatically used.
44

55
| Env name | Description
66
| -------------- | -----------
7-
| `CSC_LINK` | The HTTPS link (or base64-encoded data) to certificate (`*.p12` file).
7+
| `CSC_LINK` | The HTTPS link (or base64-encoded data, or `file://` link) to certificate (`*.p12` file).
88
| `CSC_KEY_PASSWORD` | The password to decrypt the certificate given in `CSC_LINK`.
9-
| `CSC_INSTALLER_LINK` | *osx-only* The HTTPS link (or base64-encoded data) to certificate to sign Mac App Store build (`*.p12` file).
10-
| `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) if you have several identities (otherwise don't specify it).
9+
| `CSC_NAME` | *macOS-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).
1210

1311
## Travis, AppVeyor and other CI Servers
1412
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):
@@ -26,4 +24,16 @@ To sign app on build server you need to set `CSC_LINK`, `CSC_KEY_PASSWORD` (and
2624

2725
# Where to Buy Code Signing Certificate
2826
[StartSSL](https://startssl.com/Support?v=34) is recommended.
29-
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).
27+
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).
28+
29+
# How to Export Certificate on macOS
30+
31+
1. Open Keychain.
32+
2. Select `login` keychain, and `My Certificates` category.
33+
3. Select all required certificates (hint: use cmd-click to select several):
34+
* `Developer ID Application:` to sign app for macOS.
35+
* `3rd Party Mac Developer Application:` and `3rd Party Mac Developer Installer:` to sign app for MAS (Mac App Store).
36+
37+
Please note – you can select as many certificates, as need. No restrictions on electron-builder side.
38+
All selected certificates will be imported into temporary keychain on CI server.
39+
4. Open context menu and `Export`.

docs/Options.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ Here documented only `electron-builder` specific options:
5151
| Name | Description
5252
| --- | ---
5353
| appId | <a name="BuildMetadata-appId"></a><p>The application id. Used as [CFBundleIdentifier](https://developer.apple.com/library/ios/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html#//apple_ref/doc/uid/20001431-102070) for MacOS and as [Application User Model ID](https://msdn.microsoft.com/en-us/library/windows/desktop/dd378459(v=vs.85).aspx) for Windows.</p> <p>For windows only NSIS target supports it. Squirrel.Windows is not fixed yet.</p> <p>Defaults to <code>com.electron.${name}</code>. It is strongly recommended that an explicit ID be set.</p>
54-
| app-category-type | <a name="BuildMetadata-app-category-type"></a><p>*MacOS-only.* The application category type, as shown in the Finder via *View -&gt; Arrange by Application Category* when viewing the Applications directory.</p> <p>For example, <code>app-category-type=public.app-category.developer-tools</code> will set the application category to *Developer Tools*.</p> <p>Valid values are listed in [Apple’s documentation](https://developer.apple.com/library/ios/documentation/General/Reference/InfoPlistKeyReference/Articles/LaunchServicesKeys.html#//apple_ref/doc/uid/TP40009250-SW8).</p>
54+
| app-category-type | <a name="BuildMetadata-app-category-type"></a><p>*macOS-only.* The application category type, as shown in the Finder via *View -&gt; Arrange by Application Category* when viewing the Applications directory.</p> <p>For example, <code>app-category-type=public.app-category.developer-tools</code> will set the application category to *Developer Tools*.</p> <p>Valid values are listed in [Apple’s documentation](https://developer.apple.com/library/ios/documentation/General/Reference/InfoPlistKeyReference/Articles/LaunchServicesKeys.html#//apple_ref/doc/uid/TP40009250-SW8).</p>
5555
| asar | <a name="BuildMetadata-asar"></a><p>Whether to package the application’s source code into an archive, using [Electron’s archive format](https://github.com/electron/asar). Defaults to <code>true</code>. Reasons why you may want to disable this feature are described in [an application packaging tutorial in Electron’s documentation](http://electron.atom.io/docs/latest/tutorial/application-packaging/#limitations-on-node-api/).</p> <p>Or you can pass object of any asar options.</p>
5656
| productName | <a name="BuildMetadata-productName"></a>See [AppMetadata.productName](#AppMetadata-productName).
5757
| files | <a name="BuildMetadata-files"></a><p>A [glob patterns](https://www.npmjs.com/package/glob#glob-primer) relative to the [app directory](#MetadataDirectories-app), which specifies which files to include when copying files to create the package. Defaults to <code>\*\*\/\*</code> (i.e. [hidden files are ignored by default](https://www.npmjs.com/package/glob#dots)).</p> <p>Development dependencies are never copied in any case. You don’t need to ignore it explicitly.</p> <p>[Multiple patterns](#multiple-glob-patterns) are supported. You can use <code>${os}</code> (expanded to mac, linux or win according to current platform) and <code>${arch}</code> in the pattern. If directory matched, all contents are copied. So, you can just specify <code>foo</code> to copy <code>foo</code> directory.</p> <p>Remember that default pattern <code>\*\*\/\*</code> is not added to your custom, so, you have to add it explicitly — e.g. <code>[&quot;\*\*\/\*&quot;, &quot;!ignoreMe${/\*}&quot;]</code>.</p> <p>May be specified in the platform options (e.g. in the <code>build.mac</code>).</p>
@@ -76,8 +76,8 @@ MacOS specific build options.
7676
| target | <a name="MacOptions-target"></a>Target package type: list of `default`, `dmg`, `mas`, `7z`, `zip`, `tar.xz`, `tar.lz`, `tar.gz`, `tar.bz2`. Defaults to `default` (dmg and zip for Squirrel.Mac).
7777
| identity | <a name="MacOptions-identity"></a><p>The name of certificate to use when signing. Consider using environment variables [CSC_LINK or CSC_NAME](https://github.com/electron-userland/electron-builder/wiki/Code-Signing). MAS installer identity is specified in the [.build.mas](#MasBuildOptions-identity).</p>
7878
| icon | <a name="MacOptions-icon"></a>The path to application icon. Defaults to `build/icon.icns` (consider using this convention instead of complicating your configuration).
79-
| entitlements | <a name="MacOptions-entitlements"></a><p>The path to entitlements file for signing the app. <code>build/entitlements.osx.plist</code> will be used if exists (it is a recommended way to set). MAS entitlements is specified in the [.build.mas](#MasBuildOptions-entitlements).</p>
80-
| entitlementsInherit | <a name="MacOptions-entitlementsInherit"></a><p>The path to child entitlements which inherit the security settings for signing frameworks and bundles of a distribution. <code>build/entitlements.osx.inherit.plist</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.entitlements.darwin.inherit.plist).</p> <p>This option only applies when signing with <code>entitlements</code> provided.</p>
79+
| entitlements | <a name="MacOptions-entitlements"></a><p>The path to entitlements file for signing the app. <code>build/entitlements.mac.plist</code> will be used if exists (it is a recommended way to set). MAS entitlements is specified in the [.build.mas](#MasBuildOptions-entitlements).</p>
80+
| entitlementsInherit | <a name="MacOptions-entitlementsInherit"></a><p>The path to child entitlements which inherit the security settings for signing frameworks and bundles of a distribution. <code>build/entitlements.mac.inherit.plist</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.entitlements.darwin.inherit.plist).</p> <p>This option only applies when signing with <code>entitlements</code> provided.</p>
8181

8282
<a name="DmgOptions"></a>
8383
### `.build.dmg`

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,7 @@
6565
"chalk": "^1.1.3",
6666
"cli-cursor": "^1.0.2",
6767
"debug": "^2.2.0",
68-
"deep-assign": "^2.0.0",
69-
"electron-osx-sign-tf": "0.6.0",
68+
"electron-osx-sign": "^0.4.0-beta4",
7069
"electron-packager-tf": "~7.5.2",
7170
"electron-winstaller-fixed": "~2.11.6",
7271
"fs-extra-p": "^1.0.5",

src/codeSign.ts

Lines changed: 66 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { exec, getTempName } from "./util/util"
1+
import { exec, getTempName, isEmptyOrSpaces } from "./util/util"
22
import { deleteFile, outputFile, copy, rename } from "fs-extra-p"
33
import { download } from "./util/httpRequest"
44
import { tmpdir } from "os"
@@ -11,27 +11,30 @@ import { homedir } from "os"
1111
//noinspection JSUnusedLocalSymbols
1212
const __awaiter = require("./util/awaiter")
1313

14-
export const appleCertificatePrefixes = ["Developer ID Application:", "3rd Party Mac Developer Application:", "Developer ID Installer:", "3rd Party Mac Developer Installer:"]
14+
const appleCertificatePrefixes = ["Developer ID Application:", "3rd Party Mac Developer Application:", "Developer ID Installer:", "3rd Party Mac Developer Installer:"]
1515

1616
export type CertType = "Developer ID Application" | "3rd Party Mac Developer Application" | "Developer ID Installer" | "3rd Party Mac Developer Installer"
1717

1818
export interface CodeSigningInfo {
19-
name: string
2019
keychainName?: string | null
21-
22-
installerName?: string | null
2320
}
2421

2522
export function generateKeychainName(): string {
2623
return path.join(tmpdir(), getTempName("csc") + ".keychain")
2724
}
2825

29-
function downloadUrlOrBase64(urlOrBase64: string, destination: string): BluebirdPromise<any> {
26+
export function downloadCertificate(urlOrBase64: string): BluebirdPromise<string> {
27+
const tempFile = path.join(tmpdir(), `${getTempName()}.p12`)
3028
if (urlOrBase64.startsWith("https://")) {
31-
return download(urlOrBase64, destination)
29+
return download(urlOrBase64, tempFile)
30+
.thenReturn(tempFile)
31+
}
32+
else if (urlOrBase64.startsWith("file://")) {
33+
return BluebirdPromise.resolve(urlOrBase64.substring("file://".length))
3234
}
3335
else {
34-
return outputFile(destination, new Buffer(urlOrBase64, "base64"))
36+
return outputFile(tempFile, new Buffer(urlOrBase64, "base64"))
37+
.thenReturn(tempFile)
3538
}
3639
}
3740

@@ -77,11 +80,7 @@ export async function createKeychain(keychainName: string, cscLink: string, cscK
7780
const certPaths = new Array(certLinks.length)
7881
const keychainPassword = randomBytes(8).toString("hex")
7982
return await executeFinally(BluebirdPromise.all([
80-
BluebirdPromise.map(certLinks, (link, i) => {
81-
const tempFile = path.join(tmpdir(), `${getTempName()}.p12`)
82-
certPaths[i] = tempFile
83-
return downloadUrlOrBase64(link, tempFile)
84-
}),
83+
BluebirdPromise.map(certLinks, (link, i) => downloadCertificate(link).then(it => certPaths[i] = it)),
8584
BluebirdPromise.mapSeries([
8685
["create-keychain", "-p", keychainPassword, keychainName],
8786
["unlock-keychain", "-p", keychainPassword, keychainName],
@@ -90,7 +89,7 @@ export async function createKeychain(keychainName: string, cscLink: string, cscK
9089
])
9190
.then<CodeSigningInfo>(() => importCerts(keychainName, certPaths, <Array<string>>[cscKeyPassword, cscIKeyPassword].filter(it => it != null))),
9291
errorOccurred => {
93-
const tasks = certPaths.map(it => deleteFile(it, true))
92+
const tasks = certPaths.map((it, index) => certLinks[index].startsWith("file://") ? BluebirdPromise.resolve() : deleteFile(it, true))
9493
if (errorOccurred) {
9594
tasks.push(deleteKeychain(keychainName))
9695
}
@@ -99,40 +98,19 @@ export async function createKeychain(keychainName: string, cscLink: string, cscK
9998
}
10099

101100
async function importCerts(keychainName: string, paths: Array<string>, keyPasswords: Array<string>): Promise<CodeSigningInfo> {
102-
const namePromises: Array<Promise<string>> = []
103101
for (let i = 0; i < paths.length; i++) {
104-
const password = keyPasswords[i]
105-
const certPath = paths[i]
106-
await exec("security", ["import", certPath, "-k", keychainName, "-T", "/usr/bin/codesign", "-T", "/usr/bin/productbuild", "-P", password])
107-
108-
namePromises.push(extractCommonName(password, certPath))
102+
await exec("security", ["import", paths[i], "-k", keychainName, "-T", "/usr/bin/codesign", "-T", "/usr/bin/productbuild", "-P", keyPasswords[i]])
109103
}
110104

111-
const names = await BluebirdPromise.all(namePromises)
112105
return {
113-
name: names[0],
114-
installerName: names.length > 1 ? names[1] : null,
115106
keychainName: keychainName,
116107
}
117108
}
118109

119-
function extractCommonName(password: string, certPath: string): BluebirdPromise<string> {
120-
return exec("openssl", ["pkcs12", "-nokeys", "-nodes", "-passin", "pass:" + password, "-nomacver", "-clcerts", "-in", certPath])
121-
.then(result => {
122-
const match = <Array<string | null> | null>(result.match(/^subject.*\/CN=([^\/\n]+)/m))
123-
if (match == null || match[1] == null) {
124-
throw new Error("Cannot extract common name from p12")
125-
}
126-
else {
127-
return match[1]!
128-
}
129-
})
130-
}
131-
132-
export function sign(path: string, options: CodeSigningInfo): BluebirdPromise<any> {
133-
const args = ["--deep", "--force", "--sign", options.name, path]
134-
if (options.keychainName != null) {
135-
args.push("--keychain", options.keychainName)
110+
export function sign(path: string, name: string, keychain: string): BluebirdPromise<any> {
111+
const args = ["--deep", "--force", "--sign", name, path]
112+
if (keychain != null) {
113+
args.push("--keychain", keychain)
136114
}
137115
return exec("codesign", args)
138116
}
@@ -151,20 +129,22 @@ export function deleteKeychain(keychainName: string, ignoreNotFound: boolean = t
151129
}
152130
}
153131

154-
export function downloadCertificate(cscLink: string): Promise<string> {
155-
const certPath = path.join(tmpdir(), `${getTempName()}.p12`)
156-
return downloadUrlOrBase64(cscLink, certPath)
157-
.thenReturn(certPath)
158-
}
159-
160132
export let findIdentityRawResult: Promise<Array<string>> | null = null
161133

162-
export async function findIdentity(namePrefix: CertType, qualifier?: string): Promise<string | null> {
163-
if (findIdentityRawResult == null) {
134+
async function getValidIdentities(keychain?: string | null): Promise<Array<string>> {
135+
function addKeychain(args: Array<string>) {
136+
if (keychain != null) {
137+
args.push(keychain)
138+
}
139+
return args
140+
}
141+
142+
let result = findIdentityRawResult
143+
if (result == null || keychain != null) {
164144
// https://github.com/electron-userland/electron-builder/issues/481
165145
// https://github.com/electron-userland/electron-builder/issues/535
166-
findIdentityRawResult = BluebirdPromise.all<Array<string>>([
167-
exec("security", ["find-identity", "-v"])
146+
result = BluebirdPromise.all<Array<string>>([
147+
exec("security", addKeychain(["find-identity", "-v"]))
168148
.then(it => it.trim().split("\n").filter(it => {
169149
for (let prefix of appleCertificatePrefixes) {
170150
if (it.includes(prefix)) {
@@ -173,7 +153,7 @@ export async function findIdentity(namePrefix: CertType, qualifier?: string): Pr
173153
}
174154
return false
175155
})),
176-
exec("security", ["find-identity", "-v", "-p", "codesigning"])
156+
exec("security", addKeychain(["find-identity", "-v", "-p", "codesigning"]))
177157
.then(it => it.trim().split(("\n"))),
178158
])
179159
.then(it => {
@@ -183,11 +163,18 @@ export async function findIdentity(namePrefix: CertType, qualifier?: string): Pr
183163
.map(it => it.substring(it.indexOf(")") + 1).trim())
184164
return Array.from(new Set(array))
185165
})
166+
167+
if (keychain == null) {
168+
findIdentityRawResult = result
169+
}
186170
}
171+
return result
172+
}
187173

174+
async function _findIdentity(namePrefix: CertType, qualifier?: string | null, keychain?: string | null): Promise<string | null> {
188175
// https://github.com/electron-userland/electron-builder/issues/484
189176
//noinspection SpellCheckingInspection
190-
const lines = await findIdentityRawResult
177+
const lines = await getValidIdentities(keychain)
191178
for (let line of lines) {
192179
if (qualifier != null && !line.includes(qualifier)) {
193180
continue
@@ -216,4 +203,31 @@ export async function findIdentity(namePrefix: CertType, qualifier?: string): Pr
216203
}
217204
}
218205
return null
206+
}
207+
208+
export async function findIdentity(certType: CertType, qualifier?: string | null, keychain?: string | null): Promise<string | null> {
209+
let identity = process.env.CSC_NAME || qualifier
210+
if (isEmptyOrSpaces(identity)) {
211+
if (keychain == null && process.env.CI == null && process.env.CSC_IDENTITY_AUTO_DISCOVERY === "false") {
212+
return null
213+
}
214+
return await _findIdentity(certType, null, keychain)
215+
}
216+
else {
217+
identity = identity.trim()
218+
for (let prefix of appleCertificatePrefixes) {
219+
checkPrefix(identity, prefix)
220+
}
221+
const result = await _findIdentity(certType, identity, keychain)
222+
if (result == null) {
223+
throw new Error(`Identity name "${identity}" is specified, but no valid identity with this name in the keychain`)
224+
}
225+
return result
226+
}
227+
}
228+
229+
function checkPrefix(name: string, prefix: string) {
230+
if (name.startsWith(prefix)) {
231+
throw new Error(`Please remove prefix "${prefix}" from the specified name — appropriate certificate will be chosen automatically`)
232+
}
219233
}

0 commit comments

Comments
 (0)