@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/lib/src/admin.js +29 -3
- package/lib/src/enroll.js +12 -1
- package/lib/src/index.js +4 -1
- package/lib/src/localdesk.js +96 -0
- package/lib/src/service.js +185 -34
- package/package.json +6 -3
- package/src/approvals.js +7 -2
- package/src/client.js +4 -1
- package/src/ctl.js +105 -3
- package/src/daemon.js +17 -1
- package/src/replica.js +232 -0
- package/src/secretsStore.js +136 -0
- package/src/threadStore.js +10 -0
- package/src/transport.js +27 -4
- package/src/tui/app.js +76 -9
- package/src/tui/i18n.js +85 -55
- package/src/vault.js +298 -15
- package/src/vaultControl.js +24 -0
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
|
-
|
|
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
|
+
}
|
package/src/secretsStore.js
CHANGED
|
@@ -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ó —
|
package/src/threadStore.js
CHANGED
|
@@ -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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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:
|
|
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)
|