lsh-framework 3.2.5 → 3.5.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 +21 -0
- package/README.md +72 -34
- package/dist/commands/ipfs.js +7 -12
- package/dist/commands/sync.js +49 -38
- package/dist/constants/config.js +3 -0
- package/dist/lib/floating-point-arithmetic.js +2 -2
- package/dist/lib/ipfs-client-manager.js +51 -13
- package/dist/lib/ipfs-secrets-storage.js +21 -16
- package/dist/lib/ipfs-sync.js +88 -14
- package/dist/lib/secrets-manager.js +117 -47
- package/dist/lib/sync-key-store.js +87 -0
- package/dist/services/secrets/secrets.js +77 -39
- package/package.json +16 -16
- package/dist/__tests__/fixtures/job-fixtures.js +0 -204
- package/dist/__tests__/fixtures/supabase-mocks.js +0 -252
- package/dist/daemon/job-registry.js +0 -556
- package/dist/daemon/lshd.js +0 -968
- package/dist/daemon/saas-api-routes.js +0 -599
- package/dist/daemon/saas-api-server.js +0 -231
- package/dist/examples/supabase-integration.js +0 -106
- package/dist/lib/api-response.js +0 -226
- package/dist/lib/base-command-registrar.js +0 -287
- package/dist/lib/base-job-manager.js +0 -295
- package/dist/lib/cloud-config-manager.js +0 -348
- package/dist/lib/cron-job-manager.js +0 -368
- package/dist/lib/daemon-client-helper.js +0 -145
- package/dist/lib/daemon-client.js +0 -513
- package/dist/lib/database-persistence.js +0 -727
- package/dist/lib/database-schema.js +0 -259
- package/dist/lib/database-types.js +0 -90
- package/dist/lib/enhanced-history-system.js +0 -247
- package/dist/lib/history-system.js +0 -246
- package/dist/lib/job-manager.js +0 -436
- package/dist/lib/job-storage-database.js +0 -164
- package/dist/lib/job-storage-memory.js +0 -73
- package/dist/lib/local-storage-adapter.js +0 -507
- package/dist/lib/optimized-job-scheduler.js +0 -356
- package/dist/lib/saas-audit.js +0 -215
- package/dist/lib/saas-auth.js +0 -465
- package/dist/lib/saas-billing.js +0 -503
- package/dist/lib/saas-email.js +0 -403
- package/dist/lib/saas-encryption.js +0 -221
- package/dist/lib/saas-organizations.js +0 -662
- package/dist/lib/saas-secrets.js +0 -408
- package/dist/lib/saas-types.js +0 -165
- package/dist/lib/supabase-client.js +0 -125
- package/dist/lib/supabase-utils.js +0 -396
- package/dist/services/cron/cron-registrar.js +0 -240
- package/dist/services/cron/cron.js +0 -9
- package/dist/services/daemon/daemon-registrar.js +0 -585
- package/dist/services/daemon/daemon.js +0 -9
- package/dist/services/supabase/supabase-registrar.js +0 -375
- package/dist/services/supabase/supabase.js +0 -9
|
@@ -15,6 +15,7 @@ import { createLogger } from './logger.js';
|
|
|
15
15
|
import { getIPFSSync } from './ipfs-sync.js';
|
|
16
16
|
import { deriveKeyInfo, ensureKeyImported } from './ipns-key-manager.js';
|
|
17
17
|
import { ENV_VARS, DEFAULTS } from '../constants/index.js';
|
|
18
|
+
import { extractErrorMessage } from './lsh-error.js';
|
|
18
19
|
const logger = createLogger('IPFSSecretsStorage');
|
|
19
20
|
/**
|
|
20
21
|
* IPFS Secrets Storage
|
|
@@ -107,16 +108,23 @@ export class IPFSSecretsStorage {
|
|
|
107
108
|
}
|
|
108
109
|
}
|
|
109
110
|
catch (error) {
|
|
110
|
-
|
|
111
|
-
logger.error(`Content uploaded (CID: ${cid}) but IPNS publish failed: ${err.message}\n` +
|
|
111
|
+
logger.error(`Content uploaded (CID: ${cid}) but IPNS publish failed: ${extractErrorMessage(error)}\n` +
|
|
112
112
|
`Other machines won't find it via 'lsh pull' until you re-push.`);
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
|
+
// Durable remote pin (best-effort): survives this machine going offline.
|
|
116
|
+
// No-op unless a kubo remote pinning service is configured.
|
|
117
|
+
const pinnedService = await ipfsSync.addRemotePin(cid, `lsh-${gitRepo || DEFAULTS.DEFAULT_ENVIRONMENT}-${environment}`);
|
|
118
|
+
if (pinnedService) {
|
|
119
|
+
logger.info(` 📌 Remote-pinned to "${pinnedService}" (durable)`);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
logger.warn('No remote pin — content lives only on this machine until a peer caches it. Configure LSH_PIN_SERVICE for durability.');
|
|
123
|
+
}
|
|
115
124
|
return cid;
|
|
116
125
|
}
|
|
117
126
|
catch (error) {
|
|
118
|
-
|
|
119
|
-
logger.error(`Failed to push secrets to IPFS: ${err.message}`);
|
|
127
|
+
logger.error(`Failed to push secrets to IPFS: ${extractErrorMessage(error)}`);
|
|
120
128
|
throw error;
|
|
121
129
|
}
|
|
122
130
|
}
|
|
@@ -155,8 +163,7 @@ export class IPFSSecretsStorage {
|
|
|
155
163
|
}
|
|
156
164
|
}
|
|
157
165
|
catch (error) {
|
|
158
|
-
|
|
159
|
-
logger.debug(` IPNS resolution error: ${err.message}`);
|
|
166
|
+
logger.debug(` IPNS resolution error: ${extractErrorMessage(error)}`);
|
|
160
167
|
}
|
|
161
168
|
}
|
|
162
169
|
// No fallback to local metadata — IPNS is the source of truth
|
|
@@ -185,8 +192,7 @@ export class IPFSSecretsStorage {
|
|
|
185
192
|
}
|
|
186
193
|
}
|
|
187
194
|
catch (error) {
|
|
188
|
-
|
|
189
|
-
logger.debug(` IPFS download failed: ${err.message}`);
|
|
195
|
+
logger.debug(` IPFS download failed: ${extractErrorMessage(error)}`);
|
|
190
196
|
}
|
|
191
197
|
}
|
|
192
198
|
if (!cachedData) {
|
|
@@ -201,8 +207,7 @@ export class IPFSSecretsStorage {
|
|
|
201
207
|
return secrets;
|
|
202
208
|
}
|
|
203
209
|
catch (error) {
|
|
204
|
-
|
|
205
|
-
logger.error(`Failed to pull secrets from IPFS: ${err.message}`);
|
|
210
|
+
logger.error(`Failed to pull secrets from IPFS: ${extractErrorMessage(error)}`);
|
|
206
211
|
throw error;
|
|
207
212
|
}
|
|
208
213
|
}
|
|
@@ -275,19 +280,19 @@ export class IPFSSecretsStorage {
|
|
|
275
280
|
return JSON.parse(decrypted);
|
|
276
281
|
}
|
|
277
282
|
catch (error) {
|
|
278
|
-
const
|
|
283
|
+
const msg = extractErrorMessage(error);
|
|
279
284
|
// Catch crypto errors (bad decrypt, wrong block length) AND JSON parse errors
|
|
280
285
|
// (wrong key can produce garbage that fails JSON.parse)
|
|
281
|
-
if (
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
286
|
+
if (msg.includes('bad decrypt') ||
|
|
287
|
+
msg.includes('wrong final block length') ||
|
|
288
|
+
msg.includes('Unexpected token') ||
|
|
289
|
+
msg.includes('JSON')) {
|
|
285
290
|
throw new Error('Decryption failed. This usually means:\n' +
|
|
286
291
|
' 1. You need to set LSH_SECRETS_KEY environment variable\n' +
|
|
287
292
|
' 2. The key must match the one used during encryption\n' +
|
|
288
293
|
' 3. Generate a shared key with: lsh key\n' +
|
|
289
294
|
' 4. Add it to your .env: LSH_SECRETS_KEY=<key>\n' +
|
|
290
|
-
'\nOriginal error: ' +
|
|
295
|
+
'\nOriginal error: ' + msg, { cause: error });
|
|
291
296
|
}
|
|
292
297
|
throw error;
|
|
293
298
|
}
|
package/dist/lib/ipfs-sync.js
CHANGED
|
@@ -14,6 +14,8 @@ import * as fsPromises from 'fs/promises';
|
|
|
14
14
|
import * as path from 'path';
|
|
15
15
|
import * as os from 'os';
|
|
16
16
|
import { createLogger } from './logger.js';
|
|
17
|
+
import { extractErrorMessage } from './lsh-error.js';
|
|
18
|
+
import { ENV_VARS } from '../constants/config.js';
|
|
17
19
|
const logger = createLogger('IPFSSync');
|
|
18
20
|
/**
|
|
19
21
|
* Native IPFS Sync
|
|
@@ -123,8 +125,7 @@ export class IPFSSync {
|
|
|
123
125
|
return cid;
|
|
124
126
|
}
|
|
125
127
|
catch (error) {
|
|
126
|
-
|
|
127
|
-
logger.error(`IPFS upload error: ${err.message}`);
|
|
128
|
+
logger.error(`IPFS upload error: ${extractErrorMessage(error)}`);
|
|
128
129
|
return null;
|
|
129
130
|
}
|
|
130
131
|
}
|
|
@@ -151,6 +152,7 @@ export class IPFSSync {
|
|
|
151
152
|
logger.debug('Local daemon download failed, trying gateways...');
|
|
152
153
|
}
|
|
153
154
|
// Fall back to public gateways
|
|
155
|
+
let backoffMs = 1000;
|
|
154
156
|
for (const gatewayTemplate of this.GATEWAYS) {
|
|
155
157
|
const gatewayUrl = gatewayTemplate.replace('{cid}', cid);
|
|
156
158
|
try {
|
|
@@ -165,6 +167,8 @@ export class IPFSSync {
|
|
|
165
167
|
}
|
|
166
168
|
catch {
|
|
167
169
|
logger.debug(`Gateway ${gatewayUrl} failed, trying next...`);
|
|
170
|
+
await new Promise((resolve) => { setTimeout(resolve, backoffMs); });
|
|
171
|
+
backoffMs = Math.min(backoffMs * 2, 10000);
|
|
168
172
|
}
|
|
169
173
|
}
|
|
170
174
|
logger.error(`Failed to download CID: ${cid}`);
|
|
@@ -229,8 +233,7 @@ export class IPFSSync {
|
|
|
229
233
|
return false;
|
|
230
234
|
}
|
|
231
235
|
catch (error) {
|
|
232
|
-
|
|
233
|
-
logger.error(`Pin failed: ${err.message}`);
|
|
236
|
+
logger.error(`Pin failed: ${extractErrorMessage(error)}`);
|
|
234
237
|
return false;
|
|
235
238
|
}
|
|
236
239
|
}
|
|
@@ -255,8 +258,7 @@ export class IPFSSync {
|
|
|
255
258
|
return false;
|
|
256
259
|
}
|
|
257
260
|
catch (error) {
|
|
258
|
-
|
|
259
|
-
logger.error(`Unpin failed: ${err.message}`);
|
|
261
|
+
logger.error(`Unpin failed: ${extractErrorMessage(error)}`);
|
|
260
262
|
return false;
|
|
261
263
|
}
|
|
262
264
|
}
|
|
@@ -297,8 +299,7 @@ export class IPFSSync {
|
|
|
297
299
|
await fsPromises.writeFile(this.historyPath, JSON.stringify(history, null, 2), 'utf-8');
|
|
298
300
|
}
|
|
299
301
|
catch (error) {
|
|
300
|
-
|
|
301
|
-
logger.debug(`Failed to save history: ${err.message}`);
|
|
302
|
+
logger.debug(`Failed to save history: ${extractErrorMessage(error)}`);
|
|
302
303
|
}
|
|
303
304
|
}
|
|
304
305
|
/**
|
|
@@ -327,8 +328,7 @@ export class IPFSSync {
|
|
|
327
328
|
}
|
|
328
329
|
}
|
|
329
330
|
catch (error) {
|
|
330
|
-
|
|
331
|
-
logger.error(`Failed to clear history: ${err.message}`);
|
|
331
|
+
logger.error(`Failed to clear history: ${extractErrorMessage(error)}`);
|
|
332
332
|
}
|
|
333
333
|
}
|
|
334
334
|
/**
|
|
@@ -343,6 +343,82 @@ export class IPFSSync {
|
|
|
343
343
|
getApiUrl() {
|
|
344
344
|
return this.LOCAL_IPFS_API;
|
|
345
345
|
}
|
|
346
|
+
/**
|
|
347
|
+
* List the names of remote pinning services configured in the local Kubo
|
|
348
|
+
* node (via `ipfs pin remote service add`). Returns [] on any error.
|
|
349
|
+
*/
|
|
350
|
+
async listRemoteServices() {
|
|
351
|
+
try {
|
|
352
|
+
const response = await fetch(`${this.LOCAL_IPFS_API}/pin/remote/service/ls`, {
|
|
353
|
+
method: 'POST',
|
|
354
|
+
signal: AbortSignal.timeout(5000),
|
|
355
|
+
});
|
|
356
|
+
if (!response.ok)
|
|
357
|
+
return [];
|
|
358
|
+
const data = await response.json();
|
|
359
|
+
return (data.RemoteServices || []).map((s) => s.Service).filter(Boolean);
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
return [];
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Decide which remote pinning service to pin to.
|
|
367
|
+
* - If LSH_PIN_SERVICE is set, use it only when it is actually configured.
|
|
368
|
+
* - Otherwise, use the sole configured service when exactly one exists.
|
|
369
|
+
* - Returns null when nothing is configured or the choice is ambiguous.
|
|
370
|
+
*/
|
|
371
|
+
async resolveRemoteService() {
|
|
372
|
+
const services = await this.listRemoteServices();
|
|
373
|
+
const explicit = process.env[ENV_VARS.LSH_PIN_SERVICE];
|
|
374
|
+
if (explicit) {
|
|
375
|
+
return services.includes(explicit) ? explicit : null;
|
|
376
|
+
}
|
|
377
|
+
return services.length === 1 ? services[0] : null;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Pin a CID to a configured remote pinning service so the content survives
|
|
381
|
+
* this machine going offline. This is what makes "pull anywhere, anytime"
|
|
382
|
+
* real: without it, blocks live only on the pushing node.
|
|
383
|
+
*
|
|
384
|
+
* Returns the service name on success, or null when no service is
|
|
385
|
+
* configured (the common zero-config case) or the pin request failed.
|
|
386
|
+
* Never throws — durable pinning is best-effort and the caller decides how
|
|
387
|
+
* loudly to warn.
|
|
388
|
+
*/
|
|
389
|
+
async addRemotePin(cid, pinName) {
|
|
390
|
+
try {
|
|
391
|
+
const service = await this.resolveRemoteService();
|
|
392
|
+
if (!service)
|
|
393
|
+
return null;
|
|
394
|
+
const url = `${this.LOCAL_IPFS_API}/pin/remote/add` +
|
|
395
|
+
`?arg=${encodeURIComponent(cid)}` +
|
|
396
|
+
`&service=${encodeURIComponent(service)}` +
|
|
397
|
+
`&name=${encodeURIComponent(pinName)}` +
|
|
398
|
+
`&background=true`;
|
|
399
|
+
const response = await fetch(url, {
|
|
400
|
+
method: 'POST',
|
|
401
|
+
signal: AbortSignal.timeout(30000),
|
|
402
|
+
});
|
|
403
|
+
if (!response.ok) {
|
|
404
|
+
const errorText = await response.text();
|
|
405
|
+
// An already-present pin is reported as an error by some services; treat
|
|
406
|
+
// a "already pinned"/"duplicate" message as success rather than a failure.
|
|
407
|
+
if (/already|duplicate|exists/i.test(errorText)) {
|
|
408
|
+
logger.info(`📌 Already remote-pinned on "${service}": ${cid}`);
|
|
409
|
+
return service;
|
|
410
|
+
}
|
|
411
|
+
logger.warn(`Remote pin failed on "${service}": ${errorText}`);
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
logger.info(`📌 Remote-pinned to "${service}" (durable): ${cid}`);
|
|
415
|
+
return service;
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
logger.warn(`Remote pin error: ${extractErrorMessage(error)}`);
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
346
422
|
/**
|
|
347
423
|
* Publish a CID to IPNS under the given key name.
|
|
348
424
|
* The key must already be imported into Kubo.
|
|
@@ -371,8 +447,7 @@ export class IPFSSync {
|
|
|
371
447
|
return data.Name;
|
|
372
448
|
}
|
|
373
449
|
catch (error) {
|
|
374
|
-
|
|
375
|
-
logger.warn(`IPNS publish error (attempt ${attempt}): ${err.message}`);
|
|
450
|
+
logger.warn(`IPNS publish error (attempt ${attempt}): ${extractErrorMessage(error)}`);
|
|
376
451
|
if (attempt === 2) {
|
|
377
452
|
logger.error(`IPNS publish failed after retry. Content is uploaded (CID: ${cid}) ` +
|
|
378
453
|
`but other machines won't find it via 'lsh pull' until you re-push.`);
|
|
@@ -402,8 +477,7 @@ export class IPFSSync {
|
|
|
402
477
|
return resolvedCid;
|
|
403
478
|
}
|
|
404
479
|
catch (error) {
|
|
405
|
-
|
|
406
|
-
logger.debug(`IPNS resolve error: ${err.message}`);
|
|
480
|
+
logger.debug(`IPNS resolve error: ${extractErrorMessage(error)}`);
|
|
407
481
|
return null;
|
|
408
482
|
}
|
|
409
483
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Sync .env files across machines using encrypted Supabase storage
|
|
4
4
|
*/
|
|
5
5
|
import * as fs from 'fs';
|
|
6
|
+
import * as os from 'os';
|
|
6
7
|
import * as path from 'path';
|
|
7
8
|
import * as crypto from 'crypto';
|
|
8
9
|
import { createLogger, LogLevel } from './logger.js';
|
|
@@ -10,7 +11,53 @@ import { getGitRepoInfo, hasEnvExample, ensureEnvInGitignore } from './git-utils
|
|
|
10
11
|
import { IPFSSyncLogger } from './ipfs-sync-logger.js';
|
|
11
12
|
import { IPFSSecretsStorage } from './ipfs-secrets-storage.js';
|
|
12
13
|
import { ENV_VARS } from '../constants/index.js';
|
|
14
|
+
import { extractErrorMessage } from './lsh-error.js';
|
|
15
|
+
import { SyncKeyStore } from './sync-key-store.js';
|
|
13
16
|
const logger = createLogger('SecretsManager');
|
|
17
|
+
/**
|
|
18
|
+
* Read LSH_SECRETS_KEY from a .env file without loading the entire file into process.env
|
|
19
|
+
*/
|
|
20
|
+
function readKeyFromEnvFile(envPath) {
|
|
21
|
+
try {
|
|
22
|
+
if (fs.existsSync(envPath)) {
|
|
23
|
+
const content = fs.readFileSync(envPath, 'utf8');
|
|
24
|
+
const match = content.match(/^LSH_SECRETS_KEY=['"]?([^'"\n]+)['"]?/m);
|
|
25
|
+
if (match)
|
|
26
|
+
return match[1];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Ignore read errors
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Find LSH_SECRETS_KEY from environment, local .env, global ~/.env,
|
|
36
|
+
* or the persistent SyncKeyStore at $LSH_HOME/sync_key.json.
|
|
37
|
+
* Returns null if no explicit key is found (does not generate a fallback).
|
|
38
|
+
*/
|
|
39
|
+
export function findEncryptionKey() {
|
|
40
|
+
// 1. Check environment variable
|
|
41
|
+
const envKey = process.env[ENV_VARS.LSH_SECRETS_KEY];
|
|
42
|
+
if (envKey)
|
|
43
|
+
return envKey;
|
|
44
|
+
// 2. Check local .env
|
|
45
|
+
const localKey = readKeyFromEnvFile(path.join(process.cwd(), '.env'));
|
|
46
|
+
if (localKey)
|
|
47
|
+
return localKey;
|
|
48
|
+
// 3. Check global ~/.env
|
|
49
|
+
const home = process.env[ENV_VARS.HOME] || process.env[ENV_VARS.USERPROFILE] || '';
|
|
50
|
+
if (home) {
|
|
51
|
+
const globalKey = readKeyFromEnvFile(path.join(home, '.env'));
|
|
52
|
+
if (globalKey)
|
|
53
|
+
return globalKey;
|
|
54
|
+
}
|
|
55
|
+
// 4. Check persistent sync key store ($LSH_HOME/sync_key.json)
|
|
56
|
+
const stored = new SyncKeyStore().get();
|
|
57
|
+
if (stored)
|
|
58
|
+
return stored;
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
14
61
|
export class SecretsManager {
|
|
15
62
|
storage;
|
|
16
63
|
encryptionKey;
|
|
@@ -73,57 +120,85 @@ export class SecretsManager {
|
|
|
73
120
|
* Get default encryption key from environment or machine
|
|
74
121
|
*/
|
|
75
122
|
getDefaultEncryptionKey() {
|
|
76
|
-
// Check
|
|
77
|
-
const
|
|
78
|
-
if (
|
|
79
|
-
return
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
const
|
|
123
|
+
// Check environment, local .env, and global ~/.env
|
|
124
|
+
const explicitKey = findEncryptionKey();
|
|
125
|
+
if (explicitKey)
|
|
126
|
+
return explicitKey;
|
|
127
|
+
logger.warn('No explicit LSH_SECRETS_KEY found. Generating machine-derived fallback key.');
|
|
128
|
+
logger.warn('Set LSH_SECRETS_KEY in your environment for portable, secure encryption.');
|
|
129
|
+
// Generate from multiple machine-specific entropy sources
|
|
130
|
+
const hostname = os.hostname();
|
|
131
|
+
const uid = String(os.userInfo().uid);
|
|
132
|
+
const homedir = os.homedir();
|
|
133
|
+
// Try to read /etc/machine-id (Linux) for additional entropy
|
|
134
|
+
let machineSpecific;
|
|
135
|
+
try {
|
|
136
|
+
machineSpecific = fs.readFileSync('/etc/machine-id', 'utf8').trim();
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// Fallback to CPU model on macOS / systems without machine-id
|
|
140
|
+
machineSpecific = os.cpus()[0]?.model || 'no-cpu-info';
|
|
141
|
+
}
|
|
142
|
+
const seed = `${hostname}-${uid}-${homedir}-${machineSpecific}-lsh-secrets`;
|
|
85
143
|
// Create deterministic key
|
|
86
144
|
return crypto.createHash('sha256').update(seed).digest('hex');
|
|
87
145
|
}
|
|
88
146
|
/**
|
|
89
|
-
* Encrypt a value
|
|
147
|
+
* Encrypt a value using AES-256-GCM with authentication tag
|
|
90
148
|
*/
|
|
91
149
|
encrypt(text) {
|
|
92
|
-
const iv = crypto.randomBytes(
|
|
150
|
+
const iv = crypto.randomBytes(12); // 96-bit IV recommended for GCM
|
|
93
151
|
const key = Buffer.from(this.encryptionKey, 'hex');
|
|
94
|
-
const cipher = crypto.createCipheriv('aes-256-
|
|
152
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key.slice(0, 32), iv);
|
|
95
153
|
let encrypted = cipher.update(text, 'utf8', 'hex');
|
|
96
154
|
encrypted += cipher.final('hex');
|
|
97
|
-
|
|
155
|
+
const authTag = cipher.getAuthTag();
|
|
156
|
+
// Format: iv:authTag:encrypted (3-part GCM format)
|
|
157
|
+
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;
|
|
98
158
|
}
|
|
99
159
|
/**
|
|
100
|
-
* Decrypt a value
|
|
160
|
+
* Decrypt a value (supports both AES-256-GCM and legacy AES-256-CBC)
|
|
101
161
|
*/
|
|
102
162
|
decrypt(text) {
|
|
103
163
|
try {
|
|
104
164
|
const parts = text.split(':');
|
|
105
|
-
|
|
165
|
+
const key = Buffer.from(this.encryptionKey, 'hex');
|
|
166
|
+
if (parts.length === 3) {
|
|
167
|
+
// AES-256-GCM format: iv:authTag:encrypted
|
|
168
|
+
const iv = Buffer.from(parts[0], 'hex');
|
|
169
|
+
const authTag = Buffer.from(parts[1], 'hex');
|
|
170
|
+
const encryptedText = parts[2];
|
|
171
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key.slice(0, 32), iv);
|
|
172
|
+
decipher.setAuthTag(authTag);
|
|
173
|
+
let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
|
|
174
|
+
decrypted += decipher.final('utf8');
|
|
175
|
+
return decrypted;
|
|
176
|
+
}
|
|
177
|
+
else if (parts.length === 2) {
|
|
178
|
+
// Legacy AES-256-CBC format: iv:encrypted
|
|
179
|
+
const iv = Buffer.from(parts[0], 'hex');
|
|
180
|
+
const encryptedText = parts[1];
|
|
181
|
+
const decipher = crypto.createDecipheriv('aes-256-cbc', key.slice(0, 32), iv);
|
|
182
|
+
let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
|
|
183
|
+
decrypted += decipher.final('utf8');
|
|
184
|
+
return decrypted;
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
106
187
|
throw new Error('Invalid encrypted format');
|
|
107
188
|
}
|
|
108
|
-
const iv = Buffer.from(parts[0], 'hex');
|
|
109
|
-
const encryptedText = parts[1];
|
|
110
|
-
const key = Buffer.from(this.encryptionKey, 'hex');
|
|
111
|
-
const decipher = crypto.createDecipheriv('aes-256-cbc', key.slice(0, 32), iv);
|
|
112
|
-
let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
|
|
113
|
-
decrypted += decipher.final('utf8');
|
|
114
|
-
return decrypted;
|
|
115
189
|
}
|
|
116
190
|
catch (error) {
|
|
117
|
-
const
|
|
118
|
-
if (
|
|
191
|
+
const msg = extractErrorMessage(error);
|
|
192
|
+
if (msg.includes('bad decrypt') || msg.includes('wrong final block length') ||
|
|
193
|
+
msg.includes('Unsupported state') || msg.includes('unable to authenticate')) {
|
|
119
194
|
throw new Error('Decryption failed. This usually means:\n' +
|
|
120
195
|
' 1. You need to set LSH_SECRETS_KEY environment variable\n' +
|
|
121
196
|
' 2. The key must match the one used during encryption\n' +
|
|
122
197
|
' 3. Generate a shared key with: lsh secrets key\n' +
|
|
123
198
|
' 4. Add it to your .env: LSH_SECRETS_KEY=<key>\n' +
|
|
124
|
-
'\nOriginal error: ' +
|
|
199
|
+
'\nOriginal error: ' + msg, { cause: error });
|
|
125
200
|
}
|
|
126
|
-
throw
|
|
201
|
+
throw error;
|
|
127
202
|
}
|
|
128
203
|
}
|
|
129
204
|
/**
|
|
@@ -243,8 +318,8 @@ export class SecretsManager {
|
|
|
243
318
|
}
|
|
244
319
|
// Get the effective environment name (repo-aware)
|
|
245
320
|
const effectiveEnv = this.getRepoAwareEnvironment(environment);
|
|
246
|
-
// Warn if using
|
|
247
|
-
if (!
|
|
321
|
+
// Warn if using machine-derived fallback key (no explicit key found anywhere)
|
|
322
|
+
if (!findEncryptionKey()) {
|
|
248
323
|
logger.warn('⚠️ Warning: No LSH_SECRETS_KEY set. Using machine-specific key.');
|
|
249
324
|
logger.warn(' To share secrets across machines, generate a key with: lsh key');
|
|
250
325
|
logger.warn(' Then add LSH_SECRETS_KEY=<key> to your .env on all machines');
|
|
@@ -277,10 +352,9 @@ export class SecretsManager {
|
|
|
277
352
|
}
|
|
278
353
|
}
|
|
279
354
|
catch (error) {
|
|
280
|
-
const err = error;
|
|
281
355
|
// Re-throw destructive change errors
|
|
282
|
-
if (
|
|
283
|
-
throw
|
|
356
|
+
if (extractErrorMessage(error).includes('Destructive change')) {
|
|
357
|
+
throw error;
|
|
284
358
|
}
|
|
285
359
|
// Ignore other errors (like missing secrets) and proceed
|
|
286
360
|
}
|
|
@@ -357,8 +431,8 @@ export class SecretsManager {
|
|
|
357
431
|
const finalEnv = { ...pulledEnv, ...localLshKeys };
|
|
358
432
|
// Convert to .env format
|
|
359
433
|
const envContent = this.formatEnvFile(finalEnv);
|
|
360
|
-
// Write new .env
|
|
361
|
-
fs.writeFileSync(envFilePath, envContent, 'utf8');
|
|
434
|
+
// Write new .env with restrictive permissions (owner read/write only)
|
|
435
|
+
fs.writeFileSync(envFilePath, envContent, { encoding: 'utf8', mode: 0o600 });
|
|
362
436
|
logger.info(`✅ Pulled ${secrets.length} secrets from IPFS`);
|
|
363
437
|
// Get metadata for CID display
|
|
364
438
|
const metadata = this.storage.getMetadata(environment, this.gitInfo?.repoName);
|
|
@@ -430,7 +504,7 @@ export class SecretsManager {
|
|
|
430
504
|
cloudExists: false,
|
|
431
505
|
cloudKeys: 0,
|
|
432
506
|
cloudModified: undefined,
|
|
433
|
-
keySet: !!
|
|
507
|
+
keySet: !!findEncryptionKey(),
|
|
434
508
|
keyMatches: undefined,
|
|
435
509
|
suggestions: [],
|
|
436
510
|
};
|
|
@@ -518,7 +592,7 @@ export class SecretsManager {
|
|
|
518
592
|
* Generate encryption key if not set
|
|
519
593
|
*/
|
|
520
594
|
async ensureEncryptionKey() {
|
|
521
|
-
if (
|
|
595
|
+
if (findEncryptionKey()) {
|
|
522
596
|
return true; // Key already set
|
|
523
597
|
}
|
|
524
598
|
logger.warn('⚠️ No encryption key found. Generating a new key...');
|
|
@@ -540,7 +614,7 @@ export class SecretsManager {
|
|
|
540
614
|
else {
|
|
541
615
|
content += `\n# LSH Secrets Encryption Key (do not commit!)\nLSH_SECRETS_KEY=${key}\n`;
|
|
542
616
|
}
|
|
543
|
-
fs.writeFileSync(envPath, content, 'utf8');
|
|
617
|
+
fs.writeFileSync(envPath, content, { encoding: 'utf8', mode: 0o600 });
|
|
544
618
|
// Set in current process
|
|
545
619
|
process.env[ENV_VARS.LSH_SECRETS_KEY] = key;
|
|
546
620
|
this.encryptionKey = key;
|
|
@@ -549,8 +623,7 @@ export class SecretsManager {
|
|
|
549
623
|
return true;
|
|
550
624
|
}
|
|
551
625
|
catch (error) {
|
|
552
|
-
|
|
553
|
-
logger.error(`Failed to save encryption key: ${_err.message}`);
|
|
626
|
+
logger.error(`Failed to save encryption key: ${extractErrorMessage(error)}`);
|
|
554
627
|
logger.info('Please set it manually:');
|
|
555
628
|
logger.info(`export LSH_SECRETS_KEY=${key}`);
|
|
556
629
|
return false;
|
|
@@ -581,13 +654,12 @@ LSH_SECRETS_KEY=${this.encryptionKey}
|
|
|
581
654
|
# Add your environment variables below
|
|
582
655
|
`;
|
|
583
656
|
try {
|
|
584
|
-
fs.writeFileSync(envFilePath, template, 'utf8');
|
|
657
|
+
fs.writeFileSync(envFilePath, template, { encoding: 'utf8', mode: 0o600 });
|
|
585
658
|
logger.info(`✅ Created ${envFilePath} from template`);
|
|
586
659
|
return true;
|
|
587
660
|
}
|
|
588
661
|
catch (error) {
|
|
589
|
-
|
|
590
|
-
logger.error(`Failed to create ${envFilePath}: ${_err.message}`);
|
|
662
|
+
logger.error(`Failed to create ${envFilePath}: ${extractErrorMessage(error)}`);
|
|
591
663
|
return false;
|
|
592
664
|
}
|
|
593
665
|
}
|
|
@@ -599,13 +671,12 @@ LSH_SECRETS_KEY=${this.encryptionKey}
|
|
|
599
671
|
if (!content.includes('LSH_SECRETS_KEY')) {
|
|
600
672
|
newContent += `\n# LSH Secrets Encryption Key (auto-generated)\nLSH_SECRETS_KEY=${this.encryptionKey}\n`;
|
|
601
673
|
}
|
|
602
|
-
fs.writeFileSync(envFilePath, newContent, 'utf8');
|
|
674
|
+
fs.writeFileSync(envFilePath, newContent, { encoding: 'utf8', mode: 0o600 });
|
|
603
675
|
logger.info(`✅ Created ${envFilePath} from ${path.basename(examplePath)}`);
|
|
604
676
|
return true;
|
|
605
677
|
}
|
|
606
678
|
catch (error) {
|
|
607
|
-
|
|
608
|
-
logger.error(`Failed to create ${envFilePath}: ${_err.message}`);
|
|
679
|
+
logger.error(`Failed to create ${envFilePath}: ${extractErrorMessage(error)}`);
|
|
609
680
|
return false;
|
|
610
681
|
}
|
|
611
682
|
}
|
|
@@ -679,7 +750,7 @@ LSH_SECRETS_KEY=${this.encryptionKey}
|
|
|
679
750
|
out();
|
|
680
751
|
}
|
|
681
752
|
// Step 1: Ensure encryption key exists
|
|
682
|
-
if (!
|
|
753
|
+
if (!findEncryptionKey()) {
|
|
683
754
|
logger.info('🔑 No encryption key found...');
|
|
684
755
|
await this.ensureEncryptionKey();
|
|
685
756
|
out();
|
|
@@ -982,8 +1053,7 @@ LSH_SECRETS_KEY=${this.encryptionKey}
|
|
|
982
1053
|
}
|
|
983
1054
|
catch (error) {
|
|
984
1055
|
// Don't fail operation if IPFS logging fails
|
|
985
|
-
|
|
986
|
-
logger.warn(`⚠️ Could not log to IPFS: ${err.message}`);
|
|
1056
|
+
logger.warn(`⚠️ Could not log to IPFS: ${extractErrorMessage(error)}`);
|
|
987
1057
|
}
|
|
988
1058
|
}
|
|
989
1059
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent storage for the LSH sync secret.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the design of mcli's `mcli sync key` store: a single 64-char
|
|
5
|
+
* hex value kept in ``$LSH_HOME/sync_key.json`` (default
|
|
6
|
+
* ``~/.config/lsh/sync_key.json``) with mode 0600. The
|
|
7
|
+
* ``LSH_SECRETS_KEY`` environment variable still wins when set; the
|
|
8
|
+
* store is only consulted as a fallback so users do not have to re-paste
|
|
9
|
+
* their secret on every shell.
|
|
10
|
+
*/
|
|
11
|
+
import * as crypto from 'crypto';
|
|
12
|
+
import * as fs from 'fs';
|
|
13
|
+
import * as os from 'os';
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
export const HEX64_REGEX = /^[0-9a-fA-F]{64}$/;
|
|
16
|
+
const FILENAME = 'sync_key.json';
|
|
17
|
+
/** Resolve the directory where lsh stores its config (`$LSH_HOME` or `~/.config/lsh`). */
|
|
18
|
+
export function getLshHome() {
|
|
19
|
+
const overridden = process.env.LSH_HOME;
|
|
20
|
+
if (overridden && overridden.trim().length > 0)
|
|
21
|
+
return overridden;
|
|
22
|
+
return path.join(os.homedir(), '.config', 'lsh');
|
|
23
|
+
}
|
|
24
|
+
/** Return a freshly-generated 64-char hex secret without persisting it. */
|
|
25
|
+
export function generateKey() {
|
|
26
|
+
return crypto.randomBytes(32).toString('hex');
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Persistent on-disk store for the LSH sync secret.
|
|
30
|
+
*/
|
|
31
|
+
export class SyncKeyStore {
|
|
32
|
+
/** Absolute path to the on-disk key file. */
|
|
33
|
+
get path() {
|
|
34
|
+
return path.join(getLshHome(), FILENAME);
|
|
35
|
+
}
|
|
36
|
+
/** Read the persisted key, or `null` if none is configured / file is malformed. */
|
|
37
|
+
get() {
|
|
38
|
+
const file = this.path;
|
|
39
|
+
if (!fs.existsSync(file))
|
|
40
|
+
return null;
|
|
41
|
+
try {
|
|
42
|
+
const raw = fs.readFileSync(file, 'utf-8');
|
|
43
|
+
const parsed = JSON.parse(raw);
|
|
44
|
+
if (typeof parsed.key === 'string' && HEX64_REGEX.test(parsed.key)) {
|
|
45
|
+
return parsed.key;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// fall through to null
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
/** Validate and persist a key. Throws on invalid input. */
|
|
54
|
+
set(key) {
|
|
55
|
+
if (typeof key !== 'string' || !HEX64_REGEX.test(key)) {
|
|
56
|
+
throw new Error('sync key must be a 64-char hex string');
|
|
57
|
+
}
|
|
58
|
+
this.write(key);
|
|
59
|
+
}
|
|
60
|
+
/** Generate a new key and persist it. Refuses to overwrite unless `force=true`. */
|
|
61
|
+
generate(force = false) {
|
|
62
|
+
if (fs.existsSync(this.path) && !force) {
|
|
63
|
+
throw new Error(`sync key already exists at ${this.path}; use force=true to overwrite`);
|
|
64
|
+
}
|
|
65
|
+
const key = generateKey();
|
|
66
|
+
this.write(key);
|
|
67
|
+
return key;
|
|
68
|
+
}
|
|
69
|
+
/** Delete the persisted key, if any. No-op when the file is absent. */
|
|
70
|
+
clear() {
|
|
71
|
+
try {
|
|
72
|
+
fs.unlinkSync(this.path);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
const e = err;
|
|
76
|
+
if (e.code !== 'ENOENT')
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
write(key) {
|
|
81
|
+
const dir = path.dirname(this.path);
|
|
82
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
83
|
+
// Write first, then chmod, so the secret is never world-readable.
|
|
84
|
+
fs.writeFileSync(this.path, JSON.stringify({ key }));
|
|
85
|
+
fs.chmodSync(this.path, 0o600);
|
|
86
|
+
}
|
|
87
|
+
}
|