@dotrino/vault 0.78.0 → 0.80.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.78.0",
3
+ "version": "0.80.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
@@ -47,6 +47,9 @@ export const ADMIN_OPS = Object.freeze([
47
47
  // `pending`, `pair`, `approve` y `reject` NO están, y no es un olvido: agregar aparatos
48
48
  // a distancia se quitó (ver la cabecera). Lo que queda es mirar, quitar y configurar.
49
49
  'revoke', 'audit',
50
+ // Los BLOQUEADOS por un aprobador (`src/blocked.js`): verlos y desbloquearlos. Bloquear a
51
+ // distancia también, por si el administrador llega antes que el teléfono. No toca el acta.
52
+ 'blocked', 'block', 'unblock',
50
53
  // Variables de entorno: verlas (nombres siempre; valor solo de las públicas) y
51
54
  // ponerles valor (de las dos). Borrar NO está, y es a propósito: un aparato robado
52
55
  // no debe poder dejar sin configuración a los servicios.
@@ -96,6 +99,8 @@ export const ADMIN_OPS = Object.freeze([
96
99
  */
97
100
  export const ADMIN_OPS_WHILE_LOCKED = Object.freeze([
98
101
  'audit', 'vars', 'var.set', 'var.setMany', 'var.recipients',
102
+ // Bloquear no sella nada: la lista es de la bóveda, no del acta.
103
+ 'blocked', 'block', 'unblock',
99
104
  // Las dos de abrir, obviamente: son las únicas que solo tienen sentido con el candado
100
105
  // echado. Que estén aquí NO las deja pasar sin más — siguen exigiendo cert, acta, nonce
101
106
  // y frescura como todas, y además la contraseña dentro del sobre.
@@ -147,6 +152,8 @@ export function createAdminDesk ({
147
152
  // este módulo es puro a propósito. `null` en una bóveda que no lo soporte: entonces la
148
153
  // operación existe y contesta que no, en vez de romper con un TypeError.
149
154
  unlockDesk = null,
155
+ // La lista de bloqueados, inyectada como `vars`: `{ list(), set(pub, on, { by }) }`.
156
+ blocked = null,
150
157
  now = () => Date.now()
151
158
  }) {
152
159
  const ops = new Set(ADMIN_OPS)
@@ -333,6 +340,14 @@ export function createAdminDesk ({
333
340
  // usarlo para quitar dejaba un miembro sin certificados al que la bóveda ya nunca le
334
341
  // mandaba el aviso de expulsión (mientras siga en el acta, un papel retirado
335
342
  // significa «renueva»). Ese era el dispositivo fantasma.
343
+ if (data.op === 'blocked' || data.op === 'block' || data.op === 'unblock') {
344
+ if (!blocked) return { ok: false, error: 'admin: this vault cannot block devices' }
345
+ if (data.op === 'blocked') return { ok: true, result: { items: blocked.list() } }
346
+ if (typeof data.sub !== 'string' || !data.sub) return { ok: false, error: `admin: ${data.op} needs the device key (sub)` }
347
+ const r = await blocked.set(data.sub, data.op === 'block', { by })
348
+ audit(`admin.${data.op}`, { by, device: await deviceIdOf(data.sub).catch(() => null) })
349
+ return { ok: true, result: { ok: true, was: r } }
350
+ }
336
351
  if (data.op === 'revoke') {
337
352
  if (data.sub) {
338
353
  const deviceId = await deviceIdOf(String(data.sub)).catch(() => null)
package/src/protocol.js CHANGED
@@ -65,7 +65,7 @@ export const MSG = Object.freeze({
65
65
  // empieza con el heap limpio.
66
66
  // Va FIRMADO por la maestra y el agente lo verifica contra su `iss` pineada: un
67
67
  // aviso de reinicio sin autenticar ES un ataque de denegación.
68
- SECRETS_CHANGED: 'vault.secrets.changed', // vault → servicio: { body:{op,ns,ts}, signature }
68
+ SECRETS_CHANGED: 'vault.secrets.changed', // vault → servicio: { body:{op,ns,ts}, seal, acta }
69
69
  // REPARTIR LA LLAVE DE UN CAJÓN A UN MIEMBRO NUEVO. Lo hace el SERVICIO y no la
70
70
  // bóveda, porque la bóveda no puede abrir la llave sin la frase y el servicio ya la
71
71
  // tiene: re-envolverla no le añade ningún poder. Ver `docs/secretos-sellados.md` §8.11.
@@ -74,7 +74,7 @@ export const MSG = Object.freeze({
74
74
  // el ACTA que viene dentro (también firmada) y saca de ahí la llave pública del
75
75
  // destinatario. Así, ni siquiera una bóveda comprometida puede hacerle envolver la
76
76
  // llave para alguien que la maestra no haya metido en su propio cajón.
77
- REWRAP: 'vault.rewrap', // vault → servicio: { body:{op,owner,gen,wrap,target,acta,ts}, signature }
77
+ REWRAP: 'vault.rewrap', // vault → servicio: { body:{op,owner,gen,wrap,target,acta,ts}, seal }
78
78
  REWRAP_OK: 'vault.rewrap.ok', // servicio → vault: { data:{op,owner,gen,target,wrap,ts}, signature, cert }
79
79
  // --- CONSOLA REMOTA (docs/consola-remota.md) — requiere cert `vault:admin` ---
80
80
  // Un solo mensaje con `data.op`: pending · pair · approve · reject · revoke · audit.
@@ -85,7 +85,7 @@ export const MSG = Object.freeze({
85
85
  // Aviso a TODOS los miembros de que el perfil cambió (alguien entró o salió). Es la
86
86
  // contrapartida de administrar a distancia: sin esto, un enrolamiento remoto sería
87
87
  // invisible para el resto de tus dispositivos.
88
- ADMIN_EVENT: 'vault.admin.event', // vault → todos: { body:{ev,deviceId,by,ts}, signature }
88
+ ADMIN_EVENT: 'vault.admin.event', // vault → todos: { body:{ev,deviceId,by,ts}, acta } (pedidos: + seal)
89
89
  // RÉPLICAS: la principal empuja lo que hay que servir (el acta y los sobres, que van
90
90
  // firmados de antes y no se pueden falsificar) y la réplica acusa hasta qué `seq` tiene.
91
91
  REPLICA_PUSH: 'vault.replica.push', // master → réplica: { body:{seq,acta,secrets,ts}, signature }
@@ -103,6 +103,13 @@ export const MSG = Object.freeze({
103
103
  // una forma de echar a alguien de su cuenta.
104
104
  LOGIN_CLOSE: 'vault.login.close', // aparato → vault: { data:{op,user,sid,publickey,ts}, signature }
105
105
  LOGIN_CLOSED: 'vault.login.closed', // vault → aparato: { ok }
106
+ // INCIDENTE: un agente de la cuenta (la terminal) avisa de que OTRO aparato de la cuenta
107
+ // se equivocó de clave tres veces seguidas. La bóveda lo apunta como un pedido más
108
+ // (`kind: 'incident'`), timbra a quien aprueba, y el aprobador bloquea o ignora. Bloquear
109
+ // no toca el acta: es una lista de la bóveda (`blocked.json`) que viaja a los agentes con
110
+ // `vault.devices.result` (`blocked`) y se deshace con `dotrino-vault unblock <ID>`.
111
+ INCIDENT: 'vault.incident', // agente → vault: { data:{op:'incident',kind,about,tries,publickey,ts}, signature, cert }
112
+ INCIDENT_RESULT: 'vault.incident.result', // vault → agente: { id, approvers, blocked }
106
113
  ERROR: 'vault.error' // vault → dispositivo: { error }
107
114
  })
108
115
 
package/src/service.js CHANGED
@@ -1137,8 +1137,9 @@ export async function watchSecretsChanges ({
1137
1137
  * NO SE FÍA DE LO QUE LE MANDAN, y esto es lo que hace que sea seguro incluso si la
1138
1138
  * bóveda estuviera comprometida:
1139
1139
  *
1140
- * · la petición va firmada por la MAESTRA;
1141
- * · el acta viaja dentro y se comprueba aparte (también la firma la maestra);
1140
+ * · la petición la firma la llave de SELLADO que nombra el acta (`verifyResponder`), no la
1141
+ * maestra: esto tiene que funcionar con la bóveda cerrada;
1142
+ * · el acta viaja dentro y la selló la maestra que este agente conoce;
1142
1143
  * · **la llave pública del destinatario se saca del ACTA, nunca del mensaje** — si
1143
1144
  * se cogiera del mensaje, quien lo mandara podría hacer que este agente envolviera
1144
1145
  * la llave para una pública suya;
@@ -1150,9 +1151,9 @@ export async function watchSecretsChanges ({
1150
1151
  if (!body || body.op !== 'rewrap') return
1151
1152
  const mineOwners = [`ns:${ns}`, `dev:${saved.device.publickey}`]
1152
1153
  if (!mineOwners.includes(body.owner)) return log('[vault] rewrap for a drawer that is not mine: ignored')
1153
- if (!(await verifyDeviceSig({ publickey: master, data: body, signature: payload.signature }))) {
1154
- return log('[vault] rewrap request BADLY SIGNED: ignored')
1155
- }
1154
+ try {
1155
+ await verifyResponder({ body, seal: payload.seal, acta: body.acta, masterPubkey: master, knownSeq: readServiceIdentity(dir)?.actaSeq ?? null })
1156
+ } catch (e) { return log(`[vault] rewrap request BADLY SIGNED: ignored (${e.message})`) }
1156
1157
  const acta = body.acta
1157
1158
  if (!acta || acta.sealedBy !== master || !(await verifyActa({ acta })).ok) {
1158
1159
  return log('[vault] rewrap request without a valid record: ignored')
@@ -1192,11 +1193,15 @@ export async function watchSecretsChanges ({
1192
1193
  // que un aviso falso con fecha lejana no pueda dejar fuera a los de verdad.
1193
1194
  if (inFlight.has(body.ts)) return
1194
1195
  inFlight.add(body.ts)
1195
- let valid = false
1196
+ // FIRMADO COMO TODO LO QUE SIRVE LA BÓVEDA: la llave de sellado que nombra el acta, con
1197
+ // el acta al lado. Lo firmaba la maestra, y la bóveda vive CERRADA: el aviso reventaba
1198
+ // con «vault locked» antes de salir y el servicio no se enteraba de ningún cambio
1199
+ // (la maestra solo firma el acta y reenvuelve sobres, CLAUDE.md).
1200
+ let bad = null
1196
1201
  try {
1197
- valid = await verifyDeviceSig({ publickey: master, data: body, signature: payload.signature })
1198
- } finally { inFlight.delete(body.ts) }
1199
- if (!valid) return log('[vault] change notice BADLY SIGNED: ignored (not from your vault)')
1202
+ await verifyResponder({ body, seal: payload.seal, acta: payload.acta, masterPubkey: master, knownSeq: readServiceIdentity(dir)?.actaSeq ?? null })
1203
+ } catch (e) { bad = e } finally { inFlight.delete(body.ts) }
1204
+ if (bad) return log(`[vault] change notice BADLY SIGNED: ignored (${bad.message})`)
1200
1205
  if (body.ts <= lastTs) return
1201
1206
  lastTs = body.ts
1202
1207