@dotrino/vault 0.19.0 → 0.21.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vault",
3
- "version": "0.19.0",
3
+ "version": "0.21.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
@@ -127,13 +127,22 @@ export function createAdminDesk ({
127
127
  return { ok: true, result: { ok: true } }
128
128
  }
129
129
 
130
- // Quitar un dispositivo: por `sub` se le retiran TODOS sus certificados. Con
131
- // `certNonce` a secas solo cae ese papel, y el aparato puede tener otro vigente.
130
+ // QUITAR UN DISPOSITIVO se hace por `sub` (su llave): sale del acta Y se le retiran
131
+ // todos los certificados. Las dos cosas o ninguna.
132
+ //
133
+ // `certNonce` retira UN PAPEL y nada más — el aparato sigue siendo miembro. Sirve
134
+ // para eso y solo para eso, y se conserva por compatibilidad, pero no es «quitar»:
135
+ // usarlo para quitar dejaba un miembro sin certificados al que la bóveda ya nunca le
136
+ // mandaba el aviso de expulsión (mientras siga en el acta, un papel retirado
137
+ // significa «renueva»). Ese era el dispositivo fantasma.
132
138
  if (data.op === 'revoke') {
133
139
  if (data.sub) {
140
+ const deviceId = await deviceIdOf(String(data.sub)).catch(() => null)
134
141
  const r = await desk.revokeDevice(String(data.sub))
135
- audit('admin.revoke-device', { by, certs: r?.nonces?.length ?? null })
136
- await notify('revoked', { by })
142
+ audit('admin.revoke-device', { by, device: deviceId, certs: r?.nonces?.length ?? null })
143
+ // Con el `deviceId`: el aviso a los demás dispositivos tiene que decir a QUIÉN
144
+ // quitaron, o no se puede saber si el que sobra eres tú.
145
+ await notify('revoked', { deviceId, by })
137
146
  return { ok: true, result: r || { ok: true } }
138
147
  }
139
148
  const r = await desk.revoke(String(data.certNonce || ''))
package/src/env.js CHANGED
@@ -172,7 +172,7 @@ export function applyEnv (secrets, override = overrideByDefault()) {
172
172
  * @param {Object} [opts]
173
173
  * @param {string} [opts.ns]
174
174
  * @param {string} [opts.dir]
175
- * @param {(info:{ns:string, ts:number, motivo:'cambio'|'revocado'})=>void} [opts.onUpdate]
175
+ * @param {(info:{ns:string, ts:number, reason:'changed'|'revoked'})=>void} [opts.onUpdate]
176
176
  * Reemplaza la salida por defecto. Úsalo cuando terminar el proceso no sea una
177
177
  * opción — el caso del proxio, cuyo reinicio corta el transporte de todos.
178
178
  * @param {number} [opts.exitCode=0] Salida LIMPIA: systemd con `Restart=on-failure`
@@ -183,24 +183,24 @@ export function applyEnv (secrets, override = overrideByDefault()) {
183
183
  export async function watchEnv ({ ns, dir, onUpdate, exitCode = 0, quiet = false, ...resto } = {}) {
184
184
  ns = resolveNs(ns)
185
185
  dir = dir || serviceDir(ns)
186
- const decir = (m) => { if (!quiet) console.error(m) }
186
+ const say = (m) => { if (!quiet) console.error(m) }
187
187
 
188
- const salir = (motivo) => {
189
- decir(`[dotrino-env] ${motivo === 'revocado'
188
+ const exitNow = (reason) => {
189
+ say(`[dotrino-env] ${reason === 'revoked'
190
190
  ? 'la bóveda REVOCÓ este agente: terminando (no volverá a arrancar)'
191
191
  : 'configuración nueva en la bóveda: terminando para que el supervisor lo levante limpio'}`)
192
- process.exit(motivo === 'revocado' ? 1 : exitCode)
192
+ process.exit(reason === 'revoked' ? 1 : exitCode)
193
193
  }
194
194
 
195
195
  return watchSecretsChanges({
196
196
  dir,
197
197
  ns,
198
- log: decir,
199
- onChange: ({ ts }) => (onUpdate ? onUpdate({ ns, ts, motivo: 'cambio' }) : salir('cambio')),
198
+ log: say,
199
+ onChange: ({ ts }) => (onUpdate ? onUpdate({ ns, ts, reason: 'changed' }) : exitNow('changed')),
200
200
  // Un cert revocado sale con código de FALLO a propósito: si el supervisor lo
201
201
  // levanta, va a morir otra vez al no poder leer sus secretos, y el contador de
202
202
  // reinicios fallidos es lo que hace que se note en vez de girar en silencio.
203
- onRevoked: () => (onUpdate ? onUpdate({ ns, ts: Date.now(), motivo: 'revocado' }) : salir('revocado')),
203
+ onRevoked: () => (onUpdate ? onUpdate({ ns, ts: Date.now(), reason: 'revoked' }) : exitNow('revoked')),
204
204
  ...resto
205
205
  })
206
206
  }
package/src/index.js CHANGED
@@ -206,8 +206,14 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
206
206
  reject: (deviceId) => desk.reject(deviceId),
207
207
  listPending: desk.listPending,
208
208
  listMachines,
209
- // Revoca y AVISA a la máquina con un REVOKED firmado para que se auto-borre (ahora si
210
- // está online, o al reaparecer vía handleDevices).
209
+ // QUITA LA MÁQUINA entera (por su llave): fuera del acta y sin ningún certificado
210
+ // vigente, que son las dos caras del mismo acto. Y AVISA con un REVOKED firmado para
211
+ // que se auto-borre (ahora si está online, o al reaparecer vía handleDevices).
212
+ revokeDevice: (sub) => desk.revokeDevice(sub),
213
+ // Retira UN certificado. No es quitar la máquina: sigue siendo miembro del acta, y
214
+ // quien queda así ya no recibe el aviso de expulsión (mientras siga en el acta, un
215
+ // papel retirado significa «renueva»). Usar `revokeDevice` salvo que quieras
216
+ // exactamente esto.
211
217
  revoke: (nonce) => desk.revoke(nonce),
212
218
  getSelfCert,
213
219
  onPendingChange (fn) { _onPendingChange = fn || (() => {}) },
@@ -0,0 +1,67 @@
1
+ /**
2
+ * revocation.js — CUÁNDO se le dice a un aparato que ya no es de casa.
3
+ *
4
+ * Módulo PURO (sin red, sin disco, sin `node:*`), como `enroll.js` y `admin.js`: la regla
5
+ * vive en un solo sitio, se lee entera y se prueba sin levantar nada.
6
+ *
7
+ * EL PROBLEMA QUE RESUELVE. Un dispositivo al que quitaron del perfil no se entera por su
8
+ * cuenta: lo único que le borra la cuenta es un aviso FIRMADO por la maestra
9
+ * (`vault.revoked`). Un «unauthorized» suelto no vale y no debe valer — no va firmado, así
10
+ * que cualquiera podría destruir datos ajenos con un mensaje (el wipe-DoS de
11
+ * `docs/pairing-protocol.md §2.3`). Por eso la bóveda tiene que **atender** la conexión de
12
+ * un aparato que fue suyo, aunque su papel ya no sirva, precisamente para poder mandarlo a
13
+ * paseo. Si no lo hace, el aparato se queda enseñando una cuenta que ya no existe.
14
+ *
15
+ * LA REGLA, en una línea: papel nuestro + ya no está en el acta ⇒ se le avisa.
16
+ */
17
+
18
+ /**
19
+ * Motivos de rechazo de `verifyChain` que significan «este papel ya no sirve».
20
+ *
21
+ * Los tres se producen DESPUÉS de que `verifyChain` haya comprobado la firma del propio
22
+ * aparato y la del certificado, así que quien llega hasta aquí demostró tener la llave que
23
+ * el certificado nombra. El resto de motivos (`shape`, `bad-signature`,
24
+ * `bad-action-signature`, `cert-device-mismatch`, `untrusted-issuer`…) son ruido o gente
25
+ * ajena: ahí no hay a quién avisar de nada.
26
+ */
27
+ export const STALE_PAPER = Object.freeze(['revoked', 'expired', 'scope'])
28
+ const STALE = new Set(STALE_PAPER)
29
+
30
+ /** ¿El motivo del rechazo es «tu papel ya no sirve»? */
31
+ export const isStalePaper = (reason) => STALE.has(reason)
32
+
33
+ /**
34
+ * ¿Hay que mandarle el aviso firmado de expulsión?
35
+ *
36
+ * @param {Object} o
37
+ * @param {string} o.reason por qué falló `verifyChain`.
38
+ * @param {string} o.pubkey la llave del aparato que acaba de escribir.
39
+ * @param {string} o.master la maestra de ESTA bóveda.
40
+ * @param {string|null} [o.certIss] quién firmó el certificado que presentó.
41
+ * @param {Array<{pub:string}>|null} [o.members] miembros del acta; `null` = no hay acta.
42
+ * @param {boolean} [o.knownRevoked] consta explícitamente como revocado en las
43
+ * delegaciones. Es el único criterio que queda cuando no hay acta.
44
+ * @returns {boolean}
45
+ */
46
+ export function shouldNotifyRevoked ({ reason, pubkey, master, certIss = null, members = null, knownRevoked = false } = {}) {
47
+ if (typeof pubkey !== 'string' || !pubkey) return false
48
+ if (!isStalePaper(reason)) return false
49
+ // Que el papel sea NUESTRO. No hace falta que siga siendo válido —justo por eso estamos
50
+ // aquí— pero sí que lo haya firmado esta maestra: es lo que separa a un aparato que fue
51
+ // de casa de uno que pasaba por ahí.
52
+ if (certIss && master && certIss !== master) return false
53
+ // El aparato no puede echarse a sí mismo, y la maestra tampoco se echa.
54
+ if (master && pubkey === master) return false
55
+
56
+ // EL ACTA MANDA, y es lo único que manda. Un certificado retirado o vencido NO significa
57
+ // «estás fuera»: renovar retira el anterior y cambiar permisos obliga a renovar, así que
58
+ // un aparato de casa con un papel viejo solo tiene que renovar. Decirle «estás fuera»
59
+ // ahí lo borraba solo: dabas «administra» y el dispositivo desaparecía.
60
+ if (Array.isArray(members)) return !members.some((m) => m?.pub === pubkey)
61
+
62
+ // Sin acta (bóveda anterior al acta) no se puede saber quién es del perfil: solo se
63
+ // avisa a quien conste explícitamente como revocado.
64
+ return !!knownRevoked
65
+ }
66
+
67
+ export default { STALE_PAPER, isStalePaper, shouldNotifyRevoked }