lsh-framework 3.1.26 → 3.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.
@@ -6,6 +6,9 @@ import chalk from 'chalk';
6
6
  import ora from 'ora';
7
7
  import { IPFSClientManager } from '../lib/ipfs-client-manager.js';
8
8
  import { getIPFSSync } from '../lib/ipfs-sync.js';
9
+ import { deriveKeyInfo, ensureKeyImported } from '../lib/ipns-key-manager.js';
10
+ import { getGitRepoInfo } from '../lib/git-utils.js';
11
+ import { ENV_VARS, DEFAULTS } from '../constants/index.js';
9
12
  /**
10
13
  * Register IPFS commands
11
14
  */
@@ -64,6 +67,31 @@ export function registerIPFSCommands(program) {
64
67
  console.log(chalk.gray(' Start with: lsh ipfs start'));
65
68
  }
66
69
  console.log('');
70
+ // IPNS info
71
+ const encryptionKey = process.env[ENV_VARS.LSH_SECRETS_KEY];
72
+ if (encryptionKey && daemonInfo) {
73
+ console.log(chalk.bold('IPNS:'));
74
+ try {
75
+ const gitInfo = getGitRepoInfo();
76
+ const repoName = gitInfo?.repoName || DEFAULTS.DEFAULT_ENVIRONMENT;
77
+ const keyInfo = deriveKeyInfo(encryptionKey, repoName, DEFAULTS.DEFAULT_ENVIRONMENT);
78
+ const ipnsName = await ensureKeyImported(ipfsSync.getApiUrl(), keyInfo);
79
+ if (ipnsName) {
80
+ console.log(chalk.green(` ✅ IPNS name: ${ipnsName}`));
81
+ const resolvedCid = await ipfsSync.resolveIPNS(ipnsName);
82
+ if (resolvedCid) {
83
+ console.log(` Latest CID: ${resolvedCid.substring(0, 20)}...`);
84
+ }
85
+ else {
86
+ console.log(chalk.gray(' No published record yet'));
87
+ }
88
+ }
89
+ }
90
+ catch {
91
+ console.log(chalk.gray(' Could not derive IPNS info'));
92
+ }
93
+ console.log('');
94
+ }
67
95
  // Quick actions
68
96
  console.log(chalk.bold('Quick Actions:'));
69
97
  if (!info.installed) {
@@ -73,8 +101,8 @@ export function registerIPFSCommands(program) {
73
101
  console.log(chalk.cyan(' lsh ipfs start # Start daemon'));
74
102
  }
75
103
  else {
76
- console.log(chalk.cyan(' lsh ipfs sync push # Upload encrypted secrets'));
77
- console.log(chalk.cyan(' lsh ipfs sync pull <cid> # Download by CID'));
104
+ console.log(chalk.cyan(' lsh sync push # Push secrets'));
105
+ console.log(chalk.cyan(' lsh sync pull # Pull latest via IPNS'));
78
106
  }
79
107
  console.log('');
80
108
  }
@@ -12,7 +12,8 @@ import * as crypto from 'crypto';
12
12
  import { getIPFSSync } from '../lib/ipfs-sync.js';
13
13
  import { IPFSClientManager } from '../lib/ipfs-client-manager.js';
14
14
  import { getGitRepoInfo } from '../lib/git-utils.js';
15
- import { ENV_VARS } from '../constants/index.js';
15
+ import { deriveKeyInfo, ensureKeyImported } from '../lib/ipns-key-manager.js';
16
+ import { ENV_VARS, DEFAULTS } from '../constants/index.js';
16
17
  /**
17
18
  * Register sync commands
18
19
  */
@@ -243,8 +244,28 @@ export function registerSyncCommands(program) {
243
244
  if (options.description) {
244
245
  console.log(chalk.bold('Description:'), chalk.gray(options.description));
245
246
  }
247
+ // Publish to IPNS
248
+ if (encryptionKey) {
249
+ try {
250
+ const repoName = gitInfo?.repoName || DEFAULTS.DEFAULT_ENVIRONMENT;
251
+ const env = options.env || DEFAULTS.DEFAULT_ENVIRONMENT;
252
+ const keyInfo = deriveKeyInfo(encryptionKey, repoName, env);
253
+ const ipnsName = await ensureKeyImported(ipfsSync.getApiUrl(), keyInfo);
254
+ if (ipnsName) {
255
+ const publishedName = await ipfsSync.publishToIPNS(cid, keyInfo.keyName);
256
+ if (publishedName) {
257
+ console.log(chalk.bold('IPNS:'), chalk.cyan(publishedName));
258
+ }
259
+ }
260
+ }
261
+ catch {
262
+ // Non-fatal: IPNS is a convenience
263
+ }
264
+ }
246
265
  console.log('');
247
266
  console.log(chalk.gray('Pull on another machine:'));
267
+ console.log(chalk.cyan(' lsh sync pull'));
268
+ console.log(chalk.gray('Or by specific CID:'));
248
269
  console.log(chalk.cyan(` lsh sync pull ${cid}`));
249
270
  console.log('');
250
271
  }
@@ -325,15 +346,30 @@ export function registerSyncCommands(program) {
325
346
  spinner.succeed(chalk.green('Uploaded to IPFS!'));
326
347
  console.log('');
327
348
  console.log(chalk.bold('CID:'), chalk.cyan(cid));
349
+ // Publish to IPNS
350
+ if (encryptionKey) {
351
+ try {
352
+ const repoName = gitInfo?.repoName || DEFAULTS.DEFAULT_ENVIRONMENT;
353
+ const env = options.env || DEFAULTS.DEFAULT_ENVIRONMENT;
354
+ const keyInfo = deriveKeyInfo(encryptionKey, repoName, env);
355
+ const ipnsName = await ensureKeyImported(ipfsSync.getApiUrl(), keyInfo);
356
+ if (ipnsName) {
357
+ const publishedName = await ipfsSync.publishToIPNS(cid, keyInfo.keyName);
358
+ if (publishedName) {
359
+ console.log(chalk.bold('IPNS:'), chalk.cyan(publishedName));
360
+ }
361
+ }
362
+ }
363
+ catch {
364
+ // Non-fatal
365
+ }
366
+ }
328
367
  console.log('');
329
- console.log(chalk.gray('Share this CID with teammates to pull secrets:'));
368
+ console.log(chalk.gray('Teammates can pull with just:'));
369
+ console.log(chalk.cyan(' lsh sync pull'));
370
+ console.log(chalk.gray('Or by specific CID:'));
330
371
  console.log(chalk.cyan(` lsh sync pull ${cid}`));
331
372
  console.log('');
332
- console.log(chalk.gray('Public gateway URLs:'));
333
- ipfsSync.getGatewayUrls(cid).slice(0, 2).forEach(url => {
334
- console.log(chalk.gray(` ${url}`));
335
- });
336
- console.log('');
337
373
  }
338
374
  catch (error) {
339
375
  const err = error;
@@ -342,14 +378,68 @@ export function registerSyncCommands(program) {
342
378
  process.exit(1);
343
379
  }
344
380
  });
345
- // lsh sync pull <cid>
381
+ // lsh sync pull [cid]
346
382
  syncCommand
347
- .command('pull <cid>')
348
- .description('⬇️ Pull secrets from IPFS by CID')
383
+ .command('pull [cid]')
384
+ .description('⬇️ Pull secrets from IPFS (auto-resolves via IPNS if no CID given)')
349
385
  .option('-o, --output <path>', 'Output file path', '.env')
386
+ .option('-e, --env <name>', 'Environment name', '')
350
387
  .option('--force', 'Overwrite existing file without backup')
351
388
  .action(async (cid, options) => {
352
- const spinner = ora('Downloading from IPFS...').start();
389
+ const spinner = ora(cid ? 'Downloading from IPFS...' : 'Resolving latest secrets via IPNS...').start();
390
+ // If no CID provided, resolve via IPNS
391
+ if (!cid) {
392
+ try {
393
+ const ipfsSync = getIPFSSync();
394
+ if (!await ipfsSync.checkDaemon()) {
395
+ spinner.fail(chalk.red('IPFS daemon not running'));
396
+ console.log(chalk.gray(' Start with: lsh sync start'));
397
+ process.exit(1);
398
+ }
399
+ // Get encryption key
400
+ let ipnsKey = process.env[ENV_VARS.LSH_SECRETS_KEY];
401
+ if (!ipnsKey) {
402
+ const outputPath = path.resolve(options.output);
403
+ if (fs.existsSync(outputPath)) {
404
+ const content = fs.readFileSync(outputPath, 'utf-8');
405
+ const keyMatch = content.match(/^LSH_SECRETS_KEY=(.+)$/m);
406
+ if (keyMatch) {
407
+ ipnsKey = keyMatch[1].trim().replace(/^["']|["']$/g, '');
408
+ }
409
+ }
410
+ }
411
+ if (!ipnsKey) {
412
+ spinner.fail(chalk.red('LSH_SECRETS_KEY required for IPNS resolution'));
413
+ console.log(chalk.gray(' Set it: export LSH_SECRETS_KEY=<key>'));
414
+ process.exit(1);
415
+ }
416
+ const gitInfo = getGitRepoInfo();
417
+ const repoName = gitInfo?.repoName || DEFAULTS.DEFAULT_ENVIRONMENT;
418
+ const environment = options.env || DEFAULTS.DEFAULT_ENVIRONMENT;
419
+ const keyInfo = deriveKeyInfo(ipnsKey, repoName, environment);
420
+ const ipnsName = await ensureKeyImported(ipfsSync.getApiUrl(), keyInfo);
421
+ if (!ipnsName) {
422
+ spinner.fail(chalk.red('Failed to derive IPNS key'));
423
+ process.exit(1);
424
+ }
425
+ spinner.text = `Resolving IPNS: ${ipnsName.substring(0, 20)}...`;
426
+ const resolvedCid = await ipfsSync.resolveIPNS(ipnsName);
427
+ if (!resolvedCid) {
428
+ spinner.fail(chalk.red('No secrets found via IPNS'));
429
+ console.log(chalk.gray(' No one has pushed secrets for this repo/environment yet.'));
430
+ console.log(chalk.gray(' Push first: lsh sync push'));
431
+ process.exit(1);
432
+ }
433
+ cid = resolvedCid;
434
+ spinner.succeed(chalk.green(`Resolved IPNS → CID: ${cid.substring(0, 16)}...`));
435
+ spinner.start('Downloading from IPFS...');
436
+ }
437
+ catch (error) {
438
+ const err = error;
439
+ spinner.fail(chalk.red(`IPNS resolution failed: ${err.message}`));
440
+ process.exit(1);
441
+ }
442
+ }
353
443
  try {
354
444
  const ipfsSync = getIPFSSync();
355
445
  // Download from IPFS
@@ -134,4 +134,10 @@ export const DEFAULTS = {
134
134
  DEFAULT_SHELL_UNIX: '/bin/sh',
135
135
  DEFAULT_SHELL_WIN: 'cmd.exe',
136
136
  DEFAULT_EDITOR: 'vi',
137
+ // IPNS configuration
138
+ DEFAULT_ENVIRONMENT: 'default',
139
+ IPNS_PUBLISH_LIFETIME: '87600h', // ~10 years, re-published on each push
140
+ IPNS_RESOLVE_TIMEOUT_MS: 30000, // 30s for DHT lookup
141
+ IPNS_KEY_PREFIX: 'lsh-',
142
+ IPNS_KEY_DERIVATION_CONTEXT: 'lsh-ipns-v1',
137
143
  };
@@ -256,7 +256,7 @@ export class IPFSClientManager {
256
256
  catch {
257
257
  // Daemon not ready yet, keep polling
258
258
  }
259
- await new Promise(resolve => setTimeout(resolve, 500));
259
+ await new Promise(resolve => { setTimeout(resolve, 500); });
260
260
  }
261
261
  return false;
262
262
  }
@@ -13,7 +13,8 @@ import * as os from 'os';
13
13
  import * as crypto from 'crypto';
14
14
  import { createLogger } from './logger.js';
15
15
  import { getIPFSSync } from './ipfs-sync.js';
16
- import { ENV_VARS } from '../constants/index.js';
16
+ import { deriveKeyInfo, ensureKeyImported } from './ipns-key-manager.js';
17
+ import { ENV_VARS, DEFAULTS } from '../constants/index.js';
17
18
  const logger = createLogger('IPFSSecretsStorage');
18
19
  /**
19
20
  * IPFS Secrets Storage
@@ -108,6 +109,28 @@ export class IPFSSecretsStorage {
108
109
  logger.warn(` ⚠️ IPFS upload failed: ${err.message}`);
109
110
  }
110
111
  }
112
+ // Publish to IPNS if we uploaded to the network
113
+ if (uploadedToNetwork && realCid && encryptionKey) {
114
+ try {
115
+ const repoName = gitRepo || DEFAULTS.DEFAULT_ENVIRONMENT;
116
+ const env = environment || DEFAULTS.DEFAULT_ENVIRONMENT;
117
+ const keyInfo = deriveKeyInfo(encryptionKey, repoName, env);
118
+ const ipnsName = await ensureKeyImported(ipfsSync.getApiUrl(), keyInfo);
119
+ if (ipnsName) {
120
+ const publishedName = await ipfsSync.publishToIPNS(realCid, keyInfo.keyName);
121
+ if (publishedName) {
122
+ metadata.ipns_name = publishedName;
123
+ this.metadata[this.getMetadataKey(gitRepo, environment)] = metadata;
124
+ await this.saveMetadata();
125
+ logger.info(` 🔗 Published to IPNS: ${publishedName}`);
126
+ }
127
+ }
128
+ }
129
+ catch (error) {
130
+ const err = error;
131
+ logger.warn(` ⚠️ IPNS publish failed (non-fatal): ${err.message}`);
132
+ }
133
+ }
111
134
  if (!uploadedToNetwork) {
112
135
  logger.warn(` 📁 Secrets cached locally only (no network sync)`);
113
136
  logger.warn(` 💡 Start IPFS daemon for network sync: lsh ipfs start`);
@@ -131,7 +154,41 @@ export class IPFSSecretsStorage {
131
154
  const displayEnv = gitRepo
132
155
  ? (environment ? `${gitRepo}_${environment}` : gitRepo)
133
156
  : (environment || 'default');
134
- // If no local metadata, check IPFS sync history
157
+ // If no local metadata, try IPNS resolution first
158
+ if (!metadata && encryptionKey) {
159
+ try {
160
+ const ipfsSync = getIPFSSync();
161
+ if (await ipfsSync.checkDaemon()) {
162
+ const repoName = gitRepo || DEFAULTS.DEFAULT_ENVIRONMENT;
163
+ const env = environment || DEFAULTS.DEFAULT_ENVIRONMENT;
164
+ const keyInfo = deriveKeyInfo(encryptionKey, repoName, env);
165
+ const ipnsName = await ensureKeyImported(ipfsSync.getApiUrl(), keyInfo);
166
+ if (ipnsName) {
167
+ logger.info(` 🔍 Resolving via IPNS: ${ipnsName.substring(0, 20)}...`);
168
+ const resolvedCid = await ipfsSync.resolveIPNS(ipnsName);
169
+ if (resolvedCid) {
170
+ logger.info(` ✅ IPNS resolved to CID: ${resolvedCid}`);
171
+ metadata = {
172
+ environment,
173
+ git_repo: gitRepo,
174
+ cid: resolvedCid,
175
+ ipns_name: ipnsName,
176
+ timestamp: new Date().toISOString(),
177
+ keys_count: 0,
178
+ encrypted: true,
179
+ };
180
+ this.metadata[metadataKey] = metadata;
181
+ await this.saveMetadata();
182
+ }
183
+ }
184
+ }
185
+ }
186
+ catch (error) {
187
+ const err = error;
188
+ logger.debug(` IPNS resolution failed: ${err.message}`);
189
+ }
190
+ }
191
+ // If still no metadata, check IPFS sync history
135
192
  if (!metadata && gitRepo) {
136
193
  try {
137
194
  const ipfsSync = getIPFSSync();
@@ -337,6 +337,63 @@ export class IPFSSync {
337
337
  getGatewayUrls(cid) {
338
338
  return this.GATEWAYS.map(template => template.replace('{cid}', cid));
339
339
  }
340
+ /**
341
+ * Get the Kubo API URL
342
+ */
343
+ getApiUrl() {
344
+ return this.LOCAL_IPFS_API;
345
+ }
346
+ /**
347
+ * Publish a CID to IPNS under the given key name.
348
+ * The key must already be imported into Kubo.
349
+ * Returns the IPNS name on success, null on failure.
350
+ */
351
+ async publishToIPNS(cid, keyName) {
352
+ try {
353
+ const response = await fetch(`${this.LOCAL_IPFS_API}/name/publish?arg=${cid}&key=${encodeURIComponent(keyName)}&lifetime=87600h&resolve=false`, {
354
+ method: 'POST',
355
+ signal: AbortSignal.timeout(30000),
356
+ });
357
+ if (!response.ok) {
358
+ const errorText = await response.text();
359
+ logger.warn(`IPNS publish failed: ${errorText}`);
360
+ return null;
361
+ }
362
+ const data = await response.json();
363
+ logger.info(`📡 IPNS published: ${data.Name} → ${data.Value}`);
364
+ return data.Name;
365
+ }
366
+ catch (error) {
367
+ const err = error;
368
+ logger.debug(`IPNS publish error: ${err.message}`);
369
+ return null;
370
+ }
371
+ }
372
+ /**
373
+ * Resolve an IPNS name to its current CID.
374
+ * Returns the CID (without /ipfs/ prefix) on success, null on failure/timeout.
375
+ */
376
+ async resolveIPNS(ipnsName) {
377
+ try {
378
+ const response = await fetch(`${this.LOCAL_IPFS_API}/name/resolve?arg=${encodeURIComponent(ipnsName)}&nocache=true`, {
379
+ method: 'POST',
380
+ signal: AbortSignal.timeout(30000),
381
+ });
382
+ if (!response.ok) {
383
+ return null;
384
+ }
385
+ const data = await response.json();
386
+ // Strip /ipfs/ prefix to get the raw CID
387
+ const resolvedCid = data.Path.replace(/^\/ipfs\//, '');
388
+ logger.info(`📡 IPNS resolved: ${ipnsName} → ${resolvedCid}`);
389
+ return resolvedCid;
390
+ }
391
+ catch (error) {
392
+ const err = error;
393
+ logger.debug(`IPNS resolve error: ${err.message}`);
394
+ return null;
395
+ }
396
+ }
340
397
  }
341
398
  // Singleton instance
342
399
  let ipfsSyncInstance = null;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * IPNS Key Manager
3
+ *
4
+ * Deterministic IPNS key derivation from LSH_SECRETS_KEY.
5
+ * Same key + repo + env always produces the same IPNS name,
6
+ * so teammates only need to share the encryption key.
7
+ */
8
+ import * as crypto from 'crypto';
9
+ import { DEFAULTS } from '../constants/config.js';
10
+ import { createLogger } from './logger.js';
11
+ const logger = createLogger('IPNSKeyManager');
12
+ // PKCS8 DER prefix for ed25519 private keys (RFC 8410)
13
+ // This wraps a raw 32-byte seed into a valid PKCS8 structure.
14
+ const ED25519_PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex');
15
+ /**
16
+ * Derive deterministic IPNS key info from secrets key + repo + env.
17
+ * Same inputs always produce the same key.
18
+ */
19
+ export function deriveKeyInfo(secretsKey, repoName, environment) {
20
+ const context = `${DEFAULTS.IPNS_KEY_DERIVATION_CONTEXT}:${repoName}:${environment}`;
21
+ const seed = crypto.createHmac('sha256', secretsKey)
22
+ .update(context)
23
+ .digest();
24
+ const keyName = DEFAULTS.IPNS_KEY_PREFIX +
25
+ crypto.createHash('sha256').update(seed).digest('hex').substring(0, 16);
26
+ return { keyName, seed };
27
+ }
28
+ /**
29
+ * Build a PEM-encoded PKCS8 ed25519 private key from a 32-byte seed.
30
+ * This is the format Kubo's /key/import accepts with format=pem-pkcs8-cleartext.
31
+ */
32
+ export function buildPemFromSeed(seed) {
33
+ const pkcs8Der = Buffer.concat([ED25519_PKCS8_PREFIX, seed]);
34
+ const b64 = pkcs8Der.toString('base64');
35
+ const lines = b64.match(/.{1,64}/g) || [b64];
36
+ return `-----BEGIN PRIVATE KEY-----\n${lines.join('\n')}\n-----END PRIVATE KEY-----\n`;
37
+ }
38
+ /**
39
+ * Ensure the derived key is imported into the local Kubo node.
40
+ * Idempotent: checks /key/list first, only imports if missing.
41
+ * Returns the IPNS name (peer ID) on success, null on failure.
42
+ */
43
+ export async function ensureKeyImported(kuboApiUrl, keyInfo) {
44
+ try {
45
+ // Check if key already exists
46
+ const listResponse = await fetch(`${kuboApiUrl}/key/list`, {
47
+ method: 'POST',
48
+ signal: AbortSignal.timeout(5000),
49
+ });
50
+ if (listResponse.ok) {
51
+ const listData = await listResponse.json();
52
+ const existing = listData.Keys?.find(k => k.Name === keyInfo.keyName);
53
+ if (existing) {
54
+ logger.debug(`IPNS key "${keyInfo.keyName}" already imported: ${existing.Id}`);
55
+ return existing.Id;
56
+ }
57
+ }
58
+ // Import the key
59
+ const pem = buildPemFromSeed(keyInfo.seed);
60
+ const formData = new FormData();
61
+ const blob = new Blob([pem], { type: 'application/x-pem-file' });
62
+ formData.append('file', blob, 'key.pem');
63
+ const importResponse = await fetch(`${kuboApiUrl}/key/import?arg=${encodeURIComponent(keyInfo.keyName)}&format=pem-pkcs8-cleartext`, {
64
+ method: 'POST',
65
+ body: formData,
66
+ signal: AbortSignal.timeout(5000),
67
+ });
68
+ if (!importResponse.ok) {
69
+ const errorText = await importResponse.text();
70
+ logger.warn(`Failed to import IPNS key: ${errorText}`);
71
+ return null;
72
+ }
73
+ const importData = await importResponse.json();
74
+ logger.info(`Imported IPNS key "${importData.Name}": ${importData.Id}`);
75
+ return importData.Id;
76
+ }
77
+ catch (error) {
78
+ const err = error;
79
+ logger.debug(`IPNS key import failed: ${err.message}`);
80
+ return null;
81
+ }
82
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lsh-framework",
3
- "version": "3.1.26",
3
+ "version": "3.2.0",
4
4
  "description": "Simple, cross-platform encrypted secrets manager with automatic sync, IPFS audit logs, and multi-environment support. Just run lsh sync and start managing your secrets.",
5
5
  "main": "dist/app.js",
6
6
  "bin": {