const fs = require('fs')
const tmp = require('tmp')
const path = require('path')
const mkdirp = require('mkdirp')
const Hoek = require('@hapi/hoek')
const {JSONPath} = require('jsonpath-plus')
const GpgParser = require('./gpg-parser')
const KeyServerClient = require('./key-server-client')
const debug = require('debug')('gpg-promised.KeyChain')
const DEFAULT_AGENT_CONFIG=`enable-ssh-support
default-cache-ttl 1800
max-cache-ttl 7200
`
const uniqueArray = (arr)=>{
return arr.filter((v, i, a) => {
if( v !== undefined && a.indexOf(v) === i){
return true
}
return false
})
}
const exec = require('./shell').exec
class KeyChain {
/**
* A GPG keychain
* @class
* @constructor
* @param {string} homedir Path to use as GPG homedir. Defaults to a tmp directory. See {@link https://github.com/raszi/node-tmp/blob/master/README.md|node-tmp} for more info on temp folder creation.
*/
constructor(homedir){
this.homedir = homedir
this.temp = null
}
static get GpgParser(){
return GpgParser
}
/**
* Open or create the GPG keychain
* @method
*/
async open(){
if(!this.homedir){
//use temp directory
this.temp = tmp.dirSync()
this.homedir = this.temp.name
debug('using temp directory -', this.temp.name)
}
if(!path.isAbsolute(this.homedir)){
throw new Error('path must be absolute')
}
if(!fs.existsSync(this.homedir)){
debug('creating dir -', this.homedir)
//create directory
mkdirp.sync(this.homedir, '0700' )
}
if(!fs.existsSync(this.homedir + '/gpg-agent.conf')){
debug('write default gpg-agent.conf')
fs.writeFileSync(this.homedir + '/gpg-agent.conf', DEFAULT_AGENT_CONFIG)
}
}
/**
* Call a GPG command
* @method
* @param {string} input STDIN input text
* @param {Array(string)} args Command line arguments
* @param {boolean} nonbatch Do not use the `--batch` flag
* @returns {ExecResult}
*/
async call(input, args, nonbatch=false){
const gpgArgs = ['--homedir', this.homedir, (nonbatch!=true) ? '--batch' : undefined ].concat(args)
debug('call -', gpgArgs)
const result = await exec('gpg '+gpgArgs.join(' '), undefined, input)
return result
}
/**
* Check if a secure card is inserted
* @method
* @returns {boolean}
*/
async hasCard(){
try{
const cardStatus = await this.cardStatus()
}
catch(err){
debug('hasCard - false - err -', err)
return false
}
return true
}
/**
* Is the inserted secure card set to owner trust
* @method
* @returns {boolean}
*/
async isCardTrusted(){
if(! (await this.hasCard()) ){
throw new Error('Insert card')
}
const cardStatus = await this.cardStatus()
const fingerprint = (cardStatus.fpr[0] || '').toLowerCase()
debug('cardStatus',cardStatus)
const secrets = await this.listSecretKeys(true)
debug(JSON.stringify( secrets, null, 2) )
const cardKey = KeyChain.getKeyBySubKeyId(secrets, fingerprint)
debug('cardKey', cardKey)
if(!cardKey){
return false
}
const match = KeyChain.isKeyFromCard(cardKey, cardStatus)
if(!match){
throw new Error('Card does not match secret key')
}
return true
}
/**
* Retrieve secure card metadata
* @method
* @returns {Object}
*/
async cardStatus(){
debug('cardStatus')
const command = ['--card-status', '--with-colons', '--with-fingerprint']
const response = await this.call('', command)
const list = response.stdout.toString()
debug(response.stderr.toString())
debug('\t'+list)
const status = GpgParser.parseReaderColons(list)
debug('card status', status)
return status
}
/**
* Trust the currently inserted secure card
* @method
*/
async trustCard(){
if(await this.isCardTrusted()){
debug('card already trusted')
return
}
const cardStatus = await this.cardStatus()
const subFingerprint = (cardStatus.fpr[0] || '').toLowerCase()
debug('trustCard card status', cardStatus)
debug('trustCard sub fpr', subFingerprint)
await this.recvKey(subFingerprint)
const publics = await this.listPublicKeys()
const cardKey = KeyChain.getKeyBySubKeyId(publics, subFingerprint, 'sub')
if(!cardKey){ throw new Error('Card key not found') }
debug('trustCard', cardKey)
const fingerprint = Hoek.reach(cardKey.fpr, '0.user_id')
if(!fingerprint){ throw new Error('Could not find key by subkey fingerprint', subFingerprint) }
debug('trustCard fpr', fingerprint)
await this.trustKey(fingerprint, '5')
}
/**
* Import the supplied key with owner trust
* @method
* @param {string} keyId Fingerprint/grip/email of desired key
* @param {string} level Trust level code (1 - 5)
*/
async trustKey(keyId, level){
debug('trust', keyId, level)
const trustText = (await this.call('', ['--export-ownertrust'])).stdout.toString()
const trust = '' + trustText + keyId+':' +(parseInt(level)+1)+ ':\n'
const command = ['--import-ownertrust' ]
const result = (await this.call(trust, command))
debug('updating trustdb')
debug('trustKey out', result.stdout.toString())
debug('trustKey err', result.stderr.toString())
}
/**
* Lookup keys. This uses the {@link KeyServerClient} rather than GPG to ensure we don't accidently modify the keychain
* @method
* @param {string} text Search text {@link HKPIndexSchema}
* @param {boolean} exact Exact matches only
* @param {string} [server=KeyServerClient.Addresses.ubuntu]
* @returns {string} Parsed csv-to-json search results
*/
async lookupKey(text, exact=false, keyserver=KeyServerClient.Addresses.ubuntu){
const hkpClient = new KeyServerClient(keyserver)
const result = await hkpClient.search(text)
if(result.length > 1 && result[0].type == 'info'){
return result[1]
}
return result
}
/**
* Recieve key specified by fingerprint
* @method
* @param {string} fingerprint Fingerpint/email/grip of key to recieve
* @param {string} [server=hkps://keyserver.ubuntu.com:443]
*/
async recvKey(fingerprint, server='hkps://keyserver.ubuntu.com:443'){
const command = ['--status-fd 2', '--keyserver', server, '--recv-keys', fingerprint]
const response = await this.call('', command)
const output = GpgParser.parseReaderColons(response.stdout.toString())
const status = GpgParser.parseStatusFd(response.stderr.toString())
debug('recvKey output', output)
debug('recvKey status', status)
}
/**
* Transmit
* @param {string} [server=hkps://keyserver.ubuntu.com:443]
* @param {string} fpr
*/
async sendKeys(fpr, server='hkps://keyserver.ubuntu.com:443'){
const command = ['--keyserver', server, '--send-keys']
if(fpr){
command.push(fpr)
}
await this.call('', command)
return
}
/**
* Refresh keyring public keys from specified server
* @param {string} [server=hkps://keyserver.ubuntu.com:443]
*/
async refreshKeys(server='hkps://keyserver.ubuntu.com:443'){
const command = ['--keyserver', server, '--refresh-keys']
await this.call('', command)
return
}
/**
* Sign a key
* @param {string} to
* @param {string} from
*/
async signKey(to, from){
debug('signKey -',to, '<', from)
const command = ['--command-fd 0', '--status-fd 2', '--edit-key', to]
if(from){
command.unshift('--local-user', from)
}
const result = await this.call('sign\n'+'y\nsave\nquit\n', command, false)
debug('signKey out', result.stdout.toString())
debug('signKey err', result.stderr.toString())
return
}
/**
* Create public/private key pair
* @method
* @param {Object} options
* @param {string} options.email
* @param {string} options.name
* @param {string} options.expire
* @param {string} options.passphrase
* @param {string} [options.keyType=RSA]
* @param {string} [options.keySize=4096]
* @param {string} [options.unattend=false]
*/
async generateKey({email, name, expire=0, passphrase, keyType='RSA', keySize=4096, unattend=false}){
const command = ['--generate-key']
//! https://www.gnupg.org/documentation/manuals/gnupg/Unattended-GPG-key-generation.html
let statements = 'Key-Type: ' + keyType + '\n' +
'Key-Length: ' + keySize + '\n' +
'Name-Real: ' + name + '\n' +
'Name-Email: ' + email + '\n' +
'Expire-Date: ' + expire + '\n'
if (passphrase && passphrase.length > 0 && !unattend) {
statements += 'Passphrase: ' + passphrase + '\n'
} else if (unattend && !passphrase) {
statements += '%no-protection' + '\n'
} else {
throw new Error('unsupported passphrase/unattend setting')
}
statements += '%commit' + '\n' + '%echo done' + '\n'
const result = (await this.call(statements, command)).stdout.toString()
debug('genkey', result)
}
/**
* Export ascii armor PGP public key
* @param {string} keyId
* @returns {string}
*/
async exportPublicKey(keyId){
const command = ['--armor', '--status-fd 2', '--export', keyId]
const result = await this.call('', command)
debug('exportKey stdout -', result.stdout.toString())
debug('exportKey stderr -', result.stderr.toString())
return result.stdout.toString()
}
/**
* Export ascii armor PGP secret key
* @param {string} keyId
* @returns {string}
*/
async exportSecretKey(keyId){
const command = ['--armor', '--status-fd 2', '--export-secret-keys', keyId]
const result = await this.call('', command)
debug('exportSecretKey stdout -', result.stdout.toString())
debug('exportSecretKey stderr -', result.stderr.toString())
return result.stdout.toString()
}
/**
* Import PGP key
* @param {string} key
* @returns {boolean}
*/
async importKey(key){
const command = ['--status-fd 2', '--import']
const result = await this.call(key, command)
debug('importKey stdout -', result.stdout.toString())
debug('importKey stderr -', result.stderr.toString())
const status = GpgParser.parseStatusFd(result.stderr.toString())
let imported = GpgParser.Status_GetImportedKeys(status)
debug('imported keys', imported)
return imported
}
/**
* Encrypt, sign, and armor input
* @method
* @param {string} input
* @param {Array(string)} to List of keyid, fpr or uid of message recipients
* @param {string} from Local keyid or uid to use in message signing
* @param {('pgp'|'classic'|'tofu'|'tofu+pgp'|'direct'|'always'|'auto')} [options.trust=pgp] Trust model See [`gpg --trust-model`](https://www.gnupg.org/documentation/manuals/gnupg/GPG-Configuration-Options.html#index-trust_002dmodel)
* @returns {string} ciphertext
*/
async encrypt(input, to, from, trust='pgp'){
const command = ['--encrypt', '--sign', '--armor', '--status-fd 2', '--trust-model', trust]
if(from){
command.push('--local-user')
command.push(from)
}
if(to && to.length>0){
to.map( id=>{
command.push('--recipient')
command.push(id)
})
}
const result = await this.call(input, command)
const stdout = result.stdout.toString()
const stderr = result.stderr.toString()
debug('enc output', stdout)
debug('enc status', stderr)
debug('enc status obj', GpgParser.parseStatusFd(stderr))
return stdout
}
/**
* Decrypt cipher text
* @method
* @param {string} input
* @param {Object} options
* @param {string[]} options.from List of keyid, fpr or uid(email) of allowed message signers. Defaults to allowing any trusted signer
* @param {('pgp'|'classic'|'tofu'|'tofu+pgp'|'direct'|'always'|'auto')} [options.trust=pgp] Trust model See [`gpg --trust-model`](https://www.gnupg.org/documentation/manuals/gnupg/GPG-Configuration-Options.html#index-trust_002dmodel)
* @param {Object} options.level Acceptable signer trust levels. Trust level of a specific signature is computed with respect to configured trust model
* @param {boolean} [options.level.none=false] Accept signers with no trust
* @param {boolean} [options.level.unknown=false] Accept signers with unknown/undefined trust
* @param {boolean} [options.level.never=false] Accept untrustowrthy signers, potentially with revoked or bad keys
* @param {boolean} [options.level.marginal=true] Accept signers with marginal trust
* @param {boolean} [options.level.full=true] Accept signers with full trust
* @param {boolean} [options.level.ultimate=true] Accept signers with ultimate trust
* @param {Object} options.allow Acceptable signature/signer expiry/revoke status
* @param {boolean} [options.allow.allow_expired_sig=false] Accept expired signatures
* @param {boolean} [options.allow.allow_expired_key=false] Accept expired signer key
* @param {boolean} [options.allow.allow_revoked_key=false] Accept revoked signer key
* @returns {Buffer}
*/
async decrypt(input, {
from=[], trust='pgp', level, allow
}={}){
const command = ['--decrypt','--status-fd 2', '--trust-model '+trust]
const result = await this.call(input, command)
const stdout = result.stdout
const stderr = result.stderr.toString()
const status = GpgParser.parseStatusFd(stderr)
debug('dec output', stdout)
debug('dec status', JSON.stringify(status,null,2))
const validFpr = JSONPath({
json: status,
path:'$..VALIDSIG.primary_key_fpr'
})[0]
debug(validFpr)
if(!Array.isArray(from) && from.length > 0){
from = [from]
}
else if(!from || (Array.isArray(from) && from.length < 1)){
from = [validFpr]
}
// query public keys using from list
// filter public key list to top level keyid
// merge original from and keyid list
// filter for uniqueness
const emails = from.filter((val)=>{ return val.indexOf('@') > -1 })
if(emails.length > 0){
const emailKeyList = await this.listPublicKeys(false, emails.join(' '))
debug(emailKeyList)
const emailFingerprintList = []
emailKeyList.map(key=>{
if(!Array.isArray(key.fpr)){
emailFingerprintList.push( Hoek.reach(key, 'fpr.user_id') )
}
else{
key.fpr.map(subKey=>{
emailFingerprintList.push( subKey.user_id )
})
}
})
from = uniqueArray(from.concat(emailFingerprintList))
}
debug('allowed from', from)
GpgParser.Status_AssertSignatureAllowed(status, from)
GpgParser.Status_AssertSignatureTrusted(status, level, allow)
return stdout
}
/**
* @method
* @param {string} input
* @param {string} sender
*/
async verify(input, sender){
throw new Error('not implemented')
//const command = ['--logger-fd', '1', '--verify']
const command = ['--list-packets']
const result = (await this.call(input, command, true)).stdout.toString()
debug('verify data', result)
return result
}
/**
* List of `uid.email` for every secret key with owner trust
* @returns {Array(string)}
*/
async whoami(){
const primary = await this.listSecretKeys(true)
const handles = primary.map(rec=>{
return Hoek.reach(rec, 'uid.email')
})
if(handles.length < 1 || !handles[0]){
debug('handles.length', handles.length)
throw new Error('no primary identity')
}
return handles
}
/**
* List of secret keys
* @param {boolean} ultimate Only list keys with owner trust
* @param {string} keyId Query text, accepts keyid, fingerprints or email addresses
* @returns {Array(Objects)} Parsed gpg output packets
*/
async listSecretKeys(ultimate=true, keyId){
const command = ['--list-secret-keys', '--with-colons', '--with-fingerprint', keyId]
const list = (await this.call('', command)).stdout.toString()
return GpgParser.parseColons(list).filter((record)=>{
return record.type == 'sec' && (!ultimate ? true : ( record.validity == 'u' ))
})
}
/**
* List of public keys
* @param {boolean} ultimate Only list keys with owner trust
* @param {string} keyId Query text, accepts keyid, fingerprints or email addresses
* @returns {Array(Objects)} Parsed gpg output packets
*/
async listPublicKeys(ultimate=false, keyId){
const command = ['--list-public-keys', '--with-colons', '--with-fingerprint', keyId]
const list = (await this.call('', command)).stdout.toString()
return GpgParser.parseColons(list).filter((record)=>{
return record.type == 'pub' && (!ultimate ? true : ( record.validity == 'u' ))
})
}
/**
* Encrypt/decrypt gpgtar files
* @param {Object} options
* @property {string} cwd
* @property {string} outputPath
* @property {string} to
* @property {string} sign
* @property {string} encrypt
* @property {string} decrypt
* @property {string} extractPath
* @property {string} inputPaths
* @Returns {ExecResult}
*/
async tar({cwd, outputPath, to, sign, encrypt, decrypt, extractPath, inputPaths}){
const command = ['gpgtar']
if(sign){ command.push('-s') }
if(encrypt){ command.push('-e') }
if(decrypt){
command.push('-d')
if(extractPath){ command.push(' --directory ' + extractPath) }
}
if(outputPath){ command.push(' --output ' + outputPath) }
if(to){
if(Array.isArray(to)){ command.push( to.map(t=>{return '-r '+t}).join(' ') ) }
else{ command.push('-r '+to) }
command.push( inputPaths.join(' ') )
}
const cmdStr = command.join(' ')
debug('gpgtar - [', cmdStr, ']')
return await exec(cmdStr, {
cwd,
env: {
GNUPGHOME: this.homedir
}
})
}
/**
* Takes a list of emails, keyid, fingerprints and converts the
* emails to fingerprints
* @method
* @param {string[]} list List of emails to resolve, keyid or fingerprints will be ignored
* @returns {string[]} Array of resolved fingerprints from the public keys on the key ring
*/
async resolveEmails(list){
const fingerprints = []
const emails = list.filter((val)=>{ return val.indexOf('@') > -1 })
if(emails.length > 0){
const emailKeyList = await this.listPublicKeys(false, emails.join(' '))
debug(emailKeyList)
const emailFingerprintList = []
emailKeyList.map(key=>{
if(!Array.isArray(key.fpr)){
emailFingerprintList.push( Hoek.reach(key, 'fpr.user_id') )
}
else{
key.fpr.map(subKey=>{
emailFingerprintList.push( subKey.user_id )
})
}
})
fingerprints = uniqueArray(fingerprints.concat(emailFingerprintList))
}
return fingerprints
}
/**
* Find a key based on id of a sub-key
* @param {Array(Object)} list List of parsed GPG output packets
* @param {string} sub_key_id Sub key id to search for
* @param {*} subField Subkey field (typically ssb or sub)
* @returns {Object} Parsed key from GPG output packets
*/
static getKeyBySubKeyId(list, sub_key_id, subField = 'ssb'){
debug('getKeyBySubKeyId', sub_key_id)
let result = null
list.map( key => {
let subKeys = key[subField]
if(!Array.isArray(subKeys)){
subKeys = [subKeys]
}
subKeys.map( subkey => {
const keyIdLen = subkey.keyid.length
const matchIdx = sub_key_id.toLowerCase().indexOf(subkey.keyid.toLowerCase())
const matchLen = (matchIdx > -1) ? sub_key_id.length - matchIdx : 0
if( keyIdLen == matchLen ){
result = key
}
})
} )
return result
}
/**
* Find a key by a field value
* @param {Array(Object)} list List of parsed GPG output packets
* @param {string} field Name/path to field
* @param {string} value
* @returns {Object} Parsed key from GPG output packets
*/
static getKeyByField(list, field, value){
let result = []
list.map( key => {
const fieldVal = Hoek.reach(key, field)
if(fieldVal == value){
result.push(key)
}
})
return result
}
/**
* Check if the specified secure card matches the supplied key
* @param {Object} key A parsed key with ssb field
* @param {Object} cardInfo Card info from {@link KeyChain.cardStatus}
*/
static isKeyFromCard(key, cardInfo){
debug('isKeyFromCard', key, cardInfo)
let snMatch = false
const cardSN = cardInfo.Reader[ cardInfo.Reader.length - 3 ]
debug('cardSN', cardSN)
key.ssb.map( subkey =>{
const sn = subkey.token_sn
if(sn == cardSN){
snMatch = true
}
})
return snMatch
}
/**
* Find a subkey id with specific capabilities
* @param {Object} key
* @param {string} cap Capabilities (a, c, e, d)
* @param {string} subField Field name/path
* @returns {Array(string)} List of subkey ids
*/
static getSubKeyIdByCapability(key, cap, subField='ssb'){
debug('getSubKeyIdByCapability', key, cap, subField)
const ids = []
const subKeys = key[subField]
subKeys.map( subkey => {
if(subkey.key_cap == cap){
ids.push(subkey.keyid)
}
})
return ids
}
}
module.exports = KeyChain