Skip to content

Commit

Permalink
feat: multi-cert p12
Browse files Browse the repository at this point in the history
Closes #560
  • Loading branch information
develar committed Jul 4, 2016
1 parent 3fdd1f8 commit de01c6d
Show file tree
Hide file tree
Showing 19 changed files with 237 additions and 205 deletions.
2 changes: 2 additions & 0 deletions .idea/dictionaries/develar.xml

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

2 changes: 1 addition & 1 deletion .idea/runConfigurations/CodeSignTest.xml

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

24 changes: 17 additions & 7 deletions docs/Code Signing.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
MacOS and Windows code signing is supported. Windows is dual code-signed (SHA1 & SHA256 hashing algorithms).
macOS and Windows code signing is supported. Windows is dual code-signed (SHA1 & SHA256 hashing algorithms).

On a MacOS development machine valid and appropriate identity from your keychain will be automatically used.
On a macOS 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_LINK` | The HTTPS link (or base64-encoded data, or `file://` link) 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) if you have several identities (otherwise don't specify it).
| `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).

## 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 @@ -26,4 +24,16 @@ To sign app on build server you need to set `CSC_LINK`, `CSC_KEY_PASSWORD` (and

# Where to Buy Code Signing Certificate
[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).
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).

# How to Export Certificate on macOS

1. Open Keychain.
2. Select `login` keychain, and `My Certificates` category.
3. Select all required certificates (hint: use cmd-click to select several):
* `Developer ID Application:` to sign app for macOS.
* `3rd Party Mac Developer Application:` and `3rd Party Mac Developer Installer:` to sign app for MAS (Mac App Store).

Please note – you can select as many certificates, as need. No restrictions on electron-builder side.
All selected certificates will be imported into temporary keychain on CI server.
4. Open context menu and `Export`.
6 changes: 3 additions & 3 deletions docs/Options.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Here documented only `electron-builder` specific options:
| Name | Description
| --- | ---
| 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>
| 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>
| 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>
| 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>
| productName | <a name="BuildMetadata-productName"></a>See [AppMetadata.productName](#AppMetadata-productName).
| 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>
Expand All @@ -76,8 +76,8 @@ MacOS specific build options.
| 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).
| 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>
| 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).
| 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>
| 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>
| 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>
| 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>

<a name="DmgOptions"></a>
### `.build.dmg`
Expand Down
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,7 @@
"chalk": "^1.1.3",
"cli-cursor": "^1.0.2",
"debug": "^2.2.0",
"deep-assign": "^2.0.0",
"electron-osx-sign-tf": "0.6.0",
"electron-osx-sign": "^0.4.0-beta4",
"electron-packager-tf": "~7.5.2",
"electron-winstaller-fixed": "~2.11.6",
"fs-extra-p": "^1.0.5",
Expand Down
118 changes: 66 additions & 52 deletions src/codeSign.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { exec, getTempName } from "./util/util"
import { exec, getTempName, isEmptyOrSpaces } from "./util/util"
import { deleteFile, outputFile, copy, rename } from "fs-extra-p"
import { download } from "./util/httpRequest"
import { tmpdir } from "os"
Expand All @@ -11,27 +11,30 @@ import { homedir } from "os"
//noinspection JSUnusedLocalSymbols
const __awaiter = require("./util/awaiter")

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

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

export interface CodeSigningInfo {
name: string
keychainName?: string | null

installerName?: string | null
}

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

function downloadUrlOrBase64(urlOrBase64: string, destination: string): BluebirdPromise<any> {
export function downloadCertificate(urlOrBase64: string): BluebirdPromise<string> {
const tempFile = path.join(tmpdir(), `${getTempName()}.p12`)
if (urlOrBase64.startsWith("https://")) {
return download(urlOrBase64, destination)
return download(urlOrBase64, tempFile)
.thenReturn(tempFile)
}
else if (urlOrBase64.startsWith("file://")) {
return BluebirdPromise.resolve(urlOrBase64.substring("file://".length))
}
else {
return outputFile(destination, new Buffer(urlOrBase64, "base64"))
return outputFile(tempFile, new Buffer(urlOrBase64, "base64"))
.thenReturn(tempFile)
}
}

Expand Down Expand Up @@ -77,11 +80,7 @@ export async function createKeychain(keychainName: string, cscLink: string, cscK
const certPaths = new Array(certLinks.length)
const keychainPassword = randomBytes(8).toString("hex")
return await executeFinally(BluebirdPromise.all([
BluebirdPromise.map(certLinks, (link, i) => {
const tempFile = path.join(tmpdir(), `${getTempName()}.p12`)
certPaths[i] = tempFile
return downloadUrlOrBase64(link, tempFile)
}),
BluebirdPromise.map(certLinks, (link, i) => downloadCertificate(link).then(it => certPaths[i] = it)),
BluebirdPromise.mapSeries([
["create-keychain", "-p", keychainPassword, keychainName],
["unlock-keychain", "-p", keychainPassword, keychainName],
Expand All @@ -90,7 +89,7 @@ export async function createKeychain(keychainName: string, cscLink: string, cscK
])
.then<CodeSigningInfo>(() => importCerts(keychainName, certPaths, <Array<string>>[cscKeyPassword, cscIKeyPassword].filter(it => it != null))),
errorOccurred => {
const tasks = certPaths.map(it => deleteFile(it, true))
const tasks = certPaths.map((it, index) => certLinks[index].startsWith("file://") ? BluebirdPromise.resolve() : deleteFile(it, true))
if (errorOccurred) {
tasks.push(deleteKeychain(keychainName))
}
Expand All @@ -99,40 +98,19 @@ export async function createKeychain(keychainName: string, cscLink: string, cscK
}

async function importCerts(keychainName: string, paths: Array<string>, keyPasswords: Array<string>): Promise<CodeSigningInfo> {
const namePromises: Array<Promise<string>> = []
for (let i = 0; i < paths.length; i++) {
const password = keyPasswords[i]
const certPath = paths[i]
await exec("security", ["import", certPath, "-k", keychainName, "-T", "/usr/bin/codesign", "-T", "/usr/bin/productbuild", "-P", password])

namePromises.push(extractCommonName(password, certPath))
await exec("security", ["import", paths[i], "-k", keychainName, "-T", "/usr/bin/codesign", "-T", "/usr/bin/productbuild", "-P", keyPasswords[i]])
}

const names = await BluebirdPromise.all(namePromises)
return {
name: names[0],
installerName: names.length > 1 ? names[1] : null,
keychainName: keychainName,
}
}

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.match(/^subject.*\/CN=([^\/\n]+)/m))
if (match == null || match[1] == null) {
throw new Error("Cannot extract common name from p12")
}
else {
return match[1]!
}
})
}

export function sign(path: string, options: CodeSigningInfo): BluebirdPromise<any> {
const args = ["--deep", "--force", "--sign", options.name, path]
if (options.keychainName != null) {
args.push("--keychain", options.keychainName)
export function sign(path: string, name: string, keychain: string): BluebirdPromise<any> {
const args = ["--deep", "--force", "--sign", name, path]
if (keychain != null) {
args.push("--keychain", keychain)
}
return exec("codesign", args)
}
Expand All @@ -151,20 +129,22 @@ export function deleteKeychain(keychainName: string, ignoreNotFound: boolean = t
}
}

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

export let findIdentityRawResult: Promise<Array<string>> | null = null

export async function findIdentity(namePrefix: CertType, qualifier?: string): Promise<string | null> {
if (findIdentityRawResult == null) {
async function getValidIdentities(keychain?: string | null): Promise<Array<string>> {
function addKeychain(args: Array<string>) {
if (keychain != null) {
args.push(keychain)
}
return args
}

let result = findIdentityRawResult
if (result == null || keychain != null) {
// https://github.com/electron-userland/electron-builder/issues/481
// https://github.com/electron-userland/electron-builder/issues/535
findIdentityRawResult = BluebirdPromise.all<Array<string>>([
exec("security", ["find-identity", "-v"])
result = BluebirdPromise.all<Array<string>>([
exec("security", addKeychain(["find-identity", "-v"]))
.then(it => it.trim().split("\n").filter(it => {
for (let prefix of appleCertificatePrefixes) {
if (it.includes(prefix)) {
Expand All @@ -173,7 +153,7 @@ export async function findIdentity(namePrefix: CertType, qualifier?: string): Pr
}
return false
})),
exec("security", ["find-identity", "-v", "-p", "codesigning"])
exec("security", addKeychain(["find-identity", "-v", "-p", "codesigning"]))
.then(it => it.trim().split(("\n"))),
])
.then(it => {
Expand All @@ -183,11 +163,18 @@ export async function findIdentity(namePrefix: CertType, qualifier?: string): Pr
.map(it => it.substring(it.indexOf(")") + 1).trim())
return Array.from(new Set(array))
})

if (keychain == null) {
findIdentityRawResult = result
}
}
return result
}

async function _findIdentity(namePrefix: CertType, qualifier?: string | null, keychain?: string | null): Promise<string | null> {
// https://github.com/electron-userland/electron-builder/issues/484
//noinspection SpellCheckingInspection
const lines = await findIdentityRawResult
const lines = await getValidIdentities(keychain)
for (let line of lines) {
if (qualifier != null && !line.includes(qualifier)) {
continue
Expand Down Expand Up @@ -216,4 +203,31 @@ export async function findIdentity(namePrefix: CertType, qualifier?: string): Pr
}
}
return null
}

export async function findIdentity(certType: CertType, qualifier?: string | null, keychain?: string | null): Promise<string | null> {
let identity = process.env.CSC_NAME || qualifier
if (isEmptyOrSpaces(identity)) {
if (keychain == null && process.env.CI == null && process.env.CSC_IDENTITY_AUTO_DISCOVERY === "false") {
return null
}
return await _findIdentity(certType, null, keychain)
}
else {
identity = identity.trim()
for (let prefix of appleCertificatePrefixes) {
checkPrefix(identity, prefix)
}
const result = await _findIdentity(certType, identity, keychain)
if (result == null) {
throw new Error(`Identity name "${identity}" is specified, but no valid identity with this name in the keychain`)
}
return result
}
}

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 de01c6d

Please sign in to comment.