@dotrino/vaultd 0.97.0 → 0.105.1

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/src/ctl.js CHANGED
@@ -208,6 +208,17 @@ async function cmdPair (args = []) {
208
208
  // Sin esto el nombre lo ponía el propio aparato, que por defecto usa el apodo del
209
209
  // PERFIL: acababas con varios dispositivos llamados igual que tú, y para distinguirlos
210
210
  // había que renombrarlos después —cuando ya no sabías cuál era cuál—.
211
+ // UNA BANDERA QUE NO EXISTE SE DICE, no se ignora. Escribir `--label` en vez de `--name`
212
+ // emparejaba igual y en silencio, y el aparato entraba llamándose «cli»; el error se
213
+ // veía media hora después mirando el acta, si es que se miraba. Una opción que tecleas
214
+ // y no hace nada es un fallo mudo, que es justo lo que aquí no se acepta.
215
+ const CONOCIDAS = new Set(['--admin', '--adopt', '--kms', '--name', '--new-account', '--quiet', '--scope', '--service', '--save', '--profile'])
216
+ const rara = args.find((a) => a.startsWith('--') && !CONOCIDAS.has(a))
217
+ if (rara) {
218
+ console.error('no existe la opción %s', rara)
219
+ console.error('las de `pair` son: %s', [...CONOCIDAS].join(' '))
220
+ process.exit(2)
221
+ }
211
222
  const nIdx = args.indexOf('--name')
212
223
  let label = null
213
224
  if (nIdx >= 0) {
@@ -254,6 +265,10 @@ async function cmdPair (args = []) {
254
265
  if (t === 'approve' || t === 'aprueba') { console.error('`approve` no se empareja: concédelo desde el PC con dotrino-vault caps <ID> +aprueba'); process.exit(2) }
255
266
  if (t === 'sealer' || t === 'sella') { console.error('`sella` no se empareja: concédelo desde el PC con dotrino-vault caps <ID> +sella'); process.exit(2) }
256
267
  if (t === 'sign' || t === 'read' || t === 'store') { scope.push('vault:' + t); continue }
268
+ // Un REPLICADOR nace acotado: solo esto, ni firma ni lee ni guarda. Se empareja
269
+ // —al contrario que `sella`— porque se despliega en una máquina sin teclado y el
270
+ // permiso no concede nada que haya que pensarse dos veces: reparte sobres cerrados.
271
+ if (t === 'replica' || t === 'replicador') { scope.push('vault:replica'); continue }
257
272
  // El gestor de contraseñas SÍ se empareja con su permiso puesto: es lo único que
258
273
  // va a hacer ese aparato, y pedirlo en dos pasos era el paso que nadie daba.
259
274
  if (t === 'passwords') { scope.push('vault:passwords'); continue }
@@ -548,7 +563,10 @@ async function cmdMembers () {
548
563
  // Resaltado como los otros cuatro: dice si esa máquina se lleva tus claves privadas
549
564
  // sola o si te lo pregunta. Sin traducción salía el identificador en crudo
550
565
  // (`unattended`) en una lista que lee un humano.
551
- unattended: `${B}recibe claves sin aprobación${Z}`
566
+ unattended: `${B}recibe claves sin aprobación${Z}`,
567
+ // Y el que reparte cuando esta bóveda no está. Se resalta como los demás que cambian
568
+ // lo que ese aparato puede hacerle a la cuenta.
569
+ replica: `${B}entrega tus claves con la bóveda apagada${Z}`
552
570
  }
553
571
  // El nombre del perfil es una pubkey JWK. Recortarla no la hace legible: la deja
554
572
  // pareciendo un error (`{"key_ops":["verify"],"e…`). Se muestra su huella corta, la
@@ -578,7 +596,7 @@ async function cmdMembers () {
578
596
  // se mira quién es quién — y en la lista de variables ya seria tarde.
579
597
  if (m.cn && !m.canSeal) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
580
598
  }
581
- console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba | +contrasenas | +sella | +desatendido')
599
+ console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba | +contrasenas | +sella | +desatendido | +replica')
582
600
  console.log(' «Permiso»: ese aparato solo recibe claves privadas cuando lo apruebas desde un aparato con «aprueba» (en cada arranque).')
583
601
  console.log(' «Administra» deja conectar y quitar dispositivos desde ese aparato, sin venir aquí.')
584
602
  console.log(' No deja cambiar permisos ni traspasar el mando: eso solo se hace en esta máquina.')
@@ -644,6 +662,9 @@ async function cmdCaps (args = []) {
644
662
  // podrá admitir aparatos y cambiar permisos si esta se pierde. No es un traspaso:
645
663
  // quien manda sigue mandando. Como `administra`, no se empareja: se concede aquí.
646
664
  sella: 'sealer', sealer: 'sealer',
665
+ // `replica`: reparte los sobres que esta bóveda ya firmó, cuando ella no está. No
666
+ // tiene maestra y no puede abrir nada. A diferencia de `sella`, SÍ se empareja.
667
+ replica: 'replica', replicador: 'replica',
647
668
  // `contraseñas`: el gestor (la extensión, la app del teléfono) puede PEDIR
648
669
  // credenciales de a una. Se acepta con y sin tilde: nadie escribe la ñ en una CLI.
649
670
  contrasenas: 'passwords', 'contraseñas': 'passwords',
@@ -1606,7 +1627,7 @@ function help () {
1606
1627
  El QR no lleva nada: el permiso se aplica al aprobar el código aquí
1607
1628
  pair --quiet escupe SOLO la invitación (una línea) y termina: sin QR y sin
1608
1629
  esperar. Para desplegar (un contenedor no mira un QR en pantalla)
1609
- pair --scope <lista> los PERMISOS del cert: sign,read,store,contrasenas,secrets:<ns> (sin esto: sign,read,store;
1630
+ pair --scope <lista> los PERMISOS del cert: sign,read,store,contrasenas,replica,secrets:<ns> (sin esto: sign,read,store;
1610
1631
  se combina con --service: --service eco --scope sign = bot que firma y lee su cajón)
1611
1632
  secret set <ns> <CLAVE> <valor> variable del scope <ns>: la comparten TODOS los
1612
1633
  aparatos del perfil que sirven ese namespace
@@ -1646,6 +1667,10 @@ function help () {
1646
1667
  KMS, y solo vale ahora: el perfil se está creando)
1647
1668
  Al desplegar, esto mismo va por el entorno y sin entrar a nada:
1648
1669
  DOTRINO_JOIN (o DOTRINO_JOIN_FILE) + DOTRINO_JOIN_NAME
1670
+ replica enroll <invitación>
1671
+ esta máquina entra como REPLICADOR: reparte los sobres que la
1672
+ bóveda ya firmó cuando ella no está, y no puede abrir ninguno ni
1673
+ cambiar nada. No tiene maestra. Arráncalo con: replica run
1649
1674
  pending muestra el dispositivo pendiente + su código a comparar
1650
1675
  approve <código> aprueba el dispositivo tipeando el código que MUESTRA (el vault no lo sabe)
1651
1676
  reject <deviceId> rechaza un dispositivo pendiente
@@ -1697,6 +1722,82 @@ Arrancar y parar, según dónde corra:
1697
1722
  (el CLI, dentro: docker exec -it dotrino-vault dotrino-vault status)`)
1698
1723
  }
1699
1724
 
1725
+ /**
1726
+ * `replica` — LA OTRA PIEZA, la que reparte sin decidir (`docs/replicas.md` §8.bis).
1727
+ *
1728
+ * dotrino-vault replica enroll <invitación> entra en la cuenta, UNA vez
1729
+ * dotrino-vault replica run se queda repartiendo
1730
+ * dotrino-vault replica status qué acta tiene y cuántos sobres
1731
+ *
1732
+ * No corre dentro del daemon del vault y no es un perfil: un replicador NO TIENE MAESTRA,
1733
+ * y meterlo en el gestor de perfiles sería fabricarle una. Vive en su propio directorio
1734
+ * (`DOTRINO_REPLICA_DIR`, por defecto `~/.dotrino/replica`).
1735
+ */
1736
+ async function cmdReplica (args) {
1737
+ const { replicaDir, openReplicaStore, runReplica, REPLICA_SCOPE } = await import('./replica.js')
1738
+ const sub = args[0]
1739
+ const dir = process.env.DOTRINO_REPLICA_DIR || replicaDir()
1740
+
1741
+ if (sub === 'enroll') {
1742
+ const invitacion = args.slice(1).filter((a) => !a.startsWith('-')).join(' ')
1743
+ if (!invitacion) {
1744
+ console.error('uso: dotrino-vault replica enroll "<invitación>"')
1745
+ console.error(' la invitación sale de la bóveda: dotrino-vault pair --scope replica --quiet')
1746
+ process.exit(2)
1747
+ }
1748
+ const { enrollWithVault } = await import('../lib/src/service.js')
1749
+ // `withEncKey: false` NO es una optimización: es la garantía. Sin llave de cifrado en
1750
+ // el acta no hay a dónde envolverle un sobre, así que repartir no puede convertirse en
1751
+ // leer. Regla del dueño: «recibirá todos los sobres que se generen, ningún sobre
1752
+ // firmado para él».
1753
+ const r = await enrollWithVault({
1754
+ qr: invitacion,
1755
+ label: args.includes('--label') ? args[args.indexOf('--label') + 1] : 'replicador',
1756
+ expectedScope: REPLICA_SCOPE,
1757
+ withEncKey: false,
1758
+ onCode: ({ deviceId, code }) => {
1759
+ console.log('\n Tipea ESTE código en la bóveda para aprobarlo:')
1760
+ console.log(` código: ${code}`)
1761
+ console.log(` máquina: ${deviceId}`)
1762
+ console.log(` (en la bóveda: dotrino-vault approve ${code})\n`)
1763
+ console.log(' Esperando…')
1764
+ }
1765
+ })
1766
+ openReplicaStore(dir).setLink({ device: r.device, cert: r.cert, iss: r.iss, proxy: r.proxy })
1767
+ console.log('\n Listo: este replicador ya está en la cuenta.')
1768
+ console.log(' Arráncalo con: dotrino-vault replica run\n')
1769
+ return
1770
+ }
1771
+
1772
+ if (sub === 'status') {
1773
+ const st = openReplicaStore(dir)
1774
+ if (!st.link) { console.log('Sin enrolar. Empieza por: dotrino-vault replica enroll "<invitación>"'); return }
1775
+ console.log('Replicador · datos en %s', dir)
1776
+ console.log(' acta : #%s%s', st.seq || 0, st.acta ? '' : ' (todavía ninguna: la manda la bóveda)')
1777
+ console.log(' sobres : %d', st.bundles)
1778
+ console.log(' bóveda : %s…', String(st.link.iss).slice(0, 32))
1779
+ return
1780
+ }
1781
+
1782
+ if (sub === 'run' || sub === undefined) {
1783
+ try {
1784
+ await runReplica({ dir })
1785
+ } catch (e) {
1786
+ if (e?.code === 'replica-not-enrolled') {
1787
+ console.error('\n Este replicador todavía no está en ninguna cuenta.\n')
1788
+ console.error(' En la bóveda: dotrino-vault pair --scope replica --quiet')
1789
+ console.error(' Y aquí, con eso: dotrino-vault replica enroll "<invitación>"\n')
1790
+ process.exit(2)
1791
+ }
1792
+ throw e
1793
+ }
1794
+ await new Promise(() => {})
1795
+ return
1796
+ }
1797
+ console.error('uso: dotrino-vault replica [enroll <invitación>|run|status]')
1798
+ process.exit(2)
1799
+ }
1800
+
1700
1801
  export async function runCtl (argv) {
1701
1802
  const [cmd, ...rest] = takeProfileFlag(argv)
1702
1803
  switch (cmd) {
@@ -1722,6 +1823,7 @@ export async function runCtl (argv) {
1722
1823
  case 'activity': return cmdActivity(Number(rest.find((a) => /^\d+$/.test(a))) || 30, {
1723
1824
  verify: rest.includes('--verify'), exportar: rest.includes('--export')
1724
1825
  })
1826
+ case 'replica': return cmdReplica(rest)
1725
1827
  case 'logs': return cmdLogs()
1726
1828
  case 'version':
1727
1829
  case '--version':
package/src/daemon.js CHANGED
@@ -191,7 +191,19 @@ export async function runDaemon () {
191
191
  // `vault:secrets:<ns>`. Se valida aquí porque es la maestra la que firma: nada
192
192
  // que no esté en esta lista entra en un cert, y `vault:admin` / `vault:approve` nunca
193
193
  // por este camino (se conceden a mano con `caps`).
194
- const ALLOWED = (x) => x === 'vault:sign' || x === 'vault:read' || x === 'vault:store' || /^vault:secrets:[a-z0-9-]{1,32}$/.test(x)
194
+ //
195
+ // LA LISTA VA EXPLÍCITA A PROPÓSITO, y es la única del ecosistema que debe estarlo:
196
+ // aquí no se dibuja una pantalla, se decide qué firma la maestra. Derivarla del
197
+ // pilar significaría que un permiso nuevo se pudiera emparejar el día que alguien lo
198
+ // añade, sin que nadie lo haya pensado. `vault:admin` y `vault:approve` no están, y
199
+ // esa ausencia ES la regla.
200
+ //
201
+ // `vault:passwords` faltaba y el CLI sí lo ofrecía: emparejar el gestor de
202
+ // contraseñas con su permiso se rechazaba aquí. `vault:replica` entra el 2026-09-02:
203
+ // un replicador reparte sobres que no puede abrir, así que se puede emparejar —al
204
+ // revés que sellar o administrar— y se despliega donde no hay teclado.
205
+ const ALLOWED = (x) => x === 'vault:sign' || x === 'vault:read' || x === 'vault:store' ||
206
+ x === 'vault:passwords' || x === 'vault:replica' || /^vault:secrets:[a-z0-9-]{1,32}$/.test(x)
195
207
  const asked = Array.isArray(pairReq?.scope) ? pairReq.scope.filter((x) => typeof x === 'string') : null
196
208
  if (asked && asked.some((x) => !ALLOWED(x))) {
197
209
  ipcWrite(pairFile, { v: 2, at: Date.now(), error: 'scope not allowed: ' + asked.filter((x) => !ALLOWED(x)).join(',') })
@@ -555,6 +567,10 @@ export async function runDaemon () {
555
567
  console.log('[vault] %d secret(s) applied in one go: %s', changed.length, sec.pub ? 'device' : sec.ns)
556
568
  } else if (sec.op === 'set') { await vault.setSecret(sec.ns, sec.key, sec.value, sec.public); console.log('[vault] secret saved: %s/%s', sec.ns, sec.key) }
557
569
  else if (sec.op === 'rm') { await vault.deleteSecret(sec.ns, sec.key); console.log('[vault] secret deleted: %s/%s', sec.ns, sec.key) }
570
+ // Renombrar NO pide la frase: solo hay que volver a firmar el sobre —el nombre va
571
+ // dentro de lo firmado— y eso lo hace la llave de sellado con la bóveda cerrada.
572
+ else if (sec.op === 'rename') { await vault.renameSecret(sec.ns, sec.key, sec.to); console.log('[vault] secret renamed: %s/%s → %s', sec.ns, sec.key, sec.to) }
573
+ else if (sec.op === 'dev-rename') { await vault.renameDeviceSecret(sec.pub, sec.key, sec.to); console.log('[vault] device secret renamed: %s → %s', sec.key, sec.to) }
558
574
  else if (sec.op === 'dev-set') { await vault.setDeviceSecret(sec.pub, sec.key, sec.value, sec.public); console.log('[vault] device secret saved: %s', sec.key) }
559
575
  else if (sec.op === 'dev-rm') { await vault.deleteDeviceSecret(sec.pub, sec.key); console.log('[vault] device secret deleted: %s', sec.key) }
560
576
  // Saldar lo que quedó a deber: heredarle a un aparato nuevo lo ya guardado y
package/src/replica.js ADDED
@@ -0,0 +1,232 @@
1
+ /**
2
+ * replica.js — UNA BÓVEDA QUE REPARTE Y NO DECIDE.
3
+ *
4
+ * Diseño: `docs/replicas.md` §8.bis. Es la otra pieza del par, y NO sustituye al
5
+ * multivault: una segunda bóveda tiene maestra y sella; un replicador no tiene maestra y
6
+ * no sella nunca. Las dos se conservan.
7
+ *
8
+ * Qué guarda, y no hay una cuarta cosa:
9
+ *
10
+ * · su LLAVE DE APARATO y su papel — para ser alcanzable y para firmar lo que contesta;
11
+ * · el ACTA vigente — pública y firmada, la verifica quien la recibe;
12
+ * · los SOBRES — que ya vienen cerrados a su destinatario.
13
+ *
14
+ * Por qué se puede poner en una máquina ajena, que es el punto entero: **no puede abrir
15
+ * nada de lo que reparte**. Entra al acta SIN llave de cifrado (`withEncKey: false`), así
16
+ * que no existe camino por el que le llegue un sobre dirigido a él — regla del dueño:
17
+ * «recibirá todos los sobres que se generen, ningún sobre firmado para él». Que lo
18
+ * comprometan cuesta disponibilidad, no confidencialidad.
19
+ *
20
+ * Y lo que NO hace, comprobado y no prometido: no sella actas, no emite certificados, no
21
+ * admite aparatos, no cambia permisos. Si algo de eso le llega, contesta que no.
22
+ */
23
+ import fs from 'node:fs'
24
+ import path from 'node:path'
25
+ import os from 'node:os'
26
+ import { WebSocketProxyClient } from '@dotrino/proxy-client'
27
+ import { signWithDevice, verifyDeviceSig, pubkeyId } from '@dotrino/identity/capabilities'
28
+ import { verifyActa, memberCan, sealKeyAt } from '@dotrino/identity/acta'
29
+ import { MSG } from '../lib/src/protocol.js'
30
+ import { seal } from '../lib/src/sealed.js'
31
+ import { atRestFor } from './atrest.js'
32
+ import { readJson, writeJson } from './paths.js'
33
+
34
+ export const REPLICA_SCOPE = 'vault:replica'
35
+
36
+ export const replicaDir = () =>
37
+ process.env.DOTRINO_REPLICA_DIR || path.join(os.homedir(), '.dotrino', 'replica')
38
+
39
+ const LINK = 'link.json'
40
+ const STATE = 'state.json'
41
+
42
+ /**
43
+ * MEZCLAR DOS VERSIONES DEL MISMO PAQUETE, DATO A DATO.
44
+ *
45
+ * Antes se reemplazaba el paquete entero, y eso pierde datos en un caso concreto: una
46
+ * bóveda restaurada de un respaldo empuja un paquete donde un dato está más atrasado que
47
+ * el que ya tenemos. El `seq` del acta no lo caza —el acta puede ir por delante mientras
48
+ * un dato va por detrás—, así que la comparación tiene que ser POR DATO.
49
+ *
50
+ * Gana la GENERACIÓN más alta, que es la que sube en cada escritura. Un empate se rompe
51
+ * igual en todas partes —gana el `ct` menor— y no es justicia, es determinismo: dos
52
+ * replicadores que reciban lo mismo en distinto orden acaban idénticos.
53
+ *
54
+ * Las envolturas se SUMAN: son de aparatos distintos y ninguna estorba a otra.
55
+ */
56
+ export function mergeBundle (viejo, nuevo) {
57
+ if (!viejo) return nuevo
58
+ if (!nuevo) return viejo
59
+ const entries = { ...(viejo.entries || {}) }
60
+ for (const [k, e] of Object.entries(nuevo.entries || {})) {
61
+ const a = entries[k]
62
+ if (!a) { entries[k] = e; continue }
63
+ const ga = a.gen || 0
64
+ const gb = e.gen || 0
65
+ if (gb > ga) entries[k] = e
66
+ else if (gb === ga && String(e.e?.ct ?? e.pubv ?? '') < String(a.e?.ct ?? a.pubv ?? '')) entries[k] = e
67
+ }
68
+ const porGen = new Map()
69
+ for (const w of [...(viejo.wraps || []), ...(nuevo.wraps || [])]) porGen.set(w.gen, w)
70
+ return { ...viejo, ...nuevo, entries, wraps: [...porGen.values()].sort((x, y) => x.gen - y.gen) }
71
+ }
72
+
73
+ /** La clave de un sobre: es POR (cajón, aparato), porque va tallado a quien lo pide. */
74
+ export const bundleKey = (ns, devicePub) => `${ns} ${devicePub}`
75
+
76
+ /** Lo que este replicador sabe de la cuenta: su papel, el acta y los sobres. */
77
+ export function openReplicaStore (dir) {
78
+ fs.mkdirSync(dir, { recursive: true })
79
+ const atRest = atRestFor(dir)
80
+ const leer = (f, d) => readJson(path.join(dir, f), d, atRest)
81
+ const escribir = (f, v) => writeJson(path.join(dir, f), v, atRest)
82
+
83
+ let link = leer(LINK, null)
84
+ let state = leer(STATE, null) || { seq: 0, acta: null, bundles: {} }
85
+
86
+ return {
87
+ get link () { return link },
88
+ get acta () { return state.acta },
89
+ get seq () { return state.seq },
90
+ setLink (v) { link = v; escribir(LINK, v) },
91
+ bundleFor (ns, devicePub) { return state.bundles[bundleKey(ns, devicePub)] || null },
92
+ get bundles () { return Object.keys(state.bundles).length },
93
+ /**
94
+ * SOLO HACIA ADELANTE. Es el mismo freno que el pin del cliente: un empujón con un
95
+ * acta más vieja de la que ya tenemos es un rollback, y aceptarlo convertiría al
96
+ * replicador en el camino para servir un acta donde un aparato revocado sigue dentro.
97
+ * @returns {boolean} si se aplicó (false = venía atrasado y se descartó)
98
+ */
99
+ apply ({ seq, acta, bundles }) {
100
+ if (typeof seq !== 'number' || seq < state.seq) return false
101
+ const mezclados = { ...state.bundles }
102
+ for (const [k, nuevo] of Object.entries(bundles || {})) mezclados[k] = mergeBundle(mezclados[k], nuevo)
103
+ state = { seq, acta: acta || state.acta, bundles: mezclados }
104
+ escribir(STATE, state)
105
+ return true
106
+ }
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Arranca el replicador. No crea ninguna maestra ni ningún perfil: si no está enrolado,
112
+ * lo dice y se para. Estrenar una identidad aquí sería justo lo que no debe existir.
113
+ */
114
+ export async function runReplica ({ dir = replicaDir(), proxyUrl, log = console.log } = {}) {
115
+ const store = openReplicaStore(dir)
116
+ const link = store.link
117
+ if (!link?.device?.privateJwk || !link?.cert || !link?.iss) {
118
+ throw Object.assign(
119
+ new Error('this replica is not enrolled yet: run `dotrino-vault replica enroll <invitation>` here first'),
120
+ { code: 'replica-not-enrolled' })
121
+ }
122
+ const url = proxyUrl || link.proxy || process.env.PROXY_URL || 'wss://proxy.dotrino.com'
123
+ const id = (await pubkeyId(link.device.publickey)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
124
+
125
+ const firmar = (data) => signWithDevice({ privateJwk: link.device.privateJwk, data })
126
+ // CONTESTAR es por la CONEXIÓN (`send`) y no por la llave (`sendByPubkey`): `from` es el
127
+ // identificador de quien escribe en el proxio, no su pubkey. Mandar por llave a un `from`
128
+ // no falla — se va a la nada, y quien preguntó se queda esperando el plazo entero.
129
+ // Por llave solo se habla con la BÓVEDA (`link.iss`), que es a quien queremos alcanzar
130
+ // aunque no esté conectada ahora mismo.
131
+
132
+ const client = new WebSocketProxyClient({ url })
133
+ await client.connect()
134
+ const data = { op: 'identify', publickey: link.device.publickey, token: client.token, ts: Date.now() }
135
+ await client.identify({ data, signature: (await firmar(data)).signature })
136
+ log(`[replica] ${id} · connected to ${url}`)
137
+ log(`[replica] record #${store.seq}${store.acta ? '' : ' · no record yet: waiting for the vault'}`)
138
+
139
+ /**
140
+ * UN EMPUJÓN DE LA BÓVEDA. Va firmado con la llave de sellado que el acta nombra, así
141
+ * que se comprueba de dónde viene antes de guardar nada. Y se comprueba contra el acta
142
+ * QUE VIENE, después de verificar que la selló la maestra que este replicador conoce:
143
+ * fiarse de la que ya tiene impediría para siempre recibir una nueva.
144
+ */
145
+ async function onPush (from, p) {
146
+ const body = p?.body
147
+ if (!body || body.op !== 'replica.push') return
148
+ const acta = body.acta || store.acta
149
+ if (!acta) return log('[replica] a push arrived with no record and none is stored: ignored')
150
+ if (acta.sealedBy !== link.iss) return log('[replica] the pushed record was sealed by another master: ignored')
151
+ if (!(await verifyActa({ acta })).ok) return log('[replica] the pushed record does not verify: ignored')
152
+ const pub = sealKeyAt(acta, p.seal?.seq)
153
+ if (!pub || !(await verifyDeviceSig({ publickey: pub, data: body, signature: p.seal?.sig }))) {
154
+ return log('[replica] the push is not signed by the key the record names: ignored')
155
+ }
156
+ if (!memberCan(acta, link.device.publickey, 'replica')) {
157
+ return log('[replica] the record no longer lets this device serve as a replica: ignored')
158
+ }
159
+ if (!store.apply({ seq: body.seq, acta, bundles: body.bundles })) {
160
+ return log(`[replica] a push arrived with an older record (#${body.seq} < #${store.seq}): refused`)
161
+ }
162
+ log(`[replica] record #${store.seq} · ${Object.keys(body.bundles || {}).length} bundle(s) stored`)
163
+ const ack = { op: 'replica.ack', publickey: link.device.publickey, seq: store.seq, ts: Date.now() }
164
+ client.send(from, { type: MSG.REPLICA_ACK, body: ack, signature: (await firmar(ack)).signature })
165
+ }
166
+
167
+ /**
168
+ * UNA PETICIÓN DE CLAVES. Se contesta con lo que hay guardado, cerrado a la efímera de
169
+ * quien pregunta y firmado con NUESTRA llave — que es lo que el cliente acepta cuando el
170
+ * acta nos reconoce `replica` (`verifyResponder` de `@dotrino/vault`).
171
+ *
172
+ * Lo que NO se decide aquí, y es a propósito: si quien pide tiene derecho a ese cajón.
173
+ * Lo dice el sobre, no nosotros — va cerrado a su llave, así que a quien no le toque le
174
+ * llega algo que no puede abrir. Un replicador que decidiera quién lee sería un
175
+ * replicador del que hay que fiarse, y la gracia es que no haga falta.
176
+ */
177
+ async function onSecrets (from, p) {
178
+ const ns = p?.data?.ns
179
+ const ek = p?.data?.ek
180
+ const quien = p?.data?.publickey
181
+ if (typeof ns !== 'string' || !ek || !quien) return
182
+ if (!store.acta) return client.send(from, { type: MSG.ERROR, error: 'replica: no record yet' })
183
+ const b = store.bundleFor(ns, quien)
184
+ if (!b) {
185
+ log(`[replica] asked for "${ns}" and has nothing stored for that device`)
186
+ return client.send(from, {
187
+ type: MSG.ERROR,
188
+ error: `replica: nothing stored for "${ns}" and this device — ask the vault once while it is up`
189
+ })
190
+ }
191
+ const enc = await seal({ ek, payload: { sealed: b, acta: store.acta } })
192
+ const body = { op: 'secrets.result', ns, enc, ts: Date.now() }
193
+ // `by` y no `seq`: no tenemos la llave de sellado y no debemos tenerla. Quien recibe
194
+ // mira el acta, ve que a esta llave le reconoce `replica`, y acepta.
195
+ const { signature } = await firmar(body)
196
+ client.send(from, { type: MSG.SECRETS_RESULT, body, seal: { by: link.device.publickey, sig: signature } })
197
+ log(`[replica] served "${ns}" from record #${store.seq}`)
198
+ }
199
+
200
+ /** Todo lo que DECIDE algo se contesta que no, y se dice por qué. */
201
+ const NO_DECIDE = new Set([MSG.ENROLL, MSG.ADMIN, MSG.RENEW, MSG.RENOUNCE, MSG.SIGN, MSG.ACTA_SEALED])
202
+
203
+ client.on('message', (from, p) => {
204
+ const t = p?.type
205
+ if (t === MSG.REPLICA_PUSH) return onPush(from, p).catch((e) => log('[replica] push: ' + e.message))
206
+ if (t === MSG.SECRETS) return onSecrets(from, p).catch((e) => log('[replica] secrets: ' + e.message))
207
+ if (NO_DECIDE.has(t)) {
208
+ log(`[replica] refused "${t}": a replica hands things out, it does not decide`)
209
+ return client.send(from, {
210
+ type: MSG.ERROR,
211
+ error: 'replica: this is a replica — it has no master and decides nothing'
212
+ })
213
+ }
214
+ })
215
+
216
+ // EL ARRASTRE AL ARRANCAR (`docs/replicas.md` §6). Se dice por dónde vamos en cuanto hay
217
+ // conexión: si la bóveda va por delante, contesta con un empujón. Sin esto, un
218
+ // replicador recién encendido se queda vacío hasta que alguien cambie algo — y el
219
+ // empujón del propio enrolamiento se manda cuando este proceso todavía no existe.
220
+ const saludo = { op: 'replica.ack', publickey: link.device.publickey, seq: store.seq, ts: Date.now() }
221
+ try {
222
+ client.sendByPubkey(link.iss, { type: MSG.REPLICA_ACK, body: saludo, signature: (await firmar(saludo)).signature })
223
+ } catch (e) { log('[replica] could not say hello to the vault: ' + e.message) }
224
+
225
+ log('[replica] ready · it hands out what the vault already signed, and nothing else')
226
+ return {
227
+ id,
228
+ publickey: link.device.publickey,
229
+ seq: () => store.seq,
230
+ stop: () => { try { client.close() } catch (_) {} }
231
+ }
232
+ }
@@ -72,6 +72,32 @@ const KEY_RE = /^[A-Z0-9_]{1,64}$/
72
72
  */
73
73
  export const RECOVERY = '#recovery'
74
74
 
75
+ /**
76
+ * EL CAJÓN DEL PERFIL. Los datos de tu identidad se guardan como cualquier variable —
77
+ * mismo sobre, mismo llavero, mismo histórico— porque son lo mismo: un valor que solo
78
+ * pueden leer tus aparatos. Diseño: `docs/datos-del-perfil.md`.
79
+ *
80
+ * SE LLAMA `@me` A PROPÓSITO, y es la garantía entera: un nombre de cajón válido es
81
+ * `[a-z0-9-]{1,32}`, así que **ningún servicio puede llamarse `@me`**. Por lo tanto
82
+ * `memberCanReadSecrets(acta, quien, '@me')` no puede ser cierto nunca y no hay forma de
83
+ * pedir el perfil por el camino de los secretos. No es una comprobación que alguien pueda
84
+ * olvidarse de hacer: es un nombre que el validador no deja existir.
85
+ */
86
+ export const PROFILE_NS = '@me'
87
+ export const PROFILE_OWNER = 'ns:' + PROFILE_NS
88
+
89
+ /**
90
+ * El nombre de un dato del perfil. NO vale `assertVar`: aquélla exige MAYÚSCULAS porque
91
+ * describe una variable de entorno, y aquí los campos son `nickname`, `telefono`,
92
+ * `apellidos`. Mismo espíritu —un nombre corto y previsible, sin sorpresas al serializar—
93
+ * y distinto alfabeto.
94
+ */
95
+ export function assertProfileKey (key) {
96
+ if (typeof key !== 'string' || !/^[a-z][a-z0-9_]{0,31}$/.test(key)) {
97
+ throw new Error('invalid profile field (use lowercase_with_underscores, e.g. "nickname")')
98
+ }
99
+ }
100
+
75
101
  /** Cuántas versiones anteriores se conservan, en total. Ver «EL HISTÓRICO» arriba. */
76
102
  const MAX_HISTORY = 500
77
103
 
@@ -818,6 +844,75 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
818
844
  owners () {
819
845
  return [...Object.keys(data.ns).map((k) => `ns:${k}`), ...Object.keys(data.dev).map((k) => `dev:${k}`)]
820
846
  },
847
+ // ---------------- EL PERFIL (`docs/datos-del-perfil.md`) ----------------
848
+ //
849
+ // Escribir un dato PRIVADO no necesita nada de aquí: es `putSealed(PROFILE_OWNER, …)`,
850
+ // que ya guarda un sobre hecho sin abrirlo y funciona con la bóveda cerrada. Lo que
851
+ // falta es leerlo, y el camino de lo PÚBLICO, que no se parece a ninguna variable.
852
+
853
+ /**
854
+ * Los sobres del perfil TALLADOS a un aparato: sus entradas y las envolturas suyas.
855
+ * Igual que `bundleFor`, sin `assertNs` — `@me` no es un nombre de cajón válido, y esa
856
+ * es justamente su garantía.
857
+ *
858
+ * El PAQUETE LO ARMA EL APARATO (dueño, 2026-09-03): aquí solo se entrega lo que hay.
859
+ */
860
+ profileBundleFor (devicePub) {
861
+ const bag = data.ns
862
+ const k = PROFILE_NS
863
+ if (isLegacy() || !bag[k]) return { entries: {}, wraps: [] }
864
+ const wraps = (bag[k].keyring || [])
865
+ .filter((g) => g.wraps?.[devicePub])
866
+ .map((g) => ({ gen: g.gen, wrap: g.wraps[devicePub] }))
867
+ return { entries: { ...varsOf(bag, k) }, wraps }
868
+ },
869
+
870
+ /**
871
+ * LO PÚBLICO DE VERDAD: en claro, y firmado. Es lo que recibe quien pregunta desde
872
+ * fuera y no tiene ninguna llave tuya — no hay a quién sellárselo (dueño, 2026-09-03).
873
+ *
874
+ * Va firmado igual que lo demás: un dato público sin firma es un dato que cualquiera
875
+ * puede inventar en tu nombre, que es peor que no tenerlo.
876
+ */
877
+ profilePublic () {
878
+ const bag = data.ns[PROFILE_NS]
879
+ if (!bag?.vars) return {}
880
+ const out = {}
881
+ for (const [key, e] of Object.entries(bag.vars)) {
882
+ if (e?.cls !== 'public' || typeof e.pubv !== 'string') continue
883
+ out[key] = { v: e.pubv, gen: e.gen, seal: e.seal || null, at: e.at || null, by: e.by || null }
884
+ }
885
+ return out
886
+ },
887
+
888
+ /**
889
+ * Guarda un dato PÚBLICO del perfil: en claro y con su firma. No lleva sobre ni
890
+ * envoltura porque no se le sella a nadie.
891
+ *
892
+ * `pubv` y no `v`: un `v` suelto dentro de un paquete de variables es un error duro
893
+ * desde el 2026-09-02 (`plaintext-var`), y con razón. Aquí es lo correcto, así que se
894
+ * escribe con otro nombre — el paquete del perfil lo arma otro código y esa invariante
895
+ * de los secretos se queda intacta.
896
+ */
897
+ async profilePutPublic (key, value, { by = null } = {}) {
898
+ return enFila(async () => {
899
+ if (isLegacy()) throw new Error('profile: this store is still v3 (run `migrate` first)')
900
+ if (needsMigration()) throw new NeedsMigration()
901
+ assertProfileKey(key)
902
+ if (typeof value !== 'string' || value.length > 200000) {
903
+ throw new Error('a public profile field must be a string of at most 200 kB')
904
+ }
905
+ const vars = ensureBag(data.ns, PROFILE_NS)
906
+ const before = vars[key]
907
+ const gen = (topGen(data.ns, PROFILE_NS)?.gen || 0) + 1
908
+ const seal = signer ? await signer({ owner: PROFILE_OWNER, key, gen, iv: '', ct: value }) : null
909
+ pushHistory(PROFILE_OWNER, key, before, by)
910
+ vars[key] = { cls: 'public', pub: true, owner: PROFILE_OWNER, gen, pubv: value, seal, at: Date.now(), by: by || null }
911
+ save()
912
+ return { gen }
913
+ })
914
+ },
915
+
821
916
  /** @private */
822
917
  /**
823
918
  * @param {Array|((isPublic:boolean)=>Promise<Array>)} members A quién envolver. Puede ser
@@ -895,6 +990,47 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
895
990
  return { wrapped, sealed, sinLlave: [...sinLlave] }
896
991
  },
897
992
 
993
+ /**
994
+ * RENOMBRAR UNA VARIABLE. Cambia el nombre y NADA MÁS: el mismo sobre, la misma
995
+ * generación, las mismas envolturas. El valor no se toca ni se mira.
996
+ *
997
+ * Lo único que hay que rehacer es la FIRMA, porque el nombre va dentro de lo firmado
998
+ * (§8.8: `{owner, key, gen, iv, ct}`). Sin eso el sobre quedaría diciendo que se llama
999
+ * de otra manera y el agente lo rechazaría al abrirlo — un fallo que aparecería lejos
1000
+ * de aquí y sin pista.
1001
+ *
1002
+ * Y firmar de nuevo NO pide la frase: la llave de sellado existe justo para esto, no
1003
+ * abre nada. Así que renombrar funciona con la bóveda cerrada, como escribir.
1004
+ *
1005
+ * El HISTÓRICO se muda con ella: es la misma variable, y dejarlo bajo el nombre viejo
1006
+ * lo volvería inalcanzable — se vería como un hueco, no como una decisión.
1007
+ */
1008
+ async rename (owner, from, to) { return enFila(() => this._renameRaw(owner, from, to)) },
1009
+ /** @private */
1010
+ async _renameRaw (owner, from, to) {
1011
+ if (needsMigration()) throw new NeedsMigration()
1012
+ assertKeyValue(to, 'x')
1013
+ const [kind, k] = splitOwner(owner)
1014
+ const bag = kind === 'ns' ? data.ns : data.dev
1015
+ const vars = varsOf(bag, k)
1016
+ const e = vars[from]
1017
+ if (!e) throw new Error(`there is no variable called "${from}" in ${owner}`)
1018
+ if (from === to) return { renamed: false }
1019
+ if (vars[to]) throw new Error(`"${to}" already exists in ${owner}: pick another name or remove it first`)
1020
+
1021
+ // La firma lleva el nombre dentro, así que se rehace. Si esta bóveda no puede firmar,
1022
+ // se para: dejar el sobre con la firma vieja es dejarlo roto para quien lo abra.
1023
+ if (e.e) {
1024
+ if (!signer) throw new Error('this vault cannot sign right now: renaming would leave the envelope signed with the old name')
1025
+ e.seal = await signer({ owner, key: to, gen: e.gen, iv: e.e.iv, ct: e.e.ct })
1026
+ }
1027
+ delete vars[from]
1028
+ vars[to] = e
1029
+ for (const h of data.history) if (h.owner === owner && h.key === from) h.key = to
1030
+ save()
1031
+ return { renamed: true }
1032
+ },
1033
+
898
1034
  /**
899
1035
  * ROTA de verdad: vuelve a cifrar las variables privadas del cajón con llaves nuevas
900
1036
  * y solo para los miembros dados. Es lo que corta el acceso de quien salió —
@@ -103,6 +103,16 @@ export function openThreadStore (dir) {
103
103
  },
104
104
  profileGet () { return { me: data.profile || null } },
105
105
 
106
+ // LOS DATOS DEL PERFIL EN SOBRES (`docs/datos-del-perfil.md`). Están aquí solo para
107
+ // que el filtro de métodos los reconozca: los atiende `handleProfile` en `vault.js`,
108
+ // antes de llegar a este store, porque su puerta es otra —`firma` y sin candado—.
109
+ // Si alguna vez alguien los llamara por este camino, contestan que no en vez de
110
+ // hacer algo a medias.
111
+ profilePut () { throw new Error('profilePut is handled before the store: see handleProfile') },
112
+ profileBundle () { throw new Error('profileBundle is handled before the store: see handleProfile') },
113
+ profilePublic () { throw new Error('profilePublic is handled before the store: see handleProfile') },
114
+ profileRecipients () { throw new Error('profileRecipients is handled before the store: see handleProfile') },
115
+
106
116
  // ----- DATOS SENSIBLES del usuario (F4, docs/consola-remota.md §6) -----
107
117
  //
108
118
  // Contraseñas, notas, documentos: van al contenido del perfil, cifrados con la
package/src/transport.js CHANGED
@@ -46,11 +46,33 @@ export async function createTransport ({ identity, dir, url = DEFAULT_PROXY, com
46
46
  // su `identify` pisaba al anterior. Sigue siendo el cliente oficial del paquete.
47
47
  const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
48
48
 
49
- // WebRTC off: el vault usa el proxy como transporte (RTCPeerConnection no existe
50
- // en Node). Reconexión prácticamente ilimitada: un daemon de larga duración no
51
- // debe rendirse tras unos intentos.
49
+ /**
50
+ * EL CANAL DIRECTO, ENCENDIDO Y CON LA PUERTA CERRADA.
51
+ *
52
+ * Encendido porque es el escalón 2 de la regla del transporte (`CLAUDE.md`) y en Node ya
53
+ * hay con qué (`werift`, que el pilar carga solo). Si no lo hubiera, negociar falla y se
54
+ * sigue por el proxio — el escalón 4 siempre está.
55
+ *
56
+ * Y con la puerta cerrada desde el primer día, que es lo que hace defendible encenderlo:
57
+ * **solo se le negocia a quien está en el acta.** Aceptar una señal arranca DTLS, ICE y
58
+ * SCTP, o sea código que parsea red no confiable, dentro del proceso que guarda la
59
+ * maestra. Quien decide si eso corre no puede ser el que llama a la puerta.
60
+ *
61
+ * Reconexión prácticamente ilimitada: un daemon de larga duración no debe rendirse tras
62
+ * unos intentos.
63
+ */
64
+ // La puerta tiene que decidir AL INSTANTE (la señal llega y o se atiende o no), y pedir
65
+ // el acta es asíncrono. Así que se guarda una copia y se refresca donde ya se refresca
66
+ // todo: en `identify`, que es lo que corre cada vez que el acta cambia o se reconecta.
67
+ let actaVista = null
68
+ const enElActa = (token) => {
69
+ // SIN ACTA NO SE NEGOCIA. No se sabe quién es nadie, y en la duda no se arranca a
70
+ // parsear red no confiable. Se sigue por el proxio, que es el escalón que siempre está.
71
+ if (!actaVista) return false
72
+ return (actaVista.members || []).some((m) => m?.pub === token)
73
+ }
52
74
  const client = new WebSocketProxyClient({
53
- url, enableWebRTC: false, autoReconnect: true,
75
+ url, enableWebRTC: true, acceptDirectFrom: enElActa, autoReconnect: true,
54
76
  maxReconnectAttempts: 100000, reconnectDelay: 4000
55
77
  })
56
78
 
@@ -59,6 +81,7 @@ export async function createTransport ({ identity, dir, url = DEFAULT_PROXY, com
59
81
  const identify = async () => {
60
82
  if (!client.token) return
61
83
  const record = (await identity.profileActa?.().catch(() => null))?.acta || null
84
+ actaVista = record // la copia que mira la puerta del canal directo
62
85
  const comm = commKey?.pub?.() || null
63
86
  // ¿Nos nombra el acta? Con `cn` o sin él, lo que el proxio mira es la pertenencia.
64
87
  const esMiembro = comm && (record?.members || []).some((m) => m?.pub === comm)