@dotrino/vault 0.20.0 → 0.22.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
@@ -50,7 +50,7 @@ si la máquina se compromete, revocas el cert y no había nada que robar.
50
50
  ```bash
51
51
  # en el VAULT (tu PC): abres el emparejamiento del servicio y cargas sus secretos
52
52
  dotrino-vault pair --service miapp # invitación con scope SOLO vault:secrets:miapp
53
- dotrino-vault secret set miapp API_KEY sk-…
53
+ dotrino-vault secret set miapp API_KEY sk-… # la comparten TODAS las máquinas del ns
54
54
 
55
55
  # en el PROYECTO/servidor: enrola esta máquina (pega la invitación)
56
56
  npx dotrino-env enroll --ns miapp
@@ -60,6 +60,17 @@ npx dotrino-env enroll --ns miapp
60
60
  dotrino-vault approve 7K3F-92Q1
61
61
  ```
62
62
 
63
+ Si la misma app corre en **varias máquinas**, lo que cambia de una a otra (el puerto,
64
+ la URL pública) va en el cajón **por aparato**, sin partir el `ns`:
65
+
66
+ ```bash
67
+ dotrino-vault devices # el ID del aparato: AB12-CD34
68
+ dotrino-vault secret device set AB12-CD34 PORT 8443 # solo la lee ESA máquina
69
+ ```
70
+
71
+ Llegan **mezcladas en el mismo bundle** —y por lo tanto en el mismo `process.env`—:
72
+ las del scope, con las del aparato **encima** si se llaman igual.
73
+
63
74
  El código lo **genera el servicio** y **no viaja** por la red: el vault solo puede
64
75
  echarlo de vuelta si un humano lo tipeó. Así, un vault falso no puede enrolarte y
65
76
  aprobar a ciegas no enrola a nadie. Queda `~/.dotrino/service/<ns>/service-identity.json`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vault",
3
- "version": "0.20.0",
3
+ "version": "0.22.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/enroll.js CHANGED
@@ -104,13 +104,16 @@ export async function deviceIdOf (pub) {
104
104
  * @param {(...a:any[])=>void} [opts.log]
105
105
  * @param {(c:{deviceId:string, scope:any, label:string})=>void} [opts.onChallenge] un dispositivo espera aprobación.
106
106
  * @param {()=>void} [opts.onPendingChange]
107
+ * @param {(sub:string)=>void} [opts.onDeviceRemoved] se quitó un aparato (fuera del acta y sin papeles):
108
+ * para que quien guarde algo indexado por esa llave lo suelte. Se avisa desde AQUÍ y no desde
109
+ * quien llama porque a `revokeDevice` se entra por dos puertas (el PC y la consola remota).
107
110
  * @param {string[]} [opts.defaultScope]
108
111
  * @param {number} [opts.defaultTtlMs]
109
112
  */
110
113
  export function createEnrollDesk ({
111
114
  identity, iss, proxy, send, sendByPubkey,
112
115
  audit = () => {}, log = () => {},
113
- onChallenge = () => {}, onPendingChange = () => {}, onAdopted = () => {},
116
+ onChallenge = () => {}, onPendingChange = () => {}, onAdopted = () => {}, onDeviceRemoved = () => {},
114
117
  defaultScope = ['vault:sign'], defaultTtlMs = DEVICE_TTL_MS,
115
118
  // Camino A: lo que ESTA bóveda le manda al aparato para que la meta en su acta. `encPub`
116
119
  // es su llave de CIFRADO — sin ella entra mandando pero sin poder leer el contenido.
@@ -461,6 +464,7 @@ export function createEnrollDesk ({
461
464
  return done
462
465
  })() }
463
466
  await emitRevoke(sub, mine[0]?.nonce || null)
467
+ fire(onDeviceRemoved, sub)
464
468
  return res
465
469
  }
466
470
 
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 }