@dotrino/vaultd 0.107.2 → 0.109.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/vaultd",
3
- "version": "0.107.2",
3
+ "version": "0.109.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": {
@@ -22,7 +22,7 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@dotrino/compat": "0.4.0",
25
- "@dotrino/identity": "0.79.0",
25
+ "@dotrino/identity": "0.81.0",
26
26
  "@dotrino/passmanager": "^0.10.1",
27
27
  "@dotrino/proxy-client": "0.15.0",
28
28
  "@dotrino/roadmap": "0.2.0",
package/src/ctl.js CHANGED
@@ -598,6 +598,11 @@ async function cmdMembers () {
598
598
  // QUÉ CORRE, y si cuadra con esta bóveda (CONVENCIONES §14). Sin esto una
599
599
  // incompatibilidad de versiones se ve como que ese aparato «no responde», que es el
600
600
  // fallo más caro que ha tenido el ecosistema. No bloquea nada: se enseña.
601
+ // RENUNCIAS SIN SELLAR. Ya no los tiene —la bóveda no se los reconoce— pero el acta
602
+ // que se lee arriba todavía los nombra, así que hay que decirlo aquí o la lista miente.
603
+ if (m.renounced?.length) {
604
+ console.log(' %s↓ renunció a %s (pendiente de sellar: abre la bóveda)%s', R, m.renounced.join(', '), Z)
605
+ }
601
606
  if (m.running) {
602
607
  const v = `${m.running.product} ${m.running.version}`
603
608
  if (m.running.compatible) console.log(' %scorre %s%s', D, v, Z)
package/src/subacta.js ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * subacta.js — LO DECIDIDO QUE TODAVÍA NO SELLÓ EL ACTA.
3
+ *
4
+ * Idea del dueño (2026-09-04): *«una subacta efímera que se comparte entre todos los
5
+ * dispositivos y puede tener decisiones a futuro que se aceptan cuando el acta se abre»*.
6
+ *
7
+ * EL AGUJERO QUE TAPA. Sellar el acta es de la maestra, y con el perfil cerrado la maestra
8
+ * no está. Así que una renuncia —«me quito estos permisos»— no surtía NINGÚN efecto hasta
9
+ * que alguien iba a la máquina y tecleaba la contraseña. Justo el caso que la renuncia
10
+ * existe para cubrir: te roban el teléfono, renuncias desde él, y el ladrón sigue siendo
11
+ * admin hasta que llegues. El pilar ya tenía la pieza (`extraRenounces`) y no la pasaba
12
+ * nadie.
13
+ *
14
+ * LA REGLA DURA, y es la que sostiene todo lo demás:
15
+ *
16
+ * LA SUBACTA SOLO PUEDE QUITAR, NUNCA DAR.
17
+ *
18
+ * De ahí salen las tres propiedades que la hacen segura sin maestra:
19
+ *
20
+ * · **Se autentica sola.** Cada entrada la firma EL MIEMBRO AL QUE AFECTA. No hace falta
21
+ * sellador, así que no hay una segunda autoridad al lado del acta.
22
+ * · **Una entrada falsa o repetida cuesta disponibilidad, no autoridad.** Lo peor que
23
+ * consigue quien cuele una es quitarle permisos a alguien: molesto y reversible.
24
+ * · **Nadie puede quitarle nada a otro.** Si valiera la firma de cualquiera, un servicio
25
+ * con `secrets` en un VPS alquilado podría dejar sin `admin` a tu teléfono.
26
+ *
27
+ * Y por eso el catálogo de operaciones es una LISTA BLANCA de una sola: `renounce`. La
28
+ * lista es la regla — si algún día entra otra, tiene que quitar y venir firmada por su
29
+ * sujeto, y se escribe aquí a propósito, no se cuela.
30
+ *
31
+ * JUNTAR DOS ES UNIR. Dos entradas nunca se contradicen porque las dos quitan, así que no
32
+ * hay conflictos ni hace falta un `seq`. Y **solo salen al absorberse** en el acta: nunca
33
+ * por reloj, o un aparato apagado perdería justo la que lo protege.
34
+ */
35
+ import path from 'node:path'
36
+ import { verifyRenounce } from '@dotrino/identity/acta'
37
+ import { readJson, writeJson } from './paths.js'
38
+
39
+ /** Las operaciones que caben. La lista ES la regla: todas quitan, todas las firma su sujeto. */
40
+ export const OPS = Object.freeze(['renounce'])
41
+
42
+ /** Identidad de una entrada, para no guardar dos veces la misma. */
43
+ const idOf = (e) => `${e.op}|${e.member}|${[...(e.caps || [])].sort().join(',')}|${e.ts}`
44
+
45
+ /**
46
+ * ¿Es una entrada que se puede aceptar? Comprueba la FORMA y la FIRMA; que quite es
47
+ * estructural (el catálogo) y no una comprobación aparte.
48
+ */
49
+ export async function isAcceptable (e) {
50
+ if (!e || !OPS.includes(e.op)) return false
51
+ if (e.op === 'renounce') return verifyRenounce(e).catch(() => false)
52
+ return false
53
+ }
54
+
55
+ /**
56
+ * La subacta de un perfil. Vive en su directorio y va cifrada en reposo como todo lo
57
+ * demás: sus entradas dicen qué aparatos se quedaron sin qué, y eso es material de la
58
+ * cuenta aunque no sea un secreto.
59
+ */
60
+ export function openSubacta (dir, atRest) {
61
+ const file = path.join(dir, 'subacta.json')
62
+ let data = readJson(file, null, atRest) || { v: 1, entries: [] }
63
+ const guardar = () => writeJson(file, data, atRest)
64
+
65
+ return {
66
+ /** Las entradas vigentes, tal cual se le pasan a `memberCan` y compañía. */
67
+ entries () { return [...data.entries] },
68
+ get count () { return data.entries.length },
69
+
70
+ /** Las de UN miembro. Es lo que se mira al decidir sobre él. */
71
+ forMember (pub) { return data.entries.filter((e) => e.member === pub) },
72
+
73
+ /**
74
+ * Acepta una entrada. Devuelve `{ok, reason}` — y un `false` aquí no es un fallo del
75
+ * que la manda: puede ser que ya la tuviéramos, que es el caso normal cuando dos
76
+ * aparatos se cuentan lo mismo.
77
+ */
78
+ async add (e) {
79
+ if (!(await isAcceptable(e))) return { ok: false, reason: 'no-aceptable' }
80
+ const id = idOf(e)
81
+ if (data.entries.some((x) => idOf(x) === id)) return { ok: false, reason: 'ya-estaba' }
82
+ data.entries = [...data.entries, e]
83
+ guardar()
84
+ return { ok: true, reason: 'nueva' }
85
+ },
86
+
87
+ /**
88
+ * SACA TODO para absorberlo en el acta, y vacía. Se llama con la bóveda ABIERTA, que es
89
+ * la única que puede sellar. Vaciar aquí y fallar el sellado perdería la entrada, así
90
+ * que quien llama tiene que absorber primero y vaciar después (`clear`).
91
+ */
92
+ drain () { return [...data.entries] },
93
+
94
+ /** Vacía lo ya absorbido. Se le pasa lo que se absorbió, no «todo»: mientras se sella
95
+ * puede haber entrado una nueva, y tirarla sería perder una renuncia sin aplicar. */
96
+ clear (absorbidas) {
97
+ const ids = new Set(absorbidas.map(idOf))
98
+ data.entries = data.entries.filter((e) => !ids.has(idOf(e)))
99
+ guardar()
100
+ }
101
+ }
102
+ }
package/src/tui/app.js CHANGED
@@ -134,7 +134,10 @@ function mergeMembersAndCerts (members, issued) {
134
134
  addedAt: m.addedAt || null,
135
135
  // El master es la propia bóveda: no tiene (ni necesita) certificado. Un servicio
136
136
  // tampoco lleva uno de dispositivo. Marcarlos «sin acceso» sería una alarma falsa.
137
- noAccess: !cert && !m.isMaster && !m.cn
137
+ noAccess: !cert && !m.isMaster && !m.cn,
138
+ // QUÉ CORRE ESE APARATO (CONVENCIONES §14). Lo apunta la bóveda cuando le habla, así
139
+ // que aquí solo hay que dejarlo pasar — que es justo lo que le faltaba a `addedAt`.
140
+ running: m.running || null
138
141
  }
139
142
  })
140
143
  }
@@ -266,8 +269,23 @@ function deviceRows (st, t) {
266
269
  const vars = devVarsOf(st, d.sub).length
267
270
  const debt = debtOf(st, d.sub)
268
271
  const desde = d.addedAt ? t.muted(` ${i.deviceSince}:${fmtExp(d.addedAt)}`) : ''
272
+ // QUÉ VERSIÓN CORRE, y en color de aviso si no cuadra con la nuestra.
273
+ //
274
+ // Sin esto una incompatibilidad de versiones se ve como que ese aparato «no responde»,
275
+ // que es el fallo más caro que ha tenido el ecosistema: el apagón del 1-2 de septiembre
276
+ // fueron tres servicios reintentando cada 60 s contra una bóveda que ya no les
277
+ // entendía, un día entero y sin una sola línea que lo dijera.
278
+ //
279
+ // Un aparato que todavía no ha hablado desde que arrancamos no sale: `null` es «no lo
280
+ // sé», y escribir «desconocido» en cada fila sería ruido, no información.
281
+ const corre = d.running
282
+ ? (d.running.compatible
283
+ ? t.muted(` ${i.runs}:${d.running.version}`)
284
+ : t.warn(` ${i.mismatch(d.running.product, d.running.version)}`))
285
+ : ''
269
286
  const extra = (d.certCount > 1 ? t.muted(` certs:${d.certCount}`) : '') +
270
287
  (vars ? t.muted(` vars:${vars}`) : '') +
288
+ corre +
271
289
  // EN DEUDA: en el acta y sin poder abrir lo suyo. Va en color de aviso al lado de
272
290
  // sus variables, que es donde se mira cuando algo no arranca.
273
291
  (debt ? t.warn(` ${i.deviceDebt(debt)}`) : '')
package/src/tui/i18n.js CHANGED
@@ -166,6 +166,11 @@ const es = {
166
166
  // Está en el acta y no puede entrar: o le retiraron el certificado, o se le venció.
167
167
  deviceNoAccess: 'SIN ACCESO — está en el perfil pero no puede entrar',
168
168
  deviceDebt: (n) => `no abre ${n}`,
169
+ // QUÉ CORRE CADA APARATO (CONVENCIONES §14). `runs` va en gris —es un dato— y el aviso
170
+ // en color, porque una versión que no cuadra es lo que hace que ese aparato parezca
171
+ // apagado cuando en realidad no os entendéis.
172
+ runs: 'corre',
173
+ mismatch: (producto, version) => `⚠ ${producto} ${version} no cuadra`,
169
174
  thisVault: 'esta bóveda (manda ella)',
170
175
  cantRemoveMaster: 'Esta bóveda es la que manda: no se quita a sí misma.',
171
176
  revokedCount: (n) => ` Revocados: ${n}`,
@@ -489,6 +494,8 @@ const en = {
489
494
  noLabel: '(no label)',
490
495
  deviceNoAccess: 'NO ACCESS — it is on the record but cannot get in',
491
496
  deviceDebt: (n) => `cannot open ${n}`,
497
+ runs: 'runs',
498
+ mismatch: (product, version) => `⚠ ${product} ${version} does not match`,
492
499
  thisVault: 'this vault (it is the Master)',
493
500
  cantRemoveMaster: 'This vault is the Master: it does not remove itself.',
494
501
  revokedCount: (n) => ` Revoked: ${n}`,
package/src/vault.js CHANGED
@@ -15,7 +15,7 @@ import nodeCrypto from 'node:crypto'
15
15
  import path from 'node:path'
16
16
  import { Identity } from '@dotrino/identity/node'
17
17
  import { verifyChain as verifyChainRaw, pubkeyId, verifyDeviceSig } from '@dotrino/identity/capabilities'
18
- import * as Acta from '@dotrino/identity/acta'
18
+ import * as ActaPilar from '@dotrino/identity/acta'
19
19
  import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS } from '../lib/src/enroll.js'
20
20
  import { createAdminDesk, authorBody } from '../lib/src/admin.js'
21
21
  import { shouldNotifyRevoked } from '../lib/src/revocation.js'
@@ -27,6 +27,7 @@ import { assertKeyOwnsDir } from './keyowner.js'
27
27
  import { openStore } from './store.js'
28
28
  import { openThreadStore, STORE_READ_METHODS, PROFILE_EDIT_METHODS } from './threadStore.js'
29
29
  import { openSecretsStore, assertVar, RECOVERY as RECOVERY_WRAP, PROFILE_OWNER } from './secretsStore.js'
30
+ import { openSubacta } from './subacta.js'
30
31
  import { VERSION } from './version.js'
31
32
  import { declare as compatDeclare, check as compatCheck, annotate as compatAnnotate } from '@dotrino/compat'
32
33
  import { loadManifest, brokenOf } from '@dotrino/roadmap'
@@ -372,6 +373,55 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
372
373
  /** El dictamen de un aparato, para enseñarlo o para pegárselo a un error. */
373
374
  const versionDe = (pub) => versiones.get(pub) || null
374
375
 
376
+ // ----- LA SUBACTA: lo decidido que todavía no selló el acta (`src/subacta.js`) -----
377
+ //
378
+ // Sellar es de la maestra, así que con el perfil CERRADO una renuncia no surtía ningún
379
+ // efecto: el aparato conservaba todos sus permisos hasta que alguien fuera a la máquina
380
+ // a teclear la contraseña. Justo el caso que la renuncia existe para cubrir.
381
+ //
382
+ // Se honra ya, sin sellar, y es seguro porque una entrada de la subacta SOLO PUEDE
383
+ // QUITAR: no concede nada a nadie, y va firmada por el miembro al que afecta.
384
+ const subacta = openSubacta(dir, atRestFor(dir))
385
+
386
+ /**
387
+ * EL ACTA, LEÍDA CON LA SUBACTA EN LA MANO.
388
+ *
389
+ * Envuelve al pilar en UN sitio en vez de pasar la lista en los diecisiete mostradores:
390
+ * una comprobación repetida diecisiete veces son dieciséis sitios donde olvidarla, y
391
+ * olvidarla aquí significa atender a un aparato que ya renunció.
392
+ *
393
+ * Solo se tocan las cinco que DECIDEN. El resto del pilar pasa tal cual.
394
+ */
395
+ const Acta = {
396
+ ...ActaPilar,
397
+ memberCan: (acta, pub, cap) => ActaPilar.memberCan(acta, pub, cap, subacta.forMember(pub)),
398
+ memberCanScope: (acta, pub, scope) => ActaPilar.memberCanScope(acta, pub, scope, subacta.forMember(pub)),
399
+ memberCanReadSecrets: (acta, pub, ns) => ActaPilar.memberCanReadSecrets(acta, pub, ns, subacta.forMember(pub)),
400
+ memberCanSign: (acta, pub, ns = null) => ActaPilar.memberCanSign(acta, pub, ns, subacta.forMember(pub)),
401
+ memberScopes: (acta, pub) => ActaPilar.memberScopes(acta, pub, subacta.forMember(pub)),
402
+ effectiveCaps: (acta, pub) => ActaPilar.effectiveCaps(acta, pub, subacta.forMember(pub))
403
+ }
404
+
405
+ /**
406
+ * ABSORBER lo pendiente en el acta. Solo con la bóveda abierta, que es la única que
407
+ * sella. Se vacía DESPUÉS y solo lo absorbido: mientras se sella puede entrar una nueva,
408
+ * y tirarla sería perder una renuncia sin aplicar.
409
+ */
410
+ async function absorberSubacta (motivo = 'unlock') {
411
+ const pendientes = subacta.drain()
412
+ if (!pendientes.length) return 0
413
+ const hechas = []
414
+ for (const e of pendientes) {
415
+ try { await identity.absorbRenounce(e); hechas.push(e) }
416
+ catch (err) { log(`[vault] ${motivo}: could not absorb a pending renounce: ${err.message}`) }
417
+ }
418
+ if (hechas.length) {
419
+ subacta.clear(hechas)
420
+ log(`[vault] ${motivo}: ${hechas.length} pending renounce(s) absorbed into the record`)
421
+ }
422
+ return hechas.length
423
+ }
424
+
375
425
  /**
376
426
  * LA VERSIÓN SE APUNTA DONDE YA SE COMPRUEBA LA FIRMA, y por eso esto envuelve a
377
427
  * `verifyChain` en vez de mirar el envoltorio del mensaje: `data` va firmado, así que
@@ -958,7 +1008,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
958
1008
  deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null, label: x.label || '', scope: x.scope, exp: x.exp, nonce: x.nonce,
959
1009
  running: x.sub ? versionDe(x.sub) : null
960
1010
  })))
961
- reply(from, fitChain({ type: MSG.DEVICES_RESULT, devices, revoked, acta: record, chain, vault: MI_VERSION }))
1011
+ // LA SUBACTA VIAJA CON EL ACTA. Es la mitad de «se comparte entre todos los
1012
+ // dispositivos»: sin esto, una renuncia sin sellar solo la respeta esta máquina.
1013
+ // Cada entrada va firmada por su sujeto, así que quien la recibe la verifica sola.
1014
+ reply(from, fitChain({ type: MSG.DEVICES_RESULT, devices, revoked, acta: record, chain, vault: MI_VERSION, subacta: subacta.entries() }))
962
1015
  }
963
1016
 
964
1017
  /**
@@ -2013,12 +2066,32 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2013
2066
  return reply(from, { type: MSG.ERROR, error: 'renounce: that member no longer holds any of those capabilities' })
2014
2067
  }
2015
2068
  try {
2016
- const r = await identity.absorbRenounce(record)
2069
+ // A LA SUBACTA PRIMERO, y aquí está el arreglo: desde este momento surte efecto,
2070
+ // esté la bóveda abierta o cerrada. Antes se intentaba sellar directamente, sellar es
2071
+ // de la maestra, y con el perfil cerrado la renuncia se perdía con un error — en el
2072
+ // único momento en que de verdad hace falta.
2073
+ const guardada = await subacta.add(record)
2074
+ if (!guardada.ok && guardada.reason === 'no-aceptable') {
2075
+ audit('rejected', { what: 'renounce', reason: 'no-aceptable' })
2076
+ return reply(from, { type: MSG.ERROR, error: 'renounce: the entry is not one this vault accepts' })
2077
+ }
2017
2078
  const device = await deviceIdOf(record.member).catch(() => null)
2018
2079
  audit('renounce', { device, caps: record.caps })
2019
- log(`[vault] ${device} renounced: ${(record.caps || []).join(', ')}`)
2080
+ log(`[vault] ${device} renounced: ${(record.caps || []).join(', ')} — in effect now`)
2081
+
2082
+ // Y SE SELLA SI SE PUEDE. Con la bóveda abierta entra en el acta y lo respeta toda la
2083
+ // cuenta; cerrada se queda pendiente y lo absorbe el `unlock`. Las dos ramas son
2084
+ // correctas, así que el fallo al sellar NO es un error para quien renunció.
2085
+ let seq = null
2086
+ try {
2087
+ const r = await identity.absorbRenounce(record)
2088
+ subacta.clear([record])
2089
+ seq = r?.seq ?? null
2090
+ } catch (e) {
2091
+ log(`[vault] the renounce stays pending until the vault is opened (${e.message})`)
2092
+ }
2020
2093
  await notifyMembers('renounce', { deviceId: device, caps: record.caps })
2021
- reply(from, { type: MSG.RENOUNCE_RESULT, ok: true, seq: r?.seq ?? null })
2094
+ reply(from, { type: MSG.RENOUNCE_RESULT, ok: true, seq, pending: seq === null })
2022
2095
  } catch (e) {
2023
2096
  reply(from, { type: MSG.ERROR, error: 'renounce: ' + e.message })
2024
2097
  }
@@ -3019,6 +3092,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
3019
3092
  // aquí donde puede estrenar su llave de comunicación (ver `ensureCommKeyInActa`).
3020
3093
  // Si falla, no se arrastra: abrir el perfil no puede depender de esto.
3021
3094
  if (r?.locked === false) { try { await ensureCommKeyInActa() } catch (_) {} }
3095
+ // Y ABSORBER LO PENDIENTE. La subacta ya lo estaba honrando con el perfil cerrado —de
3096
+ // eso vive—, pero mientras siga ahí solo lo sabe esta máquina: al sellarlo, entra en
3097
+ // el acta y lo respeta toda la cuenta. Si falla no se arrastra: abrir el perfil no
3098
+ // puede depender de esto, y lo pendiente sigue surtiendo efecto igual.
3099
+ if (r?.locked === false) { try { await absorberSubacta('unlock') } catch (_) {} }
3022
3100
  return { locked: r?.locked !== false }
3023
3101
  },
3024
3102
  // Forzar un empujón: lo usa `replica push` de la CLI y el smoke, para no depender
@@ -3117,7 +3195,14 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
3117
3195
  return {
3118
3196
  ...r,
3119
3197
  vault: MI_VERSION,
3120
- members: (r?.members || []).map((m) => ({ ...m, running: versionDe(m.pub) }))
3198
+ // Y LO QUE RENUNCIÓ Y NO SE HA SELLADO. Ya surte efecto —de eso vive la subacta—
3199
+ // pero si no se ve, el dueño mira la lista, lee los permisos del acta y cree que
3200
+ // ese aparato los tiene. Un freno que no se explica es otro silencio.
3201
+ members: (r?.members || []).map((m) => ({
3202
+ ...m,
3203
+ running: versionDe(m.pub),
3204
+ renounced: subacta.forMember(m.pub).flatMap((e) => e.caps || [])
3205
+ }))
3121
3206
  }
3122
3207
  },
3123
3208
  // Los namespaces que quedaron a deber una rotación (se fue un miembro y no se pudo