Skip to content

Commit

Permalink
feat: user-friendly MAS code signing
Browse files Browse the repository at this point in the history
* `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.
  • Loading branch information
develar committed May 30, 2016
1 parent 67ed60b commit fe53388
Show file tree
Hide file tree
Showing 14 changed files with 179 additions and 139 deletions.
1 change: 1 addition & 0 deletions .idea/dictionaries/develar.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 4 additions & 8 deletions docs/Code Signing.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,14 @@
OS X and Windows code signing is supported. Windows is dual code-signed (SHA1 & SHA256 hashing algorithms).

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

| Env name | Description
| -------------- | -----------
| `CSC_LINK` | The HTTPS link (or base64-encoded data) to certificate (`*.p12` file).
| `CSC_KEY_PASSWORD` | The password to decrypt the certificate given in `CSC_LINK`.
| `CSC_INSTALLER_LINK` | *osx-only* The HTTPS link (or base64-encoded data) to certificate to sign Mac App Store build (`*.p12` file).
| `CSC_INSTALLER_KEY_PASSWORD` | *osx-only* The password to decrypt the certificate given in `CSC_INSTALLER_LINK`.
| `CSC_NAME` | *osx-only* Name of certificate (to retrieve from login.keychain). Useful on a development machine (not on CI).
| `CSC_INSTALLER_NAME` | *osx-only* Name of installer certificate (to retrieve from login.keychain). Useful on a development machine (not on CI).

```
export CSC_NAME="Developer ID Application: Your Name (code)"
```
| `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).

## Travis, AppVeyor and other CI Servers
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):
Expand All @@ -29,4 +24,5 @@ To sign app on build server you need to set `CSC_LINK`, `CSC_KEY_PASSWORD` (and
In case of AppVeyor, don't forget to click on lock icon to “Toggle variable encryption”.

# Where to Buy Code Signing Certificate
[StartSSL](https://startssl.com/Support?v=34) is recommended.
[StartSSL](https://startssl.com/Support?v=34) is recommended.
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).
2 changes: 1 addition & 1 deletion docs/Options.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,6 @@ MAS (Mac Application Store) specific options (in addition to `build.osx`).

| Name | Description
| --- | ---
| 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).
| 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>
| 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>

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

<a name="LinuxBuildOptions"></a>
### `.build.linux`
Expand Down
96 changes: 57 additions & 39 deletions src/codeSign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,8 @@ export interface CodeSigningInfo {
installerName?: string | null
}

function randomString(): string {
return randomBytes(8).toString("hex")
}

export function generateKeychainName(): string {
return "csc-" + randomString() + ".keychain"
return path.join(tmpdir(), getTempName("csc") + ".keychain")
}

function downloadUrlOrBase64(urlOrBase64: string, destination: string): BluebirdPromise<any> {
Expand All @@ -35,48 +31,50 @@ function downloadUrlOrBase64(urlOrBase64: string, destination: string): Bluebird
}
}

let bundledCertKeychainAdded = false
let bundledCertKeychainAdded: Promise<any> | null = null

// "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."
// 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
// "overly complicated and introduces a race condition."
// https://github.com/electron-userland/electron-builder/issues/398
async function createCustomCertKeychain() {
// copy to temp and then atomic rename to final path
const tmpKeychainPath = path.join(homedir(), ".cache", getTempName("electron_builder_root_certs"))
const keychainPath = path.join(homedir(), ".cache", "electron_builder_root_certs.keychain")
const results = await BluebirdPromise.all<string>([
exec("security", ["list-keychains"]),
copy(path.join(__dirname, "..", "certs", "root_certs.keychain"), tmpKeychainPath)
.then(() => rename(tmpKeychainPath, keychainPath)),
])
const list = results[0]
.split("\n")
.map(it => {
let r = it.trim()
return r.substring(1, r.length - 1)
})
.filter(it => it.length > 0)

if (!list.includes(keychainPath)) {
await exec("security", ["list-keychains", "-d", "user", "-s", keychainPath].concat(list))
}
}

export async function createKeychain(keychainName: string, cscLink: string, cscKeyPassword: string, cscILink?: string | null, cscIKeyPassword?: string | null): Promise<CodeSigningInfo> {
if (!bundledCertKeychainAdded) {
// "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."
// 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
// "overly complicated and introduces a race condition."
// https://github.com/electron-userland/electron-builder/issues/398

bundledCertKeychainAdded = true

// copy to temp and then atomic rename to final path
const tmpKeychainPath = path.join(homedir(), ".cache", getTempName("electron_builder_root_certs"))
const keychainPath = path.join(homedir(), ".cache", "electron_builder_root_certs.keychain")
const results = await BluebirdPromise.all<Array<string> | string>([
exec("security", ["list-keychains"]),
copy(path.join(__dirname, "..", "certs", "root_certs.keychain"), tmpKeychainPath)
.then(() => rename(tmpKeychainPath, keychainPath)),
])
const list = (<string[]>results[0])[0]
.split("\n")
.map(it => {
let r = it.trim()
return r.substring(1, r.length - 1)
})
.filter(it => it.length > 0)

if (!list.includes(keychainPath)) {
await exec("security", ["list-keychains", "-d", "user", "-s", keychainPath].concat(list))
}
if (bundledCertKeychainAdded == null) {
bundledCertKeychainAdded = createCustomCertKeychain()
}
await bundledCertKeychainAdded

const certLinks = [cscLink]
if (cscILink != null) {
certLinks.push(cscILink)
}

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

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

export function downloadCertificate(cscLink: string): Promise<string> {
const certPath = path.join(tmpdir(), randomString() + ".p12")
const certPath = path.join(tmpdir(), `${getTempName()}.p12`)
return downloadUrlOrBase64(cscLink, certPath)
.thenReturn(certPath)
}

let findIdentityRawResult: Promise<string> | null = null

export async function findIdentity(namePrefix: string, qualifier?: string): Promise<string | null> {
if (findIdentityRawResult == null) {
findIdentityRawResult = exec("security", ["find-identity", "-v", "-p", "codesigning"])
}

const lines = (await findIdentityRawResult).split("\n")
for (let line of lines) {
if (qualifier != null && !line.includes(qualifier)) {
continue
}

const location = line.indexOf(namePrefix)
if (location >= 0) {
return line.substring(location, line.lastIndexOf('"'))
}
}
return null
}
3 changes: 1 addition & 2 deletions src/linuxPackager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,7 @@ Icon=${this.metadata.name}
}

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

const imagePath = path.join(tempDir, "icon_256x256x32.png")
Expand Down
8 changes: 3 additions & 5 deletions src/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,11 +207,6 @@ export interface OsXBuildOptions extends PlatformSpecificBuildOptions {
MAS (Mac Application Store) specific options (in addition to `build.osx`).
*/
export interface MasBuildOptions extends OsXBuildOptions {
/*
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).
*/
readonly identity?: string | null

/*
The path to entitlements file for signing the app. `build/mas.entitlements` 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).
Expand Down Expand Up @@ -263,6 +258,9 @@ export interface WinBuildOptions extends PlatformSpecificBuildOptions {
*/
readonly remoteToken?: string | null

/*
Array of signing algorithms used. Defaults to `['sha1', 'sha256']`
*/
readonly signingHashAlgorithms?: Array<string> | null
readonly signcodePath?: string | null
}
Expand Down
101 changes: 75 additions & 26 deletions src/osxPackager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Platform, OsXBuildOptions, MasBuildOptions } from "./metadata"
import * as path from "path"
import { Promise as BluebirdPromise } from "bluebird"
import { log, debug, warn } from "./util"
import { createKeychain, deleteKeychain, CodeSigningInfo, generateKeychainName } from "./codeSign"
import { createKeychain, deleteKeychain, CodeSigningInfo, generateKeychainName, findIdentity } from "./codeSign"
import deepAssign = require("deep-assign")
import { sign, flat, BaseSignOptions, SignOptions, FlatOptions } from "electron-osx-sign-tf"

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

if (this.options.cscLink != null && this.options.cscKeyPassword != null) {
if (this.options.cscLink == null) {
this.codeSigningInfo = BluebirdPromise.resolve(null)
}
else {
if (this.options.cscKeyPassword == null) {
throw new Error("cscLink is set, but cscKeyPassword not")
}

const keychainName = generateKeychainName()
cleanupTasks.push(() => deleteKeychain(keychainName))
this.codeSigningInfo = createKeychain(keychainName, this.options.cscLink, this.options.cscKeyPassword, this.options.cscInstallerLink, this.options.cscInstallerKeyPassword)
}
else {
this.codeSigningInfo = BluebirdPromise.resolve(null)
}
}

get platform() {
Expand Down Expand Up @@ -59,33 +63,77 @@ export default class OsXPackager extends PlatformPackager<OsXBuildOptions> {
}
}

private static async findIdentity(certType: string, name?: string | null): Promise<string | null> {
let identity = process.env.CSC_NAME || name
if (identity == null || identity.trim().length === 0) {
return await findIdentity(certType)
}
else {
identity = identity.trim()
checkPrefix(identity, "Developer ID Application:")
checkPrefix(identity, "3rd Party Mac Developer Application:")
checkPrefix(identity, "Developer ID Installer:")
checkPrefix(identity, "3rd Party Mac Developer Installer:")
const result = await findIdentity(certType, identity)
if (result == null) {
throw new Error(`Identity name "${identity}" is specified, but no valid identity with this name in the keychain`)
}
return result
}
}

private async sign(appOutDir: string, masOptions: MasBuildOptions | null): Promise<void> {
let codeSigningInfo = await this.codeSigningInfo
if (codeSigningInfo == null) {
codeSigningInfo = {
name: process.env.CSC_NAME || this.customBuildOptions.identity,
installerName: process.env.CSC_INSTALLER_NAME || (masOptions == null ? null : masOptions.identity),
if (process.env.CSC_LINK != null) {
throw new Error("codeSigningInfo is null, but CSC_LINK defined")
}

const identity = await OsXPackager.findIdentity(masOptions == null ? "Developer ID Application" : "3rd Party Mac Developer Application", this.customBuildOptions.identity)
if (identity == null) {
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"
if (masOptions == null) {
warn(message)
return
}
else {
throw new Error(message)
}
}
}

const identity = codeSigningInfo.name
if (<string | null>identity == null) {
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"
if (masOptions != null) {
throw new Error(message)
const installerName = masOptions == null ? null : (await OsXPackager.findIdentity("3rd Party Mac Developer Installer", this.customBuildOptions.identity))
if (installerName == null) {
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")
}

codeSigningInfo = {
name: identity,
installerName: installerName,
}
}
else {
codeSigningInfo = {
name: identity,
}
}
}
else {
if (codeSigningInfo.name == null && masOptions == null) {
throw new Error("codeSigningInfo.name is null, but CSC_LINK defined")
}
if (masOptions != null && codeSigningInfo.installerName == null) {
throw new Error("Signing is required for mas builds but CSC_INSTALLER_LINK is not specified")
}
warn(message)
return
}

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

const baseSignOptions: BaseSignOptions = {
app: path.join(appOutDir, this.appName + ".app"),
platform: masOptions == null ? "darwin" : "mas"
}
if (codeSigningInfo.keychainName != null) {
baseSignOptions.keychain = codeSigningInfo.keychainName
app: path.join(appOutDir, `${this.appName}.app`),
platform: masOptions == null ? "darwin" : "mas",
keychain: <any>codeSigningInfo.keychainName,
}

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

if (masOptions != null) {
const installerIdentity = codeSigningInfo.installerName
if (installerIdentity == null) {
throw new Error("Signing is required for mas builds but CSC_INSTALLER_LINK or CSC_INSTALLER_NAME are not specified")
}

const pkg = path.join(appOutDir, `${this.appName}-${this.metadata.version}.pkg`)
await this.doFlat(Object.assign({
pkg: pkg,
identity: installerIdentity,
identity: codeSigningInfo.installerName,
}, baseSignOptions))
this.dispatchArtifactCreated(pkg, `${this.metadata.name}-${this.metadata.version}.pkg`)
}
Expand Down Expand Up @@ -225,4 +268,10 @@ export default class OsXPackager extends PlatformPackager<OsXBuildOptions> {

this.dispatchArtifactCreated(artifactPath, `${this.metadata.name}-${this.metadata.version}.dmg`)
}
}

function checkPrefix(name: string, prefix: string) {
if (name.startsWith(prefix)) {
throw new Error(`Please remove prefix "${prefix}" from the specified name — appropriate certificate will be chosen automatically`)
}
}
Loading

0 comments on commit fe53388

Please sign in to comment.