Skip to content

Commit 26da6bd

Browse files
committed
integrate gpg with postprocessing wrapper, standardise api
1 parent 425e661 commit 26da6bd

8 files changed

Lines changed: 256 additions & 51 deletions

File tree

package-lock.json

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"date-fns": "^4.1.0",
5252
"debug": "^4.3.7",
5353
"hi-base32": "^0.5.1",
54+
"is-executable": "^2.0.1",
5455
"openpgp": "^6.2.2",
5556
"safe-stable-stringify": "^2.4.3",
5657
"toml": "^3.0.0",

src/config/Config.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import type { ValidationRule } from "../validation/Validation";
1818
export namespace Config {
1919
export interface Column {
2020
name: string;
21-
alias: string;
21+
alias: string; // TODOL: make alias optional for programmatic usage
2222
default?: string | number | string[] | number[];
2323
}
2424
export interface ColumnMap {
@@ -47,7 +47,8 @@ export namespace Config {
4747
};
4848
post_processing?: {
4949
encryption?: {
50-
key_path: string;
50+
recipient: string;
51+
gpgBinaryPath?: string;
5152
}
5253
}
5354
}

src/config/validateConfig.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ type ConfigValidator = (label: string, v: unknown) => ConfigValidatorResult;
3535

3636
const isTrue: ConfigValidator = (label: string, v: unknown) => (!v ? label : undefined);
3737
const isFalse: ConfigValidator = (label: string, v: unknown) => (!!v ? label : undefined);
38+
const isBoolean: ConfigValidator = (label: string, v: unknown) =>
39+
typeof v !== 'boolean' ? `${label} must be a boolean` : undefined;
3840
const isObject: ConfigValidator = (label: string, v: unknown) =>
3941
typeof v !== 'object' ? `Missing ${label}` : undefined;
4042
const isNumber: ConfigValidator = (label: string, v: unknown) =>
@@ -130,7 +132,8 @@ function checkPostProcessing(proc: Config.FileConfiguration["post_processing"])
130132

131133
const encryptionCheck =
132134
isOptional('[post_processing].encryption', proc.encryption, isObject) ||
133-
isString("[post_processing].encryption.key_path", proc.encryption?.key_path)
135+
isNotEmptyString("[post_processing].encryption.recipient", proc.encryption?.recipient) ||
136+
isOptional("[post_processing].encryption.gpgBinaryPath?", proc.encryption?.gpgBinaryPath, isNotEmptyString);
134137

135138
return encryptionCheck
136139
}

src/crypto/gpg.ts

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { spawnSync } from 'node:child_process';
66
import Debug from 'debug';
77
import type { Config } from '../config';
88
import { GpgErrorCode, identifyError } from './gpgError';
9+
import { isExecutableSync } from 'is-executable';
910

1011
const log = Debug('cid::engine::crypto::gpg');
1112

@@ -15,8 +16,7 @@ export type GpgOptions = {
1516
binaryPathOverride?: string;
1617
pinentryMode?: Config.CryptoPinentryMode;
1718
timeoutMs?: number;
18-
verifyRecipientKey?: boolean;
19-
verifySignerKey?: boolean;
19+
verifyKeys?: boolean;
2020
}
2121

2222

@@ -54,7 +54,7 @@ export class GpgWrapper {
5454
// For headless usage or usage on Linux/MacOS, users should ensure GPG is set on PATH or provide
5555
// path explicitly.
5656
if (isWindows()) {
57-
const candidates = resolveWindowsGpgBinCandidates();
57+
const candidates = this.resolveWindowsGpgBinCandidates();
5858
for (const c of candidates) if (existsSync(c)) {
5959
log(`[INFO] Found GPG binary at common location: ${c}`);
6060
return c;
@@ -80,6 +80,21 @@ export class GpgWrapper {
8080
return 'gpg';
8181
}
8282

83+
private resolveWindowsGpgBinCandidates(): string[] {
84+
const localAppData = process.env.LOCALAPPDATA;
85+
const programFiles = process.env['ProgramFiles'];
86+
const programFilesX86 = process.env['ProgramFiles(x86)'];
87+
88+
// Order matters
89+
const opts: string[] = [];
90+
if (localAppData) opts.push(join(localAppData, 'Programs', 'GnuPG', 'bin', 'gpg.exe'));
91+
if (programFiles) opts.push(join(programFiles, 'GnuPG', 'bin', 'gpg.exe'));
92+
if (programFilesX86) opts.push(join(programFilesX86, 'GnuPG', 'bin', 'gpg.exe'));
93+
if (programFiles) opts.push(join(programFiles, 'Gpg4win', 'bin', 'gpg.exe'));
94+
if (programFilesX86) opts.push(join(programFilesX86, 'Gpg4win', 'bin', 'gpg.exe'));
95+
return opts;
96+
}
97+
8398
private keyExists(key: string, keyType: "RECIPIENT" | "SIGNER"): boolean {
8499
const args = keyType === "RECIPIENT" ? [ '--list-keys', '--with-colons', key ] : [ '--list-secret-keys', '--with-colons', key ];
85100
const r = spawnSync(this.binPath, args, { encoding: "utf-8" });
@@ -88,12 +103,17 @@ export class GpgWrapper {
88103

89104
public async encryptFile({ inputPath, outputPath, recipient, signer, signerPassphrase }: EncryptFileInput): Promise<EncryptFileResult> {
90105
// TODO: move this into the constructor?
106+
107+
// TODO: find an alternative for checking existence/accessibility of GPG binary.
108+
// since access() on some platforms (Windows) may return false negatives due to ACLs.
109+
// For now, swapping in 3rd party library (isExecutable) to get it working, but perhaps
110+
// it is better to just attempt to run GPG and handle the errors?
91111
try {
92112
log(`[DEBUG] Checking GPG binary at path: '${this.binPath}'`);
93-
await access(this.binPath, constants.X_OK);
113+
isExecutableSync(this.binPath);
94114
log(`[DEBUG] GPG binary is executable.`);
95115
}
96-
catch {
116+
catch (err) {
97117
log(`[ERROR] GPG binary not found or not executable at path: '${this.binPath}'`);
98118
return { success: false, error: `GPG binary not found or not executable at path: '${this.binPath}'`, code: GpgErrorCode.GPG_NOT_FOUND }
99119
}
@@ -122,7 +142,7 @@ export class GpgWrapper {
122142
}
123143

124144
// check recipient key is in keyring and valid
125-
if (this.options.verifyRecipientKey) {
145+
if (this.options.verifyKeys) {
126146
log(`[DEBUG] Verifying recipient key exists in keyring: '${recipient}'`);
127147
const okay = this.keyExists(recipient, "RECIPIENT");
128148
if (!okay) {
@@ -134,7 +154,7 @@ export class GpgWrapper {
134154
}
135155

136156
// check signer key is in the keyring and valid
137-
if (signer && this.options.verifySignerKey) {
157+
if (signer && signer.length > 0 && this.options.verifyKeys) {
138158
log(`[DEBUG] Verifying signer secret key exists in keyring: '${signer}'`);
139159
const okay = this.keyExists(signer, "SIGNER");
140160
if (!okay) {
@@ -154,7 +174,7 @@ export class GpgWrapper {
154174

155175
args.push('--output', outputPath);
156176

157-
if (signer) {
177+
if (signer && signer.length > 0) {
158178
args.push('--sign', '--local-user', signer);
159179
if (this.options.pinentryMode === "loopback" && !signerPassphrase) {
160180
log(`[WARN] pinentry-mode=loopback is set but no passphrase has been provided; signing may fail if the key is protected.`);
@@ -210,20 +230,5 @@ export class GpgWrapper {
210230

211231
}
212232

213-
function resolveWindowsGpgBinCandidates(): string[] {
214-
const localAppData = process.env.LOCALAPPDATA;
215-
const programFiles = process.env['ProgramFiles'];
216-
const programFilesX86 = process.env['ProgramFiles(x86)'];
217-
218-
// Order matters
219-
const opts: string[] = [];
220-
if (localAppData) opts.push(join(localAppData, 'Programs', 'GnuPG', 'bin', 'gpg.exe'));
221-
if (programFiles) opts.push(join(programFiles, 'GnuPG', 'bin', 'gpg.exe'));
222-
if (programFilesX86) opts.push(join(programFilesX86, 'GnuPG', 'bin', 'gpg.exe'));
223-
if (programFiles) opts.push(join(programFiles, 'Gpg4win', 'bin', 'gpg.exe'));
224-
if (programFilesX86) opts.push(join(programFilesX86, 'Gpg4win', 'bin', 'gpg.exe'));
225-
return opts;
226-
}
227-
228233

229234
export const isWindows = () => process.platform === 'win32';

src/processing/postprocess.ts

Lines changed: 56 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,37 +14,76 @@
1414
// You should have received a copy of the GNU Affero General Public License
1515
// along with this program. If not, see <http://www.gnu.org/licenses/>.
1616

17+
import { GpgWrapper } from '@/crypto/gpg';
1718
import type { Config } from '../config/Config';
1819

1920
import Debug from 'debug';
2021
const log = Debug('cid::engine::process::postprocess');
2122

23+
const enum POSTPROCESSING_STEP {
24+
ENCRYPTION = 'ENCRYPTION',
25+
}
2226

23-
export interface PostprocessFileResult {}
27+
type Step = {
28+
success: boolean;
29+
step: POSTPROCESSING_STEP;
30+
outputPath?: string;
31+
};
2432

25-
interface PostprocessFileInput {
26-
config: Config.FileConfiguration,
27-
filePath: string
33+
export interface PostprocessFileResult {
34+
success: boolean;
35+
steps: Step[];
36+
outputPath?: string;
2837
}
2938

30-
export async function postprocessFile({ config, filePath }: PostprocessFileInput): Promise<PostprocessFileResult> {
31-
log(`[INFO] Starting processing of file '${filePath}' with config file '${config.meta.signature}'`);
32-
33-
if (!config.post_processing) return {}
39+
interface PostprocessFileInput {
40+
config: Config.FileConfiguration;
41+
inputPath: string;
42+
outputPath: string;
43+
options?: {
44+
// options for postprocessing params that are not included in the config file
45+
signer?: string;
46+
}
47+
}
3448

35-
if (config.post_processing.encryption) {
36-
// check that gpg exists on the system
37-
38-
// check it it the correct version of gpg (e.g. should be the gpgp installed by Kleopatra on Windows, not gpg from git bash)
49+
export async function postprocessFile({ config, inputPath, outputPath, options }: PostprocessFileInput): Promise<PostprocessFileResult> {
50+
log(`[INFO] Starting postprocessing of file '${inputPath}' with config file '${config.meta.signature}'`);
3951

40-
// find the Building Blocks public key in the user keyring
52+
const result: PostprocessFileResult = { success: true, steps: [] };
4153

42-
// encrypt the file
43-
44-
// [OPTIONAL] sign the the file with the users generated public key
54+
if (!config.post_processing) {
55+
log("[INFO] No postprocessing steps configured, skipping.");
56+
return result;
4557
}
4658

59+
if (config.post_processing.encryption) {
60+
log(`[INFO] Encryption postprocessing step configured, starting encryption of file '${inputPath}'`);
61+
log(`Encryption config: ${JSON.stringify(config.post_processing.encryption)}`);
62+
const gpg = new GpgWrapper({
63+
trustAlways: true,
64+
binaryPathOverride: config.post_processing.encryption.gpgBinaryPath,
65+
timeoutMs: 60_000,
66+
verifyKeys: true
67+
});
68+
69+
const encryptResult = await gpg.encryptFile({
70+
inputPath: inputPath,
71+
outputPath: outputPath,
72+
recipient: config.post_processing.encryption.recipient,
73+
signer: options?.signer,
74+
});
75+
76+
if (!encryptResult.success) {
77+
log(`[ERROR] Encryption failed: ${encryptResult.error} (code: ${encryptResult.code})`);
78+
result.steps.push({ success: false, step: POSTPROCESSING_STEP.ENCRYPTION });
79+
}
80+
else result.steps.push({ success: true, step: POSTPROCESSING_STEP.ENCRYPTION, outputPath: encryptResult.outputPath });
81+
}
4782

83+
// TODO: add more postprocessing steps here
4884

49-
return {}
85+
log("[INFO] Postprocessing complete.");
86+
if (result.steps.some(s => !s.success)) result.success = false;
87+
else if (result.steps.length > 0) result.outputPath = result.steps[result.steps.length - 1].outputPath; // set outputPath to last successful step outputPath
88+
return result;
5089
}

tests/config/validateConfig.test.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ const validFileConfig = (): Config.FileConfiguration => ({
6363
error_in_salt: 'Salt error',
6464
terms_and_conditions: 'T&C',
6565
},
66-
post_processing: { encryption: { key_path: '/tmp/key.pem' } },
66+
post_processing: { encryption: { recipient: "REC", gpgBinaryPath: "/usr/bin/gpg" } },
6767
});
6868

6969
// ---------------------------------------------------------------------
@@ -295,12 +295,17 @@ describe('config::validate::file', () => {
295295
});
296296

297297

298-
it('post_processing present but missing encryption.key_path', () => {
298+
it('post_processing encryption config type guards', () => {
299299
const cfg = validFileConfig();
300-
(cfg.post_processing as any) = { encryption: { key_path: 123 } }; // wrong type
301-
const res = validateConfigFile(cfg as any, 'CID', false);
302-
expect(res).toContain('[post_processing].encryption.key_path must be a string');
303-
// checkPostProcessing path and isString check.
300+
301+
(cfg.post_processing as any) = { encryption: { enabled: true, recipient: "" } };
302+
let res = validateConfigFile(cfg as any, 'CID', false);
303+
expect(res).toContain('[post_processing].encryption.recipient cannot be an empty string');
304+
305+
(cfg.post_processing as any) = { encryption: { enabled: false, recipient: "REC" } };
306+
res = validateConfigFile(cfg as any, 'CID', false);
307+
expect(res).toBeUndefined();
308+
304309
});
305310

306311
it('destination columns trigger column-level errors', () => {

0 commit comments

Comments
 (0)