@dotrino/vault 0.2.0 → 0.3.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/README.md CHANGED
@@ -37,6 +37,79 @@ const machines = await vault.listMachines() // [{ sub, deviceId, label, exp, n
37
37
  vault.close()
38
38
  ```
39
39
 
40
+ ## Credenciales del vault en vez del `.env` (Node)
41
+
42
+ La cara "dotenv" del paquete: **cualquier proyecto Node** jala sus credenciales del
43
+ vault del dueño y las deja en `process.env`. En el disco del servicio **no queda
44
+ ningún secreto**: solo la llave del dispositivo (generada ahí, nunca sale) y un
45
+ certificado con scope `vault:secrets:<ns>`. Los valores viven **solo en memoria**;
46
+ si la máquina se compromete, revocas el cert y no había nada que robar.
47
+
48
+ ### 1) Registro del cliente (una sola vez)
49
+
50
+ ```bash
51
+ # en el VAULT (tu PC): abres el emparejamiento del servicio y cargas sus secretos
52
+ dotrino-vault pair --service miapp # invitación con scope SOLO vault:secrets:miapp
53
+ dotrino-vault secret set miapp API_KEY sk-…
54
+
55
+ # en el PROYECTO/servidor: enrola esta máquina (pega la invitación)
56
+ npx dotrino-env enroll --ns miapp
57
+ # → muestra un código: dotrino-vault approve 7K3F-92Q1
58
+
59
+ # de vuelta en el VAULT: lo tipeas leyéndolo de esa pantalla
60
+ dotrino-vault approve 7K3F-92Q1
61
+ ```
62
+
63
+ El código lo **genera el servicio** y **no viaja** por la red: el vault solo puede
64
+ echarlo de vuelta si un humano lo tipeó. Así, un vault falso no puede enrolarte y
65
+ aprobar a ciegas no enrola a nadie. Queda `~/.dotrino/service/<ns>/service-identity.json`
66
+ (0600) con `{ device, cert, iss, proxy, ns }`.
67
+
68
+ Es un **comando previo**, no el primer arranque de la app: el enrolamiento necesita a
69
+ un humano leyendo el código en esta pantalla (bajo systemd/PM2 no hay TTY y el código
70
+ acabaría en un log), bloquea esperando la aprobación y **escribe** en disco consumiendo
71
+ una invitación de un solo uso. El arranque, en cambio, solo **lee** la identidad ya
72
+ guardada: es idempotente y no interactúa con nadie. Corre el `enroll` donde corres el
73
+ `npm ci` al aprovisionar la máquina.
74
+
75
+ ### 2) En el código
76
+
77
+ ```js
78
+ import '@dotrino/vault/config' // como `dotenv/config`, pero contra el vault (ns = DOTRINO_NS)
79
+ console.log(process.env.API_KEY)
80
+ ```
81
+
82
+ o explícito:
83
+
84
+ ```js
85
+ import { loadEnv } from '@dotrino/vault/env'
86
+ const { secrets } = await loadEnv({ ns: 'miapp', required: ['API_KEY'] })
87
+ ```
88
+
89
+ Es **asíncrono a propósito**: el `import` bloquea el arranque (top-level await) hasta
90
+ que los secretos estén. Si el vault no está disponible, **espera** (reintento con
91
+ backoff) — un servicio sin vault no arranca, no opera con secretos viejos ni vacíos.
92
+ Un fallo NO transitorio (sin enrolar, cert revocado, scope equivocado) sí aborta.
93
+
94
+ Para procesos que no son Node, el CLI los inyecta en el entorno de un hijo:
95
+
96
+ ```bash
97
+ dotrino-env run --ns miapp -- ./mi-binario
98
+ ```
99
+
100
+ ### API `@dotrino/vault/env`
101
+
102
+ - `loadEnv({ ns?, dir?, override?, wait?, required?, onRetry? }) → { ns, secrets, injected, skipped }`
103
+ (por defecto **no pisa** variables ya presentes en el entorno; `override: true` sí)
104
+ - `serviceDir(ns)`, `serviceRoot()`, `listEnrolled()`, `resolveNs(ns?)`
105
+ - Entorno: `DOTRINO_NS` · `DOTRINO_ENV_DIR` · `DOTRINO_ENV_HOME` · `DOTRINO_ENV_QUIET`
106
+ - CLI: `dotrino-env enroll|status|check|run` (`check` lista **nombres** de secretos, nunca valores)
107
+
108
+ Bajo el capó es `@dotrino/vault/service` (`enrollService` / `waitForSecrets`): petición
109
+ firmada por la llave del servicio + cert, respuesta **sellada** (ECDH efímero + AES-GCM,
110
+ el proxy no ve los valores) y **firmada por la maestra**, verificada contra la `iss`
111
+ pineada en el enrolamiento.
112
+
40
113
  ## Modelo de aprobación (seguro por diseño)
41
114
 
42
115
  - El **dispositivo** que se enrola genera un **código aleatorio** (`makePairingCode`) y
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dotrino-env — CLI del "dotenv contra el vault".
4
+ *
5
+ * dotrino-env enroll --ns <ns> [--qr <invitación>] enrola ESTA máquina/servicio (una vez)
6
+ * dotrino-env status qué hay enrolado aquí
7
+ * dotrino-env check [--ns <ns>] pide los secretos y lista sus NOMBRES (nunca valores)
8
+ * dotrino-env run [--ns <ns>] -- <cmd> [args…] corre un comando con los secretos en su entorno
9
+ *
10
+ * El enrolamiento es el registro del cliente contra el vault del dueño:
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)
13
+ * 3. en el vault: dotrino-vault approve <código> (lo tipeas leyéndolo de esta pantalla)
14
+ */
15
+ import fs from 'node:fs'
16
+ import path from 'node:path'
17
+ import readline from 'node:readline/promises'
18
+ import { spawn } from 'node:child_process'
19
+ import { enrollService, readServiceIdentity } from '../src/service.js'
20
+ import { loadEnv, serviceDir, serviceRoot, listEnrolled, resolveNs } from '../src/env.js'
21
+
22
+ const argv = process.argv.slice(2)
23
+ const flag = (name) => { const i = argv.indexOf('--' + name); return i >= 0 ? argv[i + 1] : undefined }
24
+ const has = (name) => argv.includes('--' + name)
25
+
26
+ function help () {
27
+ console.log(`dotrino-env — credenciales del vault en vez del .env
28
+
29
+ enroll --ns <ns> [--qr <invitación>] [--dir <dir>]
30
+ Registra ESTE servicio contra el vault (una sola vez).
31
+ Antes, en el vault: dotrino-vault pair --service <ns>
32
+ Si no pasas --qr, se pide por consola (también acepta stdin).
33
+
34
+ status servicios enrolados en esta máquina
35
+ check [--ns <ns>] pide los secretos al vault y lista sus NOMBRES (nunca los valores)
36
+ run [--ns <ns>] -- <cmd> [args…]
37
+ ejecuta <cmd> con los secretos inyectados en su entorno
38
+
39
+ En tu código:
40
+ import '@dotrino/vault/config' // ns por DOTRINO_NS
41
+ import { loadEnv } from '@dotrino/vault/env'; await loadEnv({ ns: '<ns>' })
42
+
43
+ Entorno: DOTRINO_NS · DOTRINO_ENV_DIR · DOTRINO_ENV_HOME · DOTRINO_ENV_QUIET`)
44
+ }
45
+
46
+ /**
47
+ * La invitación que imprime `dotrino-vault pair` viene en tres formas: el JSON
48
+ * crudo, el base64url del payload, o la URL de profile con `#vault=<b64>`.
49
+ * Aceptamos las tres para que el operador pegue lo que tenga a mano.
50
+ */
51
+ function parseInvite (raw) {
52
+ const s = String(raw || '').trim()
53
+ if (!s) throw new Error('invitación vacía')
54
+ if (s.startsWith('{')) return JSON.parse(s)
55
+ const b64 = s.includes('#vault=') ? s.split('#vault=')[1] : (s.includes('#') ? s.split('#').pop() : s)
56
+ const json = Buffer.from(b64.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')
57
+ if (!json.trim().startsWith('{')) throw new Error('no parece una invitación del vault')
58
+ return JSON.parse(json)
59
+ }
60
+
61
+ async function readInvite () {
62
+ if (!process.stdin.isTTY) return fs.readFileSync(0, 'utf8')
63
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
64
+ const answer = await rl.question('Pega la invitación del vault (salida de `dotrino-vault pair --service <ns>`):\n> ')
65
+ rl.close()
66
+ return answer
67
+ }
68
+
69
+ async function cmdEnroll () {
70
+ const ns = flag('ns')
71
+ if (!ns) { console.error('falta --ns <ns> (el mismo del `dotrino-vault pair --service <ns>`)'); process.exit(2) }
72
+ const dir = flag('dir') || serviceDir(ns)
73
+ if (readServiceIdentity(dir) && !has('force')) {
74
+ console.error('ya hay un servicio enrolado en %s (usa --force para re-enrolar)', dir); process.exit(2)
75
+ }
76
+ const qr = parseInvite(flag('qr') || await readInvite())
77
+
78
+ console.log('\nEnrolando el servicio "%s" contra el vault…', ns)
79
+ const { cert } = await enrollService({
80
+ qr,
81
+ ns,
82
+ dir,
83
+ label: flag('label') || 'servicio:' + ns,
84
+ onCode: ({ deviceId, code }) => {
85
+ console.log('\n Dispositivo: %s', deviceId)
86
+ console.log(' APRUEBA en el vault tipeando este código:\n')
87
+ console.log(' dotrino-vault approve %s\n', code)
88
+ console.log(' (el vault NO conoce este código: tiene que leerlo de aquí un humano)')
89
+ }
90
+ })
91
+ console.log('\nListo. Identidad del servicio en: %s', path.join(dir, 'service-identity.json'))
92
+ console.log('Certificado con scope: %s (vence %s)', (cert.scope || []).join(', '), new Date(cert.exp).toISOString())
93
+ console.log('\nEn tu app: import \'@dotrino/vault/config\' (con DOTRINO_NS=%s)', ns)
94
+ }
95
+
96
+ function cmdStatus () {
97
+ const found = listEnrolled()
98
+ if (!found.length) {
99
+ console.log('Ningún servicio enrolado en %s\n Enrola uno: dotrino-env enroll --ns <ns>', serviceRoot())
100
+ return
101
+ }
102
+ for (const ns of found) {
103
+ const id = readServiceIdentity(serviceDir(ns))
104
+ const exp = id?.cert?.exp
105
+ console.log('%s\n dir: %s\n vault: %s…\n scope: %s\n cert: vence %s',
106
+ ns, serviceDir(ns), String(id.iss).slice(0, 24), (id.cert?.scope || []).join(', '),
107
+ exp ? new Date(exp).toISOString() : '?')
108
+ }
109
+ }
110
+
111
+ async function cmdCheck () {
112
+ const ns = resolveNs(flag('ns'))
113
+ const { secrets } = await loadEnv({ ns, wait: false })
114
+ const keys = Object.keys(secrets)
115
+ console.log('ns "%s": %d secreto(s)%s', ns, keys.length, keys.length ? ':' : '')
116
+ for (const k of keys) console.log(' ' + k) // NUNCA los valores
117
+ }
118
+
119
+ async function cmdRun () {
120
+ const sep = argv.indexOf('--')
121
+ const cmd = sep >= 0 ? argv.slice(sep + 1) : []
122
+ if (!cmd.length) { console.error('uso: dotrino-env run [--ns <ns>] -- <cmd> [args…]'); process.exit(2) }
123
+ await loadEnv({ ns: flag('ns') })
124
+ const child = spawn(cmd[0], cmd.slice(1), { stdio: 'inherit', env: process.env })
125
+ child.on('exit', (code, signal) => process.exit(signal ? 1 : (code ?? 0)))
126
+ }
127
+
128
+ const run = async () => {
129
+ switch (argv[0]) {
130
+ case 'enroll': return cmdEnroll()
131
+ case 'status': return cmdStatus()
132
+ case 'check': return cmdCheck()
133
+ case 'run': return cmdRun()
134
+ case undefined:
135
+ case 'help':
136
+ case '--help':
137
+ case '-h': return help()
138
+ default: console.error('comando desconocido: %s\n', argv[0]); help(); process.exit(2)
139
+ }
140
+ }
141
+
142
+ run().catch((e) => { console.error('\n[dotrino-env] ' + e.message); process.exit(1) })
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "@dotrino/vault",
3
- "version": "0.2.0",
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 servicio del ecosistema se enrola como dispositivo y obtiene sus secretos del vault en vez del .env.",
3
+ "version": "0.3.0",
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",
7
7
  "module": "src/index.js",
8
+ "bin": {
9
+ "dotrino-env": "bin/dotrino-env.js"
10
+ },
8
11
  "exports": {
9
12
  ".": {
10
13
  "import": "./src/index.js"
@@ -12,6 +15,12 @@
12
15
  "./service": {
13
16
  "import": "./src/service.js"
14
17
  },
18
+ "./env": {
19
+ "import": "./src/env.js"
20
+ },
21
+ "./config": {
22
+ "import": "./src/config.js"
23
+ },
15
24
  "./sealed": {
16
25
  "import": "./src/sealed.js"
17
26
  },
@@ -21,6 +30,7 @@
21
30
  },
22
31
  "files": [
23
32
  "src",
33
+ "bin",
24
34
  "README.md",
25
35
  "LICENSE"
26
36
  ],
package/src/config.js ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `import '@dotrino/vault/config'` — el equivalente de `import 'dotenv/config'`,
3
+ * pero contra el vault del dueño.
4
+ *
5
+ * Bloquea el arranque (top-level await) hasta que los secretos del ns estén en
6
+ * `process.env`. Si el vault no está disponible, ESPERA (reintento con backoff):
7
+ * la regla del ecosistema es que un servicio sin vault no arranca — no opera con
8
+ * secretos viejos ni vacíos. Un fallo NO transitorio (sin enrolar, cert revocado,
9
+ * scope equivocado) sí aborta el proceso.
10
+ *
11
+ * Config por entorno:
12
+ * DOTRINO_NS namespace de secretos (si no, el único enrolado en la máquina)
13
+ * DOTRINO_ENV_DIR directorio de la identidad del servicio (si no, ~/.dotrino/service/<ns>)
14
+ * DOTRINO_ENV_QUIET '1' para no imprimir la línea de arranque
15
+ */
16
+ import { loadEnv } from './env.js'
17
+
18
+ const quiet = process.env.DOTRINO_ENV_QUIET === '1'
19
+
20
+ const { ns, injected } = await loadEnv({
21
+ onRetry: (e, ms) => {
22
+ if (!quiet) console.error('[dotrino-env] vault no disponible (%s); reintentando en %ds…', e.message, Math.round(ms / 1000))
23
+ }
24
+ })
25
+
26
+ if (!quiet) console.error('[dotrino-env] %d secreto(s) del ns "%s" cargados en process.env', injected.length, ns)
package/src/env.js ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * `@dotrino/vault/env` — el "dotenv contra el vault".
3
+ *
4
+ * Un proyecto Node cualquiera obtiene sus credenciales del vault del dueño en
5
+ * vez de llevarlas en un `.env`:
6
+ *
7
+ * import { loadEnv } from '@dotrino/vault/env'
8
+ * await loadEnv({ ns: 'miapp' }) // → process.env.API_KEY, …
9
+ *
10
+ * o, con la forma clásica de dotenv (side-effect, ns por `DOTRINO_NS`):
11
+ *
12
+ * import '@dotrino/vault/config'
13
+ *
14
+ * Lo que queda en el disco del servicio NO es un secreto: es la llave del
15
+ * dispositivo (generada aquí, nunca sale) y un certificado con scope
16
+ * `vault:secrets:<ns>`. Los valores solo viven en memoria del proceso; si la
17
+ * máquina se compromete, se revoca el cert y no había nada que robar.
18
+ *
19
+ * Enrolar una vez: npx dotrino-env enroll --ns miapp (ver bin/dotrino-env.js)
20
+ */
21
+ import fs from 'node:fs'
22
+ import os from 'node:os'
23
+ import path from 'node:path'
24
+ import { fetchSecrets, waitForSecrets, readServiceIdentity } from './service.js'
25
+ import { isValidSecretsNs } from './protocol.js'
26
+
27
+ /** Raíz donde viven las identidades de servicio de esta máquina/usuario. */
28
+ export function serviceRoot () {
29
+ return process.env.DOTRINO_ENV_HOME || path.join(os.homedir(), '.dotrino', 'service')
30
+ }
31
+
32
+ /** Directorio de la identidad del servicio `ns` (`DOTRINO_ENV_DIR` lo pisa). */
33
+ export function serviceDir (ns) {
34
+ if (process.env.DOTRINO_ENV_DIR) return process.env.DOTRINO_ENV_DIR
35
+ if (!isValidSecretsNs(ns)) throw new Error('ns inválido (usa [a-z0-9-]{1,32}, p. ej. "miapp")')
36
+ return path.join(serviceRoot(), ns)
37
+ }
38
+
39
+ /** Namespaces ya enrolados en esta máquina. */
40
+ 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)))
44
+ }
45
+
46
+ /**
47
+ * Resuelve el ns cuando no se pasa explícito: `DOTRINO_NS`, y si no, el único
48
+ * enrolado en esta máquina. Con varios, exige elegir (no adivinamos).
49
+ */
50
+ export function resolveNs (ns) {
51
+ ns = ns || process.env.DOTRINO_NS
52
+ if (ns) {
53
+ if (!isValidSecretsNs(ns)) throw new Error('ns inválido: ' + ns)
54
+ return ns
55
+ }
56
+ const found = listEnrolled()
57
+ if (found.length === 1) return found[0]
58
+ if (found.length === 0) {
59
+ throw new Error('no hay ningún servicio enrolado en esta máquina: corre `npx dotrino-env enroll --ns <tu-app>`')
60
+ }
61
+ throw new Error(`hay varios servicios enrolados (${found.join(', ')}): elige uno con DOTRINO_NS=<ns> o loadEnv({ ns })`)
62
+ }
63
+
64
+ /**
65
+ * Trae los secretos del ns desde el vault y los pone en `process.env`.
66
+ *
67
+ * @param {Object} [opts]
68
+ * @param {string} [opts.ns] Namespace (por defecto: `DOTRINO_NS` o el único enrolado).
69
+ * @param {string} [opts.dir] Dónde está `service-identity.json` (por defecto: `serviceDir(ns)`).
70
+ * @param {boolean} [opts.override] `true` = pisa variables ya presentes en el entorno (default: no).
71
+ * @param {boolean} [opts.wait] `true` (default) = si el vault no está, ESPERA (reintenta) en vez de fallar.
72
+ * @param {string[]} [opts.required] Claves que deben venir; si falta alguna, lanza.
73
+ * @param {(e:Error, ms:number)=>void} [opts.onRetry]
74
+ * @returns {Promise<{ns:string, secrets:Record<string,string>, injected:string[], skipped:string[]}>}
75
+ */
76
+ export async function loadEnv ({ ns, dir, override = false, wait = true, required = [], onRetry } = {}) {
77
+ ns = resolveNs(ns)
78
+ dir = dir || serviceDir(ns)
79
+ const load = wait ? waitForSecrets : fetchSecrets
80
+ const secrets = await load({ dir, ns, onRetry })
81
+
82
+ const missing = required.filter((k) => !(k in secrets))
83
+ if (missing.length) {
84
+ throw new Error(`faltan secretos en el ns "${ns}": ${missing.join(', ')} (agrégalos con \`dotrino-vault secret set ${ns} <CLAVE> <valor>\`)`)
85
+ }
86
+
87
+ const injected = []
88
+ const skipped = []
89
+ for (const [k, v] of Object.entries(secrets)) {
90
+ if (!override && k in process.env) { skipped.push(k); continue }
91
+ process.env[k] = String(v)
92
+ injected.push(k)
93
+ }
94
+ return { ns, secrets, injected, skipped }
95
+ }
package/src/service.js CHANGED
@@ -60,11 +60,29 @@ function installNodeGlobals () {
60
60
  }
61
61
  }
62
62
 
63
- async function freshClient (proxyUrl) {
63
+ async function freshClient (proxyUrl, connectTimeoutMs = 20000) {
64
64
  installNodeGlobals()
65
65
  const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
66
66
  const client = new WebSocketProxyClient({ url: proxyUrl, enableWebRTC: false, autoReconnect: false })
67
- await client.connect()
67
+ // connect() del cliente solo se resuelve con 'connected' y solo rechaza en el
68
+ // evento 'error' de transporte: si el socket cierra LIMPIO antes de 'connected'
69
+ // (p.ej. el proxy banea la IP y envía close 1008), la promesa quedaría colgada
70
+ // para siempre y waitForSecrets no reintentaría. Le ponemos un timeout propio.
71
+ let timer
72
+ const timeout = new Promise((_, reject) => {
73
+ timer = setTimeout(() => reject(new Error('timeout conectando al proxy')), connectTimeoutMs)
74
+ })
75
+ try {
76
+ await Promise.race([client.connect(), timeout])
77
+ } catch (e) {
78
+ try { client.close() } catch (_) {}
79
+ // El 'error' de transporte del cliente puede llegar como un Event sin
80
+ // `message` → sin esto el operador ve una línea de error vacía.
81
+ const why = e?.message || e?.type || 'error de transporte'
82
+ throw new Error(`no se pudo conectar al proxy ${proxyUrl}: ${why}`)
83
+ } finally {
84
+ clearTimeout(timer)
85
+ }
68
86
  return client
69
87
  }
70
88