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/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
+ };
@@ -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
+ };