@dotrino/vaultd 0.106.1 → 0.107.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.
@@ -128,3 +128,18 @@ export const isValidSecretsNs = (ns) => typeof ns === 'string' && /^[a-z0-9-]{1,
128
128
  * remota y el lector de `.env`, y tres copias de una regla son tres reglas.
129
129
  */
130
130
  export const isValidVarKey = (key) => typeof key === 'string' && /^[A-Z0-9_]{1,64}$/.test(key)
131
+
132
+ /**
133
+ * LA VERSIÓN DEL CABLE (CONVENCIONES §14, `@dotrino/compat`).
134
+ *
135
+ * Sube **solo cuando cambia el protocolo**, no con cada release: es lo que decide *si dos
136
+ * piezas pueden hablar*. La `version` del paquete decide otra cosa —si esa build concreta
137
+ * está rota— y por eso hacen falta las dos.
138
+ *
139
+ * Empieza en 1 hoy, con todo lo anterior sin declarar nada: a quien calla se le atiende
140
+ * hasta que cierre la ventana de migración del pilar, y después no.
141
+ */
142
+ export const VAULT_PROTOCOL = 1
143
+
144
+ /** Los protocolos que esta build entiende. Al añadir el 2, aquí van `[1, 2]`. */
145
+ export const VAULT_SPEAKS = [1]
@@ -26,7 +26,8 @@ import {
26
26
  verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from '@dotrino/identity/capabilities'
27
27
  import { openWrap, wrapForMember, decryptWithCek } from '@dotrino/identity/content'
28
28
  import { verifyActa, sealKeyAt, sealersOf, memberCan } from '@dotrino/identity/acta'
29
- import { MSG, secretsScope, isValidSecretsNs } from './protocol.js'
29
+ import { MSG, secretsScope, isValidSecretsNs, VAULT_PROTOCOL, VAULT_SPEAKS } from './protocol.js'
30
+ import { PKG_VERSION } from './version.js'
30
31
  import { makeEphemeralKey, openSealed } from './sealed.js'
31
32
  import { parseInvite } from './invite.js'
32
33
  import { atRestFor } from './atrest.js'
@@ -35,6 +36,26 @@ import { connectLocal, hasLocalVault } from './localdesk.js'
35
36
  const IDENTITY_FILE = 'service-identity.json'
36
37
  const FRESH_WINDOW_MS = 5 * 60 * 1000
37
38
 
39
+ /**
40
+ * QUÉ SOMOS, dentro de lo que ya va firmado (CONVENCIONES §14).
41
+ *
42
+ * Va DENTRO de `data` y no en el envoltorio a propósito: `data` lo firma la llave del
43
+ * aparato, así que lo que la bóveda apunta y enseña es lo que ese aparato dijo **con su
44
+ * llave**. Un inventario de versiones que cualquiera puede escribir no es un inventario.
45
+ *
46
+ * `PRODUCT` es el cliente de la bóveda —el que se instala con `@dotrino/env`—, que es
47
+ * justo la pieza que se quedaba atrás sin que nadie se enterara: pedía `^0.33.2` con la
48
+ * librería en 0.60 y enrolar contestaba `invalid cert: no-acta`, que es verdad y no es la
49
+ * causa.
50
+ */
51
+ const PRODUCT = 'vault'
52
+ export const MY_VERSION = {
53
+ product: PRODUCT,
54
+ version: PKG_VERSION,
55
+ protocol: VAULT_PROTOCOL,
56
+ speaks: VAULT_SPEAKS
57
+ }
58
+
38
59
  let _globalsInstalled = false
39
60
  function installNodeGlobals () {
40
61
  if (_globalsInstalled) return
@@ -593,7 +614,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
593
614
  const eph = await makeEphemeralKey()
594
615
  // `publicOnly` VIAJA FIRMADO (va dentro de `data`), así que nadie lo puede quitar ni
595
616
  // poner por el camino. Quien decide qué se manda es la bóveda; esto solo es la petición.
596
- const data = { op: 'secrets', ns, ek: eph.ek, publickey: device.publickey, ts: Date.now(), ...(publicOnly ? { publicOnly: true } : {}) }
617
+ const data = { op: 'secrets', ns, ek: eph.ek, publickey: device.publickey, ts: Date.now(), v: MY_VERSION, ...(publicOnly ? { publicOnly: true } : {}) }
597
618
  const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
598
619
  // A QUIÉN SE LE PREGUNTA: primero a la bóveda, y si no contesta, a los REPLICADORES
599
620
  // que el acta nombraba la última vez que la vimos (`docs/replicas.md` §8: «el servicio
@@ -0,0 +1,30 @@
1
+ /**
2
+ * version.js — la versión de `@dotrino/vault` (el CLIENTE, no el daemon).
3
+ *
4
+ * Existe porque el cliente tiene que poder decir qué versión es (CONVENCIONES §14), y esta
5
+ * es justo la pieza que se quedaba atrás sin que nadie se enterara: `@dotrino/env` pedía
6
+ * `^0.33.2` con la librería en 0.60, y enrolar contestaba `invalid cert: no-acta` — verdad,
7
+ * y no la causa.
8
+ *
9
+ * Se lee del `package.json` que viaja con el paquete. Si no se puede leer se dice `0.0.0`,
10
+ * que es un número imposible y por tanto legible como «no lo sé» — no se inventa uno
11
+ * plausible, que sería peor que no decir nada.
12
+ */
13
+ import fs from 'node:fs'
14
+ import { dirname, join } from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
16
+
17
+ function leer () {
18
+ try {
19
+ const aqui = dirname(fileURLToPath(import.meta.url))
20
+ for (const p of [join(aqui, '..', 'package.json'), join(aqui, '..', '..', 'package.json')]) {
21
+ try {
22
+ const v = JSON.parse(fs.readFileSync(p, 'utf8'))
23
+ if (v?.name === '@dotrino/vault' && typeof v.version === 'string') return v.version
24
+ } catch (_) {}
25
+ }
26
+ } catch (_) {}
27
+ return '0.0.0'
28
+ }
29
+
30
+ export const PKG_VERSION = leer()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.106.1",
3
+ "version": "0.107.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": {
@@ -21,9 +21,11 @@
21
21
  "node": ">=20"
22
22
  },
23
23
  "dependencies": {
24
+ "@dotrino/compat": "0.4.0",
24
25
  "@dotrino/identity": "0.79.0",
25
26
  "@dotrino/passmanager": "^0.10.1",
26
27
  "@dotrino/proxy-client": "0.15.0",
28
+ "@dotrino/roadmap": "0.1.0",
27
29
  "ws": "^8.18.0"
28
30
  },
29
31
  "license": "MIT",
package/src/ctl.js CHANGED
@@ -595,6 +595,18 @@ async function cmdMembers () {
595
595
  // selladas a esa llave. Se dice aquí, junto a él, porque es el único sitio donde
596
596
  // se mira quién es quién — y en la lista de variables ya seria tarde.
597
597
  if (m.cn && !m.canSeal) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
598
+ // QUÉ CORRE, y si cuadra con esta bóveda (CONVENCIONES §14). Sin esto una
599
+ // incompatibilidad de versiones se ve como que ese aparato «no responde», que es el
600
+ // fallo más caro que ha tenido el ecosistema. No bloquea nada: se enseña.
601
+ if (m.running) {
602
+ const v = `${m.running.product} ${m.running.version}`
603
+ if (m.running.compatible) console.log(' %scorre %s%s', D, v, Z)
604
+ else console.log(' %s⚠ corre %s · %s%s', R, v, m.running.reason, Z)
605
+ }
606
+ }
607
+ if (record.vault) {
608
+ console.log('\n %sEsta bóveda corre %s %s (protocolo %d)%s',
609
+ D, record.vault.product, record.vault.version, record.vault.protocol, Z)
598
610
  }
599
611
  console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba | +contrasenas | +sella | +desatendido | +replica')
600
612
  console.log(' «Permiso»: ese aparato solo recibe claves privadas cuando lo apruebas desde un aparato con «aprueba» (en cada arranque).')
package/src/vault.js CHANGED
@@ -14,7 +14,7 @@ import fs from 'node:fs'
14
14
  import nodeCrypto from 'node:crypto'
15
15
  import path from 'node:path'
16
16
  import { Identity } from '@dotrino/identity/node'
17
- import { verifyChain, pubkeyId, verifyDeviceSig } from '@dotrino/identity/capabilities'
17
+ import { verifyChain as verifyChainRaw, pubkeyId, verifyDeviceSig } from '@dotrino/identity/capabilities'
18
18
  import * as Acta 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'
@@ -27,6 +27,9 @@ 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 { VERSION } from './version.js'
31
+ import { declare as compatDeclare, check as compatCheck, annotate as compatAnnotate } from '@dotrino/compat'
32
+ import { loadManifest, brokenOf } from '@dotrino/roadmap'
30
33
  // `PENDING_TTL_MS` se usa abajo, al esperar la firma del aprobador: sin importarlo, esa
31
34
  // espera reventaba con un ReferenceError y la aprobación del mostrador de contraseñas no
32
35
  // llegaba a existir. Solo se veía por ese camino —el único que lo usa—, y no había prueba
@@ -40,7 +43,7 @@ import { localSocketPath, socketDir } from '../lib/src/localdesk.js'
40
43
  import { dataDir, ensureDir } from './paths.js'
41
44
  import net from 'node:net'
42
45
  import { atRestFor, kekFor, migrateFile, encryptText, decryptText } from './atrest.js'
43
- import { MSG, SCOPE, secretsScope, isValidSecretsNs } from './protocol.js'
46
+ import { MSG, SCOPE, secretsScope, isValidSecretsNs, VAULT_PROTOCOL, VAULT_SPEAKS } from './protocol.js'
44
47
 
45
48
  /**
46
49
  * Tope de una respuesta de la bóveda por el transporte. El proxio (`PROXY_MAX_FRAME_BYTES`)
@@ -333,9 +336,67 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
333
336
  */
334
337
  const canales = new Map()
335
338
 
339
+ /**
340
+ * QUÉ SOMOS, Y QUÉ CORRE CADA APARATO (CONVENCIONES §14, `@dotrino/compat`).
341
+ *
342
+ * El agujero que esto tapa: una incompatibilidad de versiones se manifestaba como
343
+ * SILENCIO. El que llamaba no sabía si estábamos apagados, ocupados o hablando otro
344
+ * idioma, así que reintentaba para siempre — y aquí no se sabía que nos hablaban.
345
+ *
346
+ * **Informa, no bloquea** (dueño, 2026-09-04): el dictamen se enseña y se le pega a los
347
+ * errores, y no hay ni un `if` que deje de atender por él. Si algún día se decide
348
+ * bloquear, se decide arriba y se escribe aquí a propósito.
349
+ *
350
+ * La lista de rotas sale del registro común (`@dotrino/roadmap`), que viaja DENTRO de
351
+ * esta versión: la compatibilidad es política del código, no de la cuenta.
352
+ */
353
+ const MI_VERSION = compatDeclare({
354
+ product: 'vaultd', version: VERSION, protocol: VAULT_PROTOCOL, speaks: VAULT_SPEAKS
355
+ })
356
+ let ROTAS = []
357
+ try { ROTAS = brokenOf(loadManifest()) } catch (e) { log('[vault] no compatibility registry: ' + e.message) }
358
+
359
+ /**
360
+ * Lo que dice correr cada aparato, por llave. Se apunta de lo que viene FIRMADO
361
+ * (`p.data.v`, dentro del sobre) y no del envoltorio: una versión que cualquiera puede
362
+ * escribir no vale para enseñarla como si fuera un dato.
363
+ */
364
+ const versiones = new Map()
365
+ const apuntarVersion = (pub, v) => {
366
+ if (!pub || !v) return
367
+ const dictamen = compatCheck({ mine: MI_VERSION, theirs: v, broken: ROTAS })
368
+ versiones.set(pub, { ...v, at: Date.now(), compatible: dictamen.compatible, reason: dictamen.reason })
369
+ if (!dictamen.compatible) log(`[vault] ${v.product} ${v.version}: ${dictamen.reason}`)
370
+ return dictamen
371
+ }
372
+ /** El dictamen de un aparato, para enseñarlo o para pegárselo a un error. */
373
+ const versionDe = (pub) => versiones.get(pub) || null
374
+
375
+ /**
376
+ * LA VERSIÓN SE APUNTA DONDE YA SE COMPRUEBA LA FIRMA, y por eso esto envuelve a
377
+ * `verifyChain` en vez de mirar el envoltorio del mensaje: `data` va firmado, así que
378
+ * lo que se apunta es lo que ese aparato dijo **con su llave**, no lo que cualquiera
379
+ * pueda escribir por fuera. Un inventario de versiones que se puede falsificar no es un
380
+ * inventario, es una pantalla bonita.
381
+ *
382
+ * Va envuelto y no repetido en los ocho mostradores porque una comprobación copiada
383
+ * ocho veces son siete sitios donde olvidarla.
384
+ */
385
+ const verifyChain = async (args) => {
386
+ const chk = await verifyChainRaw(args)
387
+ if (chk.ok && args?.data?.v) apuntarVersion(chk.device, args.data.v)
388
+ return chk
389
+ }
390
+
391
+
336
392
  const reply = (to, obj) => {
337
393
  try {
338
394
  const enviar = canales.get(to) || ((o) => client.send(to, o))
395
+ // ANUNCIAMOS EN CADA RESPUESTA: el otro lado decide con esto, y decide él porque es
396
+ // el que puede tener la lista al día (§14). Va en el envoltorio y no dentro de un
397
+ // cuerpo firmado a propósito — es un dato para enseñar y para explicar un fallo, no
398
+ // una credencial: nada se autoriza con él.
399
+ obj = { ...obj, v: MI_VERSION }
339
400
  const bytes = Buffer.byteLength(JSON.stringify(obj))
340
401
  if (bytes > MAX_REPLY_BYTES) {
341
402
  log(`[vault] the reply to ${obj?.type} does not fit (${bytes} bytes > ${MAX_REPLY_BYTES}): sending an error instead of killing the connection`)
@@ -889,10 +950,15 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
889
950
  // `sub` (pubkey completa) va incluida: es la DIRECCIÓN de cada dispositivo en el
890
951
  // proxy → permite a las apps AUTODESCUBRIR tus máquinas (p. ej. la terminal
891
952
  // lista tus agentes sin pegar nada). Solo la ve quien presenta un cert tuyo válido.
953
+ // QUÉ CORRE CADA UNO, y si cuadra con lo que corremos aquí (CONVENCIONES §14). Va con
954
+ // la lista porque es donde se mira: el dueño lo pidió en el administrador Y en la
955
+ // lista de dispositivos de la bóveda. `null` significa «todavía no ha hablado desde
956
+ // que arrancamos», que es distinto de «no declara» — y se dice distinto.
892
957
  const devices = await Promise.all(issued.map(async (x) => ({
893
- deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null, label: x.label || '', scope: x.scope, exp: x.exp, nonce: x.nonce
958
+ 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
+ running: x.sub ? versionDe(x.sub) : null
894
960
  })))
895
- reply(from, fitChain({ type: MSG.DEVICES_RESULT, devices, revoked, acta: record, chain }))
961
+ reply(from, fitChain({ type: MSG.DEVICES_RESULT, devices, revoked, acta: record, chain, vault: MI_VERSION }))
896
962
  }
897
963
 
898
964
  /**
@@ -1923,6 +1989,29 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1923
1989
  audit('rejected', { what: 'renounce', reason: 'signature' })
1924
1990
  return reply(from, { type: MSG.ERROR, error: 'renounce: the signature is not the member own' })
1925
1991
  }
1992
+ // UNA RENUNCIA QUE NO QUITA NADA NO SE HONRA, y esto es lo que corta el replay barato.
1993
+ //
1994
+ // El registro va firmado y lleva `ts`, pero nadie lo miraba: cualquiera que lo viera
1995
+ // pasar por el proxio podía volver a mandarlo cuando quisiera. Como `absorbRenounce`
1996
+ // SELLA UN ACTA NUEVA cada vez, repetirlo hacía subir el `seq` y empujar a todos los
1997
+ // aparatos sin que hubiera cambiado nada — y dejaba en la bitácora una renuncia con el
1998
+ // nombre del aparato, que es lo peor: la bitácora es la prueba, y ahí decía algo que
1999
+ // ese aparato no hizo.
2000
+ //
2001
+ // Mirando lo que el acta dice HOY, un registro repetido no quita nada y se para aquí.
2002
+ // Lo que esto NO cierra, y queda anotado: si el dueño vuelve a conceder ese permiso, el
2003
+ // mismo registro viejo lo quita otra vez. Cerrarlo del todo pide que el acta recuerde
2004
+ // la fecha de la última renuncia por miembro y rechace las que no sean posteriores.
2005
+ const actual = (await identity.profileActa?.().catch(() => null))?.acta || null
2006
+ if (!actual) {
2007
+ audit('rejected', { what: 'renounce', reason: 'sin-acta' })
2008
+ return reply(from, { type: MSG.ERROR, error: 'renounce: this vault has no record to apply it to' })
2009
+ }
2010
+ const tiene = new Set(Acta.effectiveCaps(actual, record.member) || [])
2011
+ if (!(record.caps || []).some((c) => tiene.has(c))) {
2012
+ audit('rejected', { what: 'renounce', reason: 'nada-que-quitar' })
2013
+ return reply(from, { type: MSG.ERROR, error: 'renounce: that member no longer holds any of those capabilities' })
2014
+ }
1926
2015
  try {
1927
2016
  const r = await identity.absorbRenounce(record)
1928
2017
  const device = await deviceIdOf(record.member).catch(() => null)
@@ -3015,7 +3104,22 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
3015
3104
  listDevices: () => identity.listDelegations(),
3016
3105
  // Acta del perfil (quién es del perfil y qué puede cada uno): lo que muestran
3017
3106
  // `dotrino-vault members` y la consola de vault.dotrino.com.
3018
- profileMembers: () => identity.profileMembers(),
3107
+ /**
3108
+ * Los miembros del acta, y QUÉ CORRE CADA UNO (CONVENCIONES §14). La versión no está en
3109
+ * el acta —el acta dice quién puede qué, no qué build es— así que se le pega aquí, que
3110
+ * es por donde salen `members` y la consola. El dueño lo pidió en los dos sitios.
3111
+ *
3112
+ * `running: null` es «todavía no ha hablado desde que arrancamos», que no es lo mismo
3113
+ * que «no declara»: lo segundo trae un dictamen con `code: 'undeclared'`.
3114
+ */
3115
+ profileMembers: async () => {
3116
+ const r = await identity.profileMembers()
3117
+ return {
3118
+ ...r,
3119
+ vault: MI_VERSION,
3120
+ members: (r?.members || []).map((m) => ({ ...m, running: versionDe(m.pub) }))
3121
+ }
3122
+ },
3019
3123
  // Los namespaces que quedaron a deber una rotación (se fue un miembro y no se pudo
3020
3124
  // rotar su llave). Lo enseñan `secret list` y la consola: si no se ve, no se salda.
3021
3125
  rotationsDue,