@dotrino/vault 0.45.1 → 0.47.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.
@@ -2,14 +2,14 @@
2
2
  /**
3
3
  * dotrino-env — CLI del "dotenv contra el vault".
4
4
  *
5
- * dotrino-env enroll --ns <ns> [--qr <invitación>] enrola ESTA máquina/servicio (una vez)
5
+ * dotrino-env enroll --vault <b> --ns <ns> [--qr <inv>] enrola ESTA máquina (una vez)
6
6
  * dotrino-env status qué hay enrolado aquí
7
7
  * dotrino-env check [--ns <ns>] pide los secretos y lista sus NOMBRES (nunca valores)
8
8
  * dotrino-env run [--ns <ns>] -- <cmd> [args…] corre un comando con los secretos en su entorno
9
9
  *
10
10
  * El enrolamiento es el registro del cliente contra el vault del dueño:
11
11
  * 1. en el vault: dotrino-vault pair --service <ns> (invitación con scope SOLO vault:secrets:<ns>)
12
- * 2. aquí: dotrino-env enroll --ns <ns> (pegas la invitación; se MUESTRA un código)
12
+ * 2. aquí: dotrino-env enroll --vault <b> --ns <ns> (pegas la invitación; se MUESTRA un código)
13
13
  * 3. en el vault: dotrino-vault approve <código> (lo tipeas leyéndolo de esta pantalla)
14
14
  */
15
15
  import fs from 'node:fs'
@@ -17,7 +17,7 @@ import path from 'node:path'
17
17
  import readline from 'node:readline/promises'
18
18
  import { spawn } from 'node:child_process'
19
19
  import { enrollService, readServiceIdentity, fetchSecrets } from '../src/service.js'
20
- import { loadEnv, serviceDir, serviceRoot, listEnrolled, resolveNs } from '../src/env.js'
20
+ import { loadEnv, serviceDir, serviceRoot, listEnrolled, resolveNs, resolveVaultLabel, isValidVaultLabel } from '../src/env.js'
21
21
  // Se usaba sin importarlo: `enroll` —el comando principal— moría con
22
22
  // «sharedParseInvite is not defined» en cuanto tocaba una invitación. Nunca
23
23
  // llegó a funcionar, y por eso el único servicio enrolado del ecosistema (el
@@ -30,14 +30,14 @@ const flag = (name) => { const i = argv.indexOf('--' + name); return i >= 0 ? ar
30
30
  function help () {
31
31
  console.log(`dotrino-env — credenciales del vault en vez del .env
32
32
 
33
- enroll --ns <ns> [--code <invitación>] [--dir <dir>]
33
+ enroll --vault <bóveda> --ns <ns> [--code <invitación>] [--dir <dir>]
34
34
  Registra ESTE servicio contra el vault (una sola vez).
35
35
  Antes, en el vault: dotrino-vault pair --service <ns>
36
36
  Si no pasas --code (alias --qr), se pide por consola (también acepta stdin).
37
37
 
38
38
  status servicios enrolados en esta máquina
39
39
  check [--ns <ns>] pide los secretos al vault y lista sus NOMBRES (nunca los valores)
40
- run [--ns <ns>] -- <cmd> [args…]
40
+ run --vault <bóveda> [--ns <ns>] -- <cmd> [args…]
41
41
  ssh-agent [--ns <ns>] [--socket <ruta>]
42
42
  agente SSH con las llaves (SSH_KEY_* del cajón) solo en memoria;
43
43
  pide el cajón al arrancar. Pon SSH_AUTH_SOCK en la ruta que imprime.
@@ -50,7 +50,7 @@ En tu código:
50
50
  El vault MANDA: sus valores pisan los del .env y los del entorno. Para una
51
51
  corrida suelta sin que pise nada: DOTRINO_ENV_OVERRIDE=0
52
52
 
53
- Entorno: DOTRINO_NS · DOTRINO_ENV_DIR · DOTRINO_ENV_HOME · DOTRINO_ENV_QUIET
53
+ Entorno: DOTRINO_NS · DOTRINO_ENV_VAULT · DOTRINO_ENV_DIR · DOTRINO_ENV_HOME · DOTRINO_ENV_QUIET
54
54
  DOTRINO_ENV_OVERRIDE`)
55
55
  }
56
56
 
@@ -73,10 +73,34 @@ async function readInvite () {
73
73
  return answer
74
74
  }
75
75
 
76
+ /**
77
+ * DE QUÉ BÓVEDA VINO, obligatorio. Etiqueta de ESTA máquina para agrupar sus cajones:
78
+ * `~/.dotrino/service/<bóveda>/<ns>`. No viaja y no se compara con nada — solo evita que
79
+ * dos bóvedas con un cajón del mismo nombre se pisen el directorio, que antes dejaba al
80
+ * primero inservible.
81
+ *
82
+ * `--vault` y no `--profile`: `dotrino-vault --profile` ya significa otra cosa y los dos
83
+ * comandos se usan seguidos.
84
+ */
85
+ function etiquetaBoveda () {
86
+ const v = resolveVaultLabel(flag('vault'))
87
+ if (!v) {
88
+ console.error('falta --vault <nombre> (o DOTRINO_ENV_VAULT)')
89
+ console.error('')
90
+ console.error(' Dice DE QUÉ BÓVEDA es este cajón. Es una etiqueta tuya, de esta máquina:')
91
+ console.error(' ~/.dotrino/service/<bóveda>/<ns>')
92
+ console.error(' Sirve para que dos bóvedas puedan tener un cajón llamado igual.')
93
+ process.exit(2)
94
+ }
95
+ if (!isValidVaultLabel(v)) { console.error('etiqueta inválida: usa [a-z0-9-]{1,32}'); process.exit(2) }
96
+ return v
97
+ }
98
+
76
99
  async function cmdEnroll () {
77
100
  const ns = flag('ns')
78
101
  if (!ns) { console.error('falta --ns <ns> (el mismo del `dotrino-vault pair --service <ns>`)'); process.exit(2) }
79
- const dir = flag('dir') || serviceDir(ns)
102
+ const perfil = etiquetaBoveda()
103
+ const dir = flag('dir') || serviceDir(bov, ns)
80
104
  const qr = parseInvite((flag('qr') || flag('code')) || await readInvite())
81
105
 
82
106
  console.log('\nEnrolando el servicio "%s" contra el vault…', ns)
@@ -115,11 +139,20 @@ async function cmdEnroll () {
115
139
  function cmdStatus () {
116
140
  const found = listEnrolled()
117
141
  if (!found.length) {
118
- console.log('Ningún servicio enrolado en %s\n Enrola uno: dotrino-env enroll --ns <ns>', serviceRoot())
142
+ console.log('Ningún servicio enrolado en %s\n Enrola uno: dotrino-env enroll --vault <bóveda> --ns <ns>', serviceRoot())
119
143
  return
120
144
  }
121
- for (const ns of found) {
122
- const id = readServiceIdentity(serviceDir(ns))
145
+ for (const e of found) {
146
+ // LO QUE QUEDÓ EN EL FORMATO VIEJO se dice y no se usa. Repescarlo en silencio sería
147
+ // fingir que la migración no hace falta; callarlo dejaría un servicio «desaparecido».
148
+ if (e.legacy) {
149
+ const viejo = path.join(serviceRoot(), e.ns)
150
+ console.log('%s ⚠ del formato anterior, sin la bóveda — no se usa', e.ns)
151
+ console.log(' muévelo: mkdir -p %s/<bóveda> && mv %s %s/<bóveda>/', serviceRoot(), viejo, serviceRoot())
152
+ continue
153
+ }
154
+ const dir = serviceDir(e.vault, e.ns)
155
+ const id = readServiceIdentity(dir)
123
156
  const c = id?.cert
124
157
  // El papel se describe por el ACTA a la que se ató. Uno del modelo viejo se dice tal
125
158
  // cual, con su fecha, porque ESO sí importa: es lo único que todavía caduca, y sabrá
@@ -127,8 +160,8 @@ function cmdStatus () {
127
160
  const papel = typeof c?.seq === 'number'
128
161
  ? `acta #${c.seq}`
129
162
  : (typeof c?.exp === 'number' ? `modelo viejo · vence ${new Date(c.exp).toISOString().slice(0, 10)}` : '?')
130
- console.log('%s\n dir: %s\n vault: %s…\n scope: %s\n cert: %s',
131
- ns, serviceDir(ns), String(id.iss).slice(0, 24), (c?.scope || []).join(', '), papel)
163
+ console.log('%s/%s\n dir: %s\n vault: %s…\n scope: %s\n cert: %s',
164
+ e.vault, e.ns, dir, String(id.iss).slice(0, 24), (c?.scope || []).join(', '), papel)
132
165
  }
133
166
  }
134
167
 
@@ -137,7 +170,7 @@ async function cmdCheck () {
137
170
  // `fetchSecrets` y no `loadEnv`: listar NO debe tener efectos secundarios. Con
138
171
  // `loadEnv` esto inyectaría el bundle en el entorno del propio `check`, que es
139
172
  // justo lo que un comando de diagnóstico no tiene por qué hacer.
140
- const secrets = await fetchSecrets({ dir: serviceDir(ns), ns })
173
+ const secrets = await fetchSecrets({ dir: serviceDir(etiquetaBoveda(), ns), ns })
141
174
  const keys = Object.keys(secrets)
142
175
  console.log('ns "%s": %d secreto(s)%s', ns, keys.length, keys.length ? ':' : '')
143
176
  for (const k of keys) console.log(' ' + k) // NUNCA los valores
@@ -151,9 +184,9 @@ async function cmdCheck () {
151
184
  async function cmdRun () {
152
185
  const sep = argv.indexOf('--')
153
186
  const cmd = sep >= 0 ? argv.slice(sep + 1) : []
154
- if (!cmd.length) { console.error('uso: dotrino-env run [--ns <ns>] -- <cmd> [args…]'); process.exit(2) }
187
+ if (!cmd.length) { console.error('uso: dotrino-env run --vault <bóveda> [--ns <ns>] -- <cmd> [args…]'); process.exit(2) }
155
188
  const onPending = () => console.error('[dotrino-env] waiting for approval on your phone…')
156
- const { injected, overridden } = await loadEnv({ ns: flag('ns'), onPending })
189
+ const { injected, overridden } = await loadEnv({ ns: flag('ns'), vault: etiquetaBoveda(), onPending })
157
190
  console.error('[dotrino-env] %d valor(es) en el entorno de %s%s', injected.length, cmd[0],
158
191
  overridden.length ? ` (pisados: ${overridden.join(', ')})` : '')
159
192
  const child = spawn(cmd[0], cmd.slice(1), { stdio: 'inherit', env: process.env })
@@ -175,7 +208,7 @@ async function cmdSshAgent () {
175
208
  const { serviceDir, resolveNs } = await import('../src/env.js')
176
209
  const { waitForSecrets } = await import('../src/service.js')
177
210
  const ns = resolveNs(flag('ns') || undefined)
178
- const dir = flag('dir') || serviceDir(ns)
211
+ const dir = flag('dir') || serviceDir(etiquetaBoveda(), ns)
179
212
  const socketPath = flag('socket') || defaultSocketPath(dir)
180
213
  console.error('[dotrino-env] asking the vault for the keys of «%s»…', ns)
181
214
  const secrets = await waitForSecrets({ dir, ns, onPending: () => console.error('[dotrino-env] waiting for approval on your phone…') })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vault",
3
- "version": "0.45.1",
3
+ "version": "0.47.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/admin.js CHANGED
@@ -60,7 +60,20 @@ export const ADMIN_OPS = Object.freeze([
60
60
  ])
61
61
 
62
62
  /**
63
- * LO ÚNICO QUE SE ATIENDE CON EL CANDADO ECHADO: mirar. Ni una operación que cambie algo.
63
+ * QUÉ SE ATIENDE CON EL CANDADO ECHADO.
64
+ *
65
+ * No es «solo mirar», y confundirlo fue un error: **guardar una variable NO necesita la
66
+ * maestra**. Sellar un valor usa las llaves PÚBLICAS de quien va a leerlo, así que la
67
+ * bóveda puede hacerlo sin ningún secreto suyo (`docs/secretos-sellados.md` §8.1) — es
68
+ * justo lo que hace barata la rotación. Meterlo en el mismo saco que revocar dejaba la
69
+ * consola sin poder configurar nada con el perfil cerrado, que es su trabajo diario.
70
+ *
71
+ * Lo que SÍ se queda fuera es `revoke`: reescribe el acta, y sellar el acta es de la
72
+ * maestra. Cerrada no hay con qué.
73
+ *
74
+ * (Si el cajón tiene su llave sellada con la frase, repartirla a los miembros puede quedar
75
+ * a deber hasta que alguien abra: eso ya lo dice la bóveda y se salda solo. Guardar el
76
+ * valor, que es lo que se pidió, se hace igual.)
64
77
  *
65
78
  * El candado ES de la consola —los aparatos ya emparejados siguen leyendo, guardando y
66
79
  * firmando con SU llave— así que cerrar el perfil tiene que cerrar exactamente esto. No
@@ -72,7 +85,7 @@ export const ADMIN_OPS = Object.freeze([
72
85
  * Se dejan las de LEER a propósito: si se cortaran todas, la consola no podría ni
73
86
  * enterarse de por qué no funciona y parecería rota. Así puede decir «está cerrada».
74
87
  */
75
- export const ADMIN_OPS_WHILE_LOCKED = Object.freeze(['audit', 'vars'])
88
+ export const ADMIN_OPS_WHILE_LOCKED = Object.freeze(['audit', 'vars', 'var.set', 'var.setMany'])
76
89
 
77
90
  /** Cuánto se recuerda un nonce ya usado (el doble de la ventana de frescura). */
78
91
  export const ADMIN_NONCE_TTL_MS = 10 * 60 * 1000
@@ -136,7 +149,7 @@ export function createAdminDesk ({
136
149
  }
137
150
  const by = await deviceIdOf(chk.device).catch(() => null)
138
151
 
139
- // CERRADA NO SE ADMINISTRA. Va después de autorizar (el candado es estado del perfil,
152
+ // CERRADA NO SE SELLA EL ACTA. Va después de autorizar (el candado es estado del perfil,
140
153
  // y a un desconocido no se le cuenta) y ANTES de quemar el nonce: quien reintente
141
154
  // tras abrir la bóveda no tiene por qué generar otro.
142
155
  //
package/src/env.js CHANGED
@@ -29,18 +29,63 @@ export function serviceRoot () {
29
29
  return process.env.DOTRINO_ENV_HOME || path.join(os.homedir(), '.dotrino', 'service')
30
30
  }
31
31
 
32
- /** Directorio de la identidad del servicio `ns` (`DOTRINO_ENV_DIR` lo pisa). */
33
- export function serviceDir (ns) {
32
+ /**
33
+ * DE QUÉ BÓVEDA VINO ESTO: `~/.dotrino/service/<bóveda>/<ns>`.
34
+ *
35
+ * Es una ETIQUETA TUYA, de esta máquina: nadie la manda por la red, no entra en el acta y
36
+ * no se compara con nada — puedes llamarla como quieras. Sirve solo para que dos bóvedas
37
+ * puedan tener un cajón que se llame igual.
38
+ *
39
+ * Se llama `--vault` y no `--profile` a propósito (dueño): `dotrino-vault --profile` ya
40
+ * significa OTRA cosa —el perfil de la bóveda— y los dos comandos se usan seguidos. Dos
41
+ * banderas con el mismo nombre y distinto significado se confunden el día que hay prisa.
42
+ *
43
+ * Antes la ruta era `<raíz>/<ns>` y el nombre del cajón era una CLAVE GLOBAL del usuario:
44
+ * si dos bóvedas usaban `aws`, el segundo enrolamiento pisaba al primero y lo dejaba
45
+ * inservible —avisaba, pero el papel del primero seguía existiendo en su bóveda sin
46
+ * identidad en disco que lo usara—. Obligar a nombrar el perfil quita esa colisión de
47
+ * raíz en vez de pedirle al usuario que se acuerde de no repetir nombres.
48
+ *
49
+ * Mismo alfabeto que el `ns` porque es lo mismo: un segmento de ruta.
50
+ */
51
+ export const isValidVaultLabel = (p) => typeof p === 'string' && /^[a-z0-9-]{1,32}$/.test(p)
52
+
53
+ /** Directorio de la identidad del servicio `ns` bajo la etiqueta de bóveda `vault`. */
54
+ export function serviceDir (vault, ns) {
34
55
  if (process.env.DOTRINO_ENV_DIR) return process.env.DOTRINO_ENV_DIR
56
+ if (!isValidVaultLabel(vault)) {
57
+ throw new Error('missing vault label: pass --vault <nombre> (use [a-z0-9-]{1,32}). It is a label of YOUR machine: it says which vault this drawer came from')
58
+ }
35
59
  if (!isValidSecretsNs(ns)) throw new Error('invalid ns (use [a-z0-9-]{1,32}, e.g. "myapp")')
36
- return path.join(serviceRoot(), ns)
60
+ return path.join(serviceRoot(), vault, ns)
37
61
  }
38
62
 
39
- /** Namespaces ya enrolados en esta máquina. */
63
+ /** La etiqueta de bóveda: `--vault`, o `DOTRINO_ENV_VAULT`. */
64
+ export const resolveVaultLabel = (v) => v || process.env.DOTRINO_ENV_VAULT || null
65
+
66
+ /**
67
+ * Lo enrolado en esta máquina: `[{ vault, ns }]`. Recorre DOS niveles.
68
+ *
69
+ * Y avisa de lo que quedó en el nivel de antes (`<raíz>/<ns>`): no se lee ni se usa —
70
+ * repescarlo en silencio sería fingir que la migración no hace falta— pero callarlo
71
+ * dejaría al dueño con un servicio que «desapareció» sin explicación.
72
+ */
40
73
  export function listEnrolled () {
41
- let names = []
42
- try { names = fs.readdirSync(serviceRoot()) } catch (_) { return [] }
43
- return names.filter((ns) => isValidSecretsNs(ns) && readServiceIdentity(path.join(serviceRoot(), ns)))
74
+ const out = []
75
+ let dirs = []
76
+ try { dirs = fs.readdirSync(serviceRoot(), { withFileTypes: true }) } catch (_) { return out }
77
+ for (const d of dirs) {
78
+ if (!d.isDirectory() || !isValidVaultLabel(d.name)) continue
79
+ // Un enrolamiento del formato viejo es un directorio con `service-identity.json`
80
+ // DENTRO; uno del nuevo tiene subdirectorios. Así se distinguen sin adivinar.
81
+ if (readServiceIdentity(path.join(serviceRoot(), d.name))) { out.push({ legacy: true, ns: d.name }); continue }
82
+ let hijos = []
83
+ try { hijos = fs.readdirSync(path.join(serviceRoot(), d.name)) } catch (_) { continue }
84
+ for (const ns of hijos) {
85
+ if (isValidSecretsNs(ns) && readServiceIdentity(path.join(serviceRoot(), d.name, ns))) out.push({ vault: d.name, ns })
86
+ }
87
+ }
88
+ return out
44
89
  }
45
90
 
46
91
  /**
@@ -98,10 +143,12 @@ let lastApplied = null
98
143
  * `overridden` = las que YA tenían otro valor en el entorno y el vault pisó. Es
99
144
  * el dato que delata un `.env` rancio, así que se reporta en vez de callarse.
100
145
  */
101
- export async function loadEnv ({ ns, dir, override, wait = true, required = [], onRetry, onPending } = {}) {
146
+ export async function loadEnv ({ ns, vault, dir, override, wait = true, required = [], onRetry, onPending } = {}) {
102
147
  if (override === undefined) override = overrideByDefault()
103
148
  ns = resolveNs(ns)
104
- dir = dir || serviceDir(ns)
149
+ // La ETIQUETA DE BÓVEDA es obligatoria salvo que se diga el directorio a pelo: es lo que
150
+ // hace que dos bóvedas puedan tener un cajón con el mismo nombre sin pisarse.
151
+ dir = dir || serviceDir(resolveVaultLabel(vault), ns)
105
152
  const load = wait ? waitForSecrets : fetchSecrets
106
153
  const secrets = await load({ dir, ns, onRetry, onPending })
107
154
 
@@ -199,9 +246,9 @@ export function applyEnv (secrets, override = overrideByDefault()) {
199
246
  * también. Se elige 0 porque salir a propósito no es un fallo.
200
247
  * @returns {Promise<{stop:()=>void}>}
201
248
  */
202
- export async function watchEnv ({ ns, dir, applied = lastApplied ?? undefined, onUpdate, exitCode = 0, quiet = false, ...rest } = {}) {
249
+ export async function watchEnv ({ ns, vault, dir, applied = lastApplied ?? undefined, onUpdate, exitCode = 0, quiet = false, ...rest } = {}) {
203
250
  ns = resolveNs(ns)
204
- dir = dir || serviceDir(ns)
251
+ dir = dir || serviceDir(resolveVaultLabel(vault), ns)
205
252
  const say = (m) => { if (!quiet) console.error(m) }
206
253
 
207
254
  const exitNow = (reason) => {