lsh-framework 3.1.25 → 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.
package/dist/cli.js CHANGED
@@ -13,6 +13,7 @@ import { registerSyncHistoryCommands } from './commands/sync-history.js';
13
13
  import { registerSyncCommands } from './commands/sync.js';
14
14
  import { registerMigrateCommand } from './commands/migrate.js';
15
15
  import { registerContextCommand } from './commands/context.js';
16
+ import { registerIPFSCommands } from './commands/ipfs.js';
16
17
  import { init_secrets } from './services/secrets/secrets.js';
17
18
  import { loadGlobalConfigSync } from './lib/config-manager.js';
18
19
  import { CLI_TEXT, CLI_HELP } from './constants/ui.js';
@@ -147,6 +148,7 @@ function findSimilarCommands(input, validCommands) {
147
148
  registerSyncCommands(program);
148
149
  registerMigrateCommand(program);
149
150
  registerContextCommand(program);
151
+ registerIPFSCommands(program);
150
152
  // Secrets management (primary feature)
151
153
  await init_secrets(program);
152
154
  // Shell completion
@@ -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
  };
@@ -171,6 +171,12 @@ export class IPFSClientManager {
171
171
  throw error;
172
172
  }
173
173
  }
174
+ /**
175
+ * Get the IPFS repo path used by lsh
176
+ */
177
+ getRepoPath() {
178
+ return path.join(this.ipfsDir, 'repo');
179
+ }
174
180
  /**
175
181
  * Start IPFS daemon
176
182
  */
@@ -179,9 +185,23 @@ export class IPFSClientManager {
179
185
  if (!clientInfo.installed) {
180
186
  throw new Error('IPFS client not installed. Run: lsh ipfs install');
181
187
  }
182
- logger.info('🚀 Starting IPFS daemon...');
183
- const ipfsRepoPath = path.join(this.ipfsDir, 'repo');
188
+ const ipfsRepoPath = this.getRepoPath();
184
189
  const ipfsCmd = clientInfo.path || 'ipfs';
190
+ // Auto-initialize repo if it doesn't exist
191
+ if (!fs.existsSync(path.join(ipfsRepoPath, 'config'))) {
192
+ logger.info('🔧 IPFS repository not found, initializing...');
193
+ try {
194
+ await execAsync(`${ipfsCmd} init`, {
195
+ env: { ...process.env, IPFS_PATH: ipfsRepoPath },
196
+ });
197
+ logger.info('✅ IPFS repository initialized');
198
+ }
199
+ catch (initError) {
200
+ const err = initError;
201
+ throw new Error(`Failed to auto-initialize IPFS repo: ${err.message}`);
202
+ }
203
+ }
204
+ logger.info('🚀 Starting IPFS daemon...');
185
205
  try {
186
206
  // Start daemon as fully detached background process
187
207
  // Using spawn with detached:true and stdio:'ignore' allows parent to exit
@@ -197,6 +217,17 @@ export class IPFSClientManager {
197
217
  if (daemon.pid) {
198
218
  fs.writeFileSync(pidPath, daemon.pid.toString(), 'utf8');
199
219
  }
220
+ // Wait for daemon to actually be ready (poll the API)
221
+ const ready = await this.waitForDaemon(10000);
222
+ if (!ready) {
223
+ // Clean up PID file since daemon didn't start
224
+ if (fs.existsSync(pidPath)) {
225
+ fs.unlinkSync(pidPath);
226
+ }
227
+ throw new Error('IPFS daemon process started but API is not responding. ' +
228
+ 'The daemon may have crashed. Check if IPFS repo is properly initialized: ' +
229
+ `IPFS_PATH=${ipfsRepoPath}`);
230
+ }
200
231
  logger.info('✅ IPFS daemon started');
201
232
  logger.info(` PID: ${daemon.pid}`);
202
233
  logger.info(' API: http://localhost:5001');
@@ -208,6 +239,27 @@ export class IPFSClientManager {
208
239
  throw error;
209
240
  }
210
241
  }
242
+ /**
243
+ * Wait for daemon API to become ready
244
+ */
245
+ async waitForDaemon(timeoutMs) {
246
+ const start = Date.now();
247
+ while (Date.now() - start < timeoutMs) {
248
+ try {
249
+ const response = await fetch('http://127.0.0.1:5001/api/v0/id', {
250
+ method: 'POST',
251
+ signal: AbortSignal.timeout(2000),
252
+ });
253
+ if (response.ok)
254
+ return true;
255
+ }
256
+ catch {
257
+ // Daemon not ready yet, keep polling
258
+ }
259
+ await new Promise(resolve => { setTimeout(resolve, 500); });
260
+ }
261
+ return false;
262
+ }
211
263
  /**
212
264
  * Stop IPFS daemon
213
265
  */
@@ -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();
@@ -130,14 +130,16 @@ export class IPFSSync {
130
130
  }
131
131
  /**
132
132
  * Download data from IPFS
133
- * Tries local daemon first, then falls back to public gateways
133
+ * Tries local daemon first (with longer timeout for DHT discovery),
134
+ * then falls back to public gateways
134
135
  */
135
136
  async download(cid) {
136
- // Try local daemon first (fastest if available)
137
+ // Try local daemon first use a longer timeout (60s) because
138
+ // the local node may need time for DHT content discovery
137
139
  try {
138
140
  const localResponse = await fetch(`${this.LOCAL_IPFS_API}/cat?arg=${cid}`, {
139
141
  method: 'POST',
140
- signal: AbortSignal.timeout(30000),
142
+ signal: AbortSignal.timeout(60000),
141
143
  });
142
144
  if (localResponse.ok) {
143
145
  const arrayBuffer = await localResponse.arrayBuffer();
@@ -335,6 +337,63 @@ export class IPFSSync {
335
337
  getGatewayUrls(cid) {
336
338
  return this.GATEWAYS.map(template => template.replace('{cid}', cid));
337
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
+ }
338
397
  }
339
398
  // Singleton instance
340
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.25",
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": {