@dotrino/vaultd 0.26.2 → 0.38.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/lib/src/env.js CHANGED
@@ -32,7 +32,7 @@ export function serviceRoot () {
32
32
  /** Directorio de la identidad del servicio `ns` (`DOTRINO_ENV_DIR` lo pisa). */
33
33
  export function serviceDir (ns) {
34
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")')
35
+ if (!isValidSecretsNs(ns)) throw new Error('invalid ns (use [a-z0-9-]{1,32}, e.g. "myapp")')
36
36
  return path.join(serviceRoot(), ns)
37
37
  }
38
38
 
@@ -50,15 +50,15 @@ export function listEnrolled () {
50
50
  export function resolveNs (ns) {
51
51
  ns = ns || process.env.DOTRINO_NS
52
52
  if (ns) {
53
- if (!isValidSecretsNs(ns)) throw new Error('ns inválido: ' + ns)
53
+ if (!isValidSecretsNs(ns)) throw new Error('invalid ns: ' + ns)
54
54
  return ns
55
55
  }
56
56
  const found = listEnrolled()
57
57
  if (found.length === 1) return found[0]
58
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>`')
59
+ throw new Error('no service enrolled on this machine: run `npx dotrino-env enroll --ns <your-app>`')
60
60
  }
61
- throw new Error(`hay varios servicios enrolados (${found.join(', ')}): elige uno con DOTRINO_NS=<ns> o loadEnv({ ns })`)
61
+ throw new Error(`several services are enrolled (${found.join(', ')}): pick one with DOTRINO_NS=<ns> or loadEnv({ ns })`)
62
62
  }
63
63
 
64
64
  /**
@@ -81,6 +81,9 @@ function overrideByDefault () {
81
81
  return process.env.DOTRINO_ENV_OVERRIDE !== '0'
82
82
  }
83
83
 
84
+ /** El último bundle que `applyEnv` puso a correr en este proceso (ver `watchEnv`). */
85
+ let lastApplied = null
86
+
84
87
  /**
85
88
  * Trae los secretos del ns desde el vault y los pone en `process.env`.
86
89
  *
@@ -104,7 +107,7 @@ export async function loadEnv ({ ns, dir, override, wait = true, required = [],
104
107
 
105
108
  const missing = required.filter((k) => !(k in secrets))
106
109
  if (missing.length) {
107
- throw new Error(`faltan secretos en el ns "${ns}": ${missing.join(', ')} (agrégalos con \`dotrino-vault secret set ${ns} <CLAVE> <valor>\`)`)
110
+ throw new Error(`missing secrets in ns "${ns}": ${missing.join(', ')} (add them with \`dotrino-vault secret set ${ns} <KEY> <value>\`)`)
108
111
  }
109
112
 
110
113
  return { ns, secrets, ...applyEnv(secrets, override) }
@@ -130,17 +133,21 @@ export async function loadEnv ({ ns, dir, override, wait = true, required = [],
130
133
  * @returns {{injected:string[], overridden:string[], skipped:string[]}}
131
134
  */
132
135
  export function applyEnv (secrets, override = overrideByDefault()) {
136
+ // Lo último que este proceso puso a correr. `watchEnv` lo toma como referencia para
137
+ // comparar, así que cualquier agente que use `loadEnv`/`applyEnv` —o sea, todos—
138
+ // queda protegido del aviso perdido sin cablear nada.
139
+ lastApplied = { ...(secrets || {}) }
133
140
  const injected = []
134
141
  const overridden = []
135
142
  const skipped = []
136
143
  for (const [k, v] of Object.entries(secrets || {})) {
137
- const previo = process.env[k]
138
- const tenia = k in process.env
139
- if (!override && tenia) { skipped.push(k); continue }
140
- const valor = String(v)
141
- process.env[k] = valor
144
+ const previous = process.env[k]
145
+ const had = k in process.env
146
+ if (!override && had) { skipped.push(k); continue }
147
+ const value = String(v)
148
+ process.env[k] = value
142
149
  injected.push(k)
143
- if (tenia && previo !== valor) overridden.push(k)
150
+ if (had && previous !== value) overridden.push(k)
144
151
  }
145
152
  return { injected, overridden, skipped }
146
153
  }
@@ -169,10 +176,22 @@ export function applyEnv (secrets, override = overrideByDefault()) {
169
176
  * el supervisor ya trae backoff y tope de intentos, que es justo lo que evita que
170
177
  * una configuración rota se convierta en un ciclo.
171
178
  *
179
+ * No depende solo del aviso: al conectar COMPARA su configuración con la de la bóveda
180
+ * (`watchSecretsChanges`), porque un agente incomunicado se pierde el aviso y antes se
181
+ * quedaba con lo viejo para siempre.
182
+ *
183
+ * OJO con lo que se compara: el bundle de la bóveda contra el bundle de la bóveda,
184
+ * nunca contra el `.env`. Recibir la configuración por primera vez —tarde, que es como
185
+ * la recibe el proxio— **no** es un cambio, y por eso esto no puede convertirse en un
186
+ * ciclo de reinicios.
187
+ *
172
188
  * @param {Object} [opts]
173
189
  * @param {string} [opts.ns]
174
190
  * @param {string} [opts.dir]
175
- * @param {(info:{ns:string, ts:number, reason:'changed'|'revoked'})=>void} [opts.onUpdate]
191
+ * @param {Record<string,string>} [opts.applied] Lo que este proceso tiene EN USO. Por
192
+ * defecto, lo último que pasó por `applyEnv` en este proceso — o sea, lo que puso a
193
+ * correr `loadEnv`.
194
+ * @param {(info:{ns:string, ts:number, reason:'changed'|'revoked', via?:'notice'|'reconcile'})=>void} [opts.onUpdate]
176
195
  * Reemplaza la salida por defecto. Úsalo cuando terminar el proceso no sea una
177
196
  * opción — el caso del proxio, cuyo reinicio corta el transporte de todos.
178
197
  * @param {number} [opts.exitCode=0] Salida LIMPIA: systemd con `Restart=on-failure`
@@ -180,27 +199,28 @@ export function applyEnv (secrets, override = overrideByDefault()) {
180
199
  * también. Se elige 0 porque salir a propósito no es un fallo.
181
200
  * @returns {Promise<{stop:()=>void}>}
182
201
  */
183
- export async function watchEnv ({ ns, dir, onUpdate, exitCode = 0, quiet = false, ...resto } = {}) {
202
+ export async function watchEnv ({ ns, dir, applied = lastApplied ?? undefined, onUpdate, exitCode = 0, quiet = false, ...rest } = {}) {
184
203
  ns = resolveNs(ns)
185
204
  dir = dir || serviceDir(ns)
186
205
  const say = (m) => { if (!quiet) console.error(m) }
187
206
 
188
207
  const exitNow = (reason) => {
189
208
  say(`[dotrino-env] ${reason === 'revoked'
190
- ? 'la bóveda REVOCÓ este agente: terminando (no volverá a arrancar)'
191
- : 'configuración nueva en la bóveda: terminando para que el supervisor lo levante limpio'}`)
209
+ ? 'the vault REVOKED this agent: exiting (it will not start again)'
210
+ : 'new config in the vault: exiting so the supervisor brings it back clean'}`)
192
211
  process.exit(reason === 'revoked' ? 1 : exitCode)
193
212
  }
194
213
 
195
214
  return watchSecretsChanges({
196
215
  dir,
197
216
  ns,
217
+ applied,
198
218
  log: say,
199
- onChange: ({ ts }) => (onUpdate ? onUpdate({ ns, ts, reason: 'changed' }) : exitNow('changed')),
219
+ onChange: ({ ts, via }) => (onUpdate ? onUpdate({ ns, ts, reason: 'changed', via }) : exitNow('changed')),
200
220
  // Un cert revocado sale con código de FALLO a propósito: si el supervisor lo
201
221
  // levanta, va a morir otra vez al no poder leer sus secretos, y el contador de
202
222
  // reinicios fallidos es lo que hace que se note en vez de girar en silencio.
203
223
  onRevoked: () => (onUpdate ? onUpdate({ ns, ts: Date.now(), reason: 'revoked' }) : exitNow('revoked')),
204
- ...resto
224
+ ...rest
205
225
  })
206
226
  }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Lector de `.env` — el formato en el que la gente YA tiene la configuración de un
3
+ * servicio, y por lo tanto la forma natural de cargarla entera de una vez.
4
+ *
5
+ * Existe aquí, en la lib pura, porque lo usan los tres sitios desde los que se cargan
6
+ * variables: el CLI (`secret import`), la TUI y la consola remota (pegar el bloque en
7
+ * la web). Tres lectores distintos serían tres formatos distintos.
8
+ *
9
+ * POR QUÉ ESTO IMPORTA MÁS DE LO QUE PARECE: cada variable guardada suelta es, para la
10
+ * bóveda, un cambio de configuración, y el servicio obedece el primero —sale y lo
11
+ * levanta su supervisor— mientras el dueño sigue tecleando las demás. Cargarlas juntas
12
+ * es lo que hace que el servicio se reinicie UNA vez, con todo puesto.
13
+ *
14
+ * Los errores salen como CÓDIGOS, no como frases: quien llama los traduce (el CLI en
15
+ * español, la consola en los dos idiomas).
16
+ */
17
+ import { isValidVarKey } from './protocol.js'
18
+
19
+ /**
20
+ * `CLAVE=valor` — la clave no lleva espacios ni `=`; el valor puede llevar de todo. Se
21
+ * toleran los espacios alrededor del `=` porque un `.env` escrito a mano los trae, y
22
+ * rechazar un archivo entero por eso sería quisquilloso sin ganar nada.
23
+ */
24
+ export const PAIR_RE = /^([^=\s]+)\s*=\s*([\s\S]*)$/
25
+
26
+ /**
27
+ * @param {string} text
28
+ * @returns {{items: Array<{op:'set', key:string, value:string}>,
29
+ * errors: Array<{code:'shape'|'dup'|'key'|'novalue'|'empty', line?:number, key?:string, first?:number}>}}
30
+ */
31
+ export function parseEnvText (text) {
32
+ /** @type {Array<{op:'set', key:string, value:string}>} */
33
+ const items = []
34
+ /** @type {Array<{code:'shape'|'dup'|'key'|'novalue'|'empty', line?:number, key?:string, first?:number}>} */
35
+ const errors = []
36
+ const seen = new Map()
37
+ const lines = String(text || '').split(/\r?\n/)
38
+
39
+ lines.forEach((raw, idx) => {
40
+ const line = idx + 1
41
+ const trimmed = raw.trim()
42
+ // Línea vacía o comentario entero. Un `#` a MITAD de línea NO se corta: una
43
+ // contraseña puede llevarlo, y recortar el valor ahí lo estropea en silencio —que
44
+ // en un secreto significa un servicio que no levanta y nadie sabe por qué.
45
+ if (!trimmed || trimmed.startsWith('#')) return
46
+ const m = PAIR_RE.exec(trimmed.replace(/^export\s+/, ''))
47
+ if (!m) return errors.push({ code: 'shape', line })
48
+
49
+ const key = m[1]
50
+ const value = unquote(m[2].trim())
51
+ if (!isValidVarKey(key)) return errors.push({ code: 'key', line, key })
52
+ if (!value) return errors.push({ code: 'novalue', line, key })
53
+ // Repetida = casi siempre un pegado a medias. Adivinar cuál de las dos quería el
54
+ // dueño no es asunto de un lector de configuración.
55
+ if (seen.has(key)) return errors.push({ code: 'dup', line, key, first: seen.get(key) })
56
+ seen.set(key, line)
57
+ items.push({ op: 'set', key, value })
58
+ })
59
+
60
+ if (!items.length && !errors.length) errors.push({ code: 'empty' })
61
+ return { items, errors }
62
+ }
63
+
64
+ /**
65
+ * Lo mismo, pero aceptando que todo venga en UNA línea (`K=v K2=v2`), que es lo que se
66
+ * puede escribir en un campo de una sola línea como el de la TUI. Un valor con espacios
67
+ * va entre comillas, igual que en la shell.
68
+ */
69
+ export function parseEnvInput (text) {
70
+ const s = String(text || '')
71
+ return parseEnvText(/\r?\n/.test(s) ? s : tokenize(s).join('\n'))
72
+ }
73
+
74
+ /** Parte por espacios, pero no dentro de comillas. */
75
+ function tokenize (line) {
76
+ const out = []
77
+ let cur = ''
78
+ let quote = null
79
+ for (const ch of line) {
80
+ if (quote) { cur += ch; if (ch === quote) quote = null; continue }
81
+ if (ch === '"' || ch === "'") { quote = ch; cur += ch; continue }
82
+ if (/\s/.test(ch)) { if (cur) { out.push(cur); cur = '' } ; continue }
83
+ cur += ch
84
+ }
85
+ if (cur) out.push(cur)
86
+ return out
87
+ }
88
+
89
+ /** Quita las comillas de FUERA: un `.env` las usa cuando el valor lleva espacios. */
90
+ function unquote (v) {
91
+ const q = v[0]
92
+ if (v.length > 1 && (q === '"' || q === "'") && v.endsWith(q)) return v.slice(1, -1)
93
+ return v
94
+ }
package/lib/src/index.js CHANGED
@@ -52,7 +52,7 @@ export { deviceIdOf }
52
52
  */
53
53
  export async function startDeviceVault (identity, { proxyUrl, client: injectedClient } = {}) {
54
54
  const iss = identity.me?.publickey
55
- if (!iss) throw new Error('sin identidad: crea/desbloquea tu identidad antes de usar el dispositivo como bóveda')
55
+ if (!iss) throw new Error('no identity: create/unlock your identity before using this device as a vault')
56
56
  const proxy = proxyUrl || 'wss://proxy.dotrino.com'
57
57
 
58
58
  // ----- self-cert P ← P (para que este dispositivo pueda además actuar de cliente
@@ -133,7 +133,7 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
133
133
  */
134
134
  async function handleRenew (from, p) {
135
135
  const d = p?.data
136
- if (!d || !p.signature || !p.cert) return send(from, { type: MSG.ERROR, error: 'petición inválida' })
136
+ if (!d || !p.signature || !p.cert) return send(from, { type: MSG.ERROR, error: 'invalid request' })
137
137
  if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) {
138
138
  return send(from, { type: MSG.ERROR, error: 'stale request: ts outside the ±5 min window (possible replay, or the device clock is off)' })
139
139
  }
@@ -151,10 +151,10 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
151
151
  // QUIEN consulta es una máquina ya revocada (reapareció), le re-emite el REVOKED firmado.
152
152
  async function handleDevices (from, p) {
153
153
  const d = p?.data
154
- if (!d || !p.signature || !p.cert) return send(from, { type: MSG.ERROR, error: 'petición inválida' })
154
+ if (!d || !p.signature || !p.cert) return send(from, { type: MSG.ERROR, error: 'invalid request' })
155
155
  if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) return
156
156
  const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, trustedIssuer: iss })
157
- if (!chk.ok) return send(from, { type: MSG.ERROR, error: 'no autorizado: ' + chk.reason })
157
+ if (!chk.ok) return send(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
158
158
  const { issued, revoked, revokedCerts } = await identity.listDelegations()
159
159
  const devices = await Promise.all((issued || []).map(async (x) => ({
160
160
  deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null,
package/lib/src/invite.js CHANGED
@@ -392,16 +392,16 @@ export function parseInvite (text) {
392
392
  // el original.
393
393
  const undoUrl = (s) => { try { return decodeURIComponent(s) } catch { return s } }
394
394
 
395
- const marca = payload[0]
396
- const resto = payload.slice(1)
397
- if (marca === FMT_SHORT) { const o = shortDecode(resto); if (o) return o }
398
- if (marca === FMT_COMPACT) { const o = compactDecode(resto); if (o) return o }
399
- if (marca === FMT_JSON) { const o = parse(undoUrl(resto)) || parse(resto); if (o) return o }
400
- if (marca === FMT_B64) { const s = b64urlDecodeStr(resto); const o = s && parse(s); if (o) return o }
395
+ const tag = payload[0]
396
+ const rest = payload.slice(1)
397
+ if (tag === FMT_SHORT) { const o = shortDecode(rest); if (o) return o }
398
+ if (tag === FMT_COMPACT) { const o = compactDecode(rest); if (o) return o }
399
+ if (tag === FMT_JSON) { const o = parse(undoUrl(rest)) || parse(rest); if (o) return o }
400
+ if (tag === FMT_B64) { const s = b64urlDecodeStr(rest); const o = s && parse(s); if (o) return o }
401
401
 
402
402
  // --- sin marca: formatos anteriores a la marca de formato (compatibilidad) ---
403
- const crudo = undoUrl(payload)
404
- if (crudo.trimStart().startsWith('{')) return parse(crudo)
403
+ const raw = undoUrl(payload)
404
+ if (raw.trimStart().startsWith('{')) return parse(raw)
405
405
  const s = b64urlDecodeStr(payload)
406
406
  return s ? parse(s) : null
407
407
  }
@@ -33,6 +33,11 @@ export const MSG = Object.freeze({
33
33
  ACTA_SEALED: 'vault.acta.sealed', // dispositivo → vault: { acta, code }
34
34
  ACTA_ADOPTED: 'vault.acta.adopted', // vault → dispositivo: { acta }
35
35
  REVOKED: 'vault.revoked', // vault → dispositivo: { body:{op,sub,nonce,iat,exp}, signature }
36
+ // «¿sigo siendo de esta casa?» — la ÚNICA pregunta que se puede hacer SIN certificado:
37
+ // va firmada con la llave del propio aparato, que es lo que el acta nombra. Existe para
38
+ // el aparato que perdió su papel: sin ella no tiene forma de enterarse de que lo echaron.
39
+ CHECK: 'vault.check', // dispositivo → vault: { data:{op:'check',publickey,ts}, signature }
40
+ CHECKED: 'vault.checked', // vault → dispositivo: { in:boolean } — y si no, el REVOKED firmado
36
41
  SIGN: 'vault.sign', // dispositivo → vault: { data, signature, cert }
37
42
  SIGNED: 'vault.signed', // vault → dispositivo: { signature, publickey, device }
38
43
  GET: 'vault.get', // dispositivo → vault: { data, signature, cert }
@@ -93,3 +98,10 @@ export const SCOPE = Object.freeze({
93
98
  export const SECRETS_SCOPE_PREFIX = 'vault:secrets:'
94
99
  export const secretsScope = (ns) => SECRETS_SCOPE_PREFIX + ns
95
100
  export const isValidSecretsNs = (ns) => typeof ns === 'string' && /^[a-z0-9-]{1,32}$/.test(ns)
101
+
102
+ /**
103
+ * Nombre de una variable de entorno: `MAYUSCULAS_CON_GUION_BAJO`, hasta 64. Vive aquí
104
+ * —y no en el cajón que la guarda— porque la comprueban también la TUI, la consola
105
+ * remota y el lector de `.env`, y tres copias de una regla son tres reglas.
106
+ */
107
+ export const isValidVarKey = (key) => typeof key === 'string' && /^[A-Z0-9_]{1,64}$/.test(key)