@dotrino/vault 0.75.0 → 0.76.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.
@@ -16,7 +16,8 @@ import fs from 'node:fs'
16
16
  import path from 'node:path'
17
17
  import readline from 'node:readline/promises'
18
18
  import { spawn } from 'node:child_process'
19
- import { enrollService, readServiceIdentity, fetchSecrets } from '../src/service.js'
19
+ import { enrollService, readServiceIdentity, fetchSecrets, storeVars } from '../src/service.js'
20
+ import { parseEnvText, parseEnvInput } from '../src/envtext.js'
20
21
  import { loadEnv, serviceDir, serviceRoot, listEnrolled, resolveNs, resolveVaultLabel, isValidVaultLabel } from '../src/env.js'
21
22
  // Se usaba sin importarlo: `enroll` —el comando principal— moría con
22
23
  // «sharedParseInvite is not defined» en cuanto tocaba una invitación. Nunca
@@ -88,6 +89,16 @@ function help () {
88
89
  qué aparato es cada servicio de esta máquina: su ID (el de
89
90
  «dotrino-vault members»), su bóveda, sus permisos. Sin red.
90
91
  status servicios enrolados en esta máquina
92
+ import [<archivo>] [--ns <ns>] [--vault <bóveda>] [--public K1,K2 | --all-public]
93
+ [--link <dir>]
94
+ sube un .env (por defecto ./.env) al cajón de ESTE servicio. Se
95
+ sella aquí; la bóveda no ve los valores. Si hay quien apruebe, se
96
+ pide aprobación y al aprobar también puede cambiar las que ya
97
+ existen; si nadie puede aprobar, solo añade las que faltan.
98
+ Borrar, nunca: eso se hace en la bóveda. --link usa el enlace de
99
+ un agente (link.json) en vez de la identidad de dotrino-env.
100
+ set K=v [K2=v2 …] [--ns <ns>] [--public K1,K2 | --all-public] [--link <dir>]
101
+ lo mismo, con los valores en la orden
91
102
  check [--ns <ns>] [--public]
92
103
  pide los secretos al vault y lista sus NOMBRES (nunca los valores)
93
104
  con --public no pide aprobación: solo mira las públicas
@@ -283,6 +294,80 @@ async function cmdInfo () {
283
294
  console.log(infos.map(formatDeviceInfo).join('\n\n'))
284
295
  }
285
296
 
297
+ /**
298
+ * `import` y `set`: SUBIR VARIABLES AL CAJÓN DE ESTE SERVICIO (dueño, 2026-09-29: «para
299
+ * facilitar la migración de ENVs a Dotrino»). El `.env` de la máquina sube tal cual; qué
300
+ * se guarda y cuándo lo decide la bóveda (`storeVars`): con aprobación puede editar, sin
301
+ * ella solo añade, y borrar no existe por aquí.
302
+ */
303
+ async function cmdStore (fromFile) {
304
+ let parsed
305
+ if (fromFile) {
306
+ const file = argv[1] && !argv[1].startsWith('--') ? argv[1] : '.env'
307
+ let text
308
+ try { text = fs.readFileSync(file, 'utf8') } catch (e) { console.error('no puedo leer %s: %s', file, e.message); process.exit(1) }
309
+ parsed = parseEnvText(text)
310
+ } else {
311
+ const pares = []
312
+ for (let i = 1; i < argv.length; i++) {
313
+ if (argv[i].startsWith('--')) { if (!['--all-public'].includes(argv[i])) i++; continue }
314
+ pares.push(argv[i])
315
+ }
316
+ parsed = parseEnvInput(pares.join(' '))
317
+ }
318
+ // UN ERROR PARA TODO: una línea mal escrita no se sube a medias, se dice y se para.
319
+ if (parsed.errors.length) {
320
+ for (const e of parsed.errors) {
321
+ console.error(e.code === 'dup' ? ` línea ${e.line}: ${e.key} repetida (ya estaba en la línea ${e.first})`
322
+ : e.code === 'novalue' ? ` línea ${e.line}: ${e.key} sin valor`
323
+ : e.code === 'key' ? ` línea ${e.line}: «${e.key}» no es un nombre de variable válido`
324
+ : e.code === 'empty' ? ' no hay ninguna variable'
325
+ : ` línea ${e.line}: no tiene la forma CLAVE=valor`)
326
+ }
327
+ console.error('No se subió nada. Corrige lo de arriba y vuelve a intentarlo.')
328
+ process.exit(1)
329
+ }
330
+ const allPublic = argv.includes('--all-public')
331
+ const publicas = new Set(String(flag('public') || '').split(',').map((k) => k.trim()).filter(Boolean))
332
+ const desconocidas = [...publicas].filter((k) => !parsed.items.some((it) => it.key === k))
333
+ if (desconocidas.length) { console.error('--public nombra variables que no se van a subir: %s', desconocidas.join(', ')); process.exit(2) }
334
+ const vars = parsed.items.map((it) => ({ key: it.key, value: it.value, public: allPublic || publicas.has(it.key) }))
335
+
336
+ // DE QUIÉN: la identidad de dotrino-env o, con --link, el enlace de un agente (terminal,
337
+ // ia, content…), que es un aparato con cajón igual que cualquier servicio.
338
+ let who
339
+ const linkDir = flag('link')
340
+ if (linkDir) {
341
+ let link
342
+ try { link = JSON.parse(fs.readFileSync(path.join(linkDir, 'link.json'), 'utf8')) } catch (e) { console.error('no puedo leer %s/link.json: %s', linkDir, e.message); process.exit(1) }
343
+ const ns = flag('ns') || link.ns
344
+ if (!ns) { console.error('ese enlace no dice su cajón: pásalo con --ns <ns>'); process.exit(2) }
345
+ who = { ns, proxyUrl: link.proxy, masterPubkey: link.iss, device: link.device, cert: link.cert, dondeDice: `${linkDir} (cajón ${ns})` }
346
+ } else {
347
+ const ns = resolveNs(flag('ns'))
348
+ const dir = serviceDir(etiquetaBoveda(), ns)
349
+ who = { dir, ns, dondeDice: `${path.basename(path.dirname(dir))}/${ns}` }
350
+ }
351
+
352
+ const pub = vars.filter((v) => v.public).map((v) => v.key)
353
+ console.log('Subiendo %d variable(s) a %s: %s', vars.length, who.dondeDice, vars.map((v) => v.key).join(', '))
354
+ if (pub.length) console.log(' públicas: %s', pub.join(', '))
355
+ const { dondeDice, ...ident } = who
356
+ const r = await storeVars({ ...ident, vars }).catch((e) => {
357
+ if (e.code === 'exists') {
358
+ console.error('\n%s\nSin nadie que pueda aprobar, desde aquí solo se añaden variables nuevas. Para cambiar una que ya existe, hazlo en la bóveda (dotrino-vault secret set) o dale a alguien el permiso de aprobar.', e.message)
359
+ } else console.error('\nno se pudo subir: %s', e.message)
360
+ process.exit(1)
361
+ })
362
+ if (r.pending) {
363
+ console.log('\nPedido de aprobación enviado (%s). Apruébalo en un aparato con «aprueba»:', r.pending)
364
+ console.log('se guardarán al aprobar. Con la aprobación también se pueden cambiar las que ya existían.')
365
+ } else {
366
+ console.log('\nGuardadas: %s', r.keys.join(', '))
367
+ console.log('(nadie en tu cuenta puede aprobar, así que solo se añaden las que faltaban)')
368
+ }
369
+ }
370
+
286
371
  async function cmdCheck () {
287
372
  const ns = resolveNs(flag('ns'))
288
373
  // `fetchSecrets` y no `loadEnv`: listar NO debe tener efectos secundarios. Con
@@ -377,6 +462,8 @@ const run = async () => {
377
462
  case 'enroll': return cmdEnroll()
378
463
  case 'status': return cmdStatus()
379
464
  case 'info': return cmdInfo()
465
+ case 'import': return cmdStore(true)
466
+ case 'set': return cmdStore(false)
380
467
  case 'check': return cmdCheck()
381
468
  case 'run': return cmdRun()
382
469
  case 'ssh-agent': return cmdSshAgent()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vault",
3
- "version": "0.75.0",
3
+ "version": "0.76.0",
4
4
  "description": "Usa ESTE dispositivo (navegador) como bóveda/CA del ecosistema Dotrino: atiende enrolamientos por el proxy y firma certificados de delegación a tus máquinas. Incluye el cliente de SERVICIO (Node): un proyecto se enrola una vez y jala sus credenciales del vault en vez del .env (`import '@dotrino/vault/config'`).",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/service.js CHANGED
@@ -512,6 +512,75 @@ export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device,
512
512
  } finally { client.close() }
513
513
  }
514
514
 
515
+ /**
516
+ * GUARDA VARIABLES EN EL CAJÓN DE ESTE SERVICIO (dueño, 2026-09-29: «para facilitar la
517
+ * migración de ENVs a Dotrino»). Es lo que hay detrás de `dotrino-env import`.
518
+ *
519
+ * Los valores se sellan AQUÍ, en la máquina del servicio, para quien diga el acta; la
520
+ * bóveda recibe sobres que no abre. La lista de destinatarios la firma la bóveda y se
521
+ * comprueba (`verifyResponder`): si el proxio pudiera cambiarla por sus llaves, abriría las
522
+ * privadas al pasar.
523
+ *
524
+ * Lo que decide la bóveda, no este cliente:
525
+ * · si hay quien apruebe, se pide aprobación siempre, y al aprobarse puede EDITAR;
526
+ * · si nadie puede aprobar, solo AÑADE lo que falta (una que ya exista falla con `exists`);
527
+ * · BORRAR no existe por aquí.
528
+ *
529
+ * @param {object} o
530
+ * @param {Array<{ key:string, value:string, public?:boolean }>} o.vars
531
+ * @param {string} [o.dir] identidad de `dotrino-env` (`service-identity.json`); o, en su
532
+ * lugar, la identidad por parámetros (`ns`, `proxyUrl`, `masterPubkey`, `device`, `cert`),
533
+ * que es como la trae un agente de remote-agent.
534
+ * @returns {Promise<{ keys: string[], pending: string|null, approval: boolean }>}
535
+ * `pending`: el id del pedido de aprobación (se guarda al aprobar); `keys`: lo guardado ya.
536
+ */
537
+ export async function storeVars ({ dir, ns, proxyUrl, masterPubkey, device, cert, vars, timeoutMs = 30000 } = {}) {
538
+ const saved = dir ? readServiceIdentity(dir) : null
539
+ ns = ns || saved?.ns
540
+ proxyUrl = proxyUrl || saved?.proxy
541
+ masterPubkey = masterPubkey || saved?.iss
542
+ device = device || saved?.device
543
+ cert = cert || saved?.cert
544
+ if (!isValidSecretsNs(ns)) throw new Error('invalid ns')
545
+ if (!proxyUrl || !masterPubkey || !device || !cert) throw new Error('service not enrolled')
546
+ if (!Array.isArray(vars) || !vars.length) throw new Error('storeVars: no variables to store')
547
+ for (const v of vars) {
548
+ if (typeof v?.key !== 'string' || !v.key) throw new Error('storeVars: every variable needs a key')
549
+ if (typeof v.value !== 'string' || !v.value) throw Object.assign(new Error(`storeVars: "${v.key}" has no value`), { code: 'novalue' })
550
+ }
551
+
552
+ const client = await freshClient(proxyUrl, 20000, masterPubkey)
553
+ try {
554
+ await identifyAsService(client, device)
555
+ const ask = async (data) => {
556
+ const signed = { ...data, ns, publickey: device.publickey, ts: Date.now() }
557
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data: signed })
558
+ const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
559
+ client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data: signed, signature, cert })
560
+ const res = await pending
561
+ if (res.type === MSG.ERROR) throw vaultError(res)
562
+ await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
563
+ if (res.body?.op !== data.op.replace(/^store$/, 'store.result') || res.body?.ns !== ns) throw new Error('the vault answered something else')
564
+ return res.body
565
+ }
566
+
567
+ const { recipients, approval } = await ask({ op: 'store.recipients' })
568
+ const { buildSealedVar, authorFromDeviceKey } = await import('./admin.js')
569
+ const author = authorFromDeviceKey(device)
570
+ const items = []
571
+ for (const v of vars) {
572
+ const sealed = await buildSealedVar({
573
+ recipients: v.public ? recipients.public : recipients.private,
574
+ owner: `ns:${ns}`, key: v.key, value: v.value, author
575
+ })
576
+ items.push({ key: v.key, sealed, public: !!v.public })
577
+ }
578
+ const nonce = [...crypto.getRandomValues(new Uint8Array(16))].map((x) => x.toString(16).padStart(2, '0')).join('')
579
+ const r = await ask({ op: 'store', items, nonce })
580
+ return { keys: r.keys || [], pending: r.pending || null, approval: !!approval }
581
+ } finally { client.close() }
582
+ }
583
+
515
584
  /**
516
585
  * Pide los secretos del ns al vault (una petición puntual; lanza si falla).
517
586
  * Usa la identidad persistida por `enrollService` salvo que se pase explícita.