@softvisio/core
Version:
Softisio core
682 lines (544 loc) • 22.3 kB
JavaScript
import "#lib/result";
import childProcess from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import File from "#lib/file";
import { exists } from "#lib/fs";
import stream from "#lib/stream";
import { TmpDir, TmpFile } from "#lib/tmp";
const GPG_BINARY = "gpg" + ( process.platform === "win32"
? ".exe"
: "" ),
KEY_VALIDITY = {
"o": "unknown",
"i": "invalid",
"d": "disabled",
"D": "disabled",
"r": "revoked",
"e": "expired",
"-": "",
"q": "",
"n": "not valid",
"m": "marginal valid",
"f": "fully valid",
"u": "ultimately valid",
"w": "well known private part",
"s": "special validity",
// XXX in "sig" records, this field may have one of these values as first character:
// "!": "signature is good",
// "-": "signature is bad",
// "?": "no public key to verify signature or public key is not usable",
// "%": "other error verifying a signature",
},
KEY_IS_VALID = new Set( [ "m", "f", "u", "w", "s" ] ),
KEY_CAPABILITIES = {
"a": "authentication",
"c": "certify",
"e": "encrypt",
"s": "sign",
"r": "restricted encryption",
"t": "timestamping",
"g": "group key",
"?": "unknown",
"d": "disabled key",
};
var GPG_PRESET_PASSPHRASE_BINARY;
export default class Gpg {
#passwords;
#home;
#privateKeysCache;
constructor ( { passwords, home } = {} ) {
this.#passwords = passwords;
if ( home ) {
if ( home === true ) {
this.#home = new TmpDir();
}
else {
this.#home = home;
}
}
}
// public
async getPrivateKeys ( { cacheKeys = true } = {} ) {
if ( cacheKeys && this.#privateKeysCache ) return result( 200, this.#privateKeysCache );
return new Promise( resolve => {
childProcess.execFile(
GPG_BINARY,
[ "--list-secret-keys", "--with-colons", "--with-keygrip", "--with-fingerprint" ],
{
"env": this.#createEnv(),
"windowsHide": true,
},
( error, stdout, stdoerr ) => {
if ( error ) {
resolve( result.catch( error, { "log": false } ) );
}
else {
try {
stdout = stdout.replace( /^.*?sec/ms, "sec" );
const data = [],
lines = stdout.split( "\n" );
let key, subkey;
for ( let line of lines ) {
line = line.trim();
if ( !line ) continue;
const fields = this.#parseColumns( line );
// sec
if ( fields.type === "sec" ) {
key = {
"id": fields.keyId,
"curveName": fields.curveName,
"validity": {
"type": fields.validity,
"text": KEY_VALIDITY[ fields.validity ],
"isValid": KEY_IS_VALID.has( fields.validity ),
},
"creationDate": fields.creationDate,
"expirationDate": fields.expirationDate,
"fingerprint": null,
"grip": null,
"capabilities": Object.fromEntries( fields.keyCapabilities.split( "" ).map( capability => [ capability, KEY_CAPABILITIES[ capability.toLowerCase() ] ] ) ),
"uids": {},
"subkeys": [],
};
data.push( key );
subkey = null;
}
// ssb
else if ( fields.type === "ssb" ) {
subkey = {
"id": fields.keyId,
"curveName": fields.curveName,
"validity": {
"type": fields.validity,
"text": KEY_VALIDITY[ fields.validity ],
"isValid": KEY_IS_VALID.has( fields.validity ),
},
"creationDate": fields.creationDate,
"expirationDate": fields.expirationDate,
"fingerprint": null,
"grip": null,
"capabilities": Object.fromEntries( fields.keyCapabilities.split( "" ).map( capability => [ capability, KEY_CAPABILITIES[ capability ] ] ) ),
};
key.subkeys.push( subkey );
}
// uid
else if ( fields.type === "uid" ) {
const uid = fields.userId;
let name, email;
const match = uid.match( /^(?<name>.+) <(?<email>.+)>$/ );
if ( match ) {
name = match.groups.name;
email = match.groups.email;
}
else {
name = null;
email = uid;
}
key.uids[ email ] = {
uid,
email,
name,
"validity": {
"type": fields.validity,
"text": KEY_VALIDITY[ fields.validity ],
"isValid": KEY_IS_VALID.has( fields.validity ),
},
};
}
// fingerprint
else if ( fields.type === "fpr" ) {
const fingerprint = fields.userId;
if ( subkey ) {
subkey.fingerprint = fingerprint;
}
else {
key.fingerprint = fingerprint;
}
}
// grip
else if ( fields.type === "grp" ) {
const grip = fields.userId;
if ( subkey ) {
subkey.grip = grip;
}
else {
key.grip = grip;
}
}
// invalid
else {
throw "Key parsing error";
}
}
this.#privateKeysCache = data;
resolve( result( 200, data ) );
}
catch ( e ) {
resolve( result.catch( e, { "log": false } ) );
}
}
}
);
} );
}
async presetPasswords ( passwords, { subkeys, cacheKeys } = {} ) {
return this.#presetPasswords( passwords, { subkeys, cacheKeys } );
}
async encrypt ( data, recipients, { cwd, armor, compressLevel, output, sign, passwords, cacheKeys } = {} ) {
const args = [];
if ( armor ) args.push( "--armor" );
if ( compressLevel != null ) args.push( "-z", compressLevel || 0 );
// sign
if ( sign ) {
args.push( "--sign" );
if ( !Array.isArray( sign ) ) sign = [ sign ];
for ( const user of sign ) {
args.push( "--local-user", user );
}
// preset passwords
const res = await this.#presetPasswords( passwords, { cacheKeys } );
if ( !res.ok ) return res;
}
// encrypt
args.push( "--encrypt" );
if ( !Array.isArray( recipients ) ) recipients = [ recipients ];
for ( const user of recipients ) {
args.push( "--recipient", user );
}
return this.#spawnGpg( args, {
cwd,
"input": data,
"output": output || true,
} );
}
async sign ( data, users, { cwd, armor, compressLevel, output, encrypt, passwords, cacheKeys } = {} ) {
const args = [];
if ( armor ) args.push( "--armor" );
if ( compressLevel != null ) args.push( "-z", compressLevel || 0 );
// sign
args.push( "--sign" );
if ( !Array.isArray( users ) ) users = [ users ];
for ( const user of users ) {
args.push( "--local-user", user );
}
// preset passwords
const res = await this.#presetPasswords( passwords, { cacheKeys } );
if ( !res.ok ) return res;
// encrypt
if ( encrypt ) {
args.push( "--encrypt" );
if ( !Array.isArray( encrypt ) ) encrypt = [ encrypt ];
for ( const user of encrypt ) {
args.push( "--recipient", user );
}
}
return this.#spawnGpg( args, {
cwd,
"input": data,
"output": output || true,
} );
}
async detachSign ( data, users, { cwd, armor, compressLevel, output, passwords, cacheKeys } = {} ) {
const args = [];
if ( armor ) args.push( "--armor" );
if ( compressLevel != null ) args.push( "-z", compressLevel || 0 );
// sign
args.push( "--detach-sign" );
if ( !Array.isArray( users ) ) users = [ users ];
for ( const user of users ) {
args.push( "--local-user", user );
}
// preset passwords
const res = await this.#presetPasswords( passwords, { cacheKeys } );
if ( !res.ok ) return res;
return this.#spawnGpg( args, {
cwd,
"input": data,
"output": output || true,
} );
}
async clearSign ( data, users, { cwd, compressLevel, output, passwords, cacheKeys } = {} ) {
const args = [];
if ( compressLevel != null ) args.push( "-z", compressLevel || 0 );
// sign
args.push( "--clear-sign" );
if ( !Array.isArray( users ) ) users = [ users ];
for ( const user of users ) {
args.push( "--local-user", user );
}
// preset passwords
const res = await this.#presetPasswords( passwords, { cacheKeys } );
if ( !res.ok ) return res;
return this.#spawnGpg( args, {
cwd,
"input": data,
"output": output || true,
} );
}
async decrypt ( data, { cwd, output, passwords, cacheKeys } = {} ) {
const args = [ "--decrypt" ];
// preset passwords
const res = await this.#presetPasswords( passwords, { cacheKeys } );
if ( !res.ok ) return res;
return this.#spawnGpg( args, {
cwd,
"input": data,
"output": output || true,
} );
}
async verify ( data, { cwd, signature, passwords, cacheKeys } = {} ) {
const args = [ "--verify" ];
var detachedSignatureTmp;
// detached signature
if ( signature ) {
detachedSignatureTmp = await this.#writeTmpFile( this.#createStream( signature ) );
args.push( detachedSignatureTmp.path, "-" );
}
// preset passwords
var res = await this.#presetPasswords( passwords, { cacheKeys } );
if ( !res.ok ) return res;
res = await this.#spawnGpg( args, {
cwd,
"input": data,
} );
// detachedSignatureTmp?.destroy();
return res;
}
async importKeys ( data, { cwd, restore } = {} ) {
const args = [ "--import", "--passphrase", "" ];
if ( restore ) {
args.push( "--import-options", "restore" );
}
this.#clearCache();
return this.#spawnGpg( args, {
cwd,
"input": data,
} );
}
async importOwnertrust ( data, { cwd } = {} ) {
const args = [ "--import-ownertrust" ];
return this.#spawnGpg( args, {
cwd,
"input": data,
} );
}
// private
#parseColumns ( line ) {
var fields = line.split( ":" );
// DOCS: https://github.com/gpg/gnupg/blob/master/doc/DETAILS
fields = {
"type": fields[ 0 ],
"validity": fields[ 1 ],
"keyLength": fields[ 2 ],
"publicKeyAlgorithm": fields[ 3 ],
"keyId": fields[ 4 ],
"creationDate": fields[ 5 ]
? new Date( Number( fields[ 5 ] ) * 1000 )
: null,
"expirationDate": fields[ 6 ]
? new Date( Number( fields[ 6 ] ) * 1000 )
: null,
// XXX
"field8": fields[ 7 ], // Certificate S/N, UID hash, trust signature info
"ownertrust": fields[ 8 ],
"userId": fields[ 9 ],
"signatureClass": fields[ 10 ],
"keyCapabilities": fields[ 11 ],
"issuerCertificateFingerprint": fields[ 12 ],
"flag": fields[ 13 ],
"tokenSerialNumber": fields[ 14 ],
"hashAlgorithm": fields[ 15 ],
"curveName": fields[ 16 ],
"complianceFlags": fields[ 17 ],
"lastUpdate": fields[ 18 ],
"origin": fields[ 19 ],
"comment": fields[ 20 ],
};
return fields;
}
#clearCache () {
this.#privateKeysCache = undefined;
}
async #presetPasswords ( passwords, { subkeys = true, cacheKeys } = {} ) {
passwords ||= this.#passwords;
if ( !passwords ) return result( 200 );
const gpgPresetPassphraseBinary = await this.#getGpgPresetPassphraseBinary();
if ( !gpgPresetPassphraseBinary ) return result( 500 );
const res = await this.getPrivateKeys( { cacheKeys } );
if ( !res.ok ) return res;
const privateKeys = res.data;
const keygrips = new Map();
KEY_ID: for ( const [ keyId, password ] of Object.entries( passwords ) ) {
if ( !password ) continue;
let found;
KEY: for ( const key of privateKeys ) {
if ( keyId === key.id || keyId === key.grip || keyId === key.fingerprint ) {
found = true;
keygrips.set( key.grip, password );
if ( subkeys ) {
for ( const subkey of key.subkeys ) {
keygrips.set( subkey.grip, password );
}
}
continue KEY_ID;
}
for ( const uid of Object.values( key.uids ) ) {
if ( uid.email === keyId || uid.uid.includes( keyId ) ) {
if ( keygrips.has( key.grip ) ) return result( [ 400, "GPG key is ambiguous" ] );
found = true;
keygrips.set( key.grip, password );
if ( subkeys ) {
for ( const subkey of key.subkeys ) {
keygrips.set( subkey.grip, password );
}
}
continue KEY;
}
}
// find subkeys
for ( const subkey of key.subkeys ) {
if ( keyId === subkey.id || keyId === subkey.grip || keyId === subkey.fingerprint ) {
found = true;
keygrips.set( subkey.grip, password );
continue KEY_ID;
}
}
}
if ( !found ) return result( [ 404, "GPG key not found" ] );
}
// cache passwords
if ( keygrips.size ) {
for ( const [ keygrip, password ] of keygrips.entries() ) {
const res = await new Promise( resolve => {
try {
const proc = childProcess.spawn( gpgPresetPassphraseBinary, [ "--preset", keygrip ], {
"env": this.#createEnv(),
"stdio": [ "pipe", "ignore", "ignore" ],
} );
proc.once( "error", e => resolve( result.catch( e, { "log": false } ) ) );
proc.once( "close", code => {
var res;
if ( code ) {
res = result( 500 );
}
else {
res = result( 200 );
}
resolve( res );
} );
proc.stdin.write( password );
proc.stdin.end();
}
catch ( e ) {
resolve( result.catch( e, { "log": false } ) );
}
} );
if ( !res.ok ) return res;
}
}
return result( 200 );
}
async #getGpgPresetPassphraseBinary () {
if ( GPG_PRESET_PASSPHRASE_BINARY == null ) {
GPG_PRESET_PASSPHRASE_BINARY = "";
if ( process.platform === "win32" ) {
let gpgDir;
for ( const dir of process.env.PATH.split( ";" ) ) {
if ( await exists( dir + "/gpg.exe" ) ) {
gpgDir = dir;
break;
}
}
if ( gpgDir ) {
if ( await exists( gpgDir + "/gpg-preset-passphrase.exe" ) ) {
GPG_PRESET_PASSPHRASE_BINARY = path.join( gpgDir, "gpg-preset-passphrase.exe" );
}
else if ( await exists( gpgDir + "/../lib/gnupg/gpg-preset-passphrase.exe" ) ) {
GPG_PRESET_PASSPHRASE_BINARY = path.join( gpgDir, "../lib/gnupg/gpg-preset-passphrase.exe" );
}
}
}
else {
GPG_PRESET_PASSPHRASE_BINARY = "/usr/lib/gnupg/gpg-preset-passphrase";
}
}
return GPG_PRESET_PASSPHRASE_BINARY;
}
#createStream ( data ) {
if ( data instanceof stream.Readable ) {
return data;
}
else if ( data instanceof File ) {
return data.stream();
}
else {
return stream.Readable.from( data );
}
}
async #writeTmpFile ( readStream ) {
const tmp = new TmpFile();
await stream.promises.pipeline( readStream, fs.createWriteStream( tmp.path ) );
return tmp;
}
async #spawnGpg ( args, { cwd, input, output } = {} ) {
args.unshift( "--pinentry-mode=loopback", "--yes", "--batch" );
if ( output ) {
args.push( "--output" );
if ( output === true ) {
args.push( "-" );
}
else {
args.push( output );
}
}
const env = { ...process.env };
if ( this.#home ) {
env[ "GNUPGHOME" ] = String( this.#home );
}
try {
const proc = childProcess.spawn( GPG_BINARY, args, {
cwd,
"env": this.#createEnv(),
"stdio": [ input
? "pipe"
: "ignore", output === true
? "pipe"
: "ignore", "ignore" ],
} );
if ( input ) {
stream.pipeline( this.#createStream( input ), proc.stdin, e => {} );
}
if ( output === true ) {
return result( 200, {
"stream": proc.stdout,
} );
}
else {
return new Promise( resolve => {
proc.once( "close", ( code, signal ) => {
if ( code ) {
resolve( result( 500 ) );
}
else {
resolve( result( 200 ) );
}
} );
} );
}
}
catch ( e ) {
return result.catch( e, { "log": false } );
}
}
#createEnv () {
var env;
if ( this.#home ) {
env = {
...process.env,
"GNUPGHOME": String( this.#home ),
};
}
return env;
}
}