bgit-cli 2.0.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/.claude/settings.local.json +19 -0
- package/.env.local +2 -0
- package/CLAUDE_PLAN.md +621 -0
- package/IMPLEMENTATION_REPORT.md +1690 -0
- package/README.md +277 -0
- package/UNIVERSAL_PLAN.md +31 -0
- package/handcash.js +36 -0
- package/index.js +158 -0
- package/index.js.backup +69 -0
- package/lib/auth.js +273 -0
- package/lib/banner.js +17 -0
- package/lib/command-router.js +191 -0
- package/lib/commands.js +157 -0
- package/lib/config.js +438 -0
- package/lib/constants.js +57 -0
- package/lib/crypto.js +164 -0
- package/lib/oauth-server.js +300 -0
- package/lib/payment.js +287 -0
- package/lib/token-manager.js +179 -0
- package/package.json +45 -0
package/lib/config.js
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bgit Configuration Management
|
|
3
|
+
*
|
|
4
|
+
* Manages encrypted token storage in ~/.bgit/config.json
|
|
5
|
+
* Uses machine-specific encryption key derived from hostname + username
|
|
6
|
+
*
|
|
7
|
+
* Security features:
|
|
8
|
+
* - Machine-specific encryption (token only works on same machine)
|
|
9
|
+
* - File permissions: 700 for directory, 600 for files
|
|
10
|
+
* - Random salt per machine
|
|
11
|
+
* - Automatic config validation and repair
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const os = require('os');
|
|
17
|
+
const crypto = require('crypto');
|
|
18
|
+
const { generateSalt, deriveKey, encrypt, decrypt } = require('./crypto');
|
|
19
|
+
const {
|
|
20
|
+
CONFIG_DIR_NAME,
|
|
21
|
+
CONFIG_FILE_NAME,
|
|
22
|
+
SALT_FILE_NAME,
|
|
23
|
+
DIR_PERMISSIONS,
|
|
24
|
+
FILE_PERMISSIONS
|
|
25
|
+
} = require('./constants');
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get the config directory path (~/.bgit)
|
|
29
|
+
* @returns {string} Absolute path to config directory
|
|
30
|
+
*/
|
|
31
|
+
function getConfigDir() {
|
|
32
|
+
return path.join(os.homedir(), CONFIG_DIR_NAME);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get the config file path (~/.bgit/config.json)
|
|
37
|
+
* @returns {string} Absolute path to config file
|
|
38
|
+
*/
|
|
39
|
+
function getConfigPath() {
|
|
40
|
+
return path.join(getConfigDir(), CONFIG_FILE_NAME);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get the salt file path (~/.bgit/.salt)
|
|
45
|
+
* @returns {string} Absolute path to salt file
|
|
46
|
+
*/
|
|
47
|
+
function getSaltPath() {
|
|
48
|
+
return path.join(getConfigDir(), SALT_FILE_NAME);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Ensure config directory exists with correct permissions
|
|
53
|
+
* Creates directory if it doesn't exist
|
|
54
|
+
* @throws {Error} If directory creation fails
|
|
55
|
+
*/
|
|
56
|
+
function ensureConfigDir() {
|
|
57
|
+
const configDir = getConfigDir();
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
if (!fs.existsSync(configDir)) {
|
|
61
|
+
fs.mkdirSync(configDir, { mode: DIR_PERMISSIONS, recursive: true });
|
|
62
|
+
console.log(`Created config directory: ${configDir}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Verify and fix permissions if needed
|
|
66
|
+
const stats = fs.statSync(configDir);
|
|
67
|
+
const currentMode = stats.mode & 0o777;
|
|
68
|
+
|
|
69
|
+
if (currentMode !== DIR_PERMISSIONS) {
|
|
70
|
+
fs.chmodSync(configDir, DIR_PERMISSIONS);
|
|
71
|
+
console.warn(`Fixed config directory permissions: ${configDir}`);
|
|
72
|
+
}
|
|
73
|
+
} catch (error) {
|
|
74
|
+
throw new Error(`Failed to create config directory: ${error.message}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Generate machine-specific identifier
|
|
80
|
+
* Combines hostname and username for uniqueness
|
|
81
|
+
* @returns {string} SHA-256 hash of machine identifiers
|
|
82
|
+
*/
|
|
83
|
+
function getMachineId() {
|
|
84
|
+
try {
|
|
85
|
+
const hostname = os.hostname();
|
|
86
|
+
const username = os.userInfo().username;
|
|
87
|
+
|
|
88
|
+
return crypto.createHash('sha256')
|
|
89
|
+
.update(hostname)
|
|
90
|
+
.update(username)
|
|
91
|
+
.digest('hex');
|
|
92
|
+
} catch (error) {
|
|
93
|
+
throw new Error(`Failed to generate machine ID: ${error.message}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get or create encryption key for this machine
|
|
99
|
+
* Key is derived from machine ID + salt using PBKDF2
|
|
100
|
+
* @returns {Buffer} 32-byte encryption key
|
|
101
|
+
* @throws {Error} If key generation fails
|
|
102
|
+
*/
|
|
103
|
+
function getMachineKey() {
|
|
104
|
+
ensureConfigDir();
|
|
105
|
+
const saltPath = getSaltPath();
|
|
106
|
+
|
|
107
|
+
let salt;
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
if (fs.existsSync(saltPath)) {
|
|
111
|
+
// Load existing salt
|
|
112
|
+
salt = fs.readFileSync(saltPath);
|
|
113
|
+
|
|
114
|
+
if (salt.length !== 32) {
|
|
115
|
+
throw new Error('Invalid salt file (corrupted)');
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
// Generate new salt
|
|
119
|
+
salt = generateSalt();
|
|
120
|
+
fs.writeFileSync(saltPath, salt, { mode: FILE_PERMISSIONS });
|
|
121
|
+
console.log(`Generated encryption salt: ${saltPath}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Verify salt file permissions
|
|
125
|
+
const stats = fs.statSync(saltPath);
|
|
126
|
+
const currentMode = stats.mode & 0o777;
|
|
127
|
+
|
|
128
|
+
if (currentMode !== FILE_PERMISSIONS) {
|
|
129
|
+
fs.chmodSync(saltPath, FILE_PERMISSIONS);
|
|
130
|
+
console.warn(`Fixed salt file permissions: ${saltPath}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Derive key from machine ID + salt
|
|
134
|
+
const machineId = getMachineId();
|
|
135
|
+
return deriveKey(machineId, salt);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
throw new Error(`Failed to get machine key: ${error.message}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Save auth token to encrypted config file
|
|
143
|
+
* @param {string} authToken - HandCash auth token to save
|
|
144
|
+
* @throws {Error} If save fails
|
|
145
|
+
*/
|
|
146
|
+
function saveToken(authToken) {
|
|
147
|
+
if (!authToken || typeof authToken !== 'string') {
|
|
148
|
+
throw new Error('Auth token must be a non-empty string');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
ensureConfigDir();
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const key = getMachineKey();
|
|
155
|
+
const encrypted = encrypt(authToken, key);
|
|
156
|
+
|
|
157
|
+
const config = {
|
|
158
|
+
version: '2.0',
|
|
159
|
+
encrypted: encrypted,
|
|
160
|
+
createdAt: new Date().toISOString(),
|
|
161
|
+
machineId: getMachineId() // Store for validation
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const configPath = getConfigPath();
|
|
165
|
+
fs.writeFileSync(
|
|
166
|
+
configPath,
|
|
167
|
+
JSON.stringify(config, null, 2),
|
|
168
|
+
{ mode: FILE_PERMISSIONS }
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
console.log(`✓ Token saved securely to: ${configPath}`);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
throw new Error(`Failed to save token: ${error.message}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Load and decrypt auth token from config file
|
|
179
|
+
* @returns {string|null} Decrypted auth token, or null if no config exists
|
|
180
|
+
* @throws {Error} If config is corrupted or decryption fails
|
|
181
|
+
*/
|
|
182
|
+
function loadToken() {
|
|
183
|
+
const configPath = getConfigPath();
|
|
184
|
+
|
|
185
|
+
if (!fs.existsSync(configPath)) {
|
|
186
|
+
return null; // No token saved yet
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
// Read config file
|
|
191
|
+
const configData = fs.readFileSync(configPath, 'utf8');
|
|
192
|
+
const config = JSON.parse(configData);
|
|
193
|
+
|
|
194
|
+
// Validate config structure
|
|
195
|
+
if (!config.encrypted || !config.machineId) {
|
|
196
|
+
throw new Error('Config file is corrupted (missing required fields)');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Verify machine ID matches
|
|
200
|
+
const currentMachineId = getMachineId();
|
|
201
|
+
if (config.machineId !== currentMachineId) {
|
|
202
|
+
console.warn('Warning: Config was created on a different machine');
|
|
203
|
+
console.warn('This may cause decryption to fail');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Decrypt token
|
|
207
|
+
const key = getMachineKey();
|
|
208
|
+
const authToken = decrypt(config.encrypted, key);
|
|
209
|
+
|
|
210
|
+
return authToken;
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (error.message.includes('tampered')) {
|
|
213
|
+
throw new Error('Config file has been tampered with. Please re-authenticate.');
|
|
214
|
+
}
|
|
215
|
+
if (error.message.includes('JSON')) {
|
|
216
|
+
throw new Error('Config file is corrupted. Please re-authenticate.');
|
|
217
|
+
}
|
|
218
|
+
throw new Error(`Failed to load token: ${error.message}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Delete config file (logout)
|
|
224
|
+
* @returns {boolean} true if config was deleted, false if it didn't exist
|
|
225
|
+
*/
|
|
226
|
+
function deleteToken() {
|
|
227
|
+
const configPath = getConfigPath();
|
|
228
|
+
|
|
229
|
+
if (fs.existsSync(configPath)) {
|
|
230
|
+
try {
|
|
231
|
+
fs.unlinkSync(configPath);
|
|
232
|
+
console.log(`✓ Token deleted: ${configPath}`);
|
|
233
|
+
return true;
|
|
234
|
+
} catch (error) {
|
|
235
|
+
throw new Error(`Failed to delete token: ${error.message}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Check if auth token exists
|
|
244
|
+
* @returns {boolean} true if config file exists
|
|
245
|
+
*/
|
|
246
|
+
function hasToken() {
|
|
247
|
+
return fs.existsSync(getConfigPath());
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Validate config file integrity
|
|
252
|
+
* Checks file permissions and structure
|
|
253
|
+
* @returns {Object} Validation result with issues array
|
|
254
|
+
*/
|
|
255
|
+
function validateConfig() {
|
|
256
|
+
const configPath = getConfigPath();
|
|
257
|
+
const issues = [];
|
|
258
|
+
|
|
259
|
+
if (!fs.existsSync(configPath)) {
|
|
260
|
+
return { valid: true, issues: [] }; // No config is valid
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
// Check file permissions
|
|
265
|
+
const stats = fs.statSync(configPath);
|
|
266
|
+
const currentMode = stats.mode & 0o777;
|
|
267
|
+
|
|
268
|
+
if (currentMode !== FILE_PERMISSIONS) {
|
|
269
|
+
issues.push(`Insecure file permissions: ${currentMode.toString(8)} (should be 600)`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Check file structure
|
|
273
|
+
const configData = fs.readFileSync(configPath, 'utf8');
|
|
274
|
+
const config = JSON.parse(configData);
|
|
275
|
+
|
|
276
|
+
if (!config.version) {
|
|
277
|
+
issues.push('Missing version field');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (!config.encrypted || !config.encrypted.ciphertext || !config.encrypted.iv || !config.encrypted.authTag) {
|
|
281
|
+
issues.push('Missing or incomplete encrypted data');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (!config.machineId) {
|
|
285
|
+
issues.push('Missing machine ID');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
valid: issues.length === 0,
|
|
290
|
+
issues
|
|
291
|
+
};
|
|
292
|
+
} catch (error) {
|
|
293
|
+
return {
|
|
294
|
+
valid: false,
|
|
295
|
+
issues: [`Config validation failed: ${error.message}`]
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Repair config file issues
|
|
302
|
+
* Fixes file permissions and recreates config if corrupted
|
|
303
|
+
* @returns {boolean} true if repairs were made
|
|
304
|
+
*/
|
|
305
|
+
function repairConfig() {
|
|
306
|
+
const configPath = getConfigPath();
|
|
307
|
+
let repaired = false;
|
|
308
|
+
|
|
309
|
+
if (!fs.existsSync(configPath)) {
|
|
310
|
+
return false; // Nothing to repair
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
// Fix file permissions
|
|
315
|
+
const stats = fs.statSync(configPath);
|
|
316
|
+
const currentMode = stats.mode & 0o777;
|
|
317
|
+
|
|
318
|
+
if (currentMode !== FILE_PERMISSIONS) {
|
|
319
|
+
fs.chmodSync(configPath, FILE_PERMISSIONS);
|
|
320
|
+
console.log(`✓ Fixed config file permissions`);
|
|
321
|
+
repaired = true;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Validate structure
|
|
325
|
+
const validation = validateConfig();
|
|
326
|
+
|
|
327
|
+
if (!validation.valid && validation.issues.some(issue => issue.includes('corrupted') || issue.includes('Missing'))) {
|
|
328
|
+
console.warn('Config file is corrupted and cannot be repaired.');
|
|
329
|
+
console.warn('Please run: bgit auth login');
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return repaired;
|
|
334
|
+
} catch (error) {
|
|
335
|
+
console.error(`Config repair failed: ${error.message}`);
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Payment mode configuration
|
|
342
|
+
* Determines which git commands require payment
|
|
343
|
+
*/
|
|
344
|
+
const PAYMENT_MODES = {
|
|
345
|
+
minimal: ['commit', 'push'], // Default: only publishing operations
|
|
346
|
+
universal: require('./commands') // All 155 git commands
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Get current payment mode
|
|
351
|
+
* @returns {string} Payment mode ('minimal' or 'universal')
|
|
352
|
+
*/
|
|
353
|
+
function getPaymentMode() {
|
|
354
|
+
const configPath = getConfigPath();
|
|
355
|
+
if (!fs.existsSync(configPath)) {
|
|
356
|
+
return 'minimal'; // Default mode
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
const configData = fs.readFileSync(configPath, 'utf8');
|
|
361
|
+
const config = JSON.parse(configData);
|
|
362
|
+
return config.paymentMode || 'minimal';
|
|
363
|
+
} catch (error) {
|
|
364
|
+
return 'minimal';
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Set payment mode
|
|
370
|
+
* @param {string} mode - 'minimal' or 'universal'
|
|
371
|
+
* @throws {Error} If invalid mode
|
|
372
|
+
*/
|
|
373
|
+
function setPaymentMode(mode) {
|
|
374
|
+
if (!PAYMENT_MODES[mode]) {
|
|
375
|
+
throw new Error(`Invalid payment mode: ${mode}. Use 'minimal' or 'universal'`);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
ensureConfigDir();
|
|
379
|
+
const configPath = getConfigPath();
|
|
380
|
+
|
|
381
|
+
let config = {};
|
|
382
|
+
if (fs.existsSync(configPath)) {
|
|
383
|
+
try {
|
|
384
|
+
const configData = fs.readFileSync(configPath, 'utf8');
|
|
385
|
+
config = JSON.parse(configData);
|
|
386
|
+
} catch (error) {
|
|
387
|
+
// If config is corrupted, start fresh
|
|
388
|
+
config = {};
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
config.paymentMode = mode;
|
|
393
|
+
fs.writeFileSync(
|
|
394
|
+
configPath,
|
|
395
|
+
JSON.stringify(config, null, 2),
|
|
396
|
+
{ mode: FILE_PERMISSIONS }
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
console.log(`✓ Payment mode set to: ${mode}`);
|
|
400
|
+
|
|
401
|
+
// Show helpful info
|
|
402
|
+
if (mode === 'universal') {
|
|
403
|
+
console.log('\n⚠️ Universal mode enabled - ALL git commands now require payment (0.001 BSV each)');
|
|
404
|
+
console.log('This includes: status, log, diff, and all other commands.');
|
|
405
|
+
} else {
|
|
406
|
+
console.log('\n✓ Minimal mode - Only commit and push require payment');
|
|
407
|
+
console.log('All read operations (status, log, diff, etc.) are FREE');
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Get list of payment-gated commands for current mode
|
|
413
|
+
* @returns {string[]} Array of command names
|
|
414
|
+
*/
|
|
415
|
+
function getPaymentGatedCommands() {
|
|
416
|
+
const mode = getPaymentMode();
|
|
417
|
+
return PAYMENT_MODES[mode];
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
module.exports = {
|
|
421
|
+
getConfigDir,
|
|
422
|
+
getConfigPath,
|
|
423
|
+
getSaltPath,
|
|
424
|
+
ensureConfigDir,
|
|
425
|
+
getMachineId,
|
|
426
|
+
getMachineKey,
|
|
427
|
+
saveToken,
|
|
428
|
+
loadToken,
|
|
429
|
+
deleteToken,
|
|
430
|
+
hasToken,
|
|
431
|
+
validateConfig,
|
|
432
|
+
repairConfig,
|
|
433
|
+
// Payment mode functions
|
|
434
|
+
getPaymentMode,
|
|
435
|
+
setPaymentMode,
|
|
436
|
+
getPaymentGatedCommands,
|
|
437
|
+
PAYMENT_MODES
|
|
438
|
+
};
|
package/lib/constants.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bgit Constants - Hardcoded application credentials and configuration
|
|
3
|
+
*
|
|
4
|
+
* IMPORTANT: Before deploying to production, you must:
|
|
5
|
+
* 1. Register your app at https://dashboard.handcash.io/
|
|
6
|
+
* 2. Obtain production APP_ID and APP_SECRET
|
|
7
|
+
* 3. Replace the placeholder values below
|
|
8
|
+
* 4. Configure OAuth redirect URLs in HandCash dashboard:
|
|
9
|
+
* Success: http://localhost:3333/callback
|
|
10
|
+
* Decline: http://localhost:3333/error
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
module.exports = {
|
|
14
|
+
// HandCash Application Credentials
|
|
15
|
+
HANDCASH_APP_ID: process.env.HANDCASH_APP_ID || '6959a82a9b2206d9465b3fe4',
|
|
16
|
+
HANDCASH_APP_SECRET: process.env.HANDCASH_APP_SECRET || '964f849219ff0feb2c03a2322fc1441d9c0ec1b0d6c3a98e60b393a94a2cac2c',
|
|
17
|
+
|
|
18
|
+
// Treasury wallet handle (where developer premiums are sent)
|
|
19
|
+
TREASURY_HANDLE: '$b0ase',
|
|
20
|
+
|
|
21
|
+
// OAuth server configuration
|
|
22
|
+
OAUTH_PORT_START: 3333,
|
|
23
|
+
OAUTH_PORT_END: 3333, // Fixed port to avoid conflicts with typical dev servers
|
|
24
|
+
OAUTH_TIMEOUT_MS: 300000, // 5 minutes
|
|
25
|
+
OAUTH_HOST: 'localhost',
|
|
26
|
+
|
|
27
|
+
// Payment amounts in BSV
|
|
28
|
+
PAYMENT_AMOUNTS: {
|
|
29
|
+
commit: 0.001, // 0.001 BSV per commit timestamp
|
|
30
|
+
push: 0.001 // 0.001 BSV per push
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
// Config file paths (relative to user home directory)
|
|
34
|
+
CONFIG_DIR_NAME: '.bgit',
|
|
35
|
+
CONFIG_FILE_NAME: 'config.json',
|
|
36
|
+
SALT_FILE_NAME: '.salt',
|
|
37
|
+
|
|
38
|
+
// Encryption settings
|
|
39
|
+
CRYPTO_ALGORITHM: 'aes-256-gcm',
|
|
40
|
+
CRYPTO_KEY_LENGTH: 32, // 256 bits
|
|
41
|
+
CRYPTO_IV_LENGTH: 12, // 96 bits for GCM
|
|
42
|
+
CRYPTO_SALT_LENGTH: 32, // 256 bits
|
|
43
|
+
CRYPTO_TAG_LENGTH: 16, // 128 bits
|
|
44
|
+
CRYPTO_PBKDF2_ITERATIONS: 100000, // 100k iterations for key derivation
|
|
45
|
+
|
|
46
|
+
// Token validation cache TTL (1 hour)
|
|
47
|
+
TOKEN_VALIDATION_CACHE_TTL_MS: 3600000,
|
|
48
|
+
|
|
49
|
+
// Payment retry configuration
|
|
50
|
+
PAYMENT_RETRY_MAX_ATTEMPTS: 3,
|
|
51
|
+
PAYMENT_RETRY_BASE_DELAY_MS: 1000,
|
|
52
|
+
PAYMENT_RETRY_MAX_DELAY_MS: 10000,
|
|
53
|
+
|
|
54
|
+
// File permissions (Unix)
|
|
55
|
+
DIR_PERMISSIONS: 0o700, // rwx------
|
|
56
|
+
FILE_PERMISSIONS: 0o600, // rw-------
|
|
57
|
+
};
|
package/lib/crypto.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bgit Cryptography Module
|
|
3
|
+
*
|
|
4
|
+
* Provides AES-256-GCM authenticated encryption for secure token storage.
|
|
5
|
+
* Uses PBKDF2 for key derivation with 100,000 iterations.
|
|
6
|
+
*
|
|
7
|
+
* Security features:
|
|
8
|
+
* - AES-256-GCM (authenticated encryption prevents tampering)
|
|
9
|
+
* - Random IV per encryption (prevents pattern analysis)
|
|
10
|
+
* - PBKDF2 key derivation (prevents brute force attacks)
|
|
11
|
+
* - Authentication tag (detects any modifications)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const crypto = require('crypto');
|
|
15
|
+
const {
|
|
16
|
+
CRYPTO_ALGORITHM,
|
|
17
|
+
CRYPTO_KEY_LENGTH,
|
|
18
|
+
CRYPTO_IV_LENGTH,
|
|
19
|
+
CRYPTO_SALT_LENGTH,
|
|
20
|
+
CRYPTO_TAG_LENGTH,
|
|
21
|
+
CRYPTO_PBKDF2_ITERATIONS
|
|
22
|
+
} = require('./constants');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Generate a cryptographically random salt
|
|
26
|
+
* @returns {Buffer} 32-byte random salt
|
|
27
|
+
*/
|
|
28
|
+
function generateSalt() {
|
|
29
|
+
return crypto.randomBytes(CRYPTO_SALT_LENGTH);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Derive encryption key from password and salt using PBKDF2
|
|
34
|
+
* @param {string|Buffer} password - Password or machine ID
|
|
35
|
+
* @param {Buffer} salt - Salt for key derivation
|
|
36
|
+
* @returns {Buffer} 32-byte encryption key
|
|
37
|
+
*/
|
|
38
|
+
function deriveKey(password, salt) {
|
|
39
|
+
try {
|
|
40
|
+
const passwordBuffer = Buffer.isBuffer(password)
|
|
41
|
+
? password
|
|
42
|
+
: Buffer.from(password, 'utf8');
|
|
43
|
+
|
|
44
|
+
return crypto.pbkdf2Sync(
|
|
45
|
+
passwordBuffer,
|
|
46
|
+
salt,
|
|
47
|
+
CRYPTO_PBKDF2_ITERATIONS,
|
|
48
|
+
CRYPTO_KEY_LENGTH,
|
|
49
|
+
'sha256'
|
|
50
|
+
);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
throw new Error(`Key derivation failed: ${error.message}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Encrypt plaintext using AES-256-GCM
|
|
58
|
+
* @param {string} plaintext - Text to encrypt
|
|
59
|
+
* @param {Buffer} key - 32-byte encryption key
|
|
60
|
+
* @returns {Object} Object containing ciphertext, iv, and authTag (all hex strings)
|
|
61
|
+
* @throws {Error} If encryption fails
|
|
62
|
+
*/
|
|
63
|
+
function encrypt(plaintext, key) {
|
|
64
|
+
if (!plaintext || typeof plaintext !== 'string') {
|
|
65
|
+
throw new Error('Plaintext must be a non-empty string');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!Buffer.isBuffer(key) || key.length !== CRYPTO_KEY_LENGTH) {
|
|
69
|
+
throw new Error(`Key must be a ${CRYPTO_KEY_LENGTH}-byte Buffer`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
// Generate random IV (Initialization Vector)
|
|
74
|
+
const iv = crypto.randomBytes(CRYPTO_IV_LENGTH);
|
|
75
|
+
|
|
76
|
+
// Create cipher
|
|
77
|
+
const cipher = crypto.createCipheriv(CRYPTO_ALGORITHM, key, iv);
|
|
78
|
+
|
|
79
|
+
// Encrypt
|
|
80
|
+
let ciphertext = cipher.update(plaintext, 'utf8', 'hex');
|
|
81
|
+
ciphertext += cipher.final('hex');
|
|
82
|
+
|
|
83
|
+
// Get authentication tag (prevents tampering)
|
|
84
|
+
const authTag = cipher.getAuthTag();
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
ciphertext,
|
|
88
|
+
iv: iv.toString('hex'),
|
|
89
|
+
authTag: authTag.toString('hex')
|
|
90
|
+
};
|
|
91
|
+
} catch (error) {
|
|
92
|
+
throw new Error(`Encryption failed: ${error.message}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Decrypt ciphertext using AES-256-GCM
|
|
98
|
+
* @param {Object} encrypted - Object with ciphertext, iv, and authTag (hex strings)
|
|
99
|
+
* @param {Buffer} key - 32-byte encryption key
|
|
100
|
+
* @returns {string} Decrypted plaintext
|
|
101
|
+
* @throws {Error} If decryption fails or authentication tag is invalid
|
|
102
|
+
*/
|
|
103
|
+
function decrypt(encrypted, key) {
|
|
104
|
+
if (!encrypted || typeof encrypted !== 'object') {
|
|
105
|
+
throw new Error('Encrypted data must be an object');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!encrypted.ciphertext || !encrypted.iv || !encrypted.authTag) {
|
|
109
|
+
throw new Error('Encrypted data must contain ciphertext, iv, and authTag');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!Buffer.isBuffer(key) || key.length !== CRYPTO_KEY_LENGTH) {
|
|
113
|
+
throw new Error(`Key must be a ${CRYPTO_KEY_LENGTH}-byte Buffer`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
// Create decipher
|
|
118
|
+
const decipher = crypto.createDecipheriv(
|
|
119
|
+
CRYPTO_ALGORITHM,
|
|
120
|
+
key,
|
|
121
|
+
Buffer.from(encrypted.iv, 'hex')
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Set authentication tag (will throw if data was tampered with)
|
|
125
|
+
decipher.setAuthTag(Buffer.from(encrypted.authTag, 'hex'));
|
|
126
|
+
|
|
127
|
+
// Decrypt
|
|
128
|
+
let plaintext = decipher.update(encrypted.ciphertext, 'hex', 'utf8');
|
|
129
|
+
plaintext += decipher.final('utf8');
|
|
130
|
+
|
|
131
|
+
return plaintext;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
// This error indicates either tampering or wrong key
|
|
134
|
+
if (error.message.includes('Unsupported state or unable to authenticate data')) {
|
|
135
|
+
throw new Error('Decryption failed: Data has been tampered with or wrong encryption key');
|
|
136
|
+
}
|
|
137
|
+
throw new Error(`Decryption failed: ${error.message}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Test encrypt/decrypt roundtrip
|
|
143
|
+
* Used for internal testing and validation
|
|
144
|
+
* @param {string} plaintext - Test plaintext
|
|
145
|
+
* @param {Buffer} key - Encryption key
|
|
146
|
+
* @returns {boolean} true if roundtrip succeeds
|
|
147
|
+
*/
|
|
148
|
+
function testRoundtrip(plaintext, key) {
|
|
149
|
+
try {
|
|
150
|
+
const encrypted = encrypt(plaintext, key);
|
|
151
|
+
const decrypted = decrypt(encrypted, key);
|
|
152
|
+
return decrypted === plaintext;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = {
|
|
159
|
+
generateSalt,
|
|
160
|
+
deriveKey,
|
|
161
|
+
encrypt,
|
|
162
|
+
decrypt,
|
|
163
|
+
testRoundtrip
|
|
164
|
+
};
|