const Hoek = require('@hapi/hoek')
const HoekTransform = require('transform-hoek').transform
const {JSONPath} = require('jsonpath-plus')
const Address = require('email-addresses')
const debug = require('debug')('gpg-parser')
const GpgColons = require('./gpg-colons')
const STATUS_TRANSFORMS = require('./gpg-status-parsers')
const uniqueArray = (arr)=>{
return arr.filter((v, i, a) => {
if( v !== undefined && a.indexOf(v) === i){
return true
}
return false
})
}
/**
* Parsers for common GPG inpuut/output formats
* @class
*/
class GpgParser {
static get DefaultSchema(){
return GpgColons.DefaultSchema
}
static mergeRecord(record, obj){
const field = record.type
if(obj[field] === undefined){
obj[field] = record
}
else if(!Array.isArray( obj[field] )){
obj[field] = [ obj[field], record ]
}
else {
obj[field].push(record)
}
}
static groupRecords(records, schema){
const output = []
let depth = []
for(let idx = 0; idx<records.length; idx++){
const record = records[idx]
//debug('group', Object.keys(record))
if(record == {}){
debug('group', 'blank')
}
if(!record.type){ continue }
//debug('group', record)
const recDepth = Hoek.reach(schema.types, record.type+'.depth', {default: 0})
//debug(recDepth, record.type)
let currentNode = null
switch(recDepth){
case 0:
//! assert depth.length > 0
debug('group case 0')
currentNode = depth[ depth.length-1 ]
//debug('current node', currentNode)
GpgParser.mergeRecord(record, currentNode)
break
case 1:
currentNode = record
depth = [ currentNode ]
output.push(currentNode)
break
case 2:
//debug('depth', depth.length)
currentNode = record
depth = [depth[0], currentNode]
//debug( depth[0])
//debug(record)
//debug('group case 2')
GpgParser.mergeRecord(currentNode, depth[0])
break
default:
throw new Error('gpg record parse error')
}
}
return output
}
/**
* Parses colon-delimited CSV into json and merge related lines into combined objects
* Useful for decoding content produced by commands using `gpg --with-colons ...`.
* See [*gnupg/DETAILS#format-of-the-colon-listings*](https://github.com/CSNW/gnupg/blob/master/doc/DETAILS#format-of-the-colon-listings) for detailed format specification
* @method
* @param {string} input
* @param {Object} [schema=GpgParser.DefaultSchema] Row header schema
*/
static parseColons(input, schema=GpgParser.DefaultSchema){
//debug(schema)
const lines = input.split('\n')
const rows = lines.map(line=>{
const row = line.split(':')
const obj = {
//text: line,
//fields: row
}
const type = row[0]
//debug('\t',type)
row.map( (val, idx)=>{
const fieldMap = Hoek.reach(schema, 'types.'+type+'.fields.'+(idx+1) )
const col = fieldMap
if(col && val.length > 0){
obj[col] = val
}
})
if(obj.type == 'uid'){
const email = Address.parseOneAddress(obj.user_id)
if(email){
obj.name = email.name
obj.email = email.address
obj.domain = email.domain
obj.username = email.local
}
}
return obj
})
return GpgParser.groupRecords(rows, schema)
}
/**
* Parse content produced by `gpg --with-colons ...` commands.
* See [*gnupg/DETAILS#format-of-the-colon-listings*](https://github.com/CSNW/gnupg/blob/master/doc/DETAILS#format-of-the-colon-listings) for detailed format specification
* @method
* @param {string} input
*/
static parseReaderColons(input){
const output = {}
const lines = input.split('\n')
const rows = lines.map(line=>{
const row = line.split(':')
output[row[0]] = row.slice(1)
/*return {
name: row[0],
values: row.slice(1)
}*/
})
return output
}
/**
* GPGStatus with at most one status field
* @typedef {Object} GPGStatus
* @property {Object} NEWSIG
* @property {Object} GOODSIG
* @property {Object} EXPSIG
* @property {Object} EXPKEYSIG
* @property {Object} REVKEYSIG
* @property {Object} BADSIG
* @property {Object} ERRSIG
* @property {Object} VALIDSIG
* @property {Object} SIG_ID
* @property {Object} ENC_TO
* @property {Object} BEGIN_DECRYPTION
* @property {Object} END_DECRYPTION
* @property {Object} DECRYPTION_KEY
* @property {Object} DECRYPTION_INFO
* @property {Object} DECRYPTION_FAILED
* @property {Object} DECRYPTION_OKAY
* @property {Object} SESSION_KEY
* @property {Object} BEGIN_ENCRYPTION
* @property {Object} END_ENCRYPTION
* @property {Object} BEGIN_SIGNING
* @property {Object} ALREADY_SIGNED
* @property {Object} SIG_CREATED
* @property {Object} PLAINTEXT
* @property {Object} PLAINTEXT_LENGTH
* @property {Object} ENCRYPTION_COMPLIANCE_MODE
* @property {Object} DECRYPTION_COMPLIANCE_MODE
* @property {Object} VERIFICATION_COMPLIANCE_MODE
* @property {Object} KEY_CONSIDERED
* @property {Object} KEYEXPIRED
* @property {Object} KEYREVOKED
* @property {Object} NO_PUBKEY
* @property {Object} NO_SECKEY
* @property {Object} KEY_CREATED
* @property {Object} KEY_NOT_CREATED
* @property {Object} TRUST_UNDEFINED
* @property {Object} TRUST_NEVER
* @property {Object} TRUST_MARGINAL
* @property {Object} TRUST_FULLY
* @property {Object} TRUST_ULTIMATE
* @property {Object} GOODMDC
* @property {Object} FAILURE
* @property {Object} SUCCESS
* @property {Object} WARNING
* @property {Object} ERROR
* @property {Object} CARDCTRL
* @property {Object} SC_OP_FAILURE
* @property {Object} SC_OP_SUCCESS
* @property {Object} INV_RECP
* @property {Object} INV_SGNR
* @property {Object} IMPORTED
* @property {Object} IMPORT_OK
* @property {Object} IMPORT_PROBLEM
* @property {Object} IMPORT_RES
* @property {Object} EXPORTED
* @property {Object} EXPORT_RES
*/
/**
* GPG Status stream parsed
* @typedef {Array.<GPGStatus>} GPGStatusArray
*/
/**
* Parses space-delimited CSV into json objects
* Useful for decoding content produced by commands using `gpg --status-fd N ...`.
* See [*gnupg/DETAILS#format-of-the-status-fd-output*](https://github.com/CSNW/gnupg/blob/master/doc/DETAILS#format-of-the-status-fd-output) for detailed format specification
* @method
* @param {string} input Input string
* @returns {GPGStatusArray}
*/
static parseStatusFd(input){
const lines = input.split('\n')
const statusLines = lines.filter(line=>{
return line.startsWith('[GNUPG:]')
})
const status = statusLines.map(statusLine=>{
const [prefix, keyword, ...args] = statusLine.split(' ')
const input = { keyword, args }
const obj = {}
const transform = STATUS_TRANSFORMS[keyword]
if(transform instanceof Function){
debug('using parser', keyword)
obj[keyword] = transform(input)
}
else if(transform){
debug('using transform', keyword)
obj[keyword] = HoekTransform(input, transform)
}
else{
obj.input = input
}
return obj
})
return status
}
/**
* @method
* @param {GPGStatusArray} status
* @param {string} keyword
* @returns {boolean}
*/
static Status_HasKeyword = (status, keyword)=>{
const exists = (JSONPath({
path: `$..${keyword}`,
json: status
}) || [])[0]
if(exists){ return true }
return false
}
/**
* @method
* @param {GPGStatusArray} status
*/
static Status_GetImportedKeys = (status)=>{
return uniqueArray(
JSONPath({
path: '$..IMPORT_OK.fingerprint',
json: status
}) || []
)
}
/**
* @method
* @param {GPGStatusArray} status
* @returns {string} Primary fingerprint
*/
static Status_GetSigPrimaryFpr = (status)=>{
const sigFpr = (JSONPath({
path: '$..VALIDSIG.primary_key_fpr',
json: status
}) || [])
return sigFpr[0]
}
/**
* @method
* @param {GPGStatusArray} status
* @param {string[]} allowed List of allowed uid, fpr or keyid
*/
static Status_AssertSignatureAllowed(status, allowed=[]){
if(!GpgParser.Status_IsSignerAllowed(status,allowed)){
throw new Error('Signer not allowed')
}
}
/**
* Decrypt cipher text
* @method
* @param {GPGStatusArray} status
* @param {Object} options
* @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 Accept signers with no trust
* @param {boolean} options.level.unknown Accept signers with unknown/undefined trust
* @param {boolean} options.level.never Accept untrustowrthy signers, potentially with revoked or bad keys
* @param {boolean} options.level.marginal Accept signers with marginal trust
* @param {boolean} options.level.full Accept signers with full trust
* @param {boolean} options.level.ultimate Accept signers with ultimate trust
* @param {Object} options.allow Acceptable signature/signer expiry/revoke status
* @param {boolean} options.allow.allow_expired_sig Accept expired signatures
* @param {boolean} options.allow.allow_expired_key Accept expired signer key
* @param {boolean} options.allow.allow_revoked_key Accept revoked signer key
*/
static Status_AssertSignatureTrusted(status, {
none=false,
unknown=false,
never= false,
marginal=true,
full=true,
ultimate=true
}={}, {
allow_expired_sig=false,
allow_expired_key=false,
allow_revoked_key=false
}={}
){
// Check signature/signer status messages
let goodness = GpgParser.Status_IsSigGood(status)
if(allow_expired_sig==true){
goodness |= GpgParser.Status_IsSigExpired(status)
}
if(allow_expired_key==true){
goodness |= GpgParser.Status_IsSigKeyExpired(status)
}
if(allow_revoked_key==true){
goodness |= GpgParser.Status_IsSigKeyRevoked(status)
}
// Check signer trustyness
let trustyness = false
if(unknown==true){
trustyness |= GpgParser.Status_IsSigTrustUnknown(status)
}
if(marginal==true){
trustyness |= GpgParser.Status_IsSigTrustMarginal(status)
}
if(full==true){
trustyness |= GpgParser.Status_IsSigTrustFully(status)
}
if(ultimate==true){
trustyness |= GpgParser.Status_IsSigTrustUltimate(status)
}
if(never==true){
debug('WARNING - TRUST_NEVER allowed in signature verification')
console.log('WARNING - TRUST_NEVER allowed in signature verification')
trustyness |= GpgParser.Status_IsSigTrustNever(status)
}
else{
trustyness &= !GpgParser.Status_IsSigTrustNever(status)
}
const goodReason = GpgParser.Status_GetSigResult(status)
const trustReason = GpgParser.Status_GetSigTrustResult(status)
if(trustReason === undefined && none === true){
debug('WARNING - TRUST_NONE allowed in signature verification')
console.log('WARNING - TRUST_NONE allowed in signature verification')
trustyness = true
}
debug('AssertSignatureTrusted - validating signature [',goodReason, trustReason,']')
if(!goodness){
throw new Error('Signature rejected - '+goodReason)
}
if(!trustyness){
throw new Error('Signature not trusted - '+trustReason)
}
debug('AssertSignatureTrusted - signature appears good and trusted [',goodReason, trustReason,']')
}
/**
* @method
* @param {GPGStatusArray} status
* @returns {string}
*/
static Status_GetSigResult = (status)=>{
if(GpgParser.Status_IsSigGood(status)){ return 'GOODSIG' }
if(GpgParser.Status_IsSigBad(status)){ return 'BADSIG' }
if(GpgParser.Status_IsSigError(status)){ return 'ERRSIG' }
if(GpgParser.Status_IsSigExpired(status)){ return 'EXPSIG' }
if(GpgParser.Status_IsSigKeyExpired(status)){ return 'EXPKEYSIG' }
if(GpgParser.Status_IsSigKeyRevoked(status)){ return 'REVKEYSIG' }
return undefined
}
/**
* @method
* @param {GPGStatusArray} status
* @returns {string}
*/
static Status_GetSigTrustResult = (status)=>{
if(GpgParser.Status_IsSigTrustUnknown(status)){ return 'TRUST_UNDEFINED' }
if(GpgParser.Status_IsSigTrustNever(status)){ return 'TRUST_NEVER' }
if(GpgParser.Status_IsSigTrustMarginal(status)){ return 'TRUST_MARGINAL' }
if(GpgParser.Status_IsSigTrustFully(status)){ return 'TRUST_FULLY' }
if(GpgParser.Status_IsSigTrustUltimate(status)){ return 'TRUST_ULTIMATE' }
return undefined
}
/**
* @method
* @param {GPGStatusArray} status
* @returns {boolean}
*/
static Status_IsSigGood = (status)=>{
return GpgParser.Status_HasKeyword(status, 'GOODSIG')
}
/**
* @method
* @param {GPGStatusArray} status
* @returns {boolean}
*/
static Status_IsSigBad = (status)=>{
return GpgParser.Status_HasKeyword(status, 'BADSIG')
}
/**
* @method
* @param {GPGStatusArray} status
* @returns {boolean}
*/
static Status_IsSigError = (status)=>{
return GpgParser.Status_HasKeyword(status, 'ERRSIG')
}
/**
* @method
* @param {GPGStatusArray} status
* @returns {boolean}
*/
static Status_IsSigExpired = (status)=>{
return GpgParser.Status_HasKeyword(status, 'EXPSIG')
}
/**
* @method
* @param {GPGStatusArray} status
* @returns {boolean}
*/
static Status_IsSigKeyExpired = (status)=>{
return GpgParser.Status_HasKeyword(status, 'EXPKEYSIG')
}
/**
* @method
* @param {GPGStatusArray} status
* @returns {boolean}
*/
static Status_IsSigKeyRevoked = (status)=>{
return GpgParser.Status_HasKeyword(status, 'REVKEYSIG')
}
/**
* @method
* @param {GPGStatusArray} status
* @returns {boolean}
*/
static Status_IsSigTrustUnknown = (status)=>{
return GpgParser.Status_HasKeyword(status, 'TRUST_UNDEFINED')
}
/**
* @method
* @param {GPGStatusArray} status
* @returns {boolean}
*/
static Status_IsSigTrustNever = (status)=>{
return GpgParser.Status_HasKeyword(status, 'TRUST_NEVER')
}
/**
* @method
* @param {GPGStatusArray} status
* @returns {boolean}
*/
static Status_IsSigTrustMarginal = (status)=>{
return GpgParser.Status_HasKeyword(status, 'TRUST_MARGINAL')
}
/**
* @method
* @param {GPGStatusArray} status
* @returns {boolean}
*/
static Status_IsSigTrustFully = (status)=>{
return GpgParser.Status_HasKeyword(status, 'TRUST_FULLY')
}
/**
* @method
* @param {GPGStatusArray} status
* @returns {boolean}
*/
static Status_IsSigTrustUltimate = (status)=>{
return GpgParser.Status_HasKeyword(status, 'TRUST_ULTIMATE')
}
/**
* @method
* @param {GPGStatusArray} status
* @param {string[]} allowed List of allowed uid, fpr or keyid
* @returns {boolean}
*/
static Status_IsSignerAllowed = (status, allowed)=>{
const fpr = GpgParser.Status_GetSigPrimaryFpr(status)
debug('IsSignerAllowed', allowed, fpr)
if(fpr.length > 0 && Array.isArray(allowed) && allowed.length > 0 && allowed.indexOf(fpr) > -1){
debug('\tallowed')
return true
}
debug('\tnot allowed')
return false
}
}
module.exports = GpgParser