Skip to content

Commit fc81a33

Browse files
committed
working gpg implementation (needs more testing)
1 parent 3e85c16 commit fc81a33

7 files changed

Lines changed: 454 additions & 559 deletions

File tree

src/crypto/gpg.ts

Lines changed: 182 additions & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -1,169 +1,207 @@
1-
import { constants, existsSync } from 'fs';
1+
2+
import { constants, existsSync } from 'node:fs';
23
import { access } from 'node:fs/promises';
3-
import { spawn, type StdioOptions } from 'node:child_process';
44
import { dirname, join } from 'node:path';
5-
import os, { homedir } from 'node:os';
5+
import { spawnSync } from 'node:child_process';
66
import Debug from 'debug';
77
import 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

1310
const 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

Comments
 (0)