scoped-gpg
Version:
GPG wrapper with a user-friendly keyring support
287 lines (239 loc) • 8.08 kB
JavaScript
;
const fs = require('fs');
const os = require('os');
const path = require('path');
const readline = require('readline');
const spawn = require('child_process').spawn;
const async = require('async');
const reopenTTY = require('reopen-tty');
const shim = require('./shim');
const GPG = process.env.GPG || 'gpg';
function ScopedGPG(options) {
this.options = options || {};
if (!this.options.keyring)
throw new Error(`keyring is a required option`);
this.keyring = this.options.keyring;
}
module.exports = ScopedGPG;
// For CLI
ScopedGPG.GPG = GPG;
function buffer(stream) {
let chunks = '';
let done = false;
stream.on('data', chunk => chunks += chunk);
stream.on('end', () => done = true);
return (callback) => {
if (done)
return callback(null, chunks);
else
stream.once('end', () => callback(null, chunks));
};
}
function bufferFile(p, file) {
return (callback) => {
async.waterfall([
callback => p.on('exit', () => callback(null)),
callback => fs.exists(file, exists => callback(null, exists)),
(exists, callback) => {
if (!exists)
return callback(null, '');
fs.readFile(file, (err, content) => {
if (err)
return callback(err);
callback(null, content.toString());
});
}
], callback);
};
}
function gpg(args, stdio, keyring, callback) {
// GPG thinks that if the path has `:` - it is an URL
if (process.platform === 'win32') {
keyring = path.relative(process.cwd(), keyring);
// GPG does not understand `\` either
keyring = keyring.replace(/\\/g, '/');
}
const pre = [ '--no-default-keyring', '--keyring', keyring, '--status-fd=3' ];
// `--status-fd=3` does not work to well on windows
let statusFile;
if (process.platform === 'win32') {
statusFile = path.join(
shim.mkdtempSync(path.join(os.tmpdir(), 'scoped-gpg-')),
'gpg-status');
pre[3] = `--status-file=${statusFile.replace(/\\/g, '/')}`;
}
const p = spawn(GPG, pre.concat(args), { stdio: stdio });
const stdout = p.stdout ? buffer(p.stdout) : (cb) => cb(null, null);
const stderr = p.stderr ? buffer(p.stderr) : (cb) => cb(null, null);
const status = process.platform === 'win32' ?
bufferFile(p, statusFile) :
p.stdio[3] ? buffer(p.stdio[3]) : (cb) => cb(null, null);
async.parallel({
code: (callback) => {
p.on('exit', (status) => callback(null, status));
},
stdout: stdout,
stderr: stderr,
status: status
}, callback);
return p.stdin;
};
function TTYPair(input, output) {
this.stdin = input;
this.stderr = output;
}
TTYPair.prototype.destroy = function destroy() {
if (this.stdin === process.stdin)
return;
this.stdin.destroy();
this.stderr.destroy();
};
function getTTY(callback) {
if (process.stdin.isTTY)
return callback(null, new TTYPair(process.stdin, process.stderr));
async.parallel({
stdin: callback => reopenTTY.stdin(callback),
stderr: callback => reopenTTY.stderr(callback)
}, (err, res) => {
if (err)
return callback(err);
callback(null, new TTYPair(res.stdin, res.stderr));
});
}
const IMPORT_RE =
/(?:^|\r\n|\n)\[GNUPG:\] (IMPORT_OK|IMPORT_PROBLEM) (.*)(?:$|\r\n|\n)/;
ScopedGPG.prototype.promptPubkey = function promptPubkey(id, callback) {
async.waterfall([
// Create temporary keyring
callback => shim.mkdtemp(path.join(os.tmpdir(), 'scoped-gpg-'), callback),
(tmp, callback) => {
tmp = path.join(tmp, 'keyring');
callback(null, tmp);
},
(keyring, callback) => {
const stdio = [ null, 'pipe', 'pipe', 'pipe' ];
const args = [ '--recv-keys', id ];
if (this.options.keyserver)
args.unshift('--keyserver', this.options.keyserver);
const stdin = gpg(args, stdio, keyring, callback);
},
(results, callback) => {
getTTY((err, tty) => callback(err, results, tty));
},
(results, tty, callback) => {
const pubkey = results.status.match(IMPORT_RE);
if (pubkey === null || pubkey[1] === 'IMPORT_PROBLEM') {
callback(new Error(`gpg failed: ${results.stdout}\n${results.stderr}`));
return;
}
let msg = (results.stdout + results.stderr).split(/\r\n|\n/g);
msg = msg.filter((line) => {
return !/^gpg: keyring.*created$/.test(line) && line;
});
msg.push(`gpg exit code ${results.code}`);
// Mostly for testing
if (this.options.confirm) {
tty.destroy();
return this.options.confirm(pubkey[2].split(' ')[1], (err, result) => {
callback(err, result);
});
}
tty.stderr.write(msg.join('\n') + '\n');
const challenge = id.slice(0, 6);
const rl = readline.createInterface({
input: tty.stdin,
output: tty.stderr
});
function done(err, result) {
rl.once('close', () => tty.destroy());
rl.close();
callback(err, result);
}
rl.setPrompt(`Enter key id (${challenge}) to add this ` +
`key to "${this.keyring}": `);
rl.prompt();
rl.on('line', (line) => {
done(null, line.toLowerCase() === challenge.toLowerCase(), tty);
});
rl.on('error', (err) => done(err));
rl.on('SIGINT', (err) => done(new Error('canceled')));
},
(confirm, callback) => {
if (!confirm)
return callback(new Error('gpg key import canceled'));
callback(null);
},
(callback) => {
const stdio = [ null, 'pipe', 'pipe', 'pipe' ];
const args = [ '--recv-keys', id ];
if (this.options.keyserver)
args.unshift('--keyserver', this.options.keyserver);
const stdin = gpg(args, stdio, this.keyring, callback);
},
(results, callback) => {
const pubkey = results.status.match(IMPORT_RE);
if (pubkey === null || pubkey[1] === 'IMPORT_PROBLEM')
callback(new Error(`gpg failed: ${results.stdout}\n${results.stderr}`));
else
callback(null);
}
], callback);
};
const PUBKEY_RE = /(?:^|\r\n|\n)\[GNUPG:\] NO_PUBKEY (.*)(?:$|\r\n|\n)/;
ScopedGPG.prototype.verify = function verify(payload, signature, options,
callback) {
if (typeof options === 'function') {
callback = options;
options = {};
}
async.waterfall([
callback => shim.mkdtemp(path.join(os.tmpdir(), 'scoped-gpg-'), callback),
(tmp, callback) => {
tmp = path.join(tmp, 'sig');
fs.writeFile(tmp, signature, err => callback(err, tmp));
},
(signature, callback) => {
const stdio = [ 'pipe', 'pipe', 'pipe', 'pipe' ];
const args = [ '--verify', signature, '-' ];
const stdin = gpg(args, stdio, this.keyring, callback);
stdin.end(payload);
},
(results, callback) => {
const pubkey = results.status.match(PUBKEY_RE);
if (pubkey !== null) {
this.promptPubkey(pubkey[1], (err) => {
if (err)
return callback(err);
// Restart
this.verify(payload, signature, options, callback);
});
return;
}
if (options.verbose) {
process.stdout.write(results.stdout);
process.stderr.write(results.stderr);
// git adds `--status-fd=1`
if (options['status-fd'] === 1)
process.stdout.write(results.status);
if (options['status-fd'] === 2)
process.stderr.write(results.status);
}
if (results.code !== 0)
callback(new Error(`gpg failed: ${results.stdout}\n${results.stderr}`));
else
callback(null, true);
}
], callback);
};
ScopedGPG.prototype.listKeys = function listKeys(callback) {
const stdio = [ null, 'pipe', 'pipe', 'pipe' ];
const args = [ '--list-keys' ];
gpg(args, stdio, this.keyring, (err, results) => {
if (err)
return callback(err);
if (results.code !== 0)
callback(new Error(`gpg failed: ${results.stdout}\n${results.stderr}`));
else
callback(null, results.stdout);
});
};