@dotrino/vault 0.16.0 → 0.18.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
@@ -234,16 +234,37 @@ pineada en el enrolamiento.
234
234
 
235
235
  `startDeviceVault(identity, { proxyUrl? }) → Promise<handle>`
236
236
 
237
- - `startPairing({ scope?, ttlMs?, label? }) → { qr, expiresInMs }`
237
+ - `startPairing({ scope?, ttlMs?, label?, mode?, account? }) → { qr, expiresInMs }`
238
+ - `stopPairing(token)`
238
239
  - `listPending() → [{ deviceId, label }]`
239
240
  - `approve(deviceId, code) → Promise<{ ok, deviceId }>` (code = lo que muestra el dispositivo)
240
241
  - `reject(deviceId)`
241
242
  - `listMachines() → Promise<[{ sub, deviceId, label, scope, exp, nonce }]>`
242
243
  - `revoke(nonce) → Promise`
243
244
  - `getSelfCert() → Promise<cert>` (self-cert `P ← P`, para actuar además de cliente)
244
- - `onPendingChange(fn)`, `close()`
245
+ - `onPendingChange(fn)`, `onAdopted(fn)`, `close()`
245
246
 
246
247
  Cripto y firma: `@dotrino/identity`. Transporte: `@dotrino/proxy-client`. No reimplementa
247
248
  nada del ecosistema.
248
249
 
250
+ ### Qué atiende, y qué NO
251
+
252
+ Esta bóveda **no es** el daemon del PC: comparte el núcleo de enrolamiento
253
+ (`lib/src/enroll.js`, el mismo archivo), pero atiende menos mensajes del protocolo.
254
+
255
+ Atiende: `vault.hello` (la llave que pide el QR corto), `vault.enroll` +
256
+ `vault.acta.sealed` (enrolar y adoptar), `vault.renew` (**renovación automática** del
257
+ cert de una máquina vigente: sin esto toda máquina enrolada caducaba a los 30 días) y
258
+ `vault.devices` (lista + revocados, con re-emisión del `REVOKED` firmado).
259
+
260
+ **No** atiende, y hoy solo existen contra el daemon `dotrino-vault`:
261
+
262
+ | Falta | Qué implica |
263
+ |---|---|
264
+ | `vault.sign` | una máquina no puede pedirle a la maestra que firme por ella |
265
+ | `vault.store` / `vault.get` | no hay store centralizado, ni edición de perfil, ni clave de contenido |
266
+ | `vault.secrets` | `@dotrino/vault/config` (el reemplazo del `.env`) **no funciona** contra un dispositivo |
267
+ | `vault.admin` | sin consola remota |
268
+ | bitácora, cifrado en reposo, candado, multi-perfil | son del daemon; en el navegador dependen de `@dotrino/identity` |
269
+
249
270
  MIT · parte de [Dotrino](https://dotrino.com).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vault",
3
- "version": "0.16.0",
3
+ "version": "0.18.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 ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * admin.js — CONSOLA REMOTA: administrar el perfil desde un dispositivo emparejado.
3
+ *
4
+ * Diseño: `dotrino-vault/docs/consola-remota.md`. Módulo PURO (sin `node:*`, sin red,
5
+ * sin disco), igual que `enroll.js`, para que lo puedan usar el daemon del PC y «este
6
+ * dispositivo es bóveda» sin duplicar la regla.
7
+ *
8
+ * QUÉ SE DELEGA Y QUÉ NO — esto es el módulo entero, el resto es plomería:
9
+ *
10
+ * sí · ver el acta y la bitácora · iniciar un emparejamiento (mostrar el QR)
11
+ * · APROBAR o rechazar a quien entra · REVOCAR a un miembro
12
+ * no · cambiar permisos · traspasar el mando · conceder `admin`
13
+ * · nada de los secretos de servicios
14
+ *
15
+ * La frontera no es un capricho: un admin puede **admitir y expulsar**, pero no
16
+ * reescribir quién manda. Así un aparato con `admin` robado hace daño **acotado y
17
+ * reversible** (se le revoca) en vez de poder traspasarse el mando y dejar al dueño
18
+ * fuera de su propia cuenta, que no tiene vuelta atrás. Las operaciones que no se
19
+ * delegan **no existen como mensaje**: no hay nada que autorizar mal.
20
+ *
21
+ * POR QUÉ APROBAR A DISTANCIA NO DEBILITA EL EMPAREJAMIENTO: el código de 6 dígitos es
22
+ * un COMPROMISO (`enroll.js`) — lo genera y lo MUESTRA el aparato que entra, y la bóveda
23
+ * solo firma si el código tecleado lo recompone. Aprobar exige haber leído la pantalla
24
+ * del aparato nuevo, se haga desde el PC o desde el teléfono. Lo que cambia es dónde
25
+ * está el humano, no qué tiene que demostrar.
26
+ */
27
+
28
+ /** Las únicas operaciones que existen a distancia. Lista cerrada, como las capacidades. */
29
+ export const ADMIN_OPS = Object.freeze(['pending', 'pair', 'approve', 'reject', 'revoke', 'audit'])
30
+
31
+ /** Cuánto se recuerda un nonce ya usado (el doble de la ventana de frescura). */
32
+ export const ADMIN_NONCE_TTL_MS = 10 * 60 * 1000
33
+
34
+ /** Tope de entradas de bitácora por petición. */
35
+ export const AUDIT_MAX = 500
36
+
37
+ /**
38
+ * @param {Object} o
39
+ * @param {Object} o.desk mostrador de emparejamiento (`createEnrollDesk`).
40
+ * @param {(scope:string[])=>Promise<any>} o.verify verifica cadena+cert; devuelve `{ok, device, reason}`.
41
+ * @param {(limit:number)=>any[]} o.readActivity últimas entradas de la bitácora.
42
+ * @param {(pub:string)=>Promise<string>} o.deviceIdOf
43
+ * @param {(ev:string, info?:object)=>Promise<void>} [o.notify] aviso a todos los miembros.
44
+ * @param {(op:string, info?:object)=>void} [o.audit]
45
+ * @param {string[]} [o.defaultScope] lo que recibe un dispositivo emparejado a distancia.
46
+ * @param {number} [o.ttlMs] vida del cert que se emita.
47
+ */
48
+ export function createAdminDesk ({
49
+ desk, verify, readActivity = () => [], deviceIdOf,
50
+ notify = async () => {}, audit = () => {},
51
+ defaultScope = ['vault:sign', 'vault:read', 'vault:store'],
52
+ ttlMs, now = () => Date.now()
53
+ }) {
54
+ const ops = new Set(ADMIN_OPS)
55
+
56
+ // NONCE de un solo uso. `sign`/`get` son idempotentes y les basta la ventana de ±5
57
+ // min; `approve` y `revoke` CAMBIAN ESTADO, así que reproducir uno dentro de esa
58
+ // ventana sí importa (re-aprobar un enrolamiento que el dueño ya rechazó, por
59
+ // ejemplo). Por eso el nonce, y por eso es obligatorio en todas las ops: una lista
60
+ // de excepciones es una invitación a equivocarse.
61
+ const seen = new Map()
62
+ function nonceAlreadyUsed (nonce) {
63
+ const t = now()
64
+ for (const [n, exp] of seen) if (exp <= t) seen.delete(n)
65
+ if (seen.has(nonce)) return true
66
+ seen.set(nonce, t + ADMIN_NONCE_TTL_MS)
67
+ return false
68
+ }
69
+
70
+ /**
71
+ * Atiende una petición ya verificada como *fresca*. Devuelve `{ ok, result }` o
72
+ * `{ ok: false, error }` — quien llama se encarga de responder por el transporte.
73
+ */
74
+ async function handle (data, { signature, cert } = {}) {
75
+ if (!data || !ops.has(data.op)) return { ok: false, error: 'admin: invalid operation' }
76
+ if (typeof data.nonce !== 'string' || data.nonce.length < 16) {
77
+ return { ok: false, error: 'admin: missing single-use nonce' }
78
+ }
79
+
80
+ const chk = await verify({ data, signature, cert })
81
+ if (!chk?.ok) {
82
+ audit('rejected', { what: 'admin', op: data.op, reason: chk?.reason })
83
+ return { ok: false, error: 'unauthorized: ' + (chk?.reason || 'cert') }
84
+ }
85
+ const by = await deviceIdOf(chk.device).catch(() => null)
86
+
87
+ // El nonce se marca DESPUÉS de autorizar: si no, cualquiera podría quemarle los
88
+ // nonces a un admin legítimo mandando basura firmada por nadie.
89
+ if (nonceAlreadyUsed(data.nonce)) {
90
+ audit('rejected', { what: 'admin', op: data.op, by, reason: 'replay' })
91
+ return { ok: false, error: 'admin: nonce already used' }
92
+ }
93
+
94
+ try {
95
+ if (data.op === 'pending') return { ok: true, result: { pending: desk.listPending() } }
96
+
97
+ if (data.op === 'audit') {
98
+ const limit = Math.min(Math.max(Number(data.limit) || 100, 1), AUDIT_MAX)
99
+ return { ok: true, result: { entries: readActivity(limit) } }
100
+ }
101
+
102
+ if (data.op === 'pair') {
103
+ // Un admin NO empareja servicios ni crea otros admins. Se corta aquí, en la
104
+ // bóveda, no en la interfaz: una pantalla no es un control de seguridad.
105
+ const scope = Array.isArray(data.scope) && data.scope.length ? data.scope : defaultScope
106
+ const forbidden = scope.find((s) => s === 'vault:admin' || String(s).startsWith('vault:secrets:'))
107
+ if (forbidden) {
108
+ audit('rejected', { what: 'admin', op: 'pair', by, reason: 'forbidden-scope', scope: forbidden })
109
+ return { ok: false, error: 'admin: cannot grant admin or service secrets from here; do that on the vault machine' }
110
+ }
111
+ const label = String(data.label || '').slice(0, 60) || 'remoto'
112
+ const r = await desk.startPairing({ scope, label, ...(ttlMs ? { ttlMs } : {}) })
113
+ audit('admin.pair', { by })
114
+ return { ok: true, result: r }
115
+ }
116
+
117
+ if (data.op === 'approve') {
118
+ const r = await desk.approve(String(data.code || ''), { deviceId: data.deviceId })
119
+ audit('admin.approve', { by, device: data.deviceId || null })
120
+ await notify('enrolled', { deviceId: r?.deviceId || data.deviceId || null, by })
121
+ return { ok: true, result: r || { ok: true } }
122
+ }
123
+
124
+ if (data.op === 'reject') {
125
+ desk.reject(data.deviceId)
126
+ audit('admin.reject', { by, device: data.deviceId || null })
127
+ return { ok: true, result: { ok: true } }
128
+ }
129
+
130
+ if (data.op === 'revoke') {
131
+ const r = await desk.revoke(String(data.certNonce || ''))
132
+ audit('admin.revoke', { by, certNonce: data.certNonce })
133
+ await notify('revoked', { certNonce: data.certNonce, by })
134
+ return { ok: true, result: r || { ok: true } }
135
+ }
136
+ } catch (e) {
137
+ audit('rejected', { what: 'admin', op: data.op, by, reason: e.message })
138
+ return { ok: false, error: e.message }
139
+ }
140
+ return { ok: false, error: 'admin: invalid operation' }
141
+ }
142
+
143
+ return { handle, get nonceCount () { return seen.size } }
144
+ }
145
+
146
+ export default { createAdminDesk, ADMIN_OPS, ADMIN_NONCE_TTL_MS }
package/src/atrest.js ADDED
Binary file
package/src/enroll.js CHANGED
@@ -52,7 +52,7 @@ export const MSG_REVOKED = 'vault.revoked'
52
52
  export const MSG_ERROR = 'vault.error'
53
53
 
54
54
  /** Los scopes del cert se corresponden 1:1 con las capacidades del acta (§D7). */
55
- const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read' }
55
+ const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read', 'vault:admin': 'admin' }
56
56
  export const scopeToCaps = (scope) =>
57
57
  (Array.isArray(scope) ? scope : [scope]).map((s) => SCOPE_TO_CAP[s]).filter(Boolean)
58
58
 
@@ -119,15 +119,15 @@ export function createEnrollDesk ({
119
119
  // único que necesita el QR corto para que el aparato le hable punto a punto.
120
120
  connToken = null
121
121
  } = {}) {
122
- if (!identity) throw new Error('createEnrollDesk: falta identity')
123
- if (!iss) throw new Error('createEnrollDesk: falta iss (pubkey de la maestra)')
122
+ if (!identity) throw new Error('createEnrollDesk: missing identity')
123
+ if (!iss) throw new Error('createEnrollDesk: missing iss (master pubkey)')
124
124
 
125
125
  // token -> { token, exp, scope, ttlMs, label, sn, state, dpub?, deviceId?, commit?, from? }
126
126
  // state: 'AWAITING_ENROLL' -> 'PENDING_CONFIRM'
127
127
  const pending = new Map()
128
128
 
129
129
  const fire = (fn, arg) => { try { fn(arg) } catch (_) {} }
130
- const reply = (to, obj) => { try { send(to, obj) } catch (e) { log('[vault] no se pudo responder:', e.message) } }
130
+ const reply = (to, obj) => { try { send(to, obj) } catch (e) { log('[vault] could not reply:', e.message) } }
131
131
  const isFresh = (d) => typeof d?.ts === 'number' && Math.abs(Date.now() - d.ts) <= FRESH_WINDOW_MS
132
132
 
133
133
  /**
@@ -196,7 +196,7 @@ export function createEnrollDesk ({
196
196
  const pend = pending.get(String(p?.sn || ''))
197
197
  if (!pend || Date.now() > pend.exp) {
198
198
  audit('rejected', { what: 'hello', reason: 'sin-sesion' })
199
- return reply(from, { type: MSG_ERROR, error: 'no hay ningún emparejamiento abierto con ese código' })
199
+ return reply(from, { type: MSG_ERROR, error: 'no pairing session open for that code' })
200
200
  }
201
201
  // La respuesta va FIRMADA por la maestra y el `sn` va dentro de lo firmado. Eso ata
202
202
  // la respuesta a ESTA sesión: no se puede reutilizar la de otro emparejamiento ni la
@@ -215,19 +215,19 @@ export function createEnrollDesk ({
215
215
  async function handleEnroll (from, p) {
216
216
  const d = p?.data
217
217
  if (!d || typeof d.dpub !== 'string' || typeof p.signature !== 'string') {
218
- return reply(from, { type: MSG_ERROR, error: 'enroll inválido' })
218
+ return reply(from, { type: MSG_ERROR, error: 'invalid enroll' })
219
219
  }
220
220
  const pend = pending.get(d.token)
221
221
  if (!pend || pend.state === 'DONE' || Date.now() > pend.exp) {
222
- return reply(from, { type: MSG_ERROR, error: 'token de emparejamiento inválido o expirado' })
222
+ return reply(from, { type: MSG_ERROR, error: 'invalid or expired pairing token' })
223
223
  }
224
- if (d.sn !== pend.sn) return reply(from, { type: MSG_ERROR, error: 'sesión inválida' })
224
+ if (d.sn !== pend.sn) return reply(from, { type: MSG_ERROR, error: 'invalid session' })
225
225
  // V7 · la INTENCIÓN viaja firmada y tiene que coincidir con el modo con el que ESTA
226
226
  // bóveda abrió el emparejamiento. Es lo que garantiza que lo que pasa es lo que el
227
227
  // humano vio anunciado en las dos pantallas, y no algo que se decidió a mitad de camino.
228
228
  const intent = d.intent || 'join'
229
229
  if (intent !== 'join' && intent !== 'adopt') {
230
- return reply(from, { type: MSG_ERROR, error: 'intención desconocida: ' + intent })
230
+ return reply(from, { type: MSG_ERROR, error: 'unknown intent: ' + intent })
231
231
  }
232
232
  if (intent !== (pend.mode || 'join')) {
233
233
  audit('rejected', { what: 'enroll', reason: 'intent-mismatch' })
@@ -235,22 +235,22 @@ export function createEnrollDesk ({
235
235
  }
236
236
  if (!isFresh(d)) {
237
237
  audit('rejected', { what: 'enroll', reason: 'stale' })
238
- return reply(from, { type: MSG_ERROR, error: 'petición vencida: ts fuera de la ventana ±5 min (posible replay, o el reloj del dispositivo está desfasado)' })
238
+ return reply(from, { type: MSG_ERROR, error: 'stale request: ts outside the ±5 min window (possible replay, or the device clock is off)' })
239
239
  }
240
240
  // PRUEBA DE POSESIÓN: la firma de `data` debe verificar contra `dpub`.
241
241
  if (!(await verifyDeviceSig({ publickey: d.dpub, data: d, signature: p.signature }))) {
242
242
  audit('rejected', { what: 'enroll', reason: 'bad-device-signature' })
243
- return reply(from, { type: MSG_ERROR, error: 'firma de dispositivo inválida' })
243
+ return reply(from, { type: MSG_ERROR, error: 'invalid device signature' })
244
244
  }
245
245
  // El COMPROMISO del código es obligatorio: sin él no se puede comprobar al aprobar
246
246
  // y volveríamos a emitir certs a ciegas. Un cliente viejo cae acá con un mensaje claro.
247
247
  if (typeof d.commit !== 'string' || !/^[0-9a-f]{64}$/.test(d.commit)) {
248
248
  audit('rejected', { what: 'enroll', reason: 'no-commit' })
249
- return reply(from, { type: MSG_ERROR, error: 'este dispositivo usa una versión antigua del emparejamiento (no envía el compromiso del código). Actualízalo y vuelve a intentarlo.' })
249
+ return reply(from, { type: MSG_ERROR, error: 'this device speaks an old pairing version (no code commitment). Update it and try again.' })
250
250
  }
251
251
  // Un solo dispositivo a la vez esperando su código (así aprobar no es ambiguo).
252
252
  if (pend.state === 'PENDING_CONFIRM' && pend.dpub && pend.dpub !== d.dpub) {
253
- return reply(from, { type: MSG_ERROR, error: 'ya hay un dispositivo usando este emparejamiento' })
253
+ return reply(from, { type: MSG_ERROR, error: 'another device is already using this pairing session' })
254
254
  }
255
255
 
256
256
  const deviceId = await deviceIdOf(d.dpub)
@@ -289,16 +289,16 @@ export function createEnrollDesk ({
289
289
  */
290
290
  async function approve (code, { deviceId } = {}) {
291
291
  code = String(code || '').trim()
292
- if (!code) throw new Error('falta el código (los dígitos que muestra el dispositivo)')
292
+ if (!code) throw new Error('missing code (the digits shown by the device)')
293
293
 
294
294
  let pend
295
295
  if (deviceId) {
296
296
  pend = findPending(deviceId)
297
- if (!pend) throw new Error('no hay ninguna máquina esperando aprobación con ese identificador')
297
+ if (!pend) throw new Error('no device awaiting approval with that id')
298
298
  } else {
299
299
  const waiting = [...pending.values()].filter((p) => p.state === 'PENDING_CONFIRM' && p.dpub)
300
- if (waiting.length === 0) throw new Error('no hay ningún dispositivo esperando aprobación')
301
- if (waiting.length > 1) throw new Error('hay más de un emparejamiento en curso; reinícialo con dotrino-vault pair')
300
+ if (waiting.length === 0) throw new Error('no device awaiting approval')
301
+ if (waiting.length > 1) throw new Error('more than one pairing in flight; restart it with dotrino-vault pair')
302
302
  pend = waiting[0]
303
303
  }
304
304
 
@@ -306,8 +306,8 @@ export function createEnrollDesk ({
306
306
  const expected = await commitCode({ code, dpub: pend.dpub, sn: pend.sn })
307
307
  if (expected !== pend.commit) {
308
308
  audit('rejected', { what: 'approve', device: pend.deviceId, reason: 'bad-code' })
309
- log('[vault] código incorrecto para %s: no se emitió ningún certificado', pend.deviceId)
310
- throw new Error('el código no coincide con el que muestra el dispositivo: no se emitió ningún certificado. Vuelve a mirarlo y prueba otra vez.')
309
+ log('[vault] wrong code for %s: no certificate was issued', pend.deviceId)
310
+ throw new Error('code does not match the one shown by the device: no certificate was issued. Check it and try again.')
311
311
  }
312
312
 
313
313
  // CAMINO A · aquí la bóveda no entrega un cert: entrega SU IDENTIDAD para que el
@@ -319,7 +319,7 @@ export function createEnrollDesk ({
319
319
  pend.state = 'AWAITING_ACTA'
320
320
  pend.approvedAt = Date.now()
321
321
  reply(pend.from, { type: MSG_ENROLL_ADOPT, code, pub: iss, encPub: encPub || null, label: vaultLabel || '' })
322
- log('[vault] adopción aprobada para %s: esperando el acta sellada', pend.deviceId)
322
+ log('[vault] adoption approved for %s: waiting for the sealed record', pend.deviceId)
323
323
  fire(onPendingChange)
324
324
  return { ok: true, deviceId: pend.deviceId, adopting: true }
325
325
  }
@@ -369,24 +369,24 @@ export function createEnrollDesk ({
369
369
  async function handleActaSealed (from, p) {
370
370
  const acta = p?.acta
371
371
  const pend = [...pending.values()].find((x) => x.state === 'AWAITING_ACTA' && (x.from === from || x.dpub))
372
- if (!pend) return reply(from, { type: MSG_ERROR, error: 'no hay ninguna adopción esperando un acta' })
373
- if (!acta || typeof acta !== 'object') return reply(from, { type: MSG_ERROR, error: 'acta ausente o ilegible' })
372
+ if (!pend) return reply(from, { type: MSG_ERROR, error: 'no adoption awaiting a record' })
373
+ if (!acta || typeof acta !== 'object') return reply(from, { type: MSG_ERROR, error: 'record missing or unreadable' })
374
374
  if (acta.sealer !== iss) {
375
375
  audit('rejected', { what: 'adopt', reason: 'not-sealer' })
376
- return reply(from, { type: MSG_ERROR, error: 'esa acta no nombra a esta bóveda como quien manda' })
376
+ return reply(from, { type: MSG_ERROR, error: 'that record does not name this vault as the sealer' })
377
377
  }
378
378
  if (acta.sealedBy !== pend.dpub) {
379
379
  audit('rejected', { what: 'adopt', reason: 'sealed-by-other' })
380
- return reply(from, { type: MSG_ERROR, error: 'esa acta no la selló el dispositivo de este emparejamiento' })
380
+ return reply(from, { type: MSG_ERROR, error: 'that record was not sealed by the device of this pairing' })
381
381
  }
382
382
  if (pend.profileId && acta.profileId !== pend.profileId) {
383
383
  audit('rejected', { what: 'adopt', reason: 'other-profile' })
384
- return reply(from, { type: MSG_ERROR, error: 'esa acta es de otra cuenta distinta a la que anunció el dispositivo' })
384
+ return reply(from, { type: MSG_ERROR, error: 'that record belongs to an account other than the one the device announced' })
385
385
  }
386
386
 
387
387
  try {
388
388
  const r = await identity.joinProfile(acta)
389
- if (!r?.joined) throw new Error(r?.reason || 'no se pudo adoptar')
389
+ if (!r?.joined) throw new Error(r?.reason || 'could not adopt')
390
390
  audit('adopt', { device: pend.deviceId, profile: acta.profileId, seq: acta.seq })
391
391
  // El acta que vuelve es la que la bóveda tiene guardada: el aparato la adopta y los
392
392
  // dos quedan en la misma versión.
@@ -400,7 +400,7 @@ export function createEnrollDesk ({
400
400
  return { ok: true, adopted: true, profileId: acta.profileId, seq: mia.seq }
401
401
  } catch (e) {
402
402
  log('[vault] no se pudo adoptar la cuenta: %s', e.message)
403
- reply(pend.from, { type: MSG_ERROR, error: 'la bóveda no pudo adoptar la cuenta: ' + e.message })
403
+ reply(pend.from, { type: MSG_ERROR, error: 'the vault could not adopt the account: ' + e.message })
404
404
  return { ok: false, error: e.message }
405
405
  }
406
406
  }
@@ -411,7 +411,7 @@ export function createEnrollDesk ({
411
411
  ? findPending(deviceId)
412
412
  : [...pending.values()].find((p) => p.state === 'PENDING_CONFIRM')
413
413
  if (!pend) return { ok: false }
414
- reply(pend.from, { type: MSG_ERROR, error: 'emparejamiento rechazado' })
414
+ reply(pend.from, { type: MSG_ERROR, error: 'pairing rejected' })
415
415
  pending.delete(pend.token)
416
416
  audit('reject', { device: pend.deviceId })
417
417
  fire(onPendingChange)
@@ -428,7 +428,7 @@ export function createEnrollDesk ({
428
428
  const body = { op: 'revoke', sub: dpub, nonce, iat: Date.now(), exp: Date.now() + DEVICE_TTL_MS }
429
429
  const { signature } = await identity.signData(body)
430
430
  try { sendByPubkey(dpub, { type: MSG_REVOKED, body, signature }) }
431
- catch (e) { log('[vault] no se pudo emitir revoke:', e.message) }
431
+ catch (e) { log('[vault] could not emit revoke:', e.message) }
432
432
  }
433
433
 
434
434
  /** Revoca una delegación por `nonce` y avisa al dispositivo para que se autoborre. */
package/src/index.js CHANGED
@@ -27,16 +27,13 @@
27
27
  */
28
28
  import { verifyChain } from '@dotrino/identity/capabilities'
29
29
  import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS, FRESH_WINDOW_MS } from './enroll.js'
30
+ // Las constantes del protocolo salen del MISMO módulo que usa el daemon: si la lista
31
+ // local se queda corta, el dispositivo deja de atender mensajes sin que nadie lo note.
32
+ import { MSG, SCOPE } from './protocol.js'
30
33
 
31
- const SIGN_SCOPE = 'vault:sign'
34
+ const SIGN_SCOPE = SCOPE.SIGN
32
35
  const SELFCERT_TTL_MS = 24 * 60 * 60 * 1000 // el self-cert P←P se regenera cada 24 h
33
-
34
- const MSG = {
35
- ENROLL: 'vault.enroll',
36
- DEVICES: 'vault.devices',
37
- DEVICES_RESULT: 'vault.devices.result',
38
- ERROR: 'vault.error'
39
- }
36
+ const RENEW_TTL_MS = DEVICE_TTL_MS // la renovación extiende la misma ventana (30 días)
40
37
 
41
38
  /** deviceId legible (p. ej. `C440-AC0E`) desde una pubkey JWK. */
42
39
  export { deviceIdOf }
@@ -49,10 +46,11 @@ export { deviceIdOf }
49
46
  * `me.publickey`, `signData`, `signDelegation`, `listDelegations`, `revokeDelegation`.
50
47
  * @param {object} [opts]
51
48
  * @param {string} [opts.proxyUrl='wss://proxy.dotrino.com']
52
- * @returns {Promise<object>} handle: { iss, proxy, client, startPairing, approve, reject,
53
- * listPending, listMachines, revoke, getSelfCert, onPendingChange, close }
49
+ * @returns {Promise<object>} handle: { iss, proxy, client, startPairing, stopPairing,
50
+ * approve, reject, listPending, listMachines, revoke, getSelfCert, onPendingChange,
51
+ * onAdopted, close }
54
52
  */
55
- export async function startDeviceVault (identity, { proxyUrl } = {}) {
53
+ export async function startDeviceVault (identity, { proxyUrl, client: injectedClient } = {}) {
56
54
  const iss = identity.me?.publickey
57
55
  if (!iss) throw new Error('sin identidad: crea/desbloquea tu identidad antes de usar el dispositivo como bóveda')
58
56
  const proxy = proxyUrl || 'wss://proxy.dotrino.com'
@@ -67,12 +65,17 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
67
65
  return cert
68
66
  }
69
67
 
70
- const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
71
- const client = new WebSocketProxyClient({
72
- url: proxy, enableWebRTC: false, autoReconnect: true,
73
- maxReconnectAttempts: 100000, reconnectDelay: 4000
74
- })
75
- await client.connect()
68
+ // `client` inyectado: solo para las pruebas (transporte de mentira). En producción se
69
+ // levanta el del ecosistema — no hay otro transporte.
70
+ const client = injectedClient || await (async () => {
71
+ const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
72
+ const c = new WebSocketProxyClient({
73
+ url: proxy, enableWebRTC: false, autoReconnect: true,
74
+ maxReconnectAttempts: 100000, reconnectDelay: 4000
75
+ })
76
+ await c.connect()
77
+ return c
78
+ })()
76
79
 
77
80
  const selfCert = await getSelfCert()
78
81
  const identify = async () => {
@@ -87,6 +90,7 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
87
90
  const send = (to, obj) => { try { client.send(to, obj) } catch (_) {} }
88
91
 
89
92
  let _onPendingChange = () => {}
93
+ let _onAdopted = () => {}
90
94
 
91
95
  // ENROLL / aprobación / revocación: núcleo COMPARTIDO con el daemon del PC y con la
92
96
  // copia vendorizada del iframe (`lib/src/enroll.js`). Un solo sitio donde vive el
@@ -99,9 +103,49 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
99
103
  sendByPubkey: (pub, obj) => { try { client.sendByPubkey(pub, obj) } catch (_) {} },
100
104
  defaultScope: [SIGN_SCOPE],
101
105
  defaultTtlMs: DEVICE_TTL_MS,
106
+ // Camino A (la cuenta del aparato pasa a vivir aquí): sin la llave de cifrado, esta
107
+ // bóveda entraría mandando una cuenta cuyo contenido no puede abrir.
108
+ encPub: identity.me?.encryptionPubkey || null,
109
+ vaultLabel: 'bóveda',
110
+ // Cita del proxio para la invitación corta (QR). Si el proxio es viejo y no las
111
+ // conoce, el desk se cae solo a la invitación larga.
112
+ connToken: async () => {
113
+ try { return (await client.requestPairingCode())?.code || null }
114
+ catch (_) { return null }
115
+ },
116
+ onAdopted: (info) => { try { _onAdopted(info) } catch (_) {} },
102
117
  onPendingChange: () => _onPendingChange()
103
118
  })
104
119
 
120
+ /** Nonces revocados, para que un cert revocado no pase ningún `verifyChain`. */
121
+ async function revocationSet () {
122
+ const { revoked } = await identity.listDelegations()
123
+ return new Set((revoked || []).map((r) => r.nonce || r))
124
+ }
125
+
126
+ /**
127
+ * RENOVACIÓN automática (igual que `dotrino-vault#handleRenew`): un dispositivo con
128
+ * cert VIGENTE y no revocado pide uno fresco —misma sub-clave y scope— sin QR ni
129
+ * aprobación: sigue siendo el mismo dispositivo, solo extiende la ventana. Un cert
130
+ * vencido o revocado NO se renueva (ahí toca re-emparejar con aprobación).
131
+ *
132
+ * Sin esto, toda máquina enrolada contra un dispositivo-bóveda caduca a los 30 días.
133
+ */
134
+ async function handleRenew (from, p) {
135
+ const d = p?.data
136
+ if (!d || !p.signature || !p.cert) return send(from, { type: MSG.ERROR, error: 'petición inválida' })
137
+ if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) {
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
+ }
140
+ const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, trustedIssuer: iss, revoked: await revocationSet() })
141
+ if (!chk.ok) return send(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
142
+ // Reusar el label del cert original (si sigue registrado en delegations).
143
+ const { issued } = await identity.listDelegations()
144
+ const prev = (issued || []).find((x) => x.nonce === p.cert.nonce)
145
+ const { cert } = await identity.signDelegation(p.cert.sub, p.cert.scope, { ttlMs: RENEW_TTL_MS, label: prev?.label || '' })
146
+ send(from, { type: MSG.RENEWED, cert })
147
+ }
148
+
105
149
  // Consulta de revocaciones (igual que `vault.devices` del daemon): responde la lista
106
150
  // de dispositivos enrolados + revocados para que el dispositivo refresque su set. Y si
107
151
  // QUIEN consulta es una máquina ya revocada (reapareció), le re-emite el REVOKED firmado.
@@ -124,7 +168,12 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
124
168
 
125
169
  client.on('message', (_from, p) => {
126
170
  if (!p || typeof p !== 'object') return
127
- if (p.type === MSG.ENROLL) desk.handleEnroll(_from, p).catch(() => {})
171
+ // El QR corto no lleva la llave: el aparato la pide con un HELLO presentando el `sn`.
172
+ if (p.type === MSG.HELLO) Promise.resolve(desk.handleHello(_from, p)).catch(() => {})
173
+ else if (p.type === MSG.ENROLL) desk.handleEnroll(_from, p).catch(() => {})
174
+ // Camino A: el aparato devuelve su acta sellada admitiendo a esta bóveda.
175
+ else if (p.type === MSG.ACTA_SEALED) Promise.resolve(desk.handleActaSealed(_from, p)).catch(() => {})
176
+ else if (p.type === MSG.RENEW) handleRenew(_from, p).catch(() => {})
128
177
  else if (p.type === MSG.DEVICES) handleDevices(_from, p).catch(() => {})
129
178
  })
130
179
 
@@ -148,6 +197,7 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
148
197
  return {
149
198
  iss, proxy, client,
150
199
  startPairing: desk.startPairing,
200
+ stopPairing: desk.stopPairing,
151
201
  // Aprueba TIPEANDO el código que muestra la máquina: el núcleo compartido recompone
152
202
  // el compromiso `SHA-256(code‖dpub‖sn)` y solo firma el cert si coincide.
153
203
  approve: (deviceId, code) => desk.approve(code, { deviceId }),
@@ -159,6 +209,8 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
159
209
  revoke: (nonce) => desk.revoke(nonce),
160
210
  getSelfCert,
161
211
  onPendingChange (fn) { _onPendingChange = fn || (() => {}) },
212
+ /** Camino A: la cuenta del aparato quedó adoptada por esta bóveda. */
213
+ onAdopted (fn) { _onAdopted = fn || (() => {}) },
162
214
  close () { try { client.close() } catch (_) {} }
163
215
  }
164
216
  }
package/src/protocol.js CHANGED
@@ -59,6 +59,16 @@ export const MSG = Object.freeze({
59
59
  // Va FIRMADO por la maestra y el agente lo verifica contra su `iss` pineada: un
60
60
  // aviso de reinicio sin autenticar ES un ataque de denegación.
61
61
  SECRETS_CHANGED: 'vault.secrets.changed', // vault → servicio: { body:{op,ns,ts}, signature }
62
+ // --- CONSOLA REMOTA (docs/consola-remota.md) — requiere cert `vault:admin` ---
63
+ // Un solo mensaje con `data.op`: pending · pair · approve · reject · revoke · audit.
64
+ // Admitir y expulsar, nada más: cambiar permisos, traspasar el mando y los secretos
65
+ // NO se exponen aquí, y no es un olvido — es el límite (§2 del diseño).
66
+ ADMIN: 'vault.admin', // admin → vault: { data:{op,…,ts,nonce}, signature, cert }
67
+ ADMIN_RESULT: 'vault.admin.result', // vault → admin: { op, result }
68
+ // Aviso a TODOS los miembros de que el perfil cambió (alguien entró o salió). Es la
69
+ // contrapartida de administrar a distancia: sin esto, un enrolamiento remoto sería
70
+ // invisible para el resto de tus dispositivos.
71
+ ADMIN_EVENT: 'vault.admin.event', // vault → todos: { body:{ev,deviceId,by,ts}, signature }
62
72
  ERROR: 'vault.error' // vault → dispositivo: { error }
63
73
  })
64
74
 
@@ -66,7 +76,11 @@ export const MSG = Object.freeze({
66
76
  export const SCOPE = Object.freeze({
67
77
  SIGN: 'vault:sign', // pedir a la maestra que firme datos (identidad)
68
78
  READ: 'vault:read', // leer nodos del árbol de contenidos
69
- STORE: 'vault:store' // leer/escribir el store de hilos + aperturas del usuario
79
+ STORE: 'vault:store', // leer/escribir el store de hilos + aperturas del usuario
80
+ // Consola remota (docs/consola-remota.md): admitir y expulsar miembros a distancia.
81
+ // NO incluye cambiar permisos, traspasar el mando ni conceder `admin`: eso es el rol
82
+ // de master y sigue siendo local. No se empareja — se concede desde el PC.
83
+ ADMIN: 'vault:admin'
70
84
  })
71
85
 
72
86
  /**
package/src/sealed.js CHANGED
@@ -76,7 +76,7 @@ export async function seal ({ ek, payload }) {
76
76
  */
77
77
  export async function openSealed ({ privateKey, enc }) {
78
78
  if (!enc || typeof enc.epk !== 'string' || typeof enc.iv !== 'string' || typeof enc.ct !== 'string') {
79
- throw new Error('sobre sellado inválido')
79
+ throw new Error('invalid sealed envelope')
80
80
  }
81
81
  const key = await deriveAesKey(privateKey, enc.epk)
82
82
  const pt = await subtle.decrypt({ name: 'AES-GCM', iv: fromB64(enc.iv) }, key, fromB64(enc.ct))
package/src/service.js CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  import { MSG, secretsScope, isValidSecretsNs } from './protocol.js'
28
28
  import { makeEphemeralKey, openSealed } from './sealed.js'
29
29
  import { parseInvite } from './invite.js'
30
+ import { atRestFor } from './atrest.js'
30
31
 
31
32
  const IDENTITY_FILE = 'service-identity.json'
32
33
  const FRESH_WINDOW_MS = 5 * 60 * 1000
@@ -169,15 +170,26 @@ function waitForMsg (client, predicate, timeoutMs = 30000) {
169
170
 
170
171
  const identityFileOf = (dir) => path.join(dir, IDENTITY_FILE)
171
172
 
172
- /** Lee la identidad persistida del servicio ({device, cert, iss, proxy, ns}) o null. */
173
+ /**
174
+ * Lee la identidad persistida del servicio ({device, cert, iss, proxy, ns}) o null.
175
+ *
176
+ * CIFRADA EN REPOSO (`atrest.js`) con una clave ligada a ESTA máquina: el archivo
177
+ * lleva la llave privada del dispositivo, así que copiarlo a otro equipo no sirve.
178
+ * Un archivo de una versión anterior (en claro) se lee igual y queda cifrado en la
179
+ * primera escritura.
180
+ */
173
181
  export function readServiceIdentity (dir) {
174
- try { return JSON.parse(fs.readFileSync(identityFileOf(dir), 'utf8')) } catch (_) { return null }
182
+ try {
183
+ const text = fs.readFileSync(identityFileOf(dir), 'utf8')
184
+ return JSON.parse(atRestFor(dir).decrypt(text))
185
+ } catch (_) { return null }
175
186
  }
176
187
 
177
188
  function writeServiceIdentity (dir, obj) {
178
189
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
179
190
  const f = identityFileOf(dir)
180
- fs.writeFileSync(f, JSON.stringify(obj, null, 2), { mode: 0o600 })
191
+ const blob = atRestFor(dir).encrypt(JSON.stringify(obj, null, 2))
192
+ fs.writeFileSync(f, blob, { mode: 0o600 })
181
193
  }
182
194
 
183
195
  /**
@@ -433,9 +445,9 @@ export async function watchSecretsChanges ({
433
445
  if (body.sub !== saved.device.publickey) return
434
446
  if (saved.cert?.nonce && body.nonce !== saved.cert.nonce) return
435
447
  if (!(await verifyDeviceSig({ publickey: master, data: body, signature: payload.signature }))) {
436
- return log('[vault] aviso de revocación MAL FIRMADO: ignorado')
448
+ return log('[vault] revocation notice BADLY SIGNED: ignored')
437
449
  }
438
- log('[vault] ⚠ la bóveda REVOCÓ el cert de este agente: apagando')
450
+ log('[vault] ⚠ the vault REVOKED this agent cert: shutting down')
439
451
  try { onRevoked?.({ nonce: body.nonce }) } catch (e) { log('[vault] ' + e.message) }
440
452
  }
441
453
 
@@ -459,13 +471,13 @@ export async function watchSecretsChanges ({
459
471
  try {
460
472
  valida = await verifyDeviceSig({ publickey: master, data: body, signature: payload.signature })
461
473
  } finally { enVuelo.delete(body.ts) }
462
- if (!valida) return log('[vault] aviso de cambio MAL FIRMADO: ignorado (no viene de tu bóveda)')
474
+ if (!valida) return log('[vault] change notice BADLY SIGNED: ignored (not from your vault)')
463
475
  if (body.ts <= ultimoTs) return
464
476
  ultimoTs = body.ts
465
477
 
466
478
  const ahora = Date.now()
467
479
  if (ahora - nacido < graceMs) {
468
- return log('[vault] aviso de cambio recién arrancado: ignorado (evita el ciclo de reinicios)')
480
+ return log('[vault] change notice right after start: ignored (avoids the restart loop)')
469
481
  }
470
482
  if (ahora - ultimoObedecido < minIntervalMs) {
471
483
  return log('[vault] aviso de cambio demasiado seguido del anterior: ignorado')
@@ -473,7 +485,7 @@ export async function watchSecretsChanges ({
473
485
  ultimoObedecido = ahora
474
486
 
475
487
  const espera = Math.floor(Math.random() * jitterMs)
476
- log(`[vault] la bóveda avisa que la configuración de «${ns}» cambió (en ${espera} ms)`)
488
+ log(`[vault] the vault reports config for "${ns}" changed (in ${espera} ms)`)
477
489
  setTimeout(() => { if (!parado) { try { onChange?.({ ns, ts: body.ts }) } catch (e) { log('[vault] ' + e.message) } } }, espera)
478
490
  }
479
491
 
@@ -486,7 +498,7 @@ export async function watchSecretsChanges ({
486
498
  // Reconectar solo: si se cae el proxio, el agente deja de ser avisable, y
487
499
  // eso es exactamente el momento en que uno querría enterarse de una rotación.
488
500
  client.on('disconnected', () => { if (!parado) reintento = setTimeout(conectar, 5000) })
489
- log('[vault] a la escucha de cambios de configuración')
501
+ log('[vault] listening for config changes')
490
502
  } catch (e) {
491
503
  if (!parado) reintento = setTimeout(conectar, 5000)
492
504
  }