1- import { constants , existsSync } from 'fs' ;
1+
2+ import { constants , existsSync } from 'node:fs' ;
23import { access } from 'node:fs/promises' ;
3- import { spawn , type StdioOptions } from 'node:child_process' ;
44import { dirname , join } from 'node:path' ;
5- import os , { homedir } from 'node:os ' ;
5+ import { spawnSync } from 'node:child_process ' ;
66import Debug from 'debug' ;
77import type { Config } from '../config' ;
8- import { GpgError , GpgErrorCode , type GpgErrorContext } from './gpgErrors' ;
9- import { parseGpgStderr } from './gpgErrorParser' ;
10- import { execFileCapture , type ExecError , type ExecResult } from './util' ;
11- import { defaultGnuPgHome , resolveViaGpgConf } from './gpgDiscover' ;
8+ import { GpgErrorCode , identifyError } from './gpgError' ;
129
1310const log = Debug ( 'cid::engine::crypto::gpg' ) ;
1411
15- export interface EncryptWithGpgInput {
16- inputPath : string ;
17- outputPath : string ;
18- gpgPath : string ;
19- recipient : string ;
20- signer ?: string ;
12+ export type GpgOptions = {
2113 armor ?: boolean ;
2214 trustAlways ?: boolean ;
23- homedir ?: string ;
15+ binaryPathOverride ?: string ;
2416 pinentryMode ?: Config . CryptoPinentryMode ;
25- passphrase ?: string ;
26- allowOverwrite ?: boolean ,
27- timeoutMs ?: number ,
17+ timeoutMs ?: number ;
18+ verifyRecipientKey ?: boolean ;
19+ verifySignerKey ?: boolean ;
2820}
2921
30- function hasPubKey ( listColons : string ) : boolean {
31- return listColons . split ( '\n' ) . some ( line => line . startsWith ( 'pub:' ) ) ;
32- }
33- function hasSecKey ( listColons : string ) : boolean {
34- return listColons . split ( '\n' ) . some ( line => line . startsWith ( 'sec:' ) ) ;
35- }
3622
37- async function assertKeyExists ( gpgPath : string , homedir : string , keyId : string , keyType : 'recipient' | 'signer' ) {
38- log ( `[DEBUG] Checking for ${ keyType } key: ${ keyId } ` ) ;
23+ export type EncryptFileInput = {
24+ inputPath : string ;
25+ outputPath : string ;
26+ recipient : string ;
27+ signer ?: string ;
28+ signerPassphrase ?: string ;
29+ }
3930
40- const listArgs = keyType === 'signer'
41- ? [ '--with-colons' , '--homedir' , homedir , '--batch' , '--yes' , '--list-secret-keys' , keyId ]
42- : [ '--with-colons' , '--homedir' , homedir , '--batch' , '--yes' , '--list-keys' , keyId ] ;
31+ export type EncryptFileResult =
32+ | { success : true ; outputPath : string }
33+ | { success : false ; error : string ; code ?: string | GpgErrorCode } ;
4334
44- let out : ExecResult
45- try { out = await execFileCapture ( gpgPath , listArgs , { ...process . env , GNUPGHOME : homedir } ) ; }
46- catch ( err ) {
47- const e = err as ExecError ;
4835
49- throwDefaultGpgError ( e . message , { binary : gpgPath , homedir, args : listArgs } ) ;
36+ export class GpgWrapper {
37+ private binPath : string ;
38+
39+ constructor ( private options : GpgOptions = { } ) {
40+ this . binPath = this . resolveGpgPath ( options . binaryPathOverride ) ;
41+ }
5042
51- throw parseGpgStderr (
52- e . stderr ?. trim ( ) . length ? e . stderr : e . stdout || "" ,
53- { binary : gpgPath , homedir, exitCode : e . code ?? null , args : listArgs , ...( keyType === "recipient" ? { recipient : keyId } : { signer : keyId } ) }
54- ) ;
43+ private resolveGpgPath ( override ?: string ) : string {
44+ // 1. if explicit path provided, use it.
45+ if ( override && existsSync ( override ) ) {
46+ log ( `[INFO] Using overridden GPG binary path: ${ override } ` ) ;
47+ return override ;
48+ }
49+ // TODO: maybe try gpgconf to discover homedir
50+
51+ // 2. Try common install locations
52+ // NOTE: We try this before PATH to give preference to known locations since most users of this library
53+ // will likely be on Windows and using Kleopatra/Gpg4win, which installs by default into AppData.
54+ // For headless usage or usage on Linux/MacOS, users should ensure GPG is set on PATH or provide
55+ // path explicitly.
56+ if ( isWindows ( ) ) {
57+ const candidates = resolveWindowsGpgBinCandidates ( ) ;
58+ for ( const c of candidates ) if ( existsSync ( c ) ) {
59+ log ( `[INFO] Found GPG binary at common location: ${ c } ` ) ;
60+ return c ;
61+ }
62+ }
63+
64+ // 2. Try PATH
65+ const which = isWindows ( ) ? 'where' : 'which' ;
66+ try {
67+ const r = spawnSync ( which , [ 'gpg' ] , { encoding : 'utf-8' } ) ;
68+ if ( r . status === 0 && r . stdout ) {
69+ const path = r . stdout . split ( / \r ? \n / ) . find ( Boolean ) ;
70+ if ( path && existsSync ( path ) ) {
71+ log ( `[INFO] Found GPG binary at PATH: ${ path } ` ) ;
72+ return path . trim ( ) ;
73+ }
74+ }
75+ }
76+ catch { /* ignore and try next method */ }
77+
78+ // 4. Fallback to 'gpg' and let error handling deal with it.
79+ log ( `[WARN] Falling back to GPG binary 'gpg' without validation.` ) ;
80+ return 'gpg' ;
5581 }
56-
57- const exists = keyType === 'signer' ? hasSecKey ( out . stdout ) : hasPubKey ( out . stdout ) ;
58-
59- if ( ! exists ) throw new GpgError (
60- keyType === 'signer' ? GpgErrorCode . SIGNER_KEY_NOT_FOUND : GpgErrorCode . RECIPIENT_KEY_NOT_FOUND ,
61- keyType === 'signer'
62- ? `Signing failed: no private key for signer "${ keyId } "`
63- : `Encryption failed: no public key for recipient "${ keyId } "` ,
64- { binary : gpgPath , homedir, exitCode : 0 , args : listArgs , ...( keyType === "recipient" ? { recipient : keyId } : { signer : keyId } ) } ,
65- keyType === 'signer'
66- ? [ 'Ensure your signing key (private key) is present in this keyring and not on a different account.' ]
67- : [ 'Import or publish the recipient\' public key into this keyring.' ]
68- ) ;
69- }
7082
71- function throwDefaultGpgError ( message : string , context : GpgErrorContext ) {
72- if ( message . includes ( "ENOENT" ) ) throw new GpgError ( GpgErrorCode . GPG_NOT_FOUND , `GPG failed to start: ${ message } ` , context ) ;
73- }
83+ private keyExists ( key : string , keyType : "RECIPIENT" | "SIGNER" ) : boolean {
84+ const args = keyType === "RECIPIENT" ? [ '--list-keys' , '--with-colons' , key ] : [ '--list-secret-keys' , '--with-colons' , key ] ;
85+ const r = spawnSync ( this . binPath , args , { encoding : "utf-8" } ) ;
86+ return r . status === 0 && r . stdout ?. length > 0 ;
87+ }
7488
75- async function ensureGpgAvailable ( gpgPath : string , homedir : string ) {
76- log ( `[DEBUG] Ensuring GPG is available at: ${ gpgPath } ` ) ;
77- const args = [ '--version' ] ;
78-
79- let out : ExecResult ;
80- try { out = await execFileCapture ( gpgPath , args , { ...process . env , GNUPGHOME : homedir } ) ; }
81- catch ( err ) {
82- const e = err as ExecError ;
83- throwDefaultGpgError ( e . message , { binary : gpgPath , homedir, args } ) ;
84-
85- throw new GpgError (
86- GpgErrorCode . GPG_NOT_FOUND ,
87- `GPG not available (exit ${ e . code } ) at "${ gpgPath } "` ,
88- { binary : gpgPath , homedir, exitCode : e . code ?? null , args } ,
89- [ 'Reinstall Gpg4win (Windows) or GnuPG (Linux/macOS) or adjust the configuration to a working gpg.exe.' ]
90- ) ;
89+ public async encryptFile ( { inputPath, outputPath, recipient, signer, signerPassphrase } : EncryptFileInput ) : Promise < EncryptFileResult > {
90+
91+ // check read permissions on input
92+ try { await access ( inputPath , constants . R_OK ) }
93+ catch {
94+ log ( `[ERROR] Unable to read input file, insufficient permissions for path: '${ inputPath } '` ) ;
95+ return { success : false , error : `Unable to read input file: '${ inputPath } '` , code : "INPUT_NOT_READABLE" }
96+ }
97+
98+ // check write permissions on output
99+ const outputDir = dirname ( outputPath ) ;
100+ try { await access ( outputDir , constants . W_OK ) }
101+ catch {
102+ log ( `[ERROR] Unable to write to output directory, insufficient permissions for path: '${ outputDir } '` ) ;
103+ return { success : false , error : `Unable to write to output path: '${ outputPath } '` , code : "OUTPUT_NOT_WRITABLE" }
104+ }
105+
106+ // check recipient key is in keyring and valid
107+ if ( this . options . verifyRecipientKey ) {
108+ const okay = this . keyExists ( recipient , "RECIPIENT" ) ;
109+ if ( ! okay ) {
110+ const msg = `Recipient key not found in local keyring: '${ recipient } '` ;
111+ log ( `[ERROR] ${ msg } ` ) ;
112+ return { success : false , error : msg , code : GpgErrorCode . RECIPIENT_KEY_NOT_FOUND }
113+ }
114+ }
115+
116+ // check signer key is in the keyring and valid
117+ if ( signer && this . options . verifySignerKey ) {
118+ const okay = this . keyExists ( signer , "SIGNER" ) ;
119+ if ( ! okay ) {
120+ const msg = `Signer secret key not found in local keyring: '${ signer } '`
121+ log ( `[ERROR] ${ msg } ` ) ;
122+ return { success : false , error : msg , code : GpgErrorCode . SIGNER_KEY_NOT_FOUND }
123+ }
124+ }
125+
126+ // https://www.gnupg.org/documentation/manuals/gnupg/GPG-Configuration-Options.html
127+ const args = [ '--batch' , '--yes' , '--status-fd' , '2' , '--no-tty' , '--with-colons' ] ;
128+
129+ if ( this . options . armor ) args . push ( '--armor' ) ;
130+ if ( this . options . trustAlways ) args . push ( '--trust-model' , 'always' ) ;
131+ if ( this . options . pinentryMode === 'loopback' ) args . push ( '--pinentry-mode' , 'loopback' ) ;
132+
133+ args . push ( '--output' , outputPath ) ;
134+
135+ if ( signer ) {
136+ args . push ( '--sign' , '--local-user' , signer ) ;
137+ if ( this . options . pinentryMode === "loopback" && ! signerPassphrase ) {
138+ log ( `[WARN] pinentry-mode=loopback is set but no passphrase has been provided; signing may fail if the key is protected.` ) ;
139+ }
140+ }
141+
142+ args . push ( '--encrypt' ) ;
143+ args . push ( '--recipient' , recipient ) ;
144+ args . push ( inputPath ) ;
145+
146+ log ( `Running cmd: ${ JSON . stringify ( this . binPath ) } ${ args . map ( a => JSON . stringify ( a ) ) . join ( ' ' ) } ` ) ;
147+
148+ let finalArgs = args . slice ( ) ;
149+ let inputData : string | undefined ;
150+
151+ if ( signer && this . options . pinentryMode === "loopback" && signerPassphrase ) {
152+ finalArgs = [ ...args . slice ( 0 , 1 ) , '--passphrase-fd' , '0' , ...args . slice ( 1 ) ] ;
153+ inputData = signerPassphrase . endsWith ( "\n" ) ? signerPassphrase : signerPassphrase + "\n" ;
154+ }
155+
156+ const result = spawnSync ( this . binPath , finalArgs , {
157+ stdio : [ 'pipe' , 'pipe' , 'pipe' ] ,
158+ env : { ...process . env } ,
159+ timeout : this . options . timeoutMs ?? 30_000 ,
160+ encoding : "utf-8" ,
161+ input : inputData
162+ } ) ;
163+
164+ if ( ( result as any ) . error ?. code === "ETIMEDOUT" ) {
165+ log ( `[ERROR] GPG timed out while encrypting file '${ inputPath } '` ) ;
166+ log ( `\tstderr: \n${ result . stderr } ` ) ;
167+ return { success : false , error : `GPG operation timed out` , code : GpgErrorCode . GENERAL_GPG_ERROR }
168+ }
169+
170+ if ( result . status == null ) {
171+ log ( `[ERROR] GPG terminated without an exit status (timeout/signal).` ) ;
172+ log ( `\tstderr: \n${ result . stderr } ` ) ;
173+ return { success : false , error : `GPG terminated without an exit status` , code : GpgErrorCode . GENERAL_GPG_ERROR }
174+ }
175+
176+ if ( result . status !== 0 ) {
177+ const errorDetail = identifyError ( result . stderr ) ;
178+
179+ log ( `[ERROR] Unable to encrypt file '${ inputPath } ;\n\terrorCode=${ errorDetail . code } ;\n\tmessage=${ errorDetail . message } '` ) ;
180+ log ( `\tstderr: \n${ result . stderr } ` ) ;
181+ return { success : false , error : errorDetail . message , code : errorDetail . code }
182+ }
183+
184+ log ( `[INFO] Successfully encrypted file ${ outputPath } ` ) ;
185+
186+ return { success : true , outputPath }
91187 }
92- log ( `[DEBUG] GPG version output: ${ out . stdout . split ( '\n' ) [ 0 ] } ` ) ;
93- }
94188
189+ }
95190
96- export async function encryptFileWithGpg ( input : EncryptWithGpgInput ) {
97- const homedir = input . homedir ?? defaultGnuPgHome ( ) ;
98-
99- await ensureGpgAvailable ( input . gpgPath , homedir ) ;
100-
101- // try { await access(inputPath, constants.R_OK); }
102- // catch {
103- // throw new GpgError(
104- // GpgErrorCode.INPUT_NOT_READABLE,
105- // `Cannot read input file: ${inputPath}`,
106- // { binary: gpg, homedir, inputPath },
107- // ['Check the file path and permissions.']
108- // );
109- // }
110-
111- // const outputDir = dirname(outputPath);
112- // try { await access(outputDir, constants.W_OK); }
113- // catch {
114- // throw new GpgError(
115- // GpgErrorCode.OUTPUT_NOT_WRITABLE,
116- // `Cannot write to output path: ${outputDir}`,
117- // { binary: gpg, homedir, outputPath },
118- // ['Check the file path and permissions.']
119- // );
120- // }
121-
122- // if (!allowOverwrite && existsSync(outputPath)) {
123- // throw new GpgError(
124- // GpgErrorCode.OUTPUT_WRITE_FAILED,
125- // `Refusing to overwrite existing file: ${outputPath}`,
126- // { outputPath },
127- // ['Pass allowOverwrite=true to permit overwriting.']);
128- // }
129-
130- // await assertKeyExists(gpg, homedir, recipient, 'recipient');
131- // if (signer) await assertKeyExists(gpg, homedir, signer, 'signer');
132-
133- // const args = ['--batch', '--yes', '--status-fd', '2', '--homedir', homedir];
134- // if (armor) args.push('--armor');
135- // if (trustAlways) args.push('--trust-model', 'always');
136- // args.push('--output', outputPath);
137-
138- // if (signer) {
139- // args.push('--sign', '--local-user', signer);
140- // if (pinentryMode === 'loopback') {
141- // args.push('--pinentry-mode', 'loopback');
142- // if (passphrase) args.push('--passphrase-fd', '0');
143- // }
144- // }
145- // args.push('--encrypt');
146- // args.push('--recipient', recipient);
147- // args.push(inputPath);
148-
149- // log(`Running: ${JSON.stringify(gpg)} ${args.map(a => JSON.stringify(a)).join(' ')}`);
150-
151- // let stderr = ''; let stdout = '';
152- // await new Promise<void>((resolve, reject) => {
153- // const stdio: StdioOptions = passphrase ? ['pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'];
154- // const p = spawn(gpg, args, { stdio, env: { ...process.env, GNUPGHOME: homedir }, timeout: timeoutMs });
155- // p.stdout?.on('data', d => (stdout += String(d)));
156- // p.stderr?.on('data', d => (stderr += String(d)));
157- // if (p.stdin && passphrase) { p.stdin.write(passphrase + '\n'); p.stdin.end(); }
158- // p.once('error', e => reject(new GpgError(
159- // GpgErrorCode.GENERAL_GPG_ERROR, `Failed to start GPG: ${(e as Error).message}`,
160- // { binary: gpg, homedir, args }, ['Check antivirus/process control tools and try again.']
161- // )));
162- // p.once('close', code => (code === 0 ? resolve() : reject(parseGpgStderr(stderr || stdout, {
163- // binary: gpg, homedir, recipient , signer, inputPath, outputPath, exitCode: code, args
164- // }))));
165- // });
166-
167- // return { outputPath, recipient, signed: Boolean(signer) };
191+ function resolveWindowsGpgBinCandidates ( ) : string [ ] {
192+ const localAppData = process . env . LOCALAPPDATA ;
193+ const programFiles = process . env [ 'ProgramFiles' ] ;
194+ const programFilesX86 = process . env [ 'ProgramFiles(x86)' ] ;
195+
196+ // Order matters
197+ const opts : string [ ] = [ ] ;
198+ if ( localAppData ) opts . push ( join ( localAppData , 'Programs' , 'GnuPG' , 'bin' , 'gpg.exe' ) ) ;
199+ if ( programFiles ) opts . push ( join ( programFiles , 'GnuPG' , 'bin' , 'gpg.exe' ) ) ;
200+ if ( programFilesX86 ) opts . push ( join ( programFilesX86 , 'GnuPG' , 'bin' , 'gpg.exe' ) ) ;
201+ if ( programFiles ) opts . push ( join ( programFiles , 'Gpg4win' , 'bin' , 'gpg.exe' ) ) ;
202+ if ( programFilesX86 ) opts . push ( join ( programFilesX86 , 'Gpg4win' , 'bin' , 'gpg.exe' ) ) ;
203+ return opts ;
168204}
169205
206+
207+ export const isWindows = ( ) => process . platform === 'win32' ;
0 commit comments