lpgp 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +52 -0
- package/README.md +218 -0
- package/dist/config.d.ts +17 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +31 -0
- package/dist/config.js.map +1 -0
- package/dist/db.d.ts +80 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +284 -0
- package/dist/db.js.map +1 -0
- package/dist/decrypt.d.ts +2 -0
- package/dist/decrypt.d.ts.map +1 -0
- package/dist/decrypt.js +25 -0
- package/dist/decrypt.js.map +1 -0
- package/dist/encrypt.d.ts +2 -0
- package/dist/encrypt.d.ts.map +1 -0
- package/dist/encrypt.js +18 -0
- package/dist/encrypt.js.map +1 -0
- package/dist/key-manager.d.ts +119 -0
- package/dist/key-manager.d.ts.map +1 -0
- package/dist/key-manager.js +1235 -0
- package/dist/key-manager.js.map +1 -0
- package/dist/key-utils.d.ts +47 -0
- package/dist/key-utils.d.ts.map +1 -0
- package/dist/key-utils.js +199 -0
- package/dist/key-utils.js.map +1 -0
- package/dist/keychain.d.ts +22 -0
- package/dist/keychain.d.ts.map +1 -0
- package/dist/keychain.js +73 -0
- package/dist/keychain.js.map +1 -0
- package/dist/pgp-tool.d.ts +3 -0
- package/dist/pgp-tool.d.ts.map +1 -0
- package/dist/pgp-tool.js +1061 -0
- package/dist/pgp-tool.js.map +1 -0
- package/dist/prompts.d.ts +11 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +109 -0
- package/dist/prompts.js.map +1 -0
- package/dist/schema.sql +86 -0
- package/dist/system-keys.d.ts +32 -0
- package/dist/system-keys.d.ts.map +1 -0
- package/dist/system-keys.js +123 -0
- package/dist/system-keys.js.map +1 -0
- package/dist/ui.d.ts +94 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +175 -0
- package/dist/ui.js.map +1 -0
- package/package.json +56 -0
|
@@ -0,0 +1,1235 @@
|
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
import * as readline from 'readline';
|
|
3
|
+
import * as openpgp from 'openpgp';
|
|
4
|
+
import { extractPublicKeyInfo, extractPrivateKeyInfo, verifyKeyPair, formatKeypairInfo, obfuscateEmail, } from './key-utils.js';
|
|
5
|
+
import { isGpgInstalled, listGpgKeys, exportGpgPublicKey, exportGpgSecretKey, getGpgHomeDir, } from './system-keys.js';
|
|
6
|
+
import { escapeablePrompt } from './prompts.js';
|
|
7
|
+
import { hasStoredPassphrase, deleteStoredPassphrase, } from './keychain.js';
|
|
8
|
+
import { colors, icons, printBanner, printSectionHeader, printDivider, showSuccess, showError, showWarning, showLoading, promptMessage, mainMenuChoice, backChoice, exitChoice, cancelChoice, showKeyValue, } from './ui.js';
|
|
9
|
+
export class KeyManager {
|
|
10
|
+
db;
|
|
11
|
+
constructor(db) {
|
|
12
|
+
this.db = db;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Check if there's a default keypair configured
|
|
16
|
+
*/
|
|
17
|
+
hasDefaultKeypair() {
|
|
18
|
+
const keypairs = this.db.select({
|
|
19
|
+
table: 'keypair',
|
|
20
|
+
where: { key: 'is_default', compare: 'is', value: 1 },
|
|
21
|
+
});
|
|
22
|
+
return keypairs.length > 0;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Get the default keypair
|
|
26
|
+
*/
|
|
27
|
+
getDefaultKeypair() {
|
|
28
|
+
const keypairs = this.db.select({
|
|
29
|
+
table: 'keypair',
|
|
30
|
+
where: { key: 'is_default', compare: 'is', value: 1 },
|
|
31
|
+
});
|
|
32
|
+
return keypairs[0] || null;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Prompt user to set up their first keypair
|
|
36
|
+
*/
|
|
37
|
+
async setupFirstKeypair() {
|
|
38
|
+
printSectionHeader('First Time Setup');
|
|
39
|
+
showWarning('No PGP keypair found. You need to set up a keypair to use this tool.');
|
|
40
|
+
console.log();
|
|
41
|
+
// Check if GPG is available to offer system import
|
|
42
|
+
const gpgAvailable = isGpgInstalled();
|
|
43
|
+
const choices = [
|
|
44
|
+
{ name: `${icons.import} Import existing keypair`, value: 'import' },
|
|
45
|
+
];
|
|
46
|
+
if (gpgAvailable) {
|
|
47
|
+
choices.push({ name: `${icons.gpg} Import from system GPG`, value: 'import-gpg' });
|
|
48
|
+
}
|
|
49
|
+
choices.push({ name: `${icons.generate} Generate new keypair`, value: 'generate' }, new inquirer.Separator(), exitChoice());
|
|
50
|
+
const { action } = await escapeablePrompt([
|
|
51
|
+
{
|
|
52
|
+
type: 'list',
|
|
53
|
+
name: 'action',
|
|
54
|
+
message: promptMessage('What would you like to do?'),
|
|
55
|
+
choices,
|
|
56
|
+
},
|
|
57
|
+
]);
|
|
58
|
+
if (action === 'exit') {
|
|
59
|
+
console.log(colors.muted('\nGoodbye!\n'));
|
|
60
|
+
process.exit(0);
|
|
61
|
+
}
|
|
62
|
+
if (action === 'import') {
|
|
63
|
+
await this.importKeypair(true);
|
|
64
|
+
}
|
|
65
|
+
else if (action === 'import-gpg') {
|
|
66
|
+
await this.importFromSystemGpg();
|
|
67
|
+
}
|
|
68
|
+
else if (action === 'generate') {
|
|
69
|
+
await this.generateKeypair(true);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Import a keypair (public + private keys)
|
|
74
|
+
*/
|
|
75
|
+
async importKeypair(setAsDefault = false) {
|
|
76
|
+
printSectionHeader('Import Keypair');
|
|
77
|
+
// Check clipboard for keys
|
|
78
|
+
let clipboardContent = '';
|
|
79
|
+
let hasPublicInClipboard = false;
|
|
80
|
+
let hasPrivateInClipboard = false;
|
|
81
|
+
try {
|
|
82
|
+
const clipboardy = (await import('clipboardy')).default;
|
|
83
|
+
clipboardContent = await clipboardy.read();
|
|
84
|
+
hasPublicInClipboard = clipboardContent.includes('BEGIN PGP PUBLIC KEY BLOCK');
|
|
85
|
+
hasPrivateInClipboard = clipboardContent.includes('BEGIN PGP PRIVATE KEY BLOCK');
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
// Clipboard not available, continue without it
|
|
89
|
+
}
|
|
90
|
+
// Prompt for keypair name
|
|
91
|
+
const { name } = await escapeablePrompt([
|
|
92
|
+
{
|
|
93
|
+
type: 'input',
|
|
94
|
+
name: 'name',
|
|
95
|
+
message: promptMessage('Keypair name (e.g., "Personal", "Work"):'),
|
|
96
|
+
default: 'Personal',
|
|
97
|
+
validate: (input) => input.trim().length > 0 || 'Name cannot be empty',
|
|
98
|
+
},
|
|
99
|
+
]);
|
|
100
|
+
// Get public and private keys
|
|
101
|
+
let publicKey = '';
|
|
102
|
+
let privateKey = '';
|
|
103
|
+
let usedBothFromClipboard = false;
|
|
104
|
+
// Check if both keys are in clipboard
|
|
105
|
+
if (hasPublicInClipboard && hasPrivateInClipboard) {
|
|
106
|
+
const { useClipboard } = await escapeablePrompt([
|
|
107
|
+
{
|
|
108
|
+
type: 'confirm',
|
|
109
|
+
name: 'useClipboard',
|
|
110
|
+
message: 'Both public and private keys detected in clipboard. Use them?',
|
|
111
|
+
default: true,
|
|
112
|
+
},
|
|
113
|
+
]);
|
|
114
|
+
if (useClipboard) {
|
|
115
|
+
// Extract both keys from clipboard
|
|
116
|
+
const publicMatch = clipboardContent.match(/-----BEGIN PGP PUBLIC KEY BLOCK-----[\s\S]*?-----END PGP PUBLIC KEY BLOCK-----/);
|
|
117
|
+
const privateMatch = clipboardContent.match(/-----BEGIN PGP PRIVATE KEY BLOCK-----[\s\S]*?-----END PGP PRIVATE KEY BLOCK-----/);
|
|
118
|
+
if (publicMatch) {
|
|
119
|
+
publicKey = publicMatch[0];
|
|
120
|
+
}
|
|
121
|
+
if (privateMatch) {
|
|
122
|
+
privateKey = privateMatch[0];
|
|
123
|
+
}
|
|
124
|
+
usedBothFromClipboard = true;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Get public key if not already extracted
|
|
128
|
+
if (!publicKey && hasPublicInClipboard) {
|
|
129
|
+
const { useClipboard } = await escapeablePrompt([
|
|
130
|
+
{
|
|
131
|
+
type: 'confirm',
|
|
132
|
+
name: 'useClipboard',
|
|
133
|
+
message: 'Public key detected in clipboard. Use it?',
|
|
134
|
+
default: true,
|
|
135
|
+
},
|
|
136
|
+
]);
|
|
137
|
+
if (useClipboard) {
|
|
138
|
+
const publicMatch = clipboardContent.match(/-----BEGIN PGP PUBLIC KEY BLOCK-----[\s\S]*?-----END PGP PUBLIC KEY BLOCK-----/);
|
|
139
|
+
if (publicMatch) {
|
|
140
|
+
publicKey = publicMatch[0];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (!publicKey) {
|
|
145
|
+
console.log(promptMessage('\nPaste your PGP PUBLIC key:'));
|
|
146
|
+
console.log(colors.muted('(Press Enter to finish, or press Enter then Ctrl+D)'));
|
|
147
|
+
publicKey = await this.readKeyInput();
|
|
148
|
+
}
|
|
149
|
+
// Validate public key format
|
|
150
|
+
if (!publicKey.includes('BEGIN PGP PUBLIC KEY BLOCK')) {
|
|
151
|
+
console.log();
|
|
152
|
+
showError('Invalid public key format');
|
|
153
|
+
console.log();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
// Get private key if not already extracted
|
|
157
|
+
if (!privateKey && hasPrivateInClipboard) {
|
|
158
|
+
const { useClipboard } = await escapeablePrompt([
|
|
159
|
+
{
|
|
160
|
+
type: 'confirm',
|
|
161
|
+
name: 'useClipboard',
|
|
162
|
+
message: 'Private key detected in clipboard. Use it?',
|
|
163
|
+
default: true,
|
|
164
|
+
},
|
|
165
|
+
]);
|
|
166
|
+
if (useClipboard) {
|
|
167
|
+
const privateMatch = clipboardContent.match(/-----BEGIN PGP PRIVATE KEY BLOCK-----[\s\S]*?-----END PGP PRIVATE KEY BLOCK-----/);
|
|
168
|
+
if (privateMatch) {
|
|
169
|
+
privateKey = privateMatch[0];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!privateKey) {
|
|
174
|
+
console.log(promptMessage('\nPaste your PGP PRIVATE key:'));
|
|
175
|
+
console.log(colors.muted('(Press Enter to finish, or press Enter then Ctrl+D)'));
|
|
176
|
+
privateKey = await this.readKeyInput();
|
|
177
|
+
}
|
|
178
|
+
// Validate private key format
|
|
179
|
+
if (!privateKey.includes('BEGIN PGP PRIVATE KEY BLOCK')) {
|
|
180
|
+
console.log();
|
|
181
|
+
showError('Invalid private key format');
|
|
182
|
+
console.log();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
// Verify keys match
|
|
186
|
+
console.log();
|
|
187
|
+
showLoading('Verifying keypair...');
|
|
188
|
+
const keysMatch = await verifyKeyPair(publicKey, privateKey);
|
|
189
|
+
if (!keysMatch) {
|
|
190
|
+
console.log();
|
|
191
|
+
showError('Public and private keys do not match. Please try again.');
|
|
192
|
+
console.log();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
// Prompt for passphrase if key is encrypted
|
|
196
|
+
const { passphrase } = await escapeablePrompt([
|
|
197
|
+
{
|
|
198
|
+
type: 'password',
|
|
199
|
+
name: 'passphrase',
|
|
200
|
+
message: promptMessage('Enter passphrase for private key (leave empty if none):'),
|
|
201
|
+
mask: '*',
|
|
202
|
+
},
|
|
203
|
+
]);
|
|
204
|
+
// Extract key information
|
|
205
|
+
try {
|
|
206
|
+
const keyInfo = await extractPrivateKeyInfo(privateKey, passphrase || undefined);
|
|
207
|
+
console.log();
|
|
208
|
+
showSuccess('Keypair verified!');
|
|
209
|
+
console.log();
|
|
210
|
+
console.log(colors.muted('Key Information:'));
|
|
211
|
+
showKeyValue(' Email', keyInfo.email);
|
|
212
|
+
showKeyValue(' Fingerprint', keyInfo.fingerprint);
|
|
213
|
+
showKeyValue(' Algorithm', `${keyInfo.algorithm} (${keyInfo.keySize})`);
|
|
214
|
+
showKeyValue(' Passphrase Protected', keyInfo.passphraseProtected ? 'Yes' : 'No');
|
|
215
|
+
console.log();
|
|
216
|
+
// Check if default should be set
|
|
217
|
+
let makeDefault = setAsDefault;
|
|
218
|
+
if (!setAsDefault) {
|
|
219
|
+
const { setDefault } = await escapeablePrompt([
|
|
220
|
+
{
|
|
221
|
+
type: 'confirm',
|
|
222
|
+
name: 'setDefault',
|
|
223
|
+
message: 'Set this as your default keypair?',
|
|
224
|
+
default: true,
|
|
225
|
+
},
|
|
226
|
+
]);
|
|
227
|
+
makeDefault = setDefault;
|
|
228
|
+
}
|
|
229
|
+
// If setting as default, unset current default
|
|
230
|
+
if (makeDefault) {
|
|
231
|
+
const currentDefaults = this.db.select({
|
|
232
|
+
table: 'keypair',
|
|
233
|
+
where: { key: 'is_default', compare: 'is', value: 1 },
|
|
234
|
+
});
|
|
235
|
+
for (const kp of currentDefaults) {
|
|
236
|
+
this.db.update('keypair', { key: 'id', value: kp.id }, { is_default: false });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// Save to database
|
|
240
|
+
this.db.insert('keypair', {
|
|
241
|
+
name: name.trim(),
|
|
242
|
+
email: keyInfo.email,
|
|
243
|
+
fingerprint: keyInfo.fingerprint,
|
|
244
|
+
public_key: publicKey,
|
|
245
|
+
private_key: privateKey,
|
|
246
|
+
passphrase_protected: keyInfo.passphraseProtected,
|
|
247
|
+
algorithm: keyInfo.algorithm,
|
|
248
|
+
key_size: keyInfo.keySize,
|
|
249
|
+
can_sign: keyInfo.canSign,
|
|
250
|
+
can_encrypt: keyInfo.canEncrypt,
|
|
251
|
+
can_certify: keyInfo.canCertify,
|
|
252
|
+
can_authenticate: keyInfo.canAuthenticate,
|
|
253
|
+
expires_at: keyInfo.expiresAt,
|
|
254
|
+
revoked: false,
|
|
255
|
+
revocation_reason: null,
|
|
256
|
+
last_used_at: null,
|
|
257
|
+
is_default: makeDefault,
|
|
258
|
+
});
|
|
259
|
+
console.log();
|
|
260
|
+
showSuccess('Keypair imported successfully!');
|
|
261
|
+
console.log();
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
console.log();
|
|
265
|
+
showError(`Error importing keypair: ${error}`);
|
|
266
|
+
console.log();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Generate a new PGP keypair
|
|
271
|
+
*/
|
|
272
|
+
async generateKeypair(setAsDefault = false) {
|
|
273
|
+
printSectionHeader('Generate New Keypair');
|
|
274
|
+
// Prompt for keypair details
|
|
275
|
+
const { name: userName } = await escapeablePrompt([
|
|
276
|
+
{
|
|
277
|
+
type: 'input',
|
|
278
|
+
name: 'name',
|
|
279
|
+
message: promptMessage('Your name:'),
|
|
280
|
+
validate: (input) => input.trim().length > 0 || 'Name cannot be empty',
|
|
281
|
+
},
|
|
282
|
+
]);
|
|
283
|
+
const { email } = await escapeablePrompt([
|
|
284
|
+
{
|
|
285
|
+
type: 'input',
|
|
286
|
+
name: 'email',
|
|
287
|
+
message: promptMessage('Your email:'),
|
|
288
|
+
validate: (input) => {
|
|
289
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
290
|
+
return emailRegex.test(input) || 'Please enter a valid email address';
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
]);
|
|
294
|
+
const { keypairName } = await escapeablePrompt([
|
|
295
|
+
{
|
|
296
|
+
type: 'input',
|
|
297
|
+
name: 'keypairName',
|
|
298
|
+
message: promptMessage('Keypair name (e.g., "Personal", "Work"):'),
|
|
299
|
+
default: 'Personal',
|
|
300
|
+
validate: (input) => input.trim().length > 0 || 'Name cannot be empty',
|
|
301
|
+
},
|
|
302
|
+
]);
|
|
303
|
+
const { passphrase } = await escapeablePrompt([
|
|
304
|
+
{
|
|
305
|
+
type: 'password',
|
|
306
|
+
name: 'passphrase',
|
|
307
|
+
message: promptMessage('Enter a passphrase to protect your private key:'),
|
|
308
|
+
mask: '*',
|
|
309
|
+
validate: (input) => input.length >= 8 || 'Passphrase must be at least 8 characters',
|
|
310
|
+
},
|
|
311
|
+
]);
|
|
312
|
+
const { passphraseConfirm } = await escapeablePrompt([
|
|
313
|
+
{
|
|
314
|
+
type: 'password',
|
|
315
|
+
name: 'passphraseConfirm',
|
|
316
|
+
message: promptMessage('Confirm passphrase:'),
|
|
317
|
+
mask: '*',
|
|
318
|
+
validate: (input) => input === passphrase || 'Passphrases do not match',
|
|
319
|
+
},
|
|
320
|
+
]);
|
|
321
|
+
console.log();
|
|
322
|
+
showLoading('Generating keypair... (this may take a moment)');
|
|
323
|
+
console.log();
|
|
324
|
+
try {
|
|
325
|
+
// Generate the keypair
|
|
326
|
+
const { privateKey, publicKey } = await openpgp.generateKey({
|
|
327
|
+
type: 'rsa',
|
|
328
|
+
rsaBits: 4096,
|
|
329
|
+
userIDs: [{ name: userName, email: email }],
|
|
330
|
+
passphrase: passphrase,
|
|
331
|
+
format: 'armored',
|
|
332
|
+
});
|
|
333
|
+
// Extract key information
|
|
334
|
+
const publicKeyInfo = await extractPublicKeyInfo(publicKey);
|
|
335
|
+
const privateKeyInfo = await extractPrivateKeyInfo(privateKey, passphrase);
|
|
336
|
+
// Store in database
|
|
337
|
+
const keypair = {
|
|
338
|
+
name: keypairName.trim(),
|
|
339
|
+
email: publicKeyInfo.email,
|
|
340
|
+
fingerprint: publicKeyInfo.fingerprint,
|
|
341
|
+
public_key: publicKey,
|
|
342
|
+
private_key: privateKey,
|
|
343
|
+
passphrase_protected: true,
|
|
344
|
+
algorithm: publicKeyInfo.algorithm,
|
|
345
|
+
key_size: publicKeyInfo.keySize,
|
|
346
|
+
can_sign: publicKeyInfo.canSign,
|
|
347
|
+
can_encrypt: publicKeyInfo.canEncrypt,
|
|
348
|
+
can_certify: publicKeyInfo.canCertify,
|
|
349
|
+
can_authenticate: publicKeyInfo.canAuthenticate,
|
|
350
|
+
expires_at: publicKeyInfo.expiresAt,
|
|
351
|
+
revoked: false,
|
|
352
|
+
revocation_reason: null,
|
|
353
|
+
last_used_at: null,
|
|
354
|
+
is_default: setAsDefault,
|
|
355
|
+
};
|
|
356
|
+
// If setting as default, unset all other defaults
|
|
357
|
+
if (setAsDefault) {
|
|
358
|
+
const allKeypairs = this.db.select({ table: 'keypair' });
|
|
359
|
+
for (const kp of allKeypairs) {
|
|
360
|
+
this.db.update('keypair', { key: 'id', value: kp.id }, { is_default: false });
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
this.db.insert('keypair', keypair);
|
|
364
|
+
showSuccess('Keypair generated successfully!');
|
|
365
|
+
console.log();
|
|
366
|
+
console.log(colors.infoBold('Keypair details:'));
|
|
367
|
+
printDivider();
|
|
368
|
+
showKeyValue('Name', keypairName);
|
|
369
|
+
showKeyValue('Email', publicKeyInfo.email);
|
|
370
|
+
showKeyValue('Fingerprint', publicKeyInfo.fingerprint);
|
|
371
|
+
showKeyValue('Algorithm', `${publicKeyInfo.algorithm} (${publicKeyInfo.keySize} bits)`);
|
|
372
|
+
printDivider();
|
|
373
|
+
console.log();
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
console.log();
|
|
377
|
+
showError(`Failed to generate keypair: ${error instanceof Error ? error.message : error}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Import a keypair from system GPG
|
|
382
|
+
*/
|
|
383
|
+
async importFromSystemGpg() {
|
|
384
|
+
printSectionHeader('Import from System GPG');
|
|
385
|
+
// Check if GPG is installed
|
|
386
|
+
if (!isGpgInstalled()) {
|
|
387
|
+
showWarning('GPG is not installed on this system.');
|
|
388
|
+
console.log(colors.muted('Install GPG to import keys from your system keyring.'));
|
|
389
|
+
console.log();
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const gpgHome = getGpgHomeDir();
|
|
393
|
+
if (gpgHome) {
|
|
394
|
+
console.log(colors.muted(`GPG directory found: ${gpgHome}\n`));
|
|
395
|
+
}
|
|
396
|
+
// List available keys
|
|
397
|
+
const { secretKeys } = listGpgKeys();
|
|
398
|
+
if (secretKeys.length === 0) {
|
|
399
|
+
showWarning('No secret keys found in your GPG keyring.');
|
|
400
|
+
console.log(colors.muted('Generate or import keys into GPG first using:'));
|
|
401
|
+
console.log(colors.muted(' gpg --gen-key'));
|
|
402
|
+
console.log();
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
console.log(colors.infoBold('Available GPG keys:\n'));
|
|
406
|
+
secretKeys.forEach((key, index) => {
|
|
407
|
+
console.log(colors.muted(`${index + 1}. ${key.name} <${key.email}>`));
|
|
408
|
+
console.log(colors.muted(` Fingerprint: ${key.fingerprint}`));
|
|
409
|
+
console.log();
|
|
410
|
+
});
|
|
411
|
+
// Prompt user to select a key
|
|
412
|
+
const { selectedIndex } = await escapeablePrompt([
|
|
413
|
+
{
|
|
414
|
+
type: 'list',
|
|
415
|
+
name: 'selectedIndex',
|
|
416
|
+
message: promptMessage('Select a key to import:'),
|
|
417
|
+
choices: [
|
|
418
|
+
...secretKeys.map((key, index) => ({
|
|
419
|
+
name: `${icons.key} ${key.name} ${colors.muted(`<${key.email}>`)}`,
|
|
420
|
+
value: index,
|
|
421
|
+
})),
|
|
422
|
+
new inquirer.Separator(),
|
|
423
|
+
cancelChoice(),
|
|
424
|
+
mainMenuChoice(),
|
|
425
|
+
new inquirer.Separator(),
|
|
426
|
+
],
|
|
427
|
+
},
|
|
428
|
+
]);
|
|
429
|
+
if (selectedIndex === -1 || selectedIndex === 'cancel' || selectedIndex === 'main-menu') {
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const selectedKey = secretKeys[selectedIndex];
|
|
433
|
+
if (!selectedKey) {
|
|
434
|
+
showError('Invalid key selection');
|
|
435
|
+
console.log();
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
// Export the keys
|
|
439
|
+
console.log();
|
|
440
|
+
showLoading('Exporting keys from GPG...');
|
|
441
|
+
console.log();
|
|
442
|
+
const publicKey = exportGpgPublicKey(selectedKey.fingerprint);
|
|
443
|
+
const privateKey = exportGpgSecretKey(selectedKey.fingerprint);
|
|
444
|
+
if (!publicKey || !privateKey) {
|
|
445
|
+
showError('Failed to export keys from GPG');
|
|
446
|
+
console.log();
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
// Prompt for keypair name
|
|
450
|
+
const { name } = await escapeablePrompt([
|
|
451
|
+
{
|
|
452
|
+
type: 'input',
|
|
453
|
+
name: 'name',
|
|
454
|
+
message: promptMessage('Keypair name:'),
|
|
455
|
+
default: selectedKey.name || 'Imported from GPG',
|
|
456
|
+
validate: (input) => input.trim().length > 0 || 'Name cannot be empty',
|
|
457
|
+
},
|
|
458
|
+
]);
|
|
459
|
+
// Prompt for passphrase
|
|
460
|
+
const { passphrase } = await escapeablePrompt([
|
|
461
|
+
{
|
|
462
|
+
type: 'password',
|
|
463
|
+
name: 'passphrase',
|
|
464
|
+
message: promptMessage('Enter GPG key passphrase (if any, leave empty if none):'),
|
|
465
|
+
mask: '*',
|
|
466
|
+
},
|
|
467
|
+
]);
|
|
468
|
+
// Extract key information
|
|
469
|
+
try {
|
|
470
|
+
const keyInfo = await extractPrivateKeyInfo(privateKey, passphrase || undefined);
|
|
471
|
+
console.log();
|
|
472
|
+
showSuccess('Key exported successfully!');
|
|
473
|
+
console.log();
|
|
474
|
+
console.log(colors.muted('Key Information:'));
|
|
475
|
+
showKeyValue(' Email', keyInfo.email);
|
|
476
|
+
showKeyValue(' Fingerprint', keyInfo.fingerprint);
|
|
477
|
+
showKeyValue(' Algorithm', `${keyInfo.algorithm} (${keyInfo.keySize})`);
|
|
478
|
+
showKeyValue(' Passphrase Protected', keyInfo.passphraseProtected ? 'Yes' : 'No');
|
|
479
|
+
console.log();
|
|
480
|
+
// Check if default should be set
|
|
481
|
+
const { setDefault } = await escapeablePrompt([
|
|
482
|
+
{
|
|
483
|
+
type: 'confirm',
|
|
484
|
+
name: 'setDefault',
|
|
485
|
+
message: 'Set this as your default keypair?',
|
|
486
|
+
default: true,
|
|
487
|
+
},
|
|
488
|
+
]);
|
|
489
|
+
// If setting as default, unset current default
|
|
490
|
+
if (setDefault) {
|
|
491
|
+
const currentDefaults = this.db.select({
|
|
492
|
+
table: 'keypair',
|
|
493
|
+
where: { key: 'is_default', compare: 'is', value: 1 },
|
|
494
|
+
});
|
|
495
|
+
for (const kp of currentDefaults) {
|
|
496
|
+
this.db.update('keypair', { key: 'id', value: kp.id }, { is_default: false });
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
// Save to database
|
|
500
|
+
this.db.insert('keypair', {
|
|
501
|
+
name: name.trim(),
|
|
502
|
+
email: keyInfo.email,
|
|
503
|
+
fingerprint: keyInfo.fingerprint,
|
|
504
|
+
public_key: publicKey,
|
|
505
|
+
private_key: privateKey,
|
|
506
|
+
passphrase_protected: keyInfo.passphraseProtected,
|
|
507
|
+
algorithm: keyInfo.algorithm,
|
|
508
|
+
key_size: keyInfo.keySize,
|
|
509
|
+
can_sign: keyInfo.canSign,
|
|
510
|
+
can_encrypt: keyInfo.canEncrypt,
|
|
511
|
+
can_certify: keyInfo.canCertify,
|
|
512
|
+
can_authenticate: keyInfo.canAuthenticate,
|
|
513
|
+
expires_at: keyInfo.expiresAt,
|
|
514
|
+
revoked: false,
|
|
515
|
+
revocation_reason: null,
|
|
516
|
+
last_used_at: null,
|
|
517
|
+
is_default: setDefault,
|
|
518
|
+
});
|
|
519
|
+
console.log();
|
|
520
|
+
showSuccess('Keypair imported from GPG successfully!');
|
|
521
|
+
console.log();
|
|
522
|
+
}
|
|
523
|
+
catch (error) {
|
|
524
|
+
console.log();
|
|
525
|
+
showError(`Error importing keypair: ${error}`);
|
|
526
|
+
console.log();
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* List all keypairs
|
|
531
|
+
*/
|
|
532
|
+
listKeypairs() {
|
|
533
|
+
const keypairs = this.db.select({ table: 'keypair' });
|
|
534
|
+
if (keypairs.length === 0) {
|
|
535
|
+
console.log();
|
|
536
|
+
showWarning('No keypairs found.');
|
|
537
|
+
console.log();
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
console.log(colors.infoBold('\nYour Keypairs:\n'));
|
|
541
|
+
for (const keypair of keypairs) {
|
|
542
|
+
printDivider();
|
|
543
|
+
console.log(formatKeypairInfo(keypair));
|
|
544
|
+
}
|
|
545
|
+
printDivider();
|
|
546
|
+
console.log();
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Show key management menu
|
|
550
|
+
*/
|
|
551
|
+
async showKeyManagementMenu() {
|
|
552
|
+
const { action } = await escapeablePrompt([
|
|
553
|
+
{
|
|
554
|
+
type: 'list',
|
|
555
|
+
name: 'action',
|
|
556
|
+
message: promptMessage('Key Management'),
|
|
557
|
+
choices: [
|
|
558
|
+
{ name: `${icons.key} View/manage my keys`, value: 'view' },
|
|
559
|
+
{ name: `${icons.contact} View/manage contacts`, value: 'contacts' },
|
|
560
|
+
{ name: `${icons.import} Import keypair`, value: 'import' },
|
|
561
|
+
{ name: `${icons.gpg} Import from system GPG`, value: 'import-gpg' },
|
|
562
|
+
{ name: `${icons.generate} Generate new keypair`, value: 'generate' },
|
|
563
|
+
new inquirer.Separator(),
|
|
564
|
+
mainMenuChoice(),
|
|
565
|
+
],
|
|
566
|
+
},
|
|
567
|
+
]);
|
|
568
|
+
switch (action) {
|
|
569
|
+
case 'view':
|
|
570
|
+
const viewResult = await this.viewAndManageKeys();
|
|
571
|
+
if (viewResult === 'main-menu')
|
|
572
|
+
return 'main-menu';
|
|
573
|
+
await this.showKeyManagementMenu();
|
|
574
|
+
break;
|
|
575
|
+
case 'contacts':
|
|
576
|
+
const contactsResult = await this.viewAndManageContacts();
|
|
577
|
+
if (contactsResult === 'main-menu')
|
|
578
|
+
return 'main-menu';
|
|
579
|
+
await this.showKeyManagementMenu();
|
|
580
|
+
break;
|
|
581
|
+
case 'import':
|
|
582
|
+
await this.importKeypair();
|
|
583
|
+
await this.showKeyManagementMenu();
|
|
584
|
+
break;
|
|
585
|
+
case 'import-gpg':
|
|
586
|
+
await this.importFromSystemGpg();
|
|
587
|
+
await this.showKeyManagementMenu();
|
|
588
|
+
break;
|
|
589
|
+
case 'generate':
|
|
590
|
+
await this.generateKeypair();
|
|
591
|
+
await this.showKeyManagementMenu();
|
|
592
|
+
break;
|
|
593
|
+
case 'back':
|
|
594
|
+
case 'main-menu':
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* View and manage individual keys
|
|
600
|
+
*/
|
|
601
|
+
async viewAndManageKeys() {
|
|
602
|
+
const keypairs = this.db.select({ table: 'keypair' });
|
|
603
|
+
if (keypairs.length === 0) {
|
|
604
|
+
console.log();
|
|
605
|
+
showWarning('No keypairs found.');
|
|
606
|
+
console.log();
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
const { keypairId } = await escapeablePrompt([
|
|
610
|
+
{
|
|
611
|
+
type: 'list',
|
|
612
|
+
name: 'keypairId',
|
|
613
|
+
message: promptMessage('Select a key to manage:'),
|
|
614
|
+
choices: [
|
|
615
|
+
...keypairs.map((kp) => ({
|
|
616
|
+
name: `${icons.key} ${kp.name} ${colors.muted(`- ${obfuscateEmail(kp.email)}`)}${kp.is_default ? ` ${icons.default} Default` : ''}`,
|
|
617
|
+
value: kp.id,
|
|
618
|
+
})),
|
|
619
|
+
new inquirer.Separator(),
|
|
620
|
+
backChoice(),
|
|
621
|
+
mainMenuChoice(),
|
|
622
|
+
new inquirer.Separator(),
|
|
623
|
+
],
|
|
624
|
+
},
|
|
625
|
+
]);
|
|
626
|
+
if (keypairId === 'main-menu') {
|
|
627
|
+
return 'main-menu';
|
|
628
|
+
}
|
|
629
|
+
if (keypairId === null || keypairId === 'back') {
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
const selectedKeypair = keypairs.find((kp) => kp.id === keypairId);
|
|
633
|
+
if (!selectedKeypair)
|
|
634
|
+
return;
|
|
635
|
+
const result = await this.manageIndividualKey(selectedKeypair);
|
|
636
|
+
if (result === 'main-menu')
|
|
637
|
+
return 'main-menu';
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Manage an individual key
|
|
641
|
+
*/
|
|
642
|
+
async manageIndividualKey(keypair) {
|
|
643
|
+
// Display key information
|
|
644
|
+
printSectionHeader('Key Details');
|
|
645
|
+
console.log(formatKeypairInfo(keypair));
|
|
646
|
+
console.log();
|
|
647
|
+
// Check if passphrase is stored in keychain
|
|
648
|
+
const hasStoredPw = keypair.passphrase_protected
|
|
649
|
+
? await hasStoredPassphrase(keypair.fingerprint)
|
|
650
|
+
: false;
|
|
651
|
+
// Build menu choices dynamically
|
|
652
|
+
const choices = [
|
|
653
|
+
{ name: `${icons.copy} Copy public key`, value: 'copy-public' },
|
|
654
|
+
{ name: `${icons.export} Export keypair`, value: 'export' },
|
|
655
|
+
{ name: `${icons.edit} Rename key`, value: 'rename' },
|
|
656
|
+
{ name: `${icons.key} Set as default`, value: 'set-default' },
|
|
657
|
+
];
|
|
658
|
+
// Add passphrase management option if applicable
|
|
659
|
+
if (hasStoredPw) {
|
|
660
|
+
choices.push({ name: `${icons.unlocked} Clear saved passphrase ${colors.muted('(from keychain)')}`, value: 'clear-passphrase' });
|
|
661
|
+
}
|
|
662
|
+
choices.push({ name: `${icons.exit} Delete key`, value: 'delete' }, new inquirer.Separator(), backChoice('Back to key list'), mainMenuChoice());
|
|
663
|
+
const { action } = await escapeablePrompt([
|
|
664
|
+
{
|
|
665
|
+
type: 'list',
|
|
666
|
+
name: 'action',
|
|
667
|
+
message: promptMessage('What would you like to do?'),
|
|
668
|
+
choices,
|
|
669
|
+
},
|
|
670
|
+
]);
|
|
671
|
+
switch (action) {
|
|
672
|
+
case 'copy-public':
|
|
673
|
+
await this.copyPublicKey(keypair);
|
|
674
|
+
return this.manageIndividualKey(keypair);
|
|
675
|
+
case 'export':
|
|
676
|
+
await this.exportKeypair(keypair);
|
|
677
|
+
return this.manageIndividualKey(keypair);
|
|
678
|
+
case 'rename':
|
|
679
|
+
await this.renameKeypair(keypair);
|
|
680
|
+
// Refresh keypair data after rename
|
|
681
|
+
const updated = this.db.select({
|
|
682
|
+
table: 'keypair',
|
|
683
|
+
where: { key: 'id', compare: 'is', value: keypair.id },
|
|
684
|
+
})[0];
|
|
685
|
+
if (updated)
|
|
686
|
+
return this.manageIndividualKey(updated);
|
|
687
|
+
break;
|
|
688
|
+
case 'set-default':
|
|
689
|
+
await this.setDefaultKeypairById(keypair.id);
|
|
690
|
+
// Refresh keypair data
|
|
691
|
+
const refreshed = this.db.select({
|
|
692
|
+
table: 'keypair',
|
|
693
|
+
where: { key: 'id', compare: 'is', value: keypair.id },
|
|
694
|
+
})[0];
|
|
695
|
+
if (refreshed)
|
|
696
|
+
return this.manageIndividualKey(refreshed);
|
|
697
|
+
break;
|
|
698
|
+
case 'clear-passphrase':
|
|
699
|
+
await this.clearStoredPassphrase(keypair);
|
|
700
|
+
return this.manageIndividualKey(keypair);
|
|
701
|
+
case 'delete':
|
|
702
|
+
const deleted = await this.deleteKeypairById(keypair.id);
|
|
703
|
+
if (!deleted) {
|
|
704
|
+
return this.manageIndividualKey(keypair);
|
|
705
|
+
}
|
|
706
|
+
break;
|
|
707
|
+
case 'main-menu':
|
|
708
|
+
return 'main-menu';
|
|
709
|
+
case 'back':
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Copy public key to clipboard
|
|
715
|
+
*/
|
|
716
|
+
async copyPublicKey(keypair) {
|
|
717
|
+
console.clear();
|
|
718
|
+
printBanner();
|
|
719
|
+
console.log(colors.successBold('Public Key:\n'));
|
|
720
|
+
printDivider();
|
|
721
|
+
console.log(keypair.public_key);
|
|
722
|
+
printDivider();
|
|
723
|
+
try {
|
|
724
|
+
const clipboardy = (await import('clipboardy')).default;
|
|
725
|
+
await clipboardy.write(keypair.public_key);
|
|
726
|
+
console.log();
|
|
727
|
+
showSuccess('Public key copied to clipboard');
|
|
728
|
+
console.log();
|
|
729
|
+
}
|
|
730
|
+
catch (error) {
|
|
731
|
+
console.log();
|
|
732
|
+
showWarning('Clipboard unavailable');
|
|
733
|
+
console.log();
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Export keypair to files
|
|
738
|
+
*/
|
|
739
|
+
async exportKeypair(keypair) {
|
|
740
|
+
const { exportType } = await escapeablePrompt([
|
|
741
|
+
{
|
|
742
|
+
type: 'list',
|
|
743
|
+
name: 'exportType',
|
|
744
|
+
message: promptMessage('What would you like to export?'),
|
|
745
|
+
choices: [
|
|
746
|
+
{ name: `${icons.view} Public key only`, value: 'public' },
|
|
747
|
+
{ name: `${icons.copy} Both public and private keys`, value: 'both' },
|
|
748
|
+
new inquirer.Separator(),
|
|
749
|
+
cancelChoice(),
|
|
750
|
+
mainMenuChoice(),
|
|
751
|
+
],
|
|
752
|
+
},
|
|
753
|
+
]);
|
|
754
|
+
if (exportType === 'cancel' || exportType === 'main-menu')
|
|
755
|
+
return;
|
|
756
|
+
const { exportMethod } = await escapeablePrompt([
|
|
757
|
+
{
|
|
758
|
+
type: 'list',
|
|
759
|
+
name: 'exportMethod',
|
|
760
|
+
message: promptMessage('How would you like to export?'),
|
|
761
|
+
choices: [
|
|
762
|
+
{ name: `${icons.clipboard} Copy to clipboard`, value: 'clipboard' },
|
|
763
|
+
{ name: `${icons.view} Display on screen`, value: 'display' },
|
|
764
|
+
new inquirer.Separator(),
|
|
765
|
+
cancelChoice(),
|
|
766
|
+
mainMenuChoice(),
|
|
767
|
+
],
|
|
768
|
+
},
|
|
769
|
+
]);
|
|
770
|
+
if (exportMethod === 'cancel' || exportMethod === 'main-menu')
|
|
771
|
+
return;
|
|
772
|
+
let content = '';
|
|
773
|
+
if (exportType === 'public') {
|
|
774
|
+
content = keypair.public_key;
|
|
775
|
+
}
|
|
776
|
+
else {
|
|
777
|
+
content = `PUBLIC KEY:\n${keypair.public_key}\n\nPRIVATE KEY:\n${keypair.private_key}`;
|
|
778
|
+
}
|
|
779
|
+
console.clear();
|
|
780
|
+
printBanner();
|
|
781
|
+
const label = exportType === 'public' ? 'Public Key' : 'Keypair';
|
|
782
|
+
console.log(colors.successBold(`Exported ${label}:\n`));
|
|
783
|
+
printDivider();
|
|
784
|
+
console.log(content);
|
|
785
|
+
printDivider();
|
|
786
|
+
if (exportMethod === 'clipboard') {
|
|
787
|
+
try {
|
|
788
|
+
const clipboardy = (await import('clipboardy')).default;
|
|
789
|
+
await clipboardy.write(content);
|
|
790
|
+
console.log();
|
|
791
|
+
showSuccess(`${label} copied to clipboard`);
|
|
792
|
+
console.log();
|
|
793
|
+
}
|
|
794
|
+
catch (error) {
|
|
795
|
+
console.log();
|
|
796
|
+
showWarning('Clipboard unavailable');
|
|
797
|
+
console.log();
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
else {
|
|
801
|
+
console.log();
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Rename a keypair
|
|
806
|
+
*/
|
|
807
|
+
async renameKeypair(keypair) {
|
|
808
|
+
const { newName } = await escapeablePrompt([
|
|
809
|
+
{
|
|
810
|
+
type: 'input',
|
|
811
|
+
name: 'newName',
|
|
812
|
+
message: promptMessage('Enter new name:'),
|
|
813
|
+
default: keypair.name,
|
|
814
|
+
validate: (input) => input.trim().length > 0 || 'Name cannot be empty',
|
|
815
|
+
},
|
|
816
|
+
]);
|
|
817
|
+
this.db.update('keypair', { key: 'id', value: keypair.id }, { name: newName.trim() });
|
|
818
|
+
console.log();
|
|
819
|
+
showSuccess('Keypair renamed!');
|
|
820
|
+
console.log();
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* Set a keypair as default by ID
|
|
824
|
+
*/
|
|
825
|
+
async setDefaultKeypairById(keypairId) {
|
|
826
|
+
const keypairs = this.db.select({ table: 'keypair' });
|
|
827
|
+
// Unset all defaults
|
|
828
|
+
for (const kp of keypairs) {
|
|
829
|
+
this.db.update('keypair', { key: 'id', value: kp.id }, { is_default: false });
|
|
830
|
+
}
|
|
831
|
+
// Set new default
|
|
832
|
+
this.db.update('keypair', { key: 'id', value: keypairId }, { is_default: true });
|
|
833
|
+
console.log();
|
|
834
|
+
showSuccess('Set as default keypair!');
|
|
835
|
+
console.log();
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* Clear stored passphrase from system keychain
|
|
839
|
+
*/
|
|
840
|
+
async clearStoredPassphrase(keypair) {
|
|
841
|
+
const { confirm } = await escapeablePrompt([
|
|
842
|
+
{
|
|
843
|
+
type: 'confirm',
|
|
844
|
+
name: 'confirm',
|
|
845
|
+
message: promptMessage('Remove saved passphrase from system keychain?'),
|
|
846
|
+
default: true,
|
|
847
|
+
},
|
|
848
|
+
]);
|
|
849
|
+
if (confirm) {
|
|
850
|
+
const deleted = await deleteStoredPassphrase(keypair.fingerprint);
|
|
851
|
+
if (deleted) {
|
|
852
|
+
console.log();
|
|
853
|
+
showSuccess('Saved passphrase removed from system keychain.');
|
|
854
|
+
console.log();
|
|
855
|
+
}
|
|
856
|
+
else {
|
|
857
|
+
console.log();
|
|
858
|
+
showWarning('Could not remove passphrase (may not exist or keychain unavailable).');
|
|
859
|
+
console.log();
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Delete a keypair by ID
|
|
865
|
+
*/
|
|
866
|
+
async deleteKeypairById(keypairId) {
|
|
867
|
+
const { confirm } = await escapeablePrompt([
|
|
868
|
+
{
|
|
869
|
+
type: 'confirm',
|
|
870
|
+
name: 'confirm',
|
|
871
|
+
message: colors.error('Are you sure? This action cannot be undone.'),
|
|
872
|
+
default: false,
|
|
873
|
+
},
|
|
874
|
+
]);
|
|
875
|
+
if (confirm) {
|
|
876
|
+
this.db.delete('keypair', { key: 'id', value: keypairId });
|
|
877
|
+
console.log();
|
|
878
|
+
showSuccess('Keypair deleted.');
|
|
879
|
+
console.log();
|
|
880
|
+
return true;
|
|
881
|
+
}
|
|
882
|
+
return false;
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Set a keypair as default
|
|
886
|
+
*/
|
|
887
|
+
async setDefaultKeypair() {
|
|
888
|
+
const keypairs = this.db.select({ table: 'keypair' });
|
|
889
|
+
if (keypairs.length === 0) {
|
|
890
|
+
console.log();
|
|
891
|
+
showWarning('No keypairs available.');
|
|
892
|
+
console.log();
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
const { keypairId } = await escapeablePrompt([
|
|
896
|
+
{
|
|
897
|
+
type: 'list',
|
|
898
|
+
name: 'keypairId',
|
|
899
|
+
message: promptMessage('Select default keypair:'),
|
|
900
|
+
choices: keypairs.map((kp) => ({
|
|
901
|
+
name: `${icons.key} ${kp.name} ${colors.muted(`(${obfuscateEmail(kp.email)})`)} ${kp.is_default ? `${icons.default} Current Default` : ''}`,
|
|
902
|
+
value: kp.id,
|
|
903
|
+
})),
|
|
904
|
+
},
|
|
905
|
+
]);
|
|
906
|
+
// Unset all defaults
|
|
907
|
+
for (const kp of keypairs) {
|
|
908
|
+
this.db.update('keypair', { key: 'id', value: kp.id }, { is_default: false });
|
|
909
|
+
}
|
|
910
|
+
// Set new default
|
|
911
|
+
this.db.update('keypair', { key: 'id', value: keypairId }, { is_default: true });
|
|
912
|
+
console.log();
|
|
913
|
+
showSuccess('Default keypair updated!');
|
|
914
|
+
console.log();
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Delete a keypair
|
|
918
|
+
*/
|
|
919
|
+
async deleteKeypair() {
|
|
920
|
+
const keypairs = this.db.select({ table: 'keypair' });
|
|
921
|
+
if (keypairs.length === 0) {
|
|
922
|
+
console.log();
|
|
923
|
+
showWarning('No keypairs available.');
|
|
924
|
+
console.log();
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
const { keypairId } = await escapeablePrompt([
|
|
928
|
+
{
|
|
929
|
+
type: 'list',
|
|
930
|
+
name: 'keypairId',
|
|
931
|
+
message: promptMessage('Select keypair to delete:'),
|
|
932
|
+
choices: keypairs.map((kp) => ({
|
|
933
|
+
name: `${icons.key} ${kp.name} ${colors.muted(`(${obfuscateEmail(kp.email)})`)}`,
|
|
934
|
+
value: kp.id,
|
|
935
|
+
})),
|
|
936
|
+
},
|
|
937
|
+
]);
|
|
938
|
+
const { confirm } = await escapeablePrompt([
|
|
939
|
+
{
|
|
940
|
+
type: 'confirm',
|
|
941
|
+
name: 'confirm',
|
|
942
|
+
message: colors.error('Are you sure? This action cannot be undone.'),
|
|
943
|
+
default: false,
|
|
944
|
+
},
|
|
945
|
+
]);
|
|
946
|
+
if (confirm) {
|
|
947
|
+
this.db.delete('keypair', { key: 'id', value: keypairId });
|
|
948
|
+
console.log();
|
|
949
|
+
showSuccess('Keypair deleted.');
|
|
950
|
+
console.log();
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Read multiline input from stdin
|
|
955
|
+
*/
|
|
956
|
+
async readMultilineInput() {
|
|
957
|
+
return new Promise((resolve) => {
|
|
958
|
+
const lines = [];
|
|
959
|
+
const rl = readline.createInterface({
|
|
960
|
+
input: process.stdin,
|
|
961
|
+
output: process.stdout,
|
|
962
|
+
});
|
|
963
|
+
rl.setPrompt('');
|
|
964
|
+
rl.on('line', (line) => {
|
|
965
|
+
lines.push(line);
|
|
966
|
+
});
|
|
967
|
+
rl.on('close', () => {
|
|
968
|
+
resolve(lines.join('\n'));
|
|
969
|
+
});
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* Read PGP key input with smart detection
|
|
974
|
+
* Allows finishing with Enter when a complete key is detected, or Ctrl+D
|
|
975
|
+
*/
|
|
976
|
+
async readKeyInput() {
|
|
977
|
+
return new Promise((resolve) => {
|
|
978
|
+
const lines = [];
|
|
979
|
+
const rl = readline.createInterface({
|
|
980
|
+
input: process.stdin,
|
|
981
|
+
output: process.stdout,
|
|
982
|
+
});
|
|
983
|
+
rl.setPrompt('');
|
|
984
|
+
rl.on('line', (line) => {
|
|
985
|
+
lines.push(line);
|
|
986
|
+
const content = lines.join('\n');
|
|
987
|
+
// Check if we have a complete key block and current line is empty
|
|
988
|
+
if (line.trim() === '' &&
|
|
989
|
+
content.includes('-----BEGIN PGP') &&
|
|
990
|
+
content.includes('-----END PGP')) {
|
|
991
|
+
rl.close();
|
|
992
|
+
resolve(content.trim());
|
|
993
|
+
}
|
|
994
|
+
});
|
|
995
|
+
rl.on('close', () => {
|
|
996
|
+
resolve(lines.join('\n'));
|
|
997
|
+
});
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* View and manage contacts
|
|
1002
|
+
*/
|
|
1003
|
+
async viewAndManageContacts() {
|
|
1004
|
+
const contacts = this.db.select({ table: 'contact' });
|
|
1005
|
+
if (contacts.length === 0) {
|
|
1006
|
+
console.log();
|
|
1007
|
+
showWarning('No contacts found.');
|
|
1008
|
+
console.log();
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
const { contactId } = await escapeablePrompt([
|
|
1012
|
+
{
|
|
1013
|
+
type: 'list',
|
|
1014
|
+
name: 'contactId',
|
|
1015
|
+
message: promptMessage('Select a contact to manage:'),
|
|
1016
|
+
choices: [
|
|
1017
|
+
...contacts.map((c) => ({
|
|
1018
|
+
name: `${icons.contact} ${c.name} ${colors.muted(`- ${c.email}`)}`,
|
|
1019
|
+
value: c.id,
|
|
1020
|
+
})),
|
|
1021
|
+
new inquirer.Separator(),
|
|
1022
|
+
backChoice(),
|
|
1023
|
+
mainMenuChoice(),
|
|
1024
|
+
new inquirer.Separator(),
|
|
1025
|
+
],
|
|
1026
|
+
},
|
|
1027
|
+
]);
|
|
1028
|
+
if (contactId === 'main-menu') {
|
|
1029
|
+
return 'main-menu';
|
|
1030
|
+
}
|
|
1031
|
+
if (contactId === null || contactId === 'back') {
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
const selectedContact = contacts.find((c) => c.id === contactId);
|
|
1035
|
+
if (!selectedContact)
|
|
1036
|
+
return;
|
|
1037
|
+
const result = await this.manageIndividualContact(selectedContact);
|
|
1038
|
+
if (result === 'main-menu')
|
|
1039
|
+
return 'main-menu';
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* Manage an individual contact
|
|
1043
|
+
*/
|
|
1044
|
+
async manageIndividualContact(contact) {
|
|
1045
|
+
// Display contact information
|
|
1046
|
+
printSectionHeader('Contact Details');
|
|
1047
|
+
showKeyValue('Name', contact.name);
|
|
1048
|
+
showKeyValue('Email', contact.email);
|
|
1049
|
+
showKeyValue('Fingerprint', contact.fingerprint);
|
|
1050
|
+
showKeyValue('Algorithm', `${contact.algorithm} (${contact.key_size})`);
|
|
1051
|
+
showKeyValue('Trusted', contact.trusted ? 'Yes' : 'No');
|
|
1052
|
+
if (contact.expires_at) {
|
|
1053
|
+
showKeyValue('Expires', contact.expires_at);
|
|
1054
|
+
}
|
|
1055
|
+
if (contact.notes) {
|
|
1056
|
+
showKeyValue('Notes', contact.notes);
|
|
1057
|
+
}
|
|
1058
|
+
console.log();
|
|
1059
|
+
const { action } = await escapeablePrompt([
|
|
1060
|
+
{
|
|
1061
|
+
type: 'list',
|
|
1062
|
+
name: 'action',
|
|
1063
|
+
message: promptMessage('What would you like to do?'),
|
|
1064
|
+
choices: [
|
|
1065
|
+
{ name: `${icons.copy} Copy public key`, value: 'copy-public' },
|
|
1066
|
+
{ name: `${icons.view} View public key`, value: 'view-public' },
|
|
1067
|
+
{ name: `${icons.edit} Rename contact`, value: 'rename' },
|
|
1068
|
+
{ name: `${icons.notes} Edit notes`, value: 'edit-notes' },
|
|
1069
|
+
{ name: `${icons.trust} Toggle trust`, value: 'toggle-trust' },
|
|
1070
|
+
{ name: `${icons.exit} Delete contact`, value: 'delete' },
|
|
1071
|
+
new inquirer.Separator(),
|
|
1072
|
+
backChoice('Back to contact list'),
|
|
1073
|
+
mainMenuChoice(),
|
|
1074
|
+
],
|
|
1075
|
+
},
|
|
1076
|
+
]);
|
|
1077
|
+
switch (action) {
|
|
1078
|
+
case 'copy-public':
|
|
1079
|
+
await this.copyContactPublicKey(contact);
|
|
1080
|
+
return this.manageIndividualContact(contact);
|
|
1081
|
+
case 'view-public':
|
|
1082
|
+
await this.viewContactPublicKey(contact);
|
|
1083
|
+
return this.manageIndividualContact(contact);
|
|
1084
|
+
case 'rename':
|
|
1085
|
+
await this.renameContact(contact);
|
|
1086
|
+
const updated = this.db.select({
|
|
1087
|
+
table: 'contact',
|
|
1088
|
+
where: { key: 'id', compare: 'is', value: contact.id },
|
|
1089
|
+
})[0];
|
|
1090
|
+
if (updated)
|
|
1091
|
+
return this.manageIndividualContact(updated);
|
|
1092
|
+
break;
|
|
1093
|
+
case 'edit-notes':
|
|
1094
|
+
await this.editContactNotes(contact);
|
|
1095
|
+
const updatedNotes = this.db.select({
|
|
1096
|
+
table: 'contact',
|
|
1097
|
+
where: { key: 'id', compare: 'is', value: contact.id },
|
|
1098
|
+
})[0];
|
|
1099
|
+
if (updatedNotes)
|
|
1100
|
+
return this.manageIndividualContact(updatedNotes);
|
|
1101
|
+
break;
|
|
1102
|
+
case 'toggle-trust':
|
|
1103
|
+
await this.toggleContactTrust(contact);
|
|
1104
|
+
const refreshed = this.db.select({
|
|
1105
|
+
table: 'contact',
|
|
1106
|
+
where: { key: 'id', compare: 'is', value: contact.id },
|
|
1107
|
+
})[0];
|
|
1108
|
+
if (refreshed)
|
|
1109
|
+
return this.manageIndividualContact(refreshed);
|
|
1110
|
+
break;
|
|
1111
|
+
case 'delete':
|
|
1112
|
+
const deleted = await this.deleteContact(contact.id);
|
|
1113
|
+
if (!deleted) {
|
|
1114
|
+
return this.manageIndividualContact(contact);
|
|
1115
|
+
}
|
|
1116
|
+
break;
|
|
1117
|
+
case 'main-menu':
|
|
1118
|
+
return 'main-menu';
|
|
1119
|
+
case 'back':
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Copy contact's public key to clipboard
|
|
1125
|
+
*/
|
|
1126
|
+
async copyContactPublicKey(contact) {
|
|
1127
|
+
console.clear();
|
|
1128
|
+
printBanner();
|
|
1129
|
+
console.log(colors.successBold(`${contact.name}'s Public Key:\n`));
|
|
1130
|
+
printDivider();
|
|
1131
|
+
console.log(contact.public_key);
|
|
1132
|
+
printDivider();
|
|
1133
|
+
try {
|
|
1134
|
+
const clipboardy = (await import('clipboardy')).default;
|
|
1135
|
+
await clipboardy.write(contact.public_key);
|
|
1136
|
+
console.log();
|
|
1137
|
+
showSuccess('Public key copied to clipboard');
|
|
1138
|
+
console.log();
|
|
1139
|
+
}
|
|
1140
|
+
catch (error) {
|
|
1141
|
+
console.log();
|
|
1142
|
+
showWarning('Clipboard unavailable');
|
|
1143
|
+
console.log();
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* View contact's public key
|
|
1148
|
+
*/
|
|
1149
|
+
async viewContactPublicKey(contact) {
|
|
1150
|
+
console.clear();
|
|
1151
|
+
printBanner();
|
|
1152
|
+
console.log(colors.successBold(`${contact.name}'s Public Key:\n`));
|
|
1153
|
+
printDivider();
|
|
1154
|
+
console.log(contact.public_key);
|
|
1155
|
+
printDivider();
|
|
1156
|
+
console.log();
|
|
1157
|
+
await escapeablePrompt([
|
|
1158
|
+
{
|
|
1159
|
+
type: 'input',
|
|
1160
|
+
name: 'continue',
|
|
1161
|
+
message: colors.muted('Press Enter to continue...'),
|
|
1162
|
+
},
|
|
1163
|
+
]);
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* Rename a contact
|
|
1167
|
+
*/
|
|
1168
|
+
async renameContact(contact) {
|
|
1169
|
+
const { newName } = await escapeablePrompt([
|
|
1170
|
+
{
|
|
1171
|
+
type: 'input',
|
|
1172
|
+
name: 'newName',
|
|
1173
|
+
message: promptMessage('Enter new name:'),
|
|
1174
|
+
default: contact.name,
|
|
1175
|
+
validate: (input) => input.trim().length > 0 || 'Name cannot be empty',
|
|
1176
|
+
},
|
|
1177
|
+
]);
|
|
1178
|
+
this.db.update('contact', { key: 'id', value: contact.id }, { name: newName.trim() });
|
|
1179
|
+
console.log();
|
|
1180
|
+
showSuccess('Contact renamed!');
|
|
1181
|
+
console.log();
|
|
1182
|
+
}
|
|
1183
|
+
/**
|
|
1184
|
+
* Edit contact notes
|
|
1185
|
+
*/
|
|
1186
|
+
async editContactNotes(contact) {
|
|
1187
|
+
const { notes } = await escapeablePrompt([
|
|
1188
|
+
{
|
|
1189
|
+
type: 'input',
|
|
1190
|
+
name: 'notes',
|
|
1191
|
+
message: promptMessage('Enter notes:'),
|
|
1192
|
+
default: contact.notes || '',
|
|
1193
|
+
},
|
|
1194
|
+
]);
|
|
1195
|
+
this.db.update('contact', { key: 'id', value: contact.id }, { notes: notes.trim() || null });
|
|
1196
|
+
console.log();
|
|
1197
|
+
showSuccess('Notes updated!');
|
|
1198
|
+
console.log();
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* Toggle contact trust status
|
|
1202
|
+
*/
|
|
1203
|
+
async toggleContactTrust(contact) {
|
|
1204
|
+
const newTrustStatus = !contact.trusted;
|
|
1205
|
+
this.db.update('contact', { key: 'id', value: contact.id }, {
|
|
1206
|
+
trusted: newTrustStatus,
|
|
1207
|
+
last_verified_at: newTrustStatus ? new Date().toISOString() : contact.last_verified_at
|
|
1208
|
+
});
|
|
1209
|
+
console.log();
|
|
1210
|
+
showSuccess(`Contact marked as ${newTrustStatus ? 'trusted' : 'untrusted'}!`);
|
|
1211
|
+
console.log();
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* Delete a contact
|
|
1215
|
+
*/
|
|
1216
|
+
async deleteContact(contactId) {
|
|
1217
|
+
const { confirm } = await escapeablePrompt([
|
|
1218
|
+
{
|
|
1219
|
+
type: 'confirm',
|
|
1220
|
+
name: 'confirm',
|
|
1221
|
+
message: colors.error('Are you sure? This action cannot be undone.'),
|
|
1222
|
+
default: false,
|
|
1223
|
+
},
|
|
1224
|
+
]);
|
|
1225
|
+
if (confirm) {
|
|
1226
|
+
this.db.delete('contact', { key: 'id', value: contactId });
|
|
1227
|
+
console.log();
|
|
1228
|
+
showSuccess('Contact deleted.');
|
|
1229
|
+
console.log();
|
|
1230
|
+
return true;
|
|
1231
|
+
}
|
|
1232
|
+
return false;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
//# sourceMappingURL=key-manager.js.map
|