Skip to content

Commit 1357833

Browse files
committed
[temp] postprocess first pass implementation
1 parent 363442d commit 1357833

10 files changed

Lines changed: 671 additions & 7 deletions

File tree

examples/file_based_usage.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
// REPLACE ALL REFERENCES TO "_generic_hasher" WITH THE DESIRED ALGORITHM IN THE ALGORITHMS DIRECTORY.
22

3+
import { existsSync, rmSync } from 'node:fs';
4+
import { encryptFileWithGpg } from '../src/crypto/gpg';
35
import { loadConfig, preprocessFile, processFile } from '../src/index';
46
import { makeHasher, ALGORITHM_ID } from './example_algorithm/_generic_hasher';
5-
import { dirname, join } from 'node:path';
7+
import { dirname, join, resolve } from 'node:path';
68
import { fileURLToPath } from 'node:url';
79

810
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -12,6 +14,8 @@ const INPUT_PATH = join(__dirname, 'example_algorithm', 'input_10.csv');
1214
const OUTPUT_PATH = join(__dirname, 'output', 'output_10.csv');
1315
const VALIDATION_ERRORS_PATH = join(__dirname, 'output', 'validation_errors.csv');
1416

17+
if (existsSync(resolve(__dirname, "output"))) rmSync(join(__dirname, "output"), { recursive: true, force: true});
18+
1519
// load configuration from file
1620
const configLoadResult = loadConfig({
1721
configPath: CONFIG_PATH,
@@ -39,4 +43,18 @@ const processFileResult = await processFile({
3943
hasherFactory: makeHasher,
4044
});
4145
// print the result, save the result, etc.
42-
console.dir(processFileResult, { depth: 3 });
46+
// console.dir(processFileResult, { depth: 3 });
47+
48+
49+
const result = await encryptFileWithGpg({
50+
inputPath: processFileResult.outputFilePath!,
51+
outputPath: processFileResult.outputFilePath! + '.gpg',
52+
gpgPath: "gpg",
53+
recipient: '5A42A8421D19427562C5DEBE0CC75DCC440E1B46',
54+
// recipient: "9463D5547670C915D23671A8DA57DA502A841FEF",
55+
homedir: process.env.HOMEDIR,
56+
// signer: "5A42A8421D19427562C5DEBE0CC75DCC440E1B46"
57+
signer: "9463D5547670C915D23671A8DA57DA502A841FEF",
58+
trustAlways: true,
59+
});
60+
console.log(result)

examples/programmatic_usage.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ const config: Config.CoreConfiguration = {
3939
reference: [],
4040
static: [ "id" ]
4141
},
42+
},
43+
post_processing: {
44+
4245
}
4346
}
4447

src/config/Config.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export namespace Config {
3232
}
3333
export type StringBasedSalt = { source: 'STRING'; value: string };
3434
export type FileBasedSalt = { source: 'FILE'; value: string; validator_regex?: string };
35+
export type CryptoPinentryMode = 'default' | 'loopback';
3536
export interface CoreConfiguration {
3637
meta: { id: string }
3738
source: ColumnMap;
@@ -43,6 +44,11 @@ export namespace Config {
4344
};
4445
salt: StringBasedSalt | FileBasedSalt;
4546
};
47+
post_processing?: {
48+
encryption?: {
49+
key_path: string;
50+
}
51+
}
4652
}
4753
export interface FileConfiguration extends CoreConfiguration {
4854
isBackup?: boolean;
@@ -61,11 +67,6 @@ export namespace Config {
6167
destination: ColumnMap;
6268
destination_map: ColumnMap;
6369
destination_errors: ColumnMap;
64-
post_processing?: {
65-
encryption?: {
66-
key_path: string;
67-
}
68-
}
6970
}
7071
}
7172

src/crypto/gpg.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { constants, existsSync } from 'fs';
2+
import { access } from 'node:fs/promises';
3+
import { spawn, type StdioOptions } from 'node:child_process';
4+
import { dirname, join } from 'node:path';
5+
import os, { homedir } from 'node:os';
6+
import Debug from 'debug';
7+
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';
12+
13+
const log = Debug('cid::engine::crypto::gpg');
14+
15+
export interface EncryptWithGpgInput {
16+
inputPath: string;
17+
outputPath: string;
18+
gpgPath: string;
19+
recipient: string;
20+
signer?: string;
21+
armor?: boolean;
22+
trustAlways?: boolean;
23+
homedir?: string;
24+
pinentryMode?: Config.CryptoPinentryMode;
25+
passphrase?: string;
26+
allowOverwrite?: boolean,
27+
timeoutMs?: number,
28+
}
29+
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+
}
36+
37+
async function assertKeyExists(gpgPath: string, homedir: string, keyId: string, keyType: 'recipient' | 'signer') {
38+
log(`[DEBUG] Checking for ${keyType} key: ${keyId}`);
39+
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];
43+
44+
let out: ExecResult
45+
try { out = await execFileCapture(gpgPath, listArgs, { ...process.env, GNUPGHOME: homedir }); }
46+
catch (err) {
47+
const e = err as ExecError;
48+
49+
throwDefaultGpgError(e.message, { binary: gpgPath, homedir, args: listArgs });
50+
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+
);
55+
}
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+
}
70+
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+
}
74+
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+
);
91+
}
92+
log(`[DEBUG] GPG version output: ${out.stdout.split('\n')[0]}`);
93+
}
94+
95+
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) };
168+
}
169+

src/crypto/gpgDiscover.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
2+
import { existsSync } from 'fs';
3+
import { join } from 'path';
4+
import os from 'node:os';
5+
import Debug from 'debug';
6+
import { execFileCapture, type ExecError } from './util';
7+
import { exec } from 'node:child_process';
8+
const log = Debug('cid::engine::crypto::gpg:discover');
9+
10+
11+
export async function resolveViaGpgConf(explicit?: string) {
12+
// 1. if explicit path provided, use it.
13+
if (explicit && explicit.length > 0) {
14+
const homedir = await tryGetHomedirViaGpgConf();
15+
log(`[INFO] Using explicit gpg binary at: ${explicit}`);
16+
return { gpg: explicit, homedir };
17+
}
18+
19+
// 2. try to use gpgconf to discover homedir
20+
const homedir = await tryGetHomedirViaGpgConf();
21+
22+
// 3. pick a candidate and validate it with the discovered homedir
23+
let gpg = "gpg";
24+
const okay = await tryGpgVersion(gpg, homedir);
25+
if (okay) {
26+
log(`[INFO] Using gpg binary at: ${gpg}`);
27+
return { gpg, homedir };
28+
}
29+
30+
// 4. PATH 'gpg' didn't work, start probing common locations
31+
if (process.platform === 'win32') {
32+
const candidate = resolveWindowsGpgBinCandidates().find(existsSync);
33+
if (candidate) {
34+
const winOkay = await tryGpgVersion(candidate, homedir);
35+
if (winOkay) {
36+
log(`[INFO] Using gpg binary at: ${candidate}`)
37+
}
38+
}
39+
}
40+
41+
// 5. Final fallback -> returrn 'gpg' and let the error handling deal with it.
42+
log(`[WARN] Falling back to gpg binary 'gpg' without validation.`);
43+
return { gpg: 'gpg', homedir };
44+
}
45+
46+
47+
48+
49+
50+
async function tryGetHomedirViaGpgConf() {
51+
try {
52+
await execFileCapture('gpgconf', ['--version'], process.env);
53+
const { stdout: homedir } = await execFileCapture('gpgconf', ['--list-dirs', 'homedir'], process.env);
54+
if (homedir && homedir.length > 0) return homedir.trim();
55+
}
56+
catch (err) {
57+
const e = err as ExecError;
58+
log(`[ERROR] gpgconf not available or failed (--list-dirs): ${e.message}`);
59+
log(`[WARN] Falling back to default GnuPG home directory.`);
60+
}
61+
return defaultGnuPgHome();
62+
}
63+
64+
async function tryGpgVersion(gpgPath: string, homedir?: string) {
65+
try {
66+
const env = homedir ? { ...process.env, GNUPGHOME: homedir } : process.env;
67+
const { stdout } = await execFileCapture(gpgPath, ['--version'], env);
68+
const firstLine = stdout.split('\n')[0];
69+
log(`[DEBUG] Discovered GPG version ${gpgPath}: ${firstLine}`);
70+
return true;
71+
}
72+
catch (err) {
73+
const e = err as ExecError;
74+
log(`[ERROR] GPG not available or failed (--version): ${e.message}`);
75+
return false;
76+
}
77+
}
78+
79+
80+
export function defaultGnuPgHome(): string {
81+
if (process.platform === 'win32') {
82+
const appData = process.env.APPDATA ?? join(os.homedir(), 'AppData', 'Roaming');
83+
return join(appData, 'gnupg');
84+
}
85+
return join(os.homedir(), '.gnupg');
86+
}
87+
88+
function resolveWindowsGpgBinCandidates(): string[] {
89+
const localAppData = process.env.LOCALAPPDATA;
90+
const programFiles = process.env['ProgramFiles'];
91+
const programFilesX86 = process.env['ProgramFiles(x86)'];
92+
93+
const opts: string[] = [];
94+
if (localAppData) opts.push(join(localAppData, 'Programs', 'GnuPG', 'bin', 'gpg.exe'));
95+
if (programFiles) opts.push(join(programFiles, 'GnuPG', 'bin', 'gpg.exe'));
96+
if (programFilesX86) opts.push(join(programFilesX86, 'GnuPG', 'bin', 'gpg.exe'));
97+
return opts;
98+
}
99+
100+
export function resolveGpgBinaryFallback(explicit?: string) {
101+
if (explicit && explicit.length > 0) return explicit;
102+
if (process.platform === 'win32') {
103+
const candidates = resolveWindowsGpgBinCandidates();
104+
const found = candidates.find(existsSync);
105+
if (found) {
106+
log(`Using gpg.exe at: ${found}`);
107+
return found;
108+
}
109+
}
110+
return 'gpg';
111+
}

0 commit comments

Comments
 (0)