@dotrino/vaultd 0.64.0 → 0.73.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/admin.js CHANGED
@@ -7,12 +7,14 @@
7
7
  *
8
8
  * QUÉ SE DELEGA Y QUÉ NO — esto es el módulo entero, el resto es plomería:
9
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
10
+ * sí · ver el acta y la bitácora · REVOCAR a un miembro
12
11
  * · VARIABLES DE ENTORNO: crearlas y darles valor (de un scope o de un aparato),
13
12
  * y ver el valor de las marcadas PÚBLICAS
14
- * no · cambiar permisos · traspasar el mando · conceder `admin`
13
+ * no · AGREGAR UN APARATO (quitado 2026-08-31, ver abajo)
14
+ * · cambiar permisos · traspasar el mando · conceder `admin`
15
15
  * · ver el valor de una variable PRIVADA · borrar variables
16
+ * · NADA que cambie algo mientras el perfil está CERRADO (ver
17
+ * `ADMIN_OPS_WHILE_LOCKED`): el candado es de la consola, y esto es la consola.
16
18
  *
17
19
  * SOBRE LAS VARIABLES, que es la rendija más nueva: lo que cruza la frontera no es «los
18
20
  * secretos» sino los que su dueño MARCÓ como mostrables. Una privada se puede reescribir
@@ -26,16 +28,25 @@
26
28
  * fuera de su propia cuenta, que no tiene vuelta atrás. Las operaciones que no se
27
29
  * delegan **no existen como mensaje**: no hay nada que autorizar mal.
28
30
  *
29
- * POR QUÉ APROBAR A DISTANCIA NO DEBILITA EL EMPAREJAMIENTO: el código de 6 dígitos es
30
- * un COMPROMISO (`enroll.js`) — lo genera y lo MUESTRA el aparato que entra, y la bóveda
31
- * solo firma si el código tecleado lo recompone. Aprobar exige haber leído la pantalla
32
- * del aparato nuevo, se haga desde el PC o desde el teléfono. Lo que cambia es dónde
33
- * está el humano, no qué tiene que demostrar.
31
+ * POR QUÉ AGREGAR APARATOS YA NO SE HACE DESDE AQUÍ (dueño, 2026-08-31). Existía para
32
+ * ahorrarse ir hasta la máquina de la bóveda. El **multivault** quita esa fricción de raíz
33
+ * —cualquier otra selladora abierta admite el aparato— así que esto dejó de comprar nada y
34
+ * se quedaba pidiendo lo caro: que la maestra pudiera firmar a distancia.
35
+ *
36
+ * Y era incompatible con la regla dura del acta: la maestra solo sella el acta y reenvuelve
37
+ * sobres, y con la bóveda cerrada no firma nada. Admitir a alguien es emitir un certificado
38
+ * Y sellar el acta: las dos cosas son de una selladora abierta, y esta consola no lo es.
39
+ *
40
+ * **Quitado de raíz, no escondido**: no existe la operación, ni el mensaje, ni el botón.
41
+ * Una funcionalidad escondida sigue siendo superficie de ataque y vuelve sola en el
42
+ * siguiente refactor.
34
43
  */
35
44
 
36
45
  /** Las únicas operaciones que existen a distancia. Lista cerrada, como las capacidades. */
37
46
  export const ADMIN_OPS = Object.freeze([
38
- 'pending', 'pair', 'approve', 'reject', 'revoke', 'audit',
47
+ // `pending`, `pair`, `approve` y `reject` NO están, y no es un olvido: agregar aparatos
48
+ // a distancia se quitó (ver la cabecera). Lo que queda es mirar, quitar y configurar.
49
+ 'status', 'revoke', 'audit',
39
50
  // Variables de entorno: verlas (nombres siempre; valor solo de las públicas) y
40
51
  // ponerles valor (de las dos). Borrar NO está, y es a propósito: un aparato robado
41
52
  // no debe poder dejar sin configuración a los servicios.
@@ -48,6 +59,21 @@ export const ADMIN_OPS = Object.freeze([
48
59
  'vars', 'var.set', 'var.setMany'
49
60
  ])
50
61
 
62
+ /**
63
+ * LO ÚNICO QUE SE ATIENDE CON EL CANDADO ECHADO: mirar. Ni una operación que cambie algo.
64
+ *
65
+ * El candado ES de la consola —los aparatos ya emparejados siguen leyendo, guardando y
66
+ * firmando con SU llave— así que cerrar el perfil tiene que cerrar exactamente esto. No
67
+ * estaba: `admin.pair` y `admin.approve` pasaban con el perfil bloqueado, y `approve`
68
+ * firmaba un certificado de 30 días CON LA MAESTRA. Esas dos ya no existen —agregar
69
+ * aparatos se quitó de aquí—, pero la regla se queda para las que quedan: revocar reescribe
70
+ * el acta y configurar toca los secretos.
71
+ *
72
+ * Se dejan las de LEER a propósito: si se cortaran todas, la consola no podría ni
73
+ * enterarse de por qué no funciona y parecería rota. Así puede decir «está cerrada».
74
+ */
75
+ export const ADMIN_OPS_WHILE_LOCKED = Object.freeze(['status', 'audit', 'vars'])
76
+
51
77
  /** Cuánto se recuerda un nonce ya usado (el doble de la ventana de frescura). */
52
78
  export const ADMIN_NONCE_TTL_MS = 10 * 60 * 1000
53
79
 
@@ -56,7 +82,8 @@ export const AUDIT_MAX = 500
56
82
 
57
83
  /**
58
84
  * @param {Object} o
59
- * @param {Object} o.desk mostrador de emparejamiento (`createEnrollDesk`).
85
+ * @param {Object} o.desk `createEnrollDesk`, del que aquí solo se usa REVOCAR
86
+ * (`revoke`/`revokeDevice`). Emparejar y aprobar ya no se atienden a distancia.
60
87
  * @param {(scope:string[])=>Promise<any>} o.verify verifica cadena+cert; devuelve `{ok, device, reason}`.
61
88
  * @param {(limit:number)=>any[]} o.readActivity últimas entradas de la bitácora.
62
89
  * @param {(pub:string)=>Promise<string>} o.deviceIdOf
@@ -67,19 +94,19 @@ export const AUDIT_MAX = 500
67
94
  * bóveda no las atiende, en vez de fingir que se aplicaron.
68
95
  * @param {(ev:string, info?:object)=>Promise<void>} [o.notify] aviso a todos los miembros.
69
96
  * @param {(op:string, info?:object)=>void} [o.audit]
70
- * @param {string[]} [o.defaultScope] lo que recibe un dispositivo emparejado a distancia.
71
- * @param {number} [o.ttlMs] vida del cert que se emita.
97
+ * @param {()=>boolean} [o.isLocked] candado del perfil. Cerrado, solo se atiende
98
+ * `ADMIN_OPS_WHILE_LOCKED`; lo demás responde con el código `vault-locked`.
72
99
  */
73
100
  export function createAdminDesk ({
74
101
  desk, verify, readActivity = () => [], deviceIdOf, vars = null,
75
- notify = async () => {}, audit = () => {},
76
- defaultScope = ['vault:sign', 'vault:read', 'vault:store'],
77
- ttlMs, now = () => Date.now()
102
+ notify = async () => {}, audit = () => {}, isLocked = () => false,
103
+ now = () => Date.now()
78
104
  }) {
79
105
  const ops = new Set(ADMIN_OPS)
106
+ const mientrasCerrada = new Set(ADMIN_OPS_WHILE_LOCKED)
80
107
 
81
108
  // NONCE de un solo uso. `sign`/`get` son idempotentes y les basta la ventana de ±5
82
- // min; `approve` y `revoke` CAMBIAN ESTADO, así que reproducir uno dentro de esa
109
+ // min; `revoke` y `var.set` CAMBIAN ESTADO, así que reproducir uno dentro de esa
83
110
  // ventana sí importa (re-aprobar un enrolamiento que el dueño ya rechazó, por
84
111
  // ejemplo). Por eso el nonce, y por eso es obligatorio en todas las ops: una lista
85
112
  // de excepciones es una invitación a equivocarse.
@@ -109,6 +136,21 @@ export function createAdminDesk ({
109
136
  }
110
137
  const by = await deviceIdOf(chk.device).catch(() => null)
111
138
 
139
+ // CERRADA NO SE ADMINISTRA. Va después de autorizar (el candado es estado del perfil,
140
+ // y a un desconocido no se le cuenta) y ANTES de quemar el nonce: quien reintente
141
+ // tras abrir la bóveda no tiene por qué generar otro.
142
+ //
143
+ // El código va aparte del texto porque el texto se traduce y el código no: quien
144
+ // llama distingue «está cerrada, ábrela» de «no tienes permiso» sin leer la frase.
145
+ if (isLocked() && !mientrasCerrada.has(data.op)) {
146
+ audit('rejected', { what: 'admin', op: data.op, by, reason: 'locked' })
147
+ return {
148
+ ok: false,
149
+ code: 'vault-locked',
150
+ error: 'vault locked: unlock the profile on the vault machine (dotrino-vault unlock) to administer it'
151
+ }
152
+ }
153
+
112
154
  // El nonce se marca DESPUÉS de autorizar: si no, cualquiera podría quemarle los
113
155
  // nonces a un admin legítimo mandando basura firmada por nadie.
114
156
  if (nonceAlreadyUsed(data.nonce)) {
@@ -117,41 +159,16 @@ export function createAdminDesk ({
117
159
  }
118
160
 
119
161
  try {
120
- if (data.op === 'pending') return { ok: true, result: { pending: desk.listPending() } }
162
+ // Lo mínimo que la consola necesita saber de la bóveda para explicarse: si está
163
+ // cerrada, lo que queda (revocar, configurar) no se puede, y hay que decir por qué en
164
+ // vez de dejar pulsar un botón para recibir un error.
165
+ if (data.op === 'status') return { ok: true, result: { locked: !!isLocked() } }
121
166
 
122
167
  if (data.op === 'audit') {
123
168
  const limit = Math.min(Math.max(Number(data.limit) || 100, 1), AUDIT_MAX)
124
169
  return { ok: true, result: { entries: readActivity(limit) } }
125
170
  }
126
171
 
127
- if (data.op === 'pair') {
128
- // Un admin NO empareja servicios ni crea otros admins. Se corta aquí, en la
129
- // bóveda, no en la interfaz: una pantalla no es un control de seguridad.
130
- const scope = Array.isArray(data.scope) && data.scope.length ? data.scope : defaultScope
131
- const forbidden = scope.find((s) => s === 'vault:admin' || String(s).startsWith('vault:secrets:'))
132
- if (forbidden) {
133
- audit('rejected', { what: 'admin', op: 'pair', by, reason: 'forbidden-scope', scope: forbidden })
134
- return { ok: false, error: 'admin: cannot grant admin or service secrets from here; do that on the vault machine' }
135
- }
136
- const label = String(data.label || '').slice(0, 60) || 'remoto'
137
- const r = await desk.startPairing({ scope, label, ...(ttlMs ? { ttlMs } : {}) })
138
- audit('admin.pair', { by })
139
- return { ok: true, result: r }
140
- }
141
-
142
- if (data.op === 'approve') {
143
- const r = await desk.approve(String(data.code || ''), { deviceId: data.deviceId })
144
- audit('admin.approve', { by, device: data.deviceId || null })
145
- await notify('enrolled', { deviceId: r?.deviceId || data.deviceId || null, by })
146
- return { ok: true, result: r || { ok: true } }
147
- }
148
-
149
- if (data.op === 'reject') {
150
- desk.reject(data.deviceId)
151
- audit('admin.reject', { by, device: data.deviceId || null })
152
- return { ok: true, result: { ok: true } }
153
- }
154
-
155
172
  // VARIABLES DE ENTORNO. El módulo solo enruta y audita: qué valor puede salir y con
156
173
  // qué se cifra lo decide la bóveda (`vars`), que es la que tiene la clave y el disco.
157
174
  if (data.op === 'vars' || data.op === 'var.set' || data.op === 'var.setMany') {
@@ -249,4 +266,4 @@ export function createAdminDesk ({
249
266
  return { handle, get nonceCount () { return seen.size } }
250
267
  }
251
268
 
252
- export default { createAdminDesk, ADMIN_OPS, ADMIN_NONCE_TTL_MS }
269
+ export default { createAdminDesk, ADMIN_OPS, ADMIN_OPS_WHILE_LOCKED, ADMIN_NONCE_TTL_MS }
package/lib/src/index.js CHANGED
@@ -26,14 +26,13 @@
26
26
  * No reimplementa nada del ecosistema.
27
27
  */
28
28
  import { verifyChain, verifyDeviceSig } from '@dotrino/identity/capabilities'
29
- import { memberCanScope } from '@dotrino/identity/acta'
29
+ import { memberCanScope, sealersOf, memberScopes } from '@dotrino/identity/acta'
30
30
  import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS, FRESH_WINDOW_MS } from './enroll.js'
31
31
  // Las constantes del protocolo salen del MISMO módulo que usa el daemon: si la lista
32
32
  // local se queda corta, el dispositivo deja de handle mensajes sin que nadie lo note.
33
33
  import { MSG, SCOPE } from './protocol.js'
34
34
 
35
35
  const SIGN_SCOPE = SCOPE.SIGN
36
- const SELFCERT_TTL_MS = 24 * 60 * 60 * 1000 // el self-cert P←P se regenera cada 24 h
37
36
  const RENEW_TTL_MS = DEVICE_TTL_MS // la renovación extiende la misma ventana (30 días)
38
37
 
39
38
  /** deviceId legible (p. ej. `C440-AC0E`) desde una pubkey JWK. */
@@ -58,10 +57,24 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
58
57
 
59
58
  // ----- self-cert P ← P (para que este dispositivo pueda además actuar de cliente
60
59
  // de sus propias máquinas: lo firma la propia P y verifyChain lo acepta) -----
60
+ /**
61
+ * Con qué se juzga un papel: el acta que tiene esta bóveda. Sustituye a `trustedIssuer`,
62
+ * que fijaba UNA llave y por eso los papeles de una segunda selladora no valían.
63
+ * Sin acta van nulos y `verifyDelegation` contesta `no-acta`: no hay con qué decidir, así
64
+ * que no se decide que sí.
65
+ */
66
+ async function contextoActa () {
67
+ const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
68
+ if (!acta) return { actaSeq: null, sealers: null }
69
+ return { actaSeq: acta.seq, sealers: sealersOf(acta) }
70
+ }
71
+
61
72
  let _selfCert = null
62
73
  const getSelfCert = async () => {
63
- if (_selfCert && _selfCert.exp > Date.now() + 60_000) return _selfCert
64
- const { cert } = await identity.signDelegation(iss, SIGN_SCOPE, { ttlMs: SELFCERT_TTL_MS })
74
+ // Se rehace cuando el acta cambia, no cuando pasa el tiempo: el papel ya no caduca.
75
+ const { actaSeq } = await contextoActa()
76
+ if (_selfCert && _selfCert.seq === actaSeq) return _selfCert
77
+ const { cert } = await identity.signDelegation(iss, SIGN_SCOPE)
65
78
  _selfCert = cert
66
79
  return cert
67
80
  }
@@ -138,13 +151,20 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
138
151
  if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) {
139
152
  return send(from, { type: MSG.ERROR, error: 'stale request: ts outside the ±5 min window (possible replay, or the device clock is off)' })
140
153
  }
141
- const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, trustedIssuer: iss, revoked: await revocationSet() })
154
+ const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, ...(await contextoActa()), revoked: await revocationSet() })
142
155
  if (!chk.ok) return send(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
143
156
  // Reusar el label del cert original (si sigue registrado en delegations).
144
157
  const { issued } = await identity.listDelegations()
145
158
  const prev = (issued || []).find((x) => x.nonce === p.cert.nonce)
146
- const { cert } = await identity.signDelegation(p.cert.sub, p.cert.scope, { ttlMs: RENEW_TTL_MS, label: prev?.label || '' })
147
- send(from, { type: MSG.RENEWED, cert })
159
+ // EL SCOPE SALE DEL ACTA, no del papel viejo: el papel dice a qué se comprometió esta
160
+ // bóveda al conectar el aparato; el acta, lo que puede HOY.
161
+ const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
162
+ if (!acta) return send(from, { type: MSG.ERROR, error: 'unauthorized: this vault has no record to decide with' })
163
+ const scope = memberScopes(acta, p.cert.sub)
164
+ if (!scope.length) return send(from, { type: MSG.ERROR, error: 'unauthorized: the record no longer lists this device' })
165
+ const { cert } = await identity.signDelegation(p.cert.sub, scope, { label: prev?.label || '' })
166
+ // El acta viaja con el papel: sin ella quien lo recibe no puede comprobar quién lo firmó.
167
+ send(from, { type: MSG.RENEWED, cert, acta })
148
168
  }
149
169
 
150
170
  // Consulta de revocaciones (igual que `vault.devices` del daemon): responde la lista
@@ -154,12 +174,12 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
154
174
  const d = p?.data
155
175
  if (!d || !p.signature || !p.cert) return send(from, { type: MSG.ERROR, error: 'invalid request' })
156
176
  if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) return
157
- const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, trustedIssuer: iss })
177
+ const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, ...(await contextoActa()) })
158
178
  if (!chk.ok) return send(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
159
179
  const { issued, revoked, revokedCerts } = await identity.listDelegations()
160
180
  const devices = await Promise.all((issued || []).map(async (x) => ({
161
181
  deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null,
162
- label: x.label || '', scope: x.scope, exp: x.exp, nonce: x.nonce
182
+ label: x.label || '', scope: x.scope, seq: x.seq, nonce: x.nonce
163
183
  })))
164
184
  send(from, { type: MSG.DEVICES_RESULT, devices, revoked: (revoked || []).map((r) => r.nonce || r) })
165
185
  // ¿el que consulta es una máquina revocada que reapareció? → re-emite el REVOKED firmado.
@@ -189,7 +209,7 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
189
209
  const chk = await verifyChain({
190
210
  data: d, signature: p.signature, cert: p.cert,
191
211
  ...(expectedScope ? { expectedScope } : {}),
192
- trustedIssuer: iss, revoked: await revocationSet(),
212
+ ...(await contextoActa()), revoked: await revocationSet(),
193
213
  })
194
214
  if (!chk.ok) {
195
215
  send(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
@@ -314,13 +334,14 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
314
334
  */
315
335
  async function listMachines () {
316
336
  const { issued } = await identity.listDelegations()
317
- const now = Date.now()
318
337
  const bySub = new Map()
319
338
  for (const x of (issued || [])) {
320
- if (!x.sub || x.revokedAt || (x.exp && x.exp <= now)) continue // revocada = fuera de la lista
339
+ if (!x.sub || x.revokedAt) continue // revocada = fuera de la lista
321
340
  if (!Array.isArray(x.scope) || !x.scope.includes(SIGN_SCOPE)) continue
322
341
  if (!x.label || x.label === 'cli') continue
323
- if (!bySub.has(x.sub) || (x.exp || 0) > (bySub.get(x.sub).exp || 0)) bySub.set(x.sub, x)
342
+ // Se queda el papel del acta MÁS NUEVA de esa llave: ya no hay «el que vence más
343
+ // tarde», porque ninguno vence.
344
+ if (!bySub.has(x.sub) || (x.seq || 0) > (bySub.get(x.sub).seq || 0)) bySub.set(x.sub, x)
324
345
  }
325
346
  return Promise.all([...bySub.values()].map(async (x) => ({ ...x, deviceId: await deviceIdOf(x.sub) })))
326
347
  }
@@ -23,9 +23,9 @@ import path from 'node:path'
23
23
  import { createHash } from 'node:crypto'
24
24
  import {
25
25
  makeDeviceKey, makeDeviceEncKey, importDeviceEncKey, signWithDevice, verifyDelegation,
26
- verifyDeviceSig, makePairingCode, commitCode, pubkeyId, PEER_SKEW_MS } from '@dotrino/identity/capabilities'
26
+ verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from '@dotrino/identity/capabilities'
27
27
  import { openWrap, wrapForMember, decryptWithCek } from '@dotrino/identity/content'
28
- import { verifyActa, sealKeyAt } from '@dotrino/identity/acta'
28
+ import { verifyActa, sealKeyAt, sealersOf } from '@dotrino/identity/acta'
29
29
  import { MSG, secretsScope, isValidSecretsNs } from './protocol.js'
30
30
  import { makeEphemeralKey, openSealed } from './sealed.js'
31
31
  import { parseInvite } from './invite.js'
@@ -33,7 +33,6 @@ import { atRestFor } from './atrest.js'
33
33
 
34
34
  const IDENTITY_FILE = 'service-identity.json'
35
35
  const FRESH_WINDOW_MS = 5 * 60 * 1000
36
- const RENEW_BEFORE_MS = 7 * 24 * 60 * 60 * 1000 // renovar el cert si vence en <7 días
37
36
 
38
37
  let _globalsInstalled = false
39
38
  function installNodeGlobals () {
@@ -297,9 +296,22 @@ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = nu
297
296
  const res = await enrolled
298
297
 
299
298
  if (res.code !== code) throw new Error('the vault echoed a code other than the one shown (possible malicious relay)')
300
- const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, skewMs: PEER_SKEW_MS, ...(expectedScope ? { expectedScope } : {}) })
299
+ // EL ACTA VIAJA CON EL PAPEL, y hace falta para juzgarlo: el certificado ya no vence
300
+ // por reloj, dice el `seq` del acta con el que se emitió, y quien lo emitió tiene que
301
+ // ser SELLADORA de ese acta. Sin acta no se puede comprobar nada de eso, y entrar a
302
+ // ciegas en un perfil es justo lo que no se hace.
303
+ const acta = res.acta
304
+ if (!acta) throw new Error('the vault did not send its record: cannot check who signed this cert')
305
+ // Y el acta tiene que ser la DEL PERFIL DEL QR. Antes esto se comprobaba mirando que el
306
+ // cert lo firmara `qr.iss`; con varias selladoras el emisor puede ser otra llave del
307
+ // mismo perfil, así que lo que se fija es el perfil, no la llave.
308
+ if (acta.profileId !== qr.iss) throw new Error('the record is from a profile other than the one in the QR')
309
+ const v = await verifyDelegation({
310
+ cert: res.cert, expectedSub: device.publickey,
311
+ actaSeq: acta.seq, sealers: sealersOf(acta),
312
+ ...(expectedScope ? { expectedScope } : {})
313
+ })
301
314
  if (!v.ok) throw new Error('invalid cert: ' + v.reason)
302
- if (res.cert.iss !== qr.iss) throw new Error('cert signed by a master other than the one in the QR')
303
315
 
304
316
  return { device, enc: { publickey: enc.encPublickey, privateJwk: enc.encPrivateJwk }, cert: res.cert, iss: qr.iss, proxy: qr.proxy || 'wss://proxy.dotrino.com' }
305
317
  } finally { client.close() }
@@ -430,8 +442,36 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
430
442
  try {
431
443
  await identifyAsService(client, device)
432
444
 
433
- // Renovación de cert best-effort si vence pronto (mientras siga vigente).
434
- if (typeof cert.exp === 'number' && cert.exp - Date.now() < RENEW_BEFORE_MS && cert.exp > Date.now()) {
445
+ // ¿Nos quedamos atrás? Lo sabemos porque la bóveda manda su acta con cada respuesta y
446
+ // apuntamos su `seq` al guardarla. Si esa acta es más nueva que el papel, puede que el
447
+ // dueño nos haya cambiado los permisos: se pide uno al día siguiente de enterarnos, no
448
+ // por calendario.
449
+ /**
450
+ * Guarda en disco lo que hemos aprendido, SIN pisar lo demás.
451
+ *
452
+ * `saved` se lee una vez al entrar, así que hay que actualizarlo en memoria además de
453
+ * escribirlo: si no, la segunda escritura parte de la foto vieja y deshace la primera.
454
+ * Eso pasó — renovar guardaba el papel nuevo y acto seguido apuntar el acta lo pisaba con
455
+ * el viejo, que ya estaba revocado. Al siguiente arranque: «unauthorized: revoked».
456
+ */
457
+ const recordar = (patch) => {
458
+ if (!saved) return
459
+ saved = { ...saved, ...patch }
460
+ if (dir) { try { writeServiceIdentity(dir, saved) } catch (_) {} }
461
+ }
462
+ /** Hasta qué acta hemos visto: es lo que dispara la renovación la próxima vez. */
463
+ const anotarActa = (acta) => {
464
+ if (typeof acta?.seq !== 'number' || saved?.actaSeq === acta.seq) return
465
+ recordar({ actaSeq: acta.seq })
466
+ }
467
+
468
+ const renovar = typeof saved?.actaSeq === 'number' && typeof cert?.seq === 'number' && saved.actaSeq > cert.seq
469
+
470
+ // RENOVAR YA NO ES UNA TAREA DE CALENDARIO. El papel no vence: lo único que obliga a
471
+ // pedir otro es que la bóveda diga que el nuestro se quedó atrás respecto del acta, y
472
+ // eso lo contesta ella cuando lo pide (`acta-vieja`). Aquí se intenta una vez y no se
473
+ // insiste: si falla, se sigue con el papel que hay — que probablemente valga.
474
+ if (renovar) {
435
475
  try {
436
476
  const data = { op: 'renew', publickey: device.publickey, ts: Date.now() }
437
477
  const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
@@ -439,8 +479,8 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
439
479
  client.sendByPubkey(masterPubkey, { type: MSG.RENEW, data, signature, cert })
440
480
  const res = await pending
441
481
  if (res.type === MSG.RENEWED && res.cert?.sub === device.publickey) {
442
- const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, skewMs: PEER_SKEW_MS, expectedScope: secretsScope(ns) })
443
- if (v.ok) { cert = res.cert; if (dir && saved) writeServiceIdentity(dir, { ...saved, cert }) }
482
+ cert = res.cert
483
+ recordar({ cert })
444
484
  }
445
485
  } catch (_) { /* la renovación no bloquea el fetch */ }
446
486
  }
@@ -458,6 +498,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
458
498
  // sigue identificada. Se espera lo que dura el pedido; denegado o vencido es error.
459
499
  if (res.body?.op === 'secrets.pending' && res.body.ns === ns) {
460
500
  await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey })
501
+ anotarActa(res.acta)
461
502
  try { onPending?.({ id: res.body.id, ns, exp: res.body.exp }) } catch (_) {}
462
503
  const until = typeof res.body.exp === 'number' ? Math.max(5000, res.body.exp - Date.now() + 5000) : approvalTimeoutMs
463
504
  res = await waitForMsg(client, (p) => (p.type === MSG.SECRETS_RESULT && p.body?.op === 'secrets.result') || p.type === MSG.ERROR, Math.min(until, approvalTimeoutMs))
@@ -479,6 +520,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
479
520
  // dentro se usa hasta después de verificar.
480
521
  const payload = await openSealed({ privateKey: eph.privateKey, enc: body.enc })
481
522
  await verifyResponder({ body, seal: res.seal, acta: payload?.acta, masterPubkey })
523
+ anotarActa(payload?.acta)
482
524
 
483
525
  // DOS CAPAS DE SOBRE, y hacen cosas distintas:
484
526
  // · la de fuera (`ek` efímera, recién abierta) tapa el TRAMO — el proxio no ve
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.64.0",
3
+ "version": "0.73.0",
4
4
  "type": "module",
5
5
  "description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
6
6
  "bin": {
@@ -20,7 +20,7 @@
20
20
  "node": ">=20"
21
21
  },
22
22
  "dependencies": {
23
- "@dotrino/identity": "^0.67.0",
23
+ "@dotrino/identity": "0.73.0",
24
24
  "@dotrino/passmanager": "^0.9.0",
25
25
  "@dotrino/proxy-client": "^0.13.1",
26
26
  "ws": "^8.18.0"
@@ -44,7 +44,7 @@
44
44
  "!lib/src/types.d.ts"
45
45
  ],
46
46
  "devDependencies": {
47
- "@dotrino/remote-agent": "^0.5.3",
47
+ "@dotrino/remote-agent": "0.6.0",
48
48
  "@types/node": "^22.0.0",
49
49
  "typescript": "5.9.3"
50
50
  }
package/src/client.js CHANGED
@@ -11,7 +11,18 @@
11
11
  * estrictamente (firmado por la maestra que vio en el QR, y para SU clave) antes de
12
12
  * guardarlo. La maestra nunca sale del vault.
13
13
  */
14
- import { makeDeviceKey, signWithDevice, verifyDelegation, verifyDeviceSig, makePairingCode, commitCode, pubkeyId, PEER_SKEW_MS } from '@dotrino/identity/capabilities'
14
+ import { makeDeviceKey, signWithDevice, verifyDelegation, verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from '@dotrino/identity/capabilities'
15
+ import { sealersOf } from '@dotrino/identity/acta'
16
+
17
+ /**
18
+ * Con qué se juzga un papel: el ACTA que la bóveda manda junto a él. Sustituye a comparar
19
+ * `cert.iss` con la maestra — con varias selladoras el emisor puede ser otra llave del mismo
20
+ * perfil, así que lo que se fija es el PERFIL, no la llave.
21
+ */
22
+ function contexto (acta) {
23
+ if (!acta) throw new Error('the vault did not send its record: cannot check who signed this cert')
24
+ return { actaSeq: acta.seq, sealers: sealersOf(acta) }
25
+ }
15
26
  import { installNodeGlobals } from './node-globals.js'
16
27
  import { MSG } from './protocol.js'
17
28
 
@@ -84,9 +95,9 @@ export async function enroll ({ qr, label = '', dir, onChallenge, approveTimeout
84
95
  const res = await enrolled
85
96
 
86
97
  // VALIDACIÓN ESTRICTA antes de persistir (cierra inyección de cert / sustitución de maestra).
87
- const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, skewMs: PEER_SKEW_MS })
98
+ if (res.acta?.profileId !== qr.iss) throw new Error('the record is from a profile other than the one you saw (possible malicious proxy)')
99
+ const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, ...contexto(res.acta) })
88
100
  if (!v.ok) throw new Error('invalid cert: ' + v.reason)
89
- if (res.cert.iss !== qr.iss) throw new Error('cert signed by a master other than the one you saw (possible malicious proxy)')
90
101
  if (res.cert.sub !== device.publickey) throw new Error('cert issued for a different device')
91
102
  // OJO: devolvemos qr.iss (la maestra que el usuario VIO), NO res.iss.
92
103
  return { device, cert: res.cert, iss: qr.iss }
@@ -151,9 +162,9 @@ export async function requestRenew ({ masterPubkey, proxyUrl, device, cert, dir
151
162
  if (res.type === MSG.ERROR) throw new Error(res.error)
152
163
  // Se valida antes de devolverlo, igual que en el enrolamiento: un cert que no está
153
164
  // firmado por la maestra que ya conocemos, o que no es para esta llave, no se guarda.
154
- const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, skewMs: PEER_SKEW_MS })
165
+ if (res.acta?.profileId !== masterPubkey) throw new Error('the record is from a profile other than the pinned one')
166
+ const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, ...contexto(res.acta) })
155
167
  if (!v.ok) throw new Error('invalid cert: ' + v.reason)
156
- if (res.cert.iss !== masterPubkey) throw new Error('cert signed by a master other than the pinned one')
157
168
  return { cert: res.cert }
158
169
  } finally { client.close() }
159
170
  }
package/src/commKey.js ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * commKey.js — LA LLAVE DE COMUNICACIÓN de esta bóveda.
3
+ *
4
+ * La maestra tiene dos trabajos y ninguno más: **sellar el acta y reenvolver los sobres**
5
+ * (dueño, 2026-08-31). Hablar por la red no es suyo. Todo lo demás —identificarse ante el
6
+ * proxio, firmar lo que se sirve— lo hace esta llave, y **su autoridad la dice el acta**,
7
+ * donde entra como un miembro más.
8
+ *
9
+ * Por qué eso importa y no es un rodeo: mientras la maestra fuera la que se identificaba,
10
+ * no podía vivir bajo llave — `identify` firma en cada conexión y en cada reconexión, así
11
+ * que una bóveda cerrada no habría existido en la red. Sacándola de ahí, la maestra solo
12
+ * sale con el perfil abierto y el candado deja de ser una bandera.
13
+ *
14
+ * Tres propiedades, y las tres son a propósito:
15
+ *
16
+ * · **Firmar no es leer.** No abre ningún valor. Por eso puede vivir cifrada en reposo
17
+ * con la llave de la máquina y usarse SIN la frase — que es lo que hace que la bóveda
18
+ * siga sirviendo con el candado echado.
19
+ * · **ESTABLE.** No rota con cada acta, al revés que la vieja `sealPub`: es un miembro,
20
+ * y un miembro no cambia de llave cada vez que se toca el acta. Rota cuando se decide,
21
+ * como cualquier aparato.
22
+ * · **Acotada por `cn`.** Entra con `cn: 'vault'`, así que el acta la trata como un
23
+ * SERVICIO: `memberCanSign` sin cajón le dice que no. Puede hablar por la bóveda, no
24
+ * firmar por la persona. Si alguien se lleva el disco, se lleva eso y nada más.
25
+ */
26
+ import path from 'node:path'
27
+ import { readJson, writeJson } from './paths.js'
28
+ import { atRestFor } from './atrest.js'
29
+ import { signWithDevice } from '@dotrino/identity/capabilities'
30
+
31
+ /** El `cn` con el que entra en el acta. Es un servicio del perfil, no un aparato tuyo. */
32
+ export const COMM_CN = 'vault'
33
+ /** Lo que puede: firmar (acotado por su `cn`). Nada de leer, guardar ni sellar. */
34
+ export const COMM_CAPS = Object.freeze(['sign'])
35
+
36
+ /** Abre (o estrena) la llave de comunicación del perfil. */
37
+ export function openCommKey (dir) {
38
+ const file = path.join(dir, 'commkey.json')
39
+ const atRest = atRestFor(dir)
40
+ let data = readJson(file, null, atRest)
41
+ if (!data || typeof data !== 'object' || !data.pub) data = null
42
+
43
+ return {
44
+ /** La pública, o `null` si todavía no se ha estrenado. */
45
+ pub: () => data?.pub || null,
46
+
47
+ /**
48
+ * Estrena la llave si no existe y devuelve su pública. Idempotente: llamarla dos veces
49
+ * NO cambia de llave — cambiarla dejaría al acta nombrando una que ya no tenemos y a la
50
+ * bóveda sin poder identificarse hasta el siguiente sellado.
51
+ */
52
+ async ensure () {
53
+ if (data?.pub) return data.pub
54
+ const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'])
55
+ const pub = JSON.stringify(await crypto.subtle.exportKey('jwk', pair.publicKey))
56
+ const priv = await crypto.subtle.exportKey('jwk', pair.privateKey)
57
+ data = { v: 1, pub, priv, createdAt: Date.now() }
58
+ writeJson(file, data, atRest)
59
+ return pub
60
+ },
61
+
62
+ /** Firma un cuerpo. `null` si aún no hay llave (no se estrena una al vuelo: ver `ensure`). */
63
+ async sign (body) {
64
+ if (!data?.priv) return null
65
+ const { signature } = await signWithDevice({ privateJwk: data.priv, publickey: data.pub, data: body })
66
+ return signature
67
+ }
68
+ }
69
+ }
70
+
71
+ export default { openCommKey, COMM_CN, COMM_CAPS }
package/src/daemon.js CHANGED
@@ -323,7 +323,7 @@ export async function runDaemon () {
323
323
  // paga; el dueño no tiene que acordarse de un comando aparte.
324
324
  case 'unlock': {
325
325
  const id = ref()
326
- await mgr.profiles.unlock(id, req.password)
326
+ await mgr.unlock(id, req.password) // por el manager: mueve la maestra a memoria y la sella
327
327
  let note = ''
328
328
  let ak = null
329
329
  try {
@@ -345,7 +345,9 @@ export async function runDaemon () {
345
345
  // los 5 minutos de ABRIRLO aunque estuvieras delante trabajando. `ref()` ya estira
346
346
  // el plazo; esta op no hace nada más, y por eso no devuelve ningún volcado.
347
347
  case 'touch': { ref(); return { done: 'ok' } }
348
- case 'lock': { mgr.profiles.lock(ref()); return { done: 'perfil bloqueado' } }
348
+ // Por el manager, no por `profiles`: cerrar tiene que SOLTAR la maestra, no solo
349
+ // apuntar que está cerrada.
350
+ case 'lock': { await mgr.lock(ref()); return { done: 'perfil bloqueado' } }
349
351
  // PONER contraseña: los secretos pasan de abrirse con la llave de la máquina a
350
352
  // abrirse con la frase. Hay que volver a cerrar la copia maestra con la nueva, o
351
353
  // quedarían ilegibles. Si el perfil YA tenía contraseña, hace falta la vieja para
package/src/manager.js CHANGED
@@ -51,8 +51,14 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
51
51
  ...(autoLockMs === undefined ? {} : { autoLockMs }),
52
52
  // Que se cierre solo tiene que VERSE: si no, quien vuelve y encuentra la consola
53
53
  // pidiendo la contraseña otra vez cree que algo se rompió.
54
- onAutoLock: (id) => log('[vault] profile %s locked itself after %d min idle (the console; devices keep working)',
55
- id, Math.round(profiles.autoLockMs / 60000))
54
+ onAutoLock: (id) => {
55
+ log('[vault] profile %s locked itself after %d min idle (the console; devices keep working)',
56
+ id, Math.round(profiles.autoLockMs / 60000))
57
+ // Y la maestra se va con el candado. Cerrarse solo tiene que soltarla igual que
58
+ // cerrarla a mano: si no, bastaba con esperar a que se cerrara sola para tenerla
59
+ // descifrada en memoria sin candado que valga.
60
+ running.get(id)?.dropMasterKey?.().catch(() => {})
61
+ }
56
62
  })
57
63
  /**
58
64
  * ACUÑAR (o simplemente LEER) la llave de una carpeta, y cerrarla enseguida.
@@ -92,6 +98,10 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
92
98
  // Para la consola remota: la contraseña llega dentro del sobre firmado y hay que
93
99
  // convertirla en la llave que abre la copia maestra de los secretos.
94
100
  deriveAdminKey: (password) => profiles.adminKey(id, password),
101
+ // La llave del perfil MIENTRAS esté abierto, o null. Con ella, enrolar un servicio
102
+ // con la bóveda abierta le envuelve su cajón en el acto en vez de dejarlo entrar
103
+ // sin poder leer nada (ver `profiles.js`, `llaves`).
104
+ openKey: () => profiles.openKey(id),
95
105
  // Camino A: nació para adoptar la cuenta de un aparato (ver profiles.add).
96
106
  forAdoption: !!p?.adopt,
97
107
  // Ya adoptó: la marca se consume (no vuelve a estar «a la espera»).
@@ -120,6 +130,27 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
120
130
  return v
121
131
  }
122
132
 
133
+ /**
134
+ * ABRIR Y CERRAR EL PERFIL, con la maestra siguiendo al candado.
135
+ *
136
+ * Va aquí y no en `profiles.js` porque el registro de perfiles no sabe de bóvedas
137
+ * corriendo: sabe de contraseñas. Quien tiene las dos cosas delante es esto.
138
+ */
139
+ async function unlock (id, password) {
140
+ const r = await profiles.unlock(id, password)
141
+ // `takeMasterKey` además la deja SELLADA la primera vez: un perfil que ya tenía
142
+ // contraseña guardaba la maestra bajo la llave de máquina, y se migra aquí, que es el
143
+ // único momento en que la llave del perfil está en la mano.
144
+ try { await running.get(id)?.takeMasterKey?.() } catch (e) { log('[vault] could not take the master key: %s', e.message) }
145
+ return r
146
+ }
147
+
148
+ async function lock (id) {
149
+ const r = profiles.lock(id)
150
+ try { await running.get(id)?.dropMasterKey?.() } catch (_) {}
151
+ return r
152
+ }
153
+
123
154
  /** Resumen para state.json / `profile ls`: identidad + candado de cada perfil. */
124
155
  const summary = () => profiles.list().map((p) => {
125
156
  const v = running.get(p.id)
@@ -128,6 +159,10 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
128
159
 
129
160
  return {
130
161
  profiles,
162
+ // Abrir y cerrar pasan por AQUÍ, no por `profiles`: son las que mueven la maestra
163
+ // dentro y fuera de la memoria. Llamar a `profiles.lock` a secas deja la llave puesta.
164
+ unlock,
165
+ lock,
131
166
  running,
132
167
  get,
133
168
  list: () => profiles.list(),