Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Traverse archives #231

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ Default to `true`.
The keychain name.
Default to system default keychain.

`ignore` - *RegExp|Function|Array.<(RegExp|Function)>*
`ignore` - *String|RegExp|Function|Array.<(String|RegExp|Function)>*

Regex, function or an array of regex's and functions that signal skipping signing a file.
Elements of other types are treated as `RegExp`.
Expand Down Expand Up @@ -250,6 +250,11 @@ Default to `true`.
Specify the URL of the timestamp authority server, default to server provided by Apple. Please note that this default server may not support signatures not furnished by Apple.
Disable the timestamp service with `none`.

`traverse-archives` - *Boolean|String|RegExp|Function|Array.<(String|RegExp|Function)>*
Option to enable automation of signing binaries inside zip-like archives.
Not specifying any pattern will lead to marking all binary files as potential zip-like archives.
Default to `false`.

`type` - *String*

Specify whether to sign app for development or for distribution.
Expand Down
7 changes: 6 additions & 1 deletion bin/electron-osx-sign-usage.txt
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ DESCRIPTION
--identity-validation, --no-identity-validation
Flag to enable/disable validation for the signing identity.

--ignore=path
--ignore=pattern/to/ignore/1,pattern/to/ignore/2
Path to skip signing. The string will be treated as a regular expression when used to match the file paths.

--keychain=keychain
Expand Down Expand Up @@ -86,6 +86,11 @@ DESCRIPTION
Specify the URL of the timestamp authority server, default to server provided by Apple.
Disable the timestamp service with ``none''.

--traverse-archives, --traverse-archives=pattern/to/archive/1,pattern/to/archive/2
Option to enable automation of signing binaries inside zip-like archives.
Not specifying any pattern will lead to marking all binary files as potential zip-like archives.
Disabled by default.

--type=type
Specify whether to sign app for development or for distribution.
Allowed values: ``development'', ``distribution''.
Expand Down
177 changes: 151 additions & 26 deletions sign.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ const util = require('./util')
const debuglog = util.debuglog
const debugwarn = util.debugwarn
const getAppContentsPath = util.getAppContentsPath
const getTempFilePath = util.getTempFilePath
const execFileAsync = util.execFileAsync
const isZipFileAsync = util.isZipFileAsync
const validateOptsAppAsync = util.validateOptsAppAsync
const validateOptsPlatformAsync = util.validateOptsPlatformAsync
const walkAsync = util.walkAsync
Expand Down Expand Up @@ -66,6 +68,10 @@ function validateSignOptsAsync (opts) {
opts['type'] = 'distribution'
}

if (opts['traverse-archives'] && typeof opts['traverse-archives'] !== 'boolean' && !(opts['traverse-archives'] instanceof Array)) {
opts['traverse-archives'] = [opts['traverse-archives']]
}

return Promise.map([
validateOptsAppAsync,
validateOptsPlatformAsync,
Expand Down Expand Up @@ -120,6 +126,149 @@ function verifySignApplicationAsync (opts) {
.thenReturn()
}

/**
* Helper function to facilitate checking if to ignore signing a file.
* @function
* @param {Object} opts - Options.
* @param {string} filePath - The file path to check whether to ignore.
* @returns {boolean} Whether to ignore the file.
*/
function ignoreFilePath (opts, filePath) {
if (opts.ignore) {
return opts.ignore.some(function (ignore) {
if (typeof ignore === 'function') {
return ignore(filePath)
}
return filePath.match(ignore)
})
}
return false
}

/***
* Helper function to facilitate whether to consider traversing a potential archive.
* @function
* @param {Object} opts - Options.
* @param {string} humanReadableFilePath - The file path to check whether to include for traversal.
* @returns {boolean} Whether to consider the potential archive for traversal.
*/
function shouldConsiderTraversingArchive (opts, humanReadableFilePath) {
if (opts['traverse-archives']) {
if (opts['traverse-archives'] === true) return true
return opts['traverse-archives'].some(function (include) {
if (typeof include === 'function') {
return include(humanReadableFilePath)
}
return humanReadableFilePath.match(include)
})
}
return false
}

/**
* Sign a zip-like archive child component of the app bundle.
* This piece of automation helps to traverse zip-like archives and sign any enclosing binary files. See #229.
* @function
* @param {Object} opts - Options.
* @param {string[]} args - Command arguments for codesign excluding the file path.
* @param {string} archiveFilePath - The path to the archive. It may be outside of the app bundle.
* @param {string} humanReadableArchiveFilePath - A file path which may not exist but helps the user understand where it's located in the app bundle.
* @returns {Promise} Promise.
*/
function signArchiveComponentsAsync (opts, args, archiveFilePath, humanReadableArchiveFilePath = undefined) {
// Get temporary directory
const tempDir = getTempFilePath('uncompressed')
const tempArchive = getTempFilePath('recompressed.zip')

// Unzip the file to the temporary directory
debuglog(`Extracting... ${humanReadableArchiveFilePath} (real path: ${archiveFilePath}) to ${tempDir}`)
return execFileAsync('unzip', [
'-d', tempDir,
archiveFilePath
])
.then(function () {
// Traverse the child components
return walkAsync(tempDir)
.then(function (childPaths) {
return Promise.mapSeries(childPaths, function (filePath) {
const relativePath = path.relative(tempDir, filePath)
const humanReadableFilePath = path.join(humanReadableArchiveFilePath, relativePath)
return signChildComponentAsync(opts, args, filePath, humanReadableFilePath)
})
})
.then(function () {
// Recompress a temporary archive
debuglog(`Recompressing temp archive... ${tempArchive}`)
return execFileAsync('zip', [
'-r',
tempArchive,
'.'
], {
cwd: tempDir
})
})
.then(function () {
// Replace the original file
debuglog(`Replacing... ${humanReadableArchiveFilePath} (real path: ${archiveFilePath}) with updated archive`)
return execFileAsync('mv', [
'-f',
tempArchive,
archiveFilePath
])
})
}, function () {
// Error from extracting files
debuglog(`Failed to extract files from ${humanReadableArchiveFilePath} (real path: ${archiveFilePath}). The file probably isn't an unarchive?`)
})
.then(function () {
// Final clean up
debuglog(`Removing temp directory... ${tempDir}`)
return execFileAsync('rm', [
'-rf',
tempDir
])
})
}

/**
* Sign a child component of the app bundle.
* @function
* @param {Object} opts - Options.
* @param {string[]} args - Command arguments for codesign excluding the file path.
* @param {string} filePath - The file to codesign that must exist. It may be outside of the app bundle.
* @param {string} humanReadableFilePath - A file path which may not exist but helps the user understand where it's located in the app bundle. This could be a fake path to an image that's inside an archive in the app bundle, but needs uncompressing the archive first before reaching it.
* @returns {Promise} Promise.
*/
function signChildComponentAsync (opts, args, filePath, humanReadableFilePath = undefined) {
if (humanReadableFilePath === undefined) humanReadableFilePath = filePath

if (ignoreFilePath(opts, humanReadableFilePath)) {
debuglog('Skipped... ' + humanReadableFilePath)
return Promise.resolve()
}

var promise
if (shouldConsiderTraversingArchive(opts, humanReadableFilePath)) {
// Sign the child components if the file is an archive
promise = isZipFileAsync(filePath)
.then(function (archive) {
if (archive) {
debuglog(`File ${humanReadableFilePath} (real path: ${filePath}) identified as a potential archive for traversal.`)
return signArchiveComponentsAsync(opts, args, filePath, humanReadableFilePath)
}
return Promise.resolve()
})
} else {
promise = Promise.resolve()
}

return promise
.then(function () {
debuglog('Signing... ' + humanReadableFilePath)
return execFileAsync('codesign', args.concat(filePath))
})
}

/**
* This function returns a promise codesigning only.
* @function
Expand All @@ -129,18 +278,6 @@ function verifySignApplicationAsync (opts) {
function signApplicationAsync (opts) {
return walkAsync(getAppContentsPath(opts))
.then(function (childPaths) {
function ignoreFilePath (opts, filePath) {
if (opts.ignore) {
return opts.ignore.some(function (ignore) {
if (typeof ignore === 'function') {
return ignore(filePath)
}
return filePath.match(ignore)
})
}
return false
}

if (opts.binaries) childPaths = childPaths.concat(opts.binaries)

var args = [
Expand Down Expand Up @@ -211,18 +348,11 @@ function signApplicationAsync (opts) {
if (opts.entitlements) {
// Sign with entitlements
promise = Promise.mapSeries(childPaths, function (filePath) {
if (ignoreFilePath(opts, filePath)) {
debuglog('Skipped... ' + filePath)
return
}
debuglog('Signing... ' + filePath)

let entitlementsFile = opts['entitlements-inherit']
if (filePath.includes('Library/LoginItems')) {
entitlementsFile = opts['entitlements-loginhelper']
}

return execFileAsync('codesign', args.concat('--entitlements', entitlementsFile, filePath))
return signChildComponentAsync(opts, args.concat('--entitlements', entitlementsFile), filePath)
})
.then(function () {
debuglog('Signing... ' + opts.app)
Expand All @@ -231,12 +361,7 @@ function signApplicationAsync (opts) {
} else {
// Otherwise normally
promise = Promise.mapSeries(childPaths, function (filePath) {
if (ignoreFilePath(opts, filePath)) {
debuglog('Skipped... ' + filePath)
return
}
debuglog('Signing... ' + filePath)
return execFileAsync('codesign', args.concat(filePath))
return signChildComponentAsync(opts, args, filePath)
})
.then(function () {
debuglog('Signing... ' + opts.app)
Expand Down
6 changes: 2 additions & 4 deletions util-entitlements.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,17 @@

'use strict'

const os = require('os')
const path = require('path')

const plist = require('plist')

const util = require('./util')
const debuglog = util.debuglog
const getAppContentsPath = util.getAppContentsPath
const getTempFilePath = util.getTempFilePath
const readFileAsync = util.readFileAsync
const writeFileAsync = util.writeFileAsync

let tmpFileCounter = 0

/**
* This function returns a promise completing the entitlements automation: The process includes checking in `Info.plist` for `ElectronTeamID` or setting parsed value from identity, and checking in entitlements file for `com.apple.security.application-groups` or inserting new into array. A temporary entitlements file may be created to replace the input for any changes introduced.
* @function
Expand Down Expand Up @@ -90,7 +88,7 @@ module.exports.preAutoEntitlements = function (opts) {
debuglog('`com.apple.security.application-groups` found in entitlements file: ' + appIdentifier)
}
// Create temporary entitlements file
const entitlementsPath = path.join(os.tmpdir(), `tmp-entitlements-${process.pid.toString(16)}-${(tmpFileCounter++).toString(16)}.plist`)
const entitlementsPath = getTempFilePath('entitlements.plist')
opts.entitlements = entitlementsPath
return writeFileAsync(entitlementsPath, plist.build(entitlements), 'utf8')
.then(function () {
Expand Down
2 changes: 1 addition & 1 deletion util-identities.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const execFileAsync = util.execFileAsync
/**
* @constructor
* @param {string} name - Name of the signing identity.
* @param {String} hash - SHA-1 hash of the identity.
* @param {string} hash - SHA-1 hash of the identity.
*/
var Identity = module.exports.Identity = function (name, hash) {
this.name = name
Expand Down
57 changes: 55 additions & 2 deletions util.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

const child = require('child_process')
const fs = require('fs')
const os = require('os')
const path = require('path')

const Promise = require('bluebird')
Expand All @@ -28,6 +29,57 @@ debugwarn.log = console.warn.bind(console)
/** @function */
const isBinaryFileAsync = module.exports.isBinaryFileAsync = Promise.promisify(require('isbinaryfile'))

/**
* Reads the beginning of a file and returns whether it contains the signature.
* @function
* @param {string} filePath - Path to a file.
* @param {Buffer} signature - Binary signature in a Buffer.
* @returns {Promise} Whether the file starts with the signature.
*/
function testFileSignatureAsync (filePath, signature) {
// Based on isbinaryfile's implementation

return new Promise(function (resolve, reject) {
fs.stat(filePath, function (err, stat) {
if (err) return reject(err)
if (!stat.isFile()) return resolve(false)

fs.open(filePath, 'r', function (err, descriptor) {
if (err) return reject(err)
const bytes = Buffer.alloc(signature.length)
fs.read(descriptor, bytes, 0, bytes.length, 0, function (err, size, bytes) {
if (err) return reject(err)
fs.close(descriptor, function (err) {
if (err) return reject(err)
// Match signature
resolve(size === signature.length && Buffer.compare(bytes, signature) === 0)
})
})
})
})
})
}

// Zip file signature taken from https://en.wikipedia.org/wiki/List_of_file_signatures
const zipFileSignature = Buffer.from([0x50, 0x4B, 0x03, 0x04])

/**
* Returns whether the file begins with a zip file signature.
* @function
* @param {string} filePath - Path to a file.
* @returns {Promise} Whether the file is a zip archive.
*/
module.exports.isZipFileAsync = function (filePath) {
return testFileSignatureAsync(filePath, zipFileSignature)
}

let tempFileCounter = 0

/** @function */
module.exports.getTempFilePath = function (fileName) {
return path.join(os.tmpdir(), `tmp-${process.pid.toString(16)}-${(tempFileCounter++).toString(16)}-${fileName}`)
}

/** @function */
const removePassword = function (input) {
return input.replace(/(-P |pass:|\/p|-pass )([^ ]+)/, function (match, p1, p2) {
Expand Down Expand Up @@ -67,6 +119,9 @@ module.exports.readFileAsync = Promise.promisify(fs.readFile)
/** @function */
module.exports.writeFileAsync = Promise.promisify(fs.writeFile)

/** @function */
const unlinkAsync = Promise.promisify(fs.unlink)

/**
* This function returns a flattened list of elements from an array of lists.
* @function
Expand Down Expand Up @@ -210,8 +265,6 @@ module.exports.validateOptsPlatformAsync = function (opts) {
module.exports.walkAsync = function (dirPath) {
debuglog('Walking... ' + dirPath)

var unlinkAsync = Promise.promisify(fs.unlink)

function _walkAsync (dirPath) {
return readdirAsync(dirPath)
.then(function (names) {
Expand Down