@dotrino/vaultd 0.96.0 → 0.98.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/src/enroll.js +12 -1
- package/lib/src/service.js +110 -16
- package/package.json +2 -2
- package/src/ctl.js +86 -1
- package/src/daemon.js +18 -3
- package/src/replica.js +199 -0
- package/src/secretsStore.js +36 -3
- package/src/tui/app.js +36 -9
- package/src/tui/i18n.js +51 -38
- package/src/vault.js +90 -3
package/lib/src/enroll.js
CHANGED
|
@@ -52,7 +52,18 @@ export const MSG_REVOKED = 'vault.revoked'
|
|
|
52
52
|
export const MSG_ERROR = 'vault.error'
|
|
53
53
|
|
|
54
54
|
/** Los scopes del cert se corresponden 1:1 con las capacidades del acta (§D7). */
|
|
55
|
-
const SCOPE_TO_CAP = {
|
|
55
|
+
const SCOPE_TO_CAP = {
|
|
56
|
+
'vault:sign': 'sign',
|
|
57
|
+
'vault:store': 'store',
|
|
58
|
+
'vault:read': 'read',
|
|
59
|
+
'vault:admin': 'admin',
|
|
60
|
+
'vault:passwords': 'passwords',
|
|
61
|
+
// `replica` SÍ se empareja, al revés que `sealer` y `admin`. La razón es la misma que
|
|
62
|
+
// hace estrecho al permiso: un replicador reparte sobres que no puede abrir y no cambia
|
|
63
|
+
// nada. Y se despliega sin teclado —un contenedor, una máquina ajena—, que es justo
|
|
64
|
+
// donde obligar a un segundo paso a mano es el paso que nadie da.
|
|
65
|
+
'vault:replica': 'replica'
|
|
66
|
+
}
|
|
56
67
|
export const scopeToCaps = (scope) =>
|
|
57
68
|
(Array.isArray(scope) ? scope : [scope]).map((s) => SCOPE_TO_CAP[s]).filter(Boolean)
|
|
58
69
|
|
package/lib/src/service.js
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
makeDeviceKey, makeDeviceEncKey, importDeviceEncKey, signWithDevice, verifyDelegation,
|
|
26
26
|
verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from '@dotrino/identity/capabilities'
|
|
27
27
|
import { openWrap, wrapForMember, decryptWithCek } from '@dotrino/identity/content'
|
|
28
|
-
import { verifyActa, sealKeyAt, sealersOf } from '@dotrino/identity/acta'
|
|
28
|
+
import { verifyActa, sealKeyAt, sealersOf, memberCan } from '@dotrino/identity/acta'
|
|
29
29
|
import { MSG, secretsScope, isValidSecretsNs } from './protocol.js'
|
|
30
30
|
import { makeEphemeralKey, openSealed } from './sealed.js'
|
|
31
31
|
import { parseInvite } from './invite.js'
|
|
@@ -241,7 +241,17 @@ function writeServiceIdentity (dir, obj) {
|
|
|
241
241
|
* @param {number} [opts.approveTimeoutMs]
|
|
242
242
|
* @returns {Promise<{device, enc:{publickey:string, privateJwk:object}, cert, iss:string, proxy:string}>}
|
|
243
243
|
*/
|
|
244
|
-
|
|
244
|
+
/**
|
|
245
|
+
* @param {boolean} [opts.withEncKey] Estrenar llave de CIFRADO y registrarla en el acta.
|
|
246
|
+
* Por defecto sí: sin ella el aparato entra pero no le llega ningún secreto.
|
|
247
|
+
*
|
|
248
|
+
* Un REPLICADOR entra con `false`, y no es una optimización: es su garantía. Regla del
|
|
249
|
+
* dueño (2026-09-02), *«recibirá todos los sobres que se generen, ningún sobre firmado
|
|
250
|
+
* para él»*. Sin `encPub` en el acta no hay a dónde envolverle nada, así que repartir no
|
|
251
|
+
* puede convertirse en leer ni por descuido ni por un cambio futuro. Es el mismo cerrojo
|
|
252
|
+
* que tiene la llave de comunicación.
|
|
253
|
+
*/
|
|
254
|
+
export async function enrollWithVault ({ qr, label = 'agent', expectedScope = null, onCode, approveTimeoutMs = 180000, withEncKey = true } = {}) {
|
|
245
255
|
// `parseInvite` y NO `JSON.parse`: el vault no imprime JSON desde hace rato.
|
|
246
256
|
// `dotrino-vault pair` emite la URL del QR y el código compacto (`c…`/`t…`, ver
|
|
247
257
|
// invite.js); `parseInvite` acepta todas las formas, incluida la vieja.
|
|
@@ -271,14 +281,15 @@ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = nu
|
|
|
271
281
|
try {
|
|
272
282
|
const device = await makeDeviceKey({ label })
|
|
273
283
|
// La llave de CIFRADO: es a la que la bóveda sella cada variable. Sin ella el
|
|
274
|
-
// aparato entra al acta pero no le llega ningún secreto, y no da error.
|
|
275
|
-
|
|
284
|
+
// aparato entra al acta pero no le llega ningún secreto, y no da error. Un replicador
|
|
285
|
+
// entra así A PROPÓSITO (ver `withEncKey`): reparte sobres cerrados y no abre ninguno.
|
|
286
|
+
const enc = withEncKey ? await makeDeviceEncKey() : null
|
|
276
287
|
const deviceId = (await pubkeyId(device.publickey)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
|
|
277
288
|
// Código de emparejamiento ALEATORIO: se muestra y NO se envía. La bóveda lo
|
|
278
289
|
// aprende solo cuando un humano lo tipea → aprobar exige TENER esta máquina.
|
|
279
290
|
const code = makePairingCode()
|
|
280
291
|
const commit = await commitCode({ code, dpub: device.publickey, sn: qr.sn })
|
|
281
|
-
const data = { op: 'enroll', intent: 'join', dpub: device.publickey, encPub: enc
|
|
292
|
+
const data = { op: 'enroll', intent: 'join', dpub: device.publickey, encPub: enc?.encPublickey || null, token: qr.token || qr.sn, sn: qr.sn, commit, label, ts: Date.now() }
|
|
282
293
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
283
294
|
|
|
284
295
|
const enrolled = new Promise((resolve, reject) => {
|
|
@@ -313,7 +324,7 @@ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = nu
|
|
|
313
324
|
})
|
|
314
325
|
if (!v.ok) throw new Error('invalid cert: ' + v.reason)
|
|
315
326
|
|
|
316
|
-
return { device, enc: { publickey: enc.encPublickey, privateJwk: enc.encPrivateJwk }, cert: res.cert, iss: qr.iss, proxy: qr.proxy || 'wss://proxy.dotrino.com' }
|
|
327
|
+
return { device, enc: enc ? { publickey: enc.encPublickey, privateJwk: enc.encPrivateJwk } : null, cert: res.cert, iss: qr.iss, proxy: qr.proxy || 'wss://proxy.dotrino.com' }
|
|
317
328
|
} finally { client.close() }
|
|
318
329
|
}
|
|
319
330
|
|
|
@@ -459,10 +470,23 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
459
470
|
saved = { ...saved, ...patch }
|
|
460
471
|
if (dir) { try { writeServiceIdentity(dir, saved) } catch (_) {} }
|
|
461
472
|
}
|
|
462
|
-
/**
|
|
473
|
+
/**
|
|
474
|
+
* Hasta qué acta hemos visto. Dispara la renovación la próxima vez, y desde que hay
|
|
475
|
+
* replicadores es además el freno anti-rollback (`verifyResponder`).
|
|
476
|
+
*
|
|
477
|
+
* SOLO SUBE. Antes escribía cualquier `seq` que llegara, también uno MENOR: bastaba que
|
|
478
|
+
* alguien contestara con un acta vieja para que el pin bajara y dejara de proteger de
|
|
479
|
+
* ahí en adelante. Un pin que retrocede no es un pin — se llama `maxSeq` en el diseño
|
|
480
|
+
* justo por esto (`acta-de-perfil.md` §2.3).
|
|
481
|
+
*/
|
|
463
482
|
const anotarActa = (acta) => {
|
|
464
|
-
if (typeof acta?.seq !== 'number'
|
|
465
|
-
|
|
483
|
+
if (typeof acta?.seq !== 'number') return
|
|
484
|
+
if (typeof saved?.actaSeq === 'number' && acta.seq <= saved.actaSeq) return
|
|
485
|
+
// Y QUIÉNES REPARTEN. Sale del acta, que llega firmada en cada respuesta, así que no
|
|
486
|
+
// hay una lista que mantener a mano ni un `.env` que se olvide: darle `replica` a un
|
|
487
|
+
// aparato basta para que sus servicios sepan preguntarle cuando la bóveda no esté.
|
|
488
|
+
const replicas = (acta.members || []).filter((m) => memberCan(acta, m.pub, 'replica')).map((m) => m.pub)
|
|
489
|
+
recordar({ actaSeq: acta.seq, replicas })
|
|
466
490
|
}
|
|
467
491
|
|
|
468
492
|
// DOS MOTIVOS PARA PEDIR PAPEL NUEVO:
|
|
@@ -503,16 +527,35 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
503
527
|
// poner por el camino. Quien decide qué se manda es la bóveda; esto solo es la petición.
|
|
504
528
|
const data = { op: 'secrets', ns, ek: eph.ek, publickey: device.publickey, ts: Date.now(), ...(publicOnly ? { publicOnly: true } : {}) }
|
|
505
529
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
530
|
+
// A QUIÉN SE LE PREGUNTA: primero a la bóveda, y si no contesta, a los REPLICADORES
|
|
531
|
+
// que el acta nombraba la última vez que la vimos (`docs/replicas.md` §8: «el servicio
|
|
532
|
+
// lleva una lista de bóvedas y las prueba en orden»).
|
|
533
|
+
//
|
|
534
|
+
// Sin esto, un replicador no servía absolutamente para nada: el mensaje se mandaba a
|
|
535
|
+
// la llave de la bóveda y, con la bóveda apagada, no llegaba a ninguna parte. Lo
|
|
536
|
+
// destapó el smoke con las dos máquinas encendidas y la bóveda muerta, que es
|
|
537
|
+
// exactamente el caso que un replicador existe para cubrir.
|
|
538
|
+
//
|
|
539
|
+
// La bóveda va PRIMERO siempre: un replicador solo puede estar igual de al día o menos.
|
|
540
|
+
const destinos = [masterPubkey, ...(saved?.replicas || []).filter((k) => k !== masterPubkey)]
|
|
541
|
+
// El plazo se reparte: no tiene sentido esperar el timeout entero a una bóveda apagada
|
|
542
|
+
// y quedarse sin tiempo para preguntarle a quien sí está.
|
|
543
|
+
const porDestino = Math.max(4000, Math.round(timeoutMs / destinos.length))
|
|
544
|
+
let res = null
|
|
545
|
+
let ultimo = null
|
|
546
|
+
for (const destino of destinos) {
|
|
547
|
+
const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, porDestino)
|
|
548
|
+
client.sendByPubkey(destino, { type: MSG.SECRETS, data, signature, cert })
|
|
549
|
+
try { res = await pending; break } catch (e) { ultimo = e }
|
|
550
|
+
}
|
|
551
|
+
if (!res) throw ultimo || new Error('nobody answered: neither the vault nor any replica')
|
|
509
552
|
if (res.type === MSG.ERROR) throw new Error(res.error)
|
|
510
553
|
|
|
511
554
|
// CAJÓN CON APROBACIÓN: la bóveda contesta «pendiente» (firmado) y la respuesta de
|
|
512
555
|
// verdad llega cuando el aparato que aprueba firme — por esta misma conexión, que
|
|
513
556
|
// sigue identificada. Se espera lo que dura el pedido; denegado o vencido es error.
|
|
514
557
|
if (res.body?.op === 'secrets.pending' && res.body.ns === ns) {
|
|
515
|
-
await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey })
|
|
558
|
+
await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
|
|
516
559
|
anotarActa(res.acta)
|
|
517
560
|
try { onPending?.({ id: res.body.id, ns, exp: res.body.exp }) } catch (_) {}
|
|
518
561
|
const until = typeof res.body.exp === 'number' ? Math.max(5000, res.body.exp - Date.now() + 5000) : approvalTimeoutMs
|
|
@@ -534,7 +577,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
534
577
|
// confía en nadie — solo saca el acta con la que SÍ se comprueba. Nada de lo que hay
|
|
535
578
|
// dentro se usa hasta después de verificar.
|
|
536
579
|
const payload = await openSealed({ privateKey: eph.privateKey, enc: body.enc })
|
|
537
|
-
await verifyResponder({ body, seal: res.seal, acta: payload?.acta, masterPubkey })
|
|
580
|
+
await verifyResponder({ body, seal: res.seal, acta: payload?.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
|
|
538
581
|
anotarActa(payload?.acta)
|
|
539
582
|
|
|
540
583
|
// DOS CAPAS DE SOBRE, y hacen cosas distintas:
|
|
@@ -578,8 +621,32 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
578
621
|
* Sin el paso 1 el paso 2 no vale nada: un acta cualquiera nombraría la llave que
|
|
579
622
|
* quisiera.
|
|
580
623
|
*/
|
|
581
|
-
|
|
582
|
-
|
|
624
|
+
/**
|
|
625
|
+
* ¿QUIÉN PUEDE HABERME CONTESTADO ESTO? Hay dos respuestas legítimas y una condición.
|
|
626
|
+
*
|
|
627
|
+
* · **La bóveda.** Firma con la llave de sellado que el acta nombra para ese `seq`. Es
|
|
628
|
+
* el camino de siempre y no cambia.
|
|
629
|
+
* · **Un REPLICADOR** (`seal.by`). No tiene esa llave —no tiene maestra— así que firma
|
|
630
|
+
* con la suya de aparato, y lo que la autoriza es que el acta le reconozca `replica`.
|
|
631
|
+
* Reparte lo que la bóveda ya selló: los sobres de dentro se siguen comprobando contra
|
|
632
|
+
* la llave de sellado (`makeSealCheck`), así que un replicador no puede inventarse
|
|
633
|
+
* contenido, solo entregarlo.
|
|
634
|
+
*
|
|
635
|
+
* LA CONDICIÓN, y es la que cierra el agujero que `replicas.md` §6.1 llama «construir el
|
|
636
|
+
* ataque»: **un replicador solo le contesta a quien YA conoce la cuenta.** Un replicador
|
|
637
|
+
* atrasado presenta un acta donde un aparato revocado sigue siendo miembro; contra eso, a
|
|
638
|
+
* quien tiene `maxSeq` pineado lo protege el pin, pero a un aparato que llega nuevo no hay
|
|
639
|
+
* con qué compararlo. Así que sin pin no se le cree: que conteste la bóveda.
|
|
640
|
+
*
|
|
641
|
+
* El precio, dicho en voz alta: un aparato nuevo necesita la bóveda encendida una vez. El
|
|
642
|
+
* oráculo de frescura (§2.4.2 del acta) es lo que quitaría ese precio; mientras no exista,
|
|
643
|
+
* esto es el freno, y es un freno de verdad y no un aviso.
|
|
644
|
+
*
|
|
645
|
+
* @param {number|null} [knownSeq] El `seq` más alto que este agente ya vio de esta cuenta.
|
|
646
|
+
* `null`/ausente = no conoce la cuenta, y entonces un replicador no vale.
|
|
647
|
+
*/
|
|
648
|
+
export async function verifyResponder ({ body, seal, acta, masterPubkey, knownSeq = null }) {
|
|
649
|
+
if (!seal?.sig) {
|
|
583
650
|
throw new Error('the reply carries no sealing signature: this vault is too old to serve (update it)')
|
|
584
651
|
}
|
|
585
652
|
if (!acta) throw new Error('the reply carries no record: cannot tell which key was allowed to sign it')
|
|
@@ -587,6 +654,33 @@ export async function verifyResponder ({ body, seal, acta, masterPubkey }) {
|
|
|
587
654
|
throw new Error('the record was not sealed by the master this agent knows: refusing the reply')
|
|
588
655
|
}
|
|
589
656
|
if (!(await verifyActa({ acta })).ok) throw new Error('the record does not verify')
|
|
657
|
+
|
|
658
|
+
// NUNCA HACIA ATRÁS. Vale para los dos caminos: un acta con `seq` menor del que ya
|
|
659
|
+
// vimos es un rollback, venga de donde venga.
|
|
660
|
+
if (typeof knownSeq === 'number' && typeof acta.seq === 'number' && acta.seq < knownSeq) {
|
|
661
|
+
throw Object.assign(
|
|
662
|
+
new Error(`the record went backwards (#${acta.seq} after #${knownSeq}): refusing the reply`),
|
|
663
|
+
{ code: 'stale-record' })
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (seal.by) {
|
|
667
|
+
if (typeof knownSeq !== 'number') {
|
|
668
|
+
throw Object.assign(
|
|
669
|
+
new Error('a replica answered, and this device has never seen the account: ask the vault itself the first time'),
|
|
670
|
+
{ code: 'replica-unknown-account' })
|
|
671
|
+
}
|
|
672
|
+
if (!memberCan(acta, seal.by, 'replica')) {
|
|
673
|
+
throw new Error('the reply comes from a device the record does not allow to serve as a replica')
|
|
674
|
+
}
|
|
675
|
+
if (!(await verifyDeviceSig({ publickey: seal.by, data: body, signature: seal.sig }))) {
|
|
676
|
+
throw new Error('the replica signature does not check out')
|
|
677
|
+
}
|
|
678
|
+
return
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (typeof seal.seq !== 'number') {
|
|
682
|
+
throw new Error('the reply carries no sealing signature: this vault is too old to serve (update it)')
|
|
683
|
+
}
|
|
590
684
|
const pub = sealKeyAt(acta, seal.seq)
|
|
591
685
|
if (!pub) throw new Error(`the record names no sealing key for #${seal.seq}`)
|
|
592
686
|
if (!(await verifyDeviceSig({ publickey: pub, data: body, signature: seal.sig }))) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.98.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
|
|
6
6
|
"bin": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"node": ">=20"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@dotrino/identity": "0.
|
|
23
|
+
"@dotrino/identity": "0.79.0",
|
|
24
24
|
"@dotrino/passmanager": "^0.10.1",
|
|
25
25
|
"@dotrino/proxy-client": "^0.13.1",
|
|
26
26
|
"ws": "^8.18.0"
|
package/src/ctl.js
CHANGED
|
@@ -254,6 +254,10 @@ async function cmdPair (args = []) {
|
|
|
254
254
|
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
255
|
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
256
|
if (t === 'sign' || t === 'read' || t === 'store') { scope.push('vault:' + t); continue }
|
|
257
|
+
// Un REPLICADOR nace acotado: solo esto, ni firma ni lee ni guarda. Se empareja
|
|
258
|
+
// —al contrario que `sella`— porque se despliega en una máquina sin teclado y el
|
|
259
|
+
// permiso no concede nada que haya que pensarse dos veces: reparte sobres cerrados.
|
|
260
|
+
if (t === 'replica' || t === 'replicador') { scope.push('vault:replica'); continue }
|
|
257
261
|
// El gestor de contraseñas SÍ se empareja con su permiso puesto: es lo único que
|
|
258
262
|
// va a hacer ese aparato, y pedirlo en dos pasos era el paso que nadie daba.
|
|
259
263
|
if (t === 'passwords') { scope.push('vault:passwords'); continue }
|
|
@@ -1606,7 +1610,7 @@ function help () {
|
|
|
1606
1610
|
El QR no lleva nada: el permiso se aplica al aprobar el código aquí
|
|
1607
1611
|
pair --quiet escupe SOLO la invitación (una línea) y termina: sin QR y sin
|
|
1608
1612
|
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;
|
|
1613
|
+
pair --scope <lista> los PERMISOS del cert: sign,read,store,contrasenas,replica,secrets:<ns> (sin esto: sign,read,store;
|
|
1610
1614
|
se combina con --service: --service eco --scope sign = bot que firma y lee su cajón)
|
|
1611
1615
|
secret set <ns> <CLAVE> <valor> variable del scope <ns>: la comparten TODOS los
|
|
1612
1616
|
aparatos del perfil que sirven ese namespace
|
|
@@ -1646,6 +1650,10 @@ function help () {
|
|
|
1646
1650
|
KMS, y solo vale ahora: el perfil se está creando)
|
|
1647
1651
|
Al desplegar, esto mismo va por el entorno y sin entrar a nada:
|
|
1648
1652
|
DOTRINO_JOIN (o DOTRINO_JOIN_FILE) + DOTRINO_JOIN_NAME
|
|
1653
|
+
replica enroll <invitación>
|
|
1654
|
+
esta máquina entra como REPLICADOR: reparte los sobres que la
|
|
1655
|
+
bóveda ya firmó cuando ella no está, y no puede abrir ninguno ni
|
|
1656
|
+
cambiar nada. No tiene maestra. Arráncalo con: replica run
|
|
1649
1657
|
pending muestra el dispositivo pendiente + su código a comparar
|
|
1650
1658
|
approve <código> aprueba el dispositivo tipeando el código que MUESTRA (el vault no lo sabe)
|
|
1651
1659
|
reject <deviceId> rechaza un dispositivo pendiente
|
|
@@ -1697,6 +1705,82 @@ Arrancar y parar, según dónde corra:
|
|
|
1697
1705
|
(el CLI, dentro: docker exec -it dotrino-vault dotrino-vault status)`)
|
|
1698
1706
|
}
|
|
1699
1707
|
|
|
1708
|
+
/**
|
|
1709
|
+
* `replica` — LA OTRA PIEZA, la que reparte sin decidir (`docs/replicas.md` §8.bis).
|
|
1710
|
+
*
|
|
1711
|
+
* dotrino-vault replica enroll <invitación> entra en la cuenta, UNA vez
|
|
1712
|
+
* dotrino-vault replica run se queda repartiendo
|
|
1713
|
+
* dotrino-vault replica status qué acta tiene y cuántos sobres
|
|
1714
|
+
*
|
|
1715
|
+
* No corre dentro del daemon del vault y no es un perfil: un replicador NO TIENE MAESTRA,
|
|
1716
|
+
* y meterlo en el gestor de perfiles sería fabricarle una. Vive en su propio directorio
|
|
1717
|
+
* (`DOTRINO_REPLICA_DIR`, por defecto `~/.dotrino/replica`).
|
|
1718
|
+
*/
|
|
1719
|
+
async function cmdReplica (args) {
|
|
1720
|
+
const { replicaDir, openReplicaStore, runReplica, REPLICA_SCOPE } = await import('./replica.js')
|
|
1721
|
+
const sub = args[0]
|
|
1722
|
+
const dir = process.env.DOTRINO_REPLICA_DIR || replicaDir()
|
|
1723
|
+
|
|
1724
|
+
if (sub === 'enroll') {
|
|
1725
|
+
const invitacion = args.slice(1).filter((a) => !a.startsWith('-')).join(' ')
|
|
1726
|
+
if (!invitacion) {
|
|
1727
|
+
console.error('uso: dotrino-vault replica enroll "<invitación>"')
|
|
1728
|
+
console.error(' la invitación sale de la bóveda: dotrino-vault pair --scope replica --quiet')
|
|
1729
|
+
process.exit(2)
|
|
1730
|
+
}
|
|
1731
|
+
const { enrollWithVault } = await import('../lib/src/service.js')
|
|
1732
|
+
// `withEncKey: false` NO es una optimización: es la garantía. Sin llave de cifrado en
|
|
1733
|
+
// el acta no hay a dónde envolverle un sobre, así que repartir no puede convertirse en
|
|
1734
|
+
// leer. Regla del dueño: «recibirá todos los sobres que se generen, ningún sobre
|
|
1735
|
+
// firmado para él».
|
|
1736
|
+
const r = await enrollWithVault({
|
|
1737
|
+
qr: invitacion,
|
|
1738
|
+
label: args.includes('--label') ? args[args.indexOf('--label') + 1] : 'replicador',
|
|
1739
|
+
expectedScope: REPLICA_SCOPE,
|
|
1740
|
+
withEncKey: false,
|
|
1741
|
+
onCode: ({ deviceId, code }) => {
|
|
1742
|
+
console.log('\n Tipea ESTE código en la bóveda para aprobarlo:')
|
|
1743
|
+
console.log(` código: ${code}`)
|
|
1744
|
+
console.log(` máquina: ${deviceId}`)
|
|
1745
|
+
console.log(` (en la bóveda: dotrino-vault approve ${code})\n`)
|
|
1746
|
+
console.log(' Esperando…')
|
|
1747
|
+
}
|
|
1748
|
+
})
|
|
1749
|
+
openReplicaStore(dir).setLink({ device: r.device, cert: r.cert, iss: r.iss, proxy: r.proxy })
|
|
1750
|
+
console.log('\n Listo: este replicador ya está en la cuenta.')
|
|
1751
|
+
console.log(' Arráncalo con: dotrino-vault replica run\n')
|
|
1752
|
+
return
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
if (sub === 'status') {
|
|
1756
|
+
const st = openReplicaStore(dir)
|
|
1757
|
+
if (!st.link) { console.log('Sin enrolar. Empieza por: dotrino-vault replica enroll "<invitación>"'); return }
|
|
1758
|
+
console.log('Replicador · datos en %s', dir)
|
|
1759
|
+
console.log(' acta : #%s%s', st.seq || 0, st.acta ? '' : ' (todavía ninguna: la manda la bóveda)')
|
|
1760
|
+
console.log(' sobres : %d', st.bundles)
|
|
1761
|
+
console.log(' bóveda : %s…', String(st.link.iss).slice(0, 32))
|
|
1762
|
+
return
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
if (sub === 'run' || sub === undefined) {
|
|
1766
|
+
try {
|
|
1767
|
+
await runReplica({ dir })
|
|
1768
|
+
} catch (e) {
|
|
1769
|
+
if (e?.code === 'replica-not-enrolled') {
|
|
1770
|
+
console.error('\n Este replicador todavía no está en ninguna cuenta.\n')
|
|
1771
|
+
console.error(' En la bóveda: dotrino-vault pair --scope replica --quiet')
|
|
1772
|
+
console.error(' Y aquí, con eso: dotrino-vault replica enroll "<invitación>"\n')
|
|
1773
|
+
process.exit(2)
|
|
1774
|
+
}
|
|
1775
|
+
throw e
|
|
1776
|
+
}
|
|
1777
|
+
await new Promise(() => {})
|
|
1778
|
+
return
|
|
1779
|
+
}
|
|
1780
|
+
console.error('uso: dotrino-vault replica [enroll <invitación>|run|status]')
|
|
1781
|
+
process.exit(2)
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1700
1784
|
export async function runCtl (argv) {
|
|
1701
1785
|
const [cmd, ...rest] = takeProfileFlag(argv)
|
|
1702
1786
|
switch (cmd) {
|
|
@@ -1722,6 +1806,7 @@ export async function runCtl (argv) {
|
|
|
1722
1806
|
case 'activity': return cmdActivity(Number(rest.find((a) => /^\d+$/.test(a))) || 30, {
|
|
1723
1807
|
verify: rest.includes('--verify'), exportar: rest.includes('--export')
|
|
1724
1808
|
})
|
|
1809
|
+
case 'replica': return cmdReplica(rest)
|
|
1725
1810
|
case 'logs': return cmdLogs()
|
|
1726
1811
|
case 'version':
|
|
1727
1812
|
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(',') })
|
|
@@ -333,10 +345,13 @@ export async function runDaemon () {
|
|
|
333
345
|
// cajón envuelto para exactamente quien dice el acta — creando lo que falta,
|
|
334
346
|
// reemplazando lo que alguien metiera mal y quitando lo que sobre.
|
|
335
347
|
const r = await mgr.get(id)?.resealAll?.(ak)
|
|
336
|
-
if (r?.wrapped) console.log('[vault] keyring rebuilt on unlock: %d wrap(s) in %d drawer(s)%s',
|
|
337
|
-
r.wrapped, r.drawers, r.dropped ? `, ${r.dropped} stale one(s) dropped` : ''
|
|
348
|
+
if (r?.wrapped) console.log('[vault] keyring rebuilt on unlock: %d wrap(s) in %d drawer(s)%s%s',
|
|
349
|
+
r.wrapped, r.drawers, r.dropped ? `, ${r.dropped} stale one(s) dropped` : '',
|
|
350
|
+
r.sealed ? `, ${r.sealed} value(s) left in the clear now sealed` : '')
|
|
338
351
|
if (r?.dropped) note = ` · llavero al día (${r.dropped} envoltura(s) de más retirada(s))`
|
|
339
352
|
else if (r?.wrapped) note = ' · llavero al día'
|
|
353
|
+
// Se SUMA, no pisa: es lo que el dueño necesita ver de esta apertura en concreto.
|
|
354
|
+
if (r?.sealed) note += ` · ${r.sealed} valor(es) que estaban sin cifrar ya están sellados`
|
|
340
355
|
// ABIERTA PERO SIN LLAVERO NO ES «ABIERTA». Si un cajón no se pudo reenvolver, sus
|
|
341
356
|
// aparatos no van a poder leer nada y hay que decirlo AQUÍ: contestar
|
|
342
357
|
// «desbloqueado» a secas dejaba la bóveda pareciendo sana, y el fallo aparecía
|
package/src/replica.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
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
|
+
/** La clave de un sobre: es POR (cajón, aparato), porque va tallado a quien lo pide. */
|
|
43
|
+
export const bundleKey = (ns, devicePub) => `${ns} ${devicePub}`
|
|
44
|
+
|
|
45
|
+
/** Lo que este replicador sabe de la cuenta: su papel, el acta y los sobres. */
|
|
46
|
+
export function openReplicaStore (dir) {
|
|
47
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
48
|
+
const atRest = atRestFor(dir)
|
|
49
|
+
const leer = (f, d) => readJson(path.join(dir, f), d, atRest)
|
|
50
|
+
const escribir = (f, v) => writeJson(path.join(dir, f), v, atRest)
|
|
51
|
+
|
|
52
|
+
let link = leer(LINK, null)
|
|
53
|
+
let state = leer(STATE, null) || { seq: 0, acta: null, bundles: {} }
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
get link () { return link },
|
|
57
|
+
get acta () { return state.acta },
|
|
58
|
+
get seq () { return state.seq },
|
|
59
|
+
setLink (v) { link = v; escribir(LINK, v) },
|
|
60
|
+
bundleFor (ns, devicePub) { return state.bundles[bundleKey(ns, devicePub)] || null },
|
|
61
|
+
get bundles () { return Object.keys(state.bundles).length },
|
|
62
|
+
/**
|
|
63
|
+
* SOLO HACIA ADELANTE. Es el mismo freno que el pin del cliente: un empujón con un
|
|
64
|
+
* acta más vieja de la que ya tenemos es un rollback, y aceptarlo convertiría al
|
|
65
|
+
* replicador en el camino para servir un acta donde un aparato revocado sigue dentro.
|
|
66
|
+
* @returns {boolean} si se aplicó (false = venía atrasado y se descartó)
|
|
67
|
+
*/
|
|
68
|
+
apply ({ seq, acta, bundles }) {
|
|
69
|
+
if (typeof seq !== 'number' || seq < state.seq) return false
|
|
70
|
+
state = { seq, acta: acta || state.acta, bundles: { ...state.bundles, ...(bundles || {}) } }
|
|
71
|
+
escribir(STATE, state)
|
|
72
|
+
return true
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Arranca el replicador. No crea ninguna maestra ni ningún perfil: si no está enrolado,
|
|
79
|
+
* lo dice y se para. Estrenar una identidad aquí sería justo lo que no debe existir.
|
|
80
|
+
*/
|
|
81
|
+
export async function runReplica ({ dir = replicaDir(), proxyUrl, log = console.log } = {}) {
|
|
82
|
+
const store = openReplicaStore(dir)
|
|
83
|
+
const link = store.link
|
|
84
|
+
if (!link?.device?.privateJwk || !link?.cert || !link?.iss) {
|
|
85
|
+
throw Object.assign(
|
|
86
|
+
new Error('this replica is not enrolled yet: run `dotrino-vault replica enroll <invitation>` here first'),
|
|
87
|
+
{ code: 'replica-not-enrolled' })
|
|
88
|
+
}
|
|
89
|
+
const url = proxyUrl || link.proxy || process.env.PROXY_URL || 'wss://proxy.dotrino.com'
|
|
90
|
+
const id = (await pubkeyId(link.device.publickey)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
|
|
91
|
+
|
|
92
|
+
const firmar = (data) => signWithDevice({ privateJwk: link.device.privateJwk, data })
|
|
93
|
+
// CONTESTAR es por la CONEXIÓN (`send`) y no por la llave (`sendByPubkey`): `from` es el
|
|
94
|
+
// identificador de quien escribe en el proxio, no su pubkey. Mandar por llave a un `from`
|
|
95
|
+
// no falla — se va a la nada, y quien preguntó se queda esperando el plazo entero.
|
|
96
|
+
// Por llave solo se habla con la BÓVEDA (`link.iss`), que es a quien queremos alcanzar
|
|
97
|
+
// aunque no esté conectada ahora mismo.
|
|
98
|
+
|
|
99
|
+
const client = new WebSocketProxyClient({ url })
|
|
100
|
+
await client.connect()
|
|
101
|
+
const data = { op: 'identify', publickey: link.device.publickey, token: client.token, ts: Date.now() }
|
|
102
|
+
await client.identify({ data, signature: (await firmar(data)).signature })
|
|
103
|
+
log(`[replica] ${id} · connected to ${url}`)
|
|
104
|
+
log(`[replica] record #${store.seq}${store.acta ? '' : ' · no record yet: waiting for the vault'}`)
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* UN EMPUJÓN DE LA BÓVEDA. Va firmado con la llave de sellado que el acta nombra, así
|
|
108
|
+
* que se comprueba de dónde viene antes de guardar nada. Y se comprueba contra el acta
|
|
109
|
+
* QUE VIENE, después de verificar que la selló la maestra que este replicador conoce:
|
|
110
|
+
* fiarse de la que ya tiene impediría para siempre recibir una nueva.
|
|
111
|
+
*/
|
|
112
|
+
async function onPush (from, p) {
|
|
113
|
+
const body = p?.body
|
|
114
|
+
if (!body || body.op !== 'replica.push') return
|
|
115
|
+
const acta = body.acta || store.acta
|
|
116
|
+
if (!acta) return log('[replica] a push arrived with no record and none is stored: ignored')
|
|
117
|
+
if (acta.sealedBy !== link.iss) return log('[replica] the pushed record was sealed by another master: ignored')
|
|
118
|
+
if (!(await verifyActa({ acta })).ok) return log('[replica] the pushed record does not verify: ignored')
|
|
119
|
+
const pub = sealKeyAt(acta, p.seal?.seq)
|
|
120
|
+
if (!pub || !(await verifyDeviceSig({ publickey: pub, data: body, signature: p.seal?.sig }))) {
|
|
121
|
+
return log('[replica] the push is not signed by the key the record names: ignored')
|
|
122
|
+
}
|
|
123
|
+
if (!memberCan(acta, link.device.publickey, 'replica')) {
|
|
124
|
+
return log('[replica] the record no longer lets this device serve as a replica: ignored')
|
|
125
|
+
}
|
|
126
|
+
if (!store.apply({ seq: body.seq, acta, bundles: body.bundles })) {
|
|
127
|
+
return log(`[replica] a push arrived with an older record (#${body.seq} < #${store.seq}): refused`)
|
|
128
|
+
}
|
|
129
|
+
log(`[replica] record #${store.seq} · ${Object.keys(body.bundles || {}).length} bundle(s) stored`)
|
|
130
|
+
const ack = { op: 'replica.ack', publickey: link.device.publickey, seq: store.seq, ts: Date.now() }
|
|
131
|
+
client.send(from, { type: MSG.REPLICA_ACK, body: ack, signature: (await firmar(ack)).signature })
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* UNA PETICIÓN DE CLAVES. Se contesta con lo que hay guardado, cerrado a la efímera de
|
|
136
|
+
* quien pregunta y firmado con NUESTRA llave — que es lo que el cliente acepta cuando el
|
|
137
|
+
* acta nos reconoce `replica` (`verifyResponder` de `@dotrino/vault`).
|
|
138
|
+
*
|
|
139
|
+
* Lo que NO se decide aquí, y es a propósito: si quien pide tiene derecho a ese cajón.
|
|
140
|
+
* Lo dice el sobre, no nosotros — va cerrado a su llave, así que a quien no le toque le
|
|
141
|
+
* llega algo que no puede abrir. Un replicador que decidiera quién lee sería un
|
|
142
|
+
* replicador del que hay que fiarse, y la gracia es que no haga falta.
|
|
143
|
+
*/
|
|
144
|
+
async function onSecrets (from, p) {
|
|
145
|
+
const ns = p?.data?.ns
|
|
146
|
+
const ek = p?.data?.ek
|
|
147
|
+
const quien = p?.data?.publickey
|
|
148
|
+
if (typeof ns !== 'string' || !ek || !quien) return
|
|
149
|
+
if (!store.acta) return client.send(from, { type: MSG.ERROR, error: 'replica: no record yet' })
|
|
150
|
+
const b = store.bundleFor(ns, quien)
|
|
151
|
+
if (!b) {
|
|
152
|
+
log(`[replica] asked for "${ns}" and has nothing stored for that device`)
|
|
153
|
+
return client.send(from, {
|
|
154
|
+
type: MSG.ERROR,
|
|
155
|
+
error: `replica: nothing stored for "${ns}" and this device — ask the vault once while it is up`
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
const enc = await seal({ ek, payload: { sealed: b, acta: store.acta } })
|
|
159
|
+
const body = { op: 'secrets.result', ns, enc, ts: Date.now() }
|
|
160
|
+
// `by` y no `seq`: no tenemos la llave de sellado y no debemos tenerla. Quien recibe
|
|
161
|
+
// mira el acta, ve que a esta llave le reconoce `replica`, y acepta.
|
|
162
|
+
const { signature } = await firmar(body)
|
|
163
|
+
client.send(from, { type: MSG.SECRETS_RESULT, body, seal: { by: link.device.publickey, sig: signature } })
|
|
164
|
+
log(`[replica] served "${ns}" from record #${store.seq}`)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Todo lo que DECIDE algo se contesta que no, y se dice por qué. */
|
|
168
|
+
const NO_DECIDE = new Set([MSG.ENROLL, MSG.ADMIN, MSG.RENEW, MSG.RENOUNCE, MSG.SIGN, MSG.ACTA_SEALED])
|
|
169
|
+
|
|
170
|
+
client.on('message', (from, p) => {
|
|
171
|
+
const t = p?.type
|
|
172
|
+
if (t === MSG.REPLICA_PUSH) return onPush(from, p).catch((e) => log('[replica] push: ' + e.message))
|
|
173
|
+
if (t === MSG.SECRETS) return onSecrets(from, p).catch((e) => log('[replica] secrets: ' + e.message))
|
|
174
|
+
if (NO_DECIDE.has(t)) {
|
|
175
|
+
log(`[replica] refused "${t}": a replica hands things out, it does not decide`)
|
|
176
|
+
return client.send(from, {
|
|
177
|
+
type: MSG.ERROR,
|
|
178
|
+
error: 'replica: this is a replica — it has no master and decides nothing'
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
// EL ARRASTRE AL ARRANCAR (`docs/replicas.md` §6). Se dice por dónde vamos en cuanto hay
|
|
184
|
+
// conexión: si la bóveda va por delante, contesta con un empujón. Sin esto, un
|
|
185
|
+
// replicador recién encendido se queda vacío hasta que alguien cambie algo — y el
|
|
186
|
+
// empujón del propio enrolamiento se manda cuando este proceso todavía no existe.
|
|
187
|
+
const saludo = { op: 'replica.ack', publickey: link.device.publickey, seq: store.seq, ts: Date.now() }
|
|
188
|
+
try {
|
|
189
|
+
client.sendByPubkey(link.iss, { type: MSG.REPLICA_ACK, body: saludo, signature: (await firmar(saludo)).signature })
|
|
190
|
+
} catch (e) { log('[replica] could not say hello to the vault: ' + e.message) }
|
|
191
|
+
|
|
192
|
+
log('[replica] ready · it hands out what the vault already signed, and nothing else')
|
|
193
|
+
return {
|
|
194
|
+
id,
|
|
195
|
+
publickey: link.device.publickey,
|
|
196
|
+
seq: () => store.seq,
|
|
197
|
+
stop: () => { try { client.close() } catch (_) {} }
|
|
198
|
+
}
|
|
199
|
+
}
|
package/src/secretsStore.js
CHANGED
|
@@ -834,11 +834,44 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
834
834
|
if (!bag[k]) return { wrapped: 0, sinLlave: [] }
|
|
835
835
|
const priv = await openRecovery(adminKey)
|
|
836
836
|
let wrapped = 0
|
|
837
|
+
let sealed = 0
|
|
837
838
|
const sinLlave = new Set()
|
|
839
|
+
const vars = varsOf(bag, k)
|
|
840
|
+
const paraVisibilidad = async (esPublica) => (typeof members === 'function' ? members(esPublica) : members)
|
|
841
|
+
|
|
842
|
+
// UN VALOR QUE SE QUEDÓ EN CLARO SE SELLA AQUÍ, que es el único sitio donde se puede.
|
|
843
|
+
//
|
|
844
|
+
// Una pública escrita antes del 2026-09-02 se guardó legible y SIN sobre, así que no
|
|
845
|
+
// tiene generación ni entrada en el llavero. Y el llavero es lo único que este
|
|
846
|
+
// método reenvuelve: por muchas veces que se abra la bóveda, a esa variable no se le
|
|
847
|
+
// crea una envoltura nunca. Sus aparatos la reclaman para siempre («no key yet») y la
|
|
848
|
+
// consola invita a abrir la bóveda, que es justo lo que no lo arregla. Peor: desde
|
|
849
|
+
// 2026-09-02 el agente rechaza el cajón ENTERO por ella, así que una variable de
|
|
850
|
+
// prueba deja sin leer el token que está al lado.
|
|
851
|
+
//
|
|
852
|
+
// Con la bóveda abierta el valor está delante, así que se cifra como si se acabara
|
|
853
|
+
// de escribir. No es un repliegue ni una migración a medias: es la conversión que
|
|
854
|
+
// faltaba, y ocurre en el único momento en que hay con qué hacerla.
|
|
855
|
+
for (const [key, e] of Object.entries(vars)) {
|
|
856
|
+
if (typeof e.v !== 'string' || e.e) continue
|
|
857
|
+
const cek = await sealer.newKey()
|
|
858
|
+
const gen = (topGen(bag, k)?.gen || 0) + 1
|
|
859
|
+
const r = await sealer.wrapFor(cek, await paraVisibilidad(!!e.pub))
|
|
860
|
+
r.wraps[RECOVERY] = await sealer.wrapForKey(cek, data.recovery.pub)
|
|
861
|
+
const sobre = await sealer.encrypt(cek, e.v, gen)
|
|
862
|
+
const seal = signer ? await signer({ owner, key, gen, iv: sobre.iv, ct: sobre.ct }) : null
|
|
863
|
+
// `pub` y `at` se conservan: no es un valor nuevo, es el mismo mejor guardado.
|
|
864
|
+
vars[key] = { pub: !!e.pub, owner, gen, e: sobre, seal, at: e.at || Date.now(), by: e.by || null }
|
|
865
|
+
bag[k].keyring = [...(bag[k].keyring || []), { gen, createdAt: Date.now(), wraps: r.wraps }]
|
|
866
|
+
for (const x of r.sinLlave) sinLlave.add(x)
|
|
867
|
+
sealed++
|
|
868
|
+
}
|
|
869
|
+
|
|
838
870
|
// De qué variable es cada generación: es lo que dice si esa CEK es de una pública.
|
|
871
|
+
// Se calcula DESPUÉS de sellar, para que las generaciones recién creadas entren.
|
|
839
872
|
const visibilidad = new Map()
|
|
840
|
-
for (const e of Object.values(
|
|
841
|
-
const paraGen = async (gen) => (
|
|
873
|
+
for (const e of Object.values(vars)) if (e.gen != null) visibilidad.set(e.gen, !!e.pub)
|
|
874
|
+
const paraGen = async (gen) => paraVisibilidad(visibilidad.get(gen) === true)
|
|
842
875
|
for (const g of bag[k].keyring || []) {
|
|
843
876
|
const w = g.wraps?.[RECOVERY]
|
|
844
877
|
if (!w) continue
|
|
@@ -859,7 +892,7 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
859
892
|
wrapped += Object.keys(r.wraps).length
|
|
860
893
|
}
|
|
861
894
|
save()
|
|
862
|
-
return { wrapped, sinLlave: [...sinLlave] }
|
|
895
|
+
return { wrapped, sealed, sinLlave: [...sinLlave] }
|
|
863
896
|
},
|
|
864
897
|
|
|
865
898
|
/**
|
package/src/tui/app.js
CHANGED
|
@@ -37,6 +37,7 @@ import { qrToString } from '../qr.js'
|
|
|
37
37
|
import { dict, otherLang, loadLang, saveLang } from './i18n.js'
|
|
38
38
|
import * as vc from '../vaultControl.js'
|
|
39
39
|
import { VERSION } from '../version.js'
|
|
40
|
+
import { DEVICE_CAPS } from '@dotrino/identity/acta'
|
|
40
41
|
|
|
41
42
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
42
43
|
|
|
@@ -338,7 +339,18 @@ function pairModeRows (st, t) {
|
|
|
338
339
|
* permiso que hay que entender para un servidor: dice si esa máquina se lleva tus claves
|
|
339
340
|
* privadas sola o si te lo pregunta. Verlo apagado es la mitad del mensaje.
|
|
340
341
|
*/
|
|
341
|
-
|
|
342
|
+
/**
|
|
343
|
+
* EL ORDEN LO ELIGE ESTA PANTALLA; LA LISTA LA MANDA EL ACTA.
|
|
344
|
+
*
|
|
345
|
+
* Antes esto era una lista suelta escrita a mano, y por eso cada permiso nuevo se quedó
|
|
346
|
+
* invisible aquí durante semanas —`sealer`, `unattended` y `secrets`, tres veces el mismo
|
|
347
|
+
* fallo—. Ahora el orden se cura a mano, que es una decisión de diseño, pero lo que no
|
|
348
|
+
* esté nombrado se añade al final: un permiso puede salir en mal sitio, nunca desaparecer.
|
|
349
|
+
* `test/tui-permisos.test.mjs` exige además que todos tengan nombre en los dos idiomas.
|
|
350
|
+
*/
|
|
351
|
+
const ORDEN = ['sign', 'store', 'read', 'admin', 'approve', 'passwords', 'sealer', 'unattended', 'replica']
|
|
352
|
+
const CAPS_ORDER = [...ORDEN.filter((c) => DEVICE_CAPS.includes(c)),
|
|
353
|
+
...DEVICE_CAPS.filter((c) => !ORDEN.includes(c))]
|
|
342
354
|
|
|
343
355
|
/**
|
|
344
356
|
* Los permisos que se le pueden dar A ESTE miembro, en orden.
|
|
@@ -1349,6 +1361,8 @@ async function toggleVisibility (term, st, wasPublic, apply) {
|
|
|
1349
1361
|
if (r.ok) { flash(st, wasPublic ? i.nowPrivate : i.nowPublic); st.secrets = r.v }
|
|
1350
1362
|
}
|
|
1351
1363
|
if (wasPublic) return run()
|
|
1364
|
+
// Solo se pregunta al ABRIRLA. Volverla privada es la dirección segura y se hace sin
|
|
1365
|
+
// confirmar: pedir permiso para cerrar algo solo entrena a decir que sí.
|
|
1352
1366
|
setConfirm(st, { text: i.makePublicConfirm, onYes: run, onNo: () => { st.confirm = null } })
|
|
1353
1367
|
}
|
|
1354
1368
|
|
|
@@ -1394,17 +1408,23 @@ async function onKeyDevVars (term, st, key) {
|
|
|
1394
1408
|
}
|
|
1395
1409
|
|
|
1396
1410
|
/**
|
|
1397
|
-
* Al crear una variable se
|
|
1398
|
-
*
|
|
1399
|
-
*
|
|
1400
|
-
*
|
|
1411
|
+
* Al crear una variable se pregunta SI ES PRIVADA. Se pregunta al crearla, y no después,
|
|
1412
|
+
* porque es cuando quien la escribe sabe qué es: un puerto se puede enseñar, una llave de
|
|
1413
|
+
* producción no.
|
|
1414
|
+
*
|
|
1415
|
+
* OJO AL SENTIDO. Antes se preguntaba lo contrario («¿que se pueda ver?») y el defecto
|
|
1416
|
+
* —Enter o Esc— caía en `onNo`, que era la privada, o sea la segura. Al dar la vuelta a la
|
|
1417
|
+
* pregunta, ese mismo defecto pasaría a ser la PÚBLICA sin que nadie lo notara: cada
|
|
1418
|
+
* variable nueva se entregaría sin pedir permiso. Por eso el defecto se declara aquí
|
|
1419
|
+
* (`defaultYes`) en vez de heredarse del manejador de teclas.
|
|
1401
1420
|
*/
|
|
1402
1421
|
function askVisibility (term, st, done) {
|
|
1403
1422
|
const i = L(st)
|
|
1404
1423
|
setConfirm(st, {
|
|
1405
|
-
text: i.
|
|
1406
|
-
|
|
1407
|
-
|
|
1424
|
+
text: i.newVarPrivateAsk,
|
|
1425
|
+
defaultYes: true,
|
|
1426
|
+
onYes: () => { st.confirm = null; done(false) }, // privada
|
|
1427
|
+
onNo: () => { st.confirm = null; done(true) } // pública
|
|
1408
1428
|
})
|
|
1409
1429
|
}
|
|
1410
1430
|
|
|
@@ -1554,7 +1574,14 @@ async function onConfirmKey (st, key) {
|
|
|
1554
1574
|
// La tecla es `y` (yes) en los dos idiomas —como el resto, mnemónico inglés—;
|
|
1555
1575
|
// `s` (sí) se sigue aceptando por costumbre, pero no se anuncia en la ayuda.
|
|
1556
1576
|
if (ch === 's' || ch === 'y') { const f = cf.onYes; st.confirm = null; await f?.() }
|
|
1557
|
-
else if (ch === 'n'
|
|
1577
|
+
else if (ch === 'n') { const f = cf.onNo; st.confirm = null; await f?.() }
|
|
1578
|
+
// Enter y Esc toman el CAMINO SEGURO, y cuál es lo dice quien pregunta. Sin esto el
|
|
1579
|
+
// defecto era siempre «no», y una pregunta formulada al revés lo volvía el peligroso.
|
|
1580
|
+
else if (key.name === 'escape' || key.name === 'enter' || key.name === 'ctrl-c') {
|
|
1581
|
+
const f = cf.defaultYes ? cf.onYes : cf.onNo
|
|
1582
|
+
st.confirm = null
|
|
1583
|
+
await f?.()
|
|
1584
|
+
}
|
|
1558
1585
|
}
|
|
1559
1586
|
|
|
1560
1587
|
// --------------------------------- render ----------------------------------
|
package/src/tui/i18n.js
CHANGED
|
@@ -39,45 +39,52 @@ const es = {
|
|
|
39
39
|
// PERMISOS de un dispositivo (§9.1: se dice el beneficio, no el scope del cert).
|
|
40
40
|
titleCaps: 'Permisos del dispositivo',
|
|
41
41
|
capsFor: (id, name) => `Permisos de ${id}${name ? ' · ' + name : ''}`,
|
|
42
|
-
capsNoMember: ' (este dispositivo ya no está en el
|
|
42
|
+
capsNoMember: ' (este dispositivo ya no está en el perfil)',
|
|
43
43
|
capsApplyHint: 'Enter marca o desmarca · G guarda los cambios · Esc sale',
|
|
44
|
-
capsPending: (n) => `${n} cambio(s) sin guardar (*) · G para guardarlos
|
|
45
|
-
capsSaved: (n) => `${n} cambio(s) guardados
|
|
44
|
+
capsPending: (n) => `${n} cambio(s) sin guardar (*) · G para guardarlos todos de una vez · Esc para descartarlos`,
|
|
45
|
+
capsSaved: (n) => `${n} cambio(s) guardados de una vez`,
|
|
46
46
|
capsNothing: 'No has cambiado nada',
|
|
47
47
|
capsDiscard: '¿Descartar los cambios sin guardar?',
|
|
48
|
+
// EL NOMBRE DICE EL ACTO, NO LA CONSECUENCIA (dueño, 2026-09-02: «que digan exactamente
|
|
49
|
+
// lo que hacen y no lo que implican»). «Sellar el acta» y «Administrar el perfil» eran
|
|
50
|
+
// lo segundo: hay que saberse el modelo para traducirlos. Lo que hacen es admitir
|
|
51
|
+
// aparatos y conectar o quitar dispositivos, y eso es lo que se lee ahora.
|
|
48
52
|
capName: {
|
|
49
53
|
// Lleva el cajón dentro: «las claves de proxy» dice QUÉ abre; «lee sus claves» te deja
|
|
50
54
|
// preguntándote cuáles. Solo se le ofrece a un servicio (los que tienen `cn`).
|
|
51
55
|
secrets: (cn) => `Leer las claves de «${cn}»`,
|
|
52
|
-
sign: '
|
|
56
|
+
sign: 'Entrar a las apps como tú',
|
|
53
57
|
store: 'Guardar tus datos',
|
|
54
58
|
read: 'Leer tus datos',
|
|
55
|
-
admin: '
|
|
59
|
+
admin: 'Conectar y quitar dispositivos',
|
|
56
60
|
passwords: 'Pedir tus contraseñas',
|
|
57
|
-
approve: '
|
|
58
|
-
sealer: '
|
|
59
|
-
unattended: '
|
|
61
|
+
approve: 'Autorizar a otros aparatos',
|
|
62
|
+
sealer: 'Admitir aparatos sin esta bóveda',
|
|
63
|
+
unattended: 'Llevarse claves sin pedirte permiso',
|
|
64
|
+
replica: 'Entregar tus claves con la bóveda apagada'
|
|
60
65
|
},
|
|
61
66
|
capHint: {
|
|
62
67
|
secrets: (cn) => `abrir SU cajón y ninguno más: las variables de «${cn}»`,
|
|
63
68
|
sign: 'usar tu identidad en las apps del ecosistema',
|
|
64
69
|
store: 'escribir en tu bóveda (perfil, contenido, datos sensibles)',
|
|
65
70
|
read: 'ver lo que guardaste',
|
|
66
|
-
admin: '
|
|
71
|
+
admin: 'hacerlo desde ese aparato, sin venir aquí. No puede cambiar permisos',
|
|
67
72
|
passwords: 'el gestor: pedir una credencial por sitio, nunca la lista entera',
|
|
68
73
|
approve: 'dar el visto bueno cuando otro aparato pide claves privadas',
|
|
69
74
|
// Sellar solo significa algo en un aparato que HAGA de bóveda (otra máquina con el
|
|
70
75
|
// daemon, o un navegador que se puso de bóveda). En un teléfono normal se concede y no
|
|
71
76
|
// lo usa nadie. Se enseña igual, y se dice: verlo desactivado explica el modelo;
|
|
72
77
|
// esconderlo hacía creer que no existe (dueño, 2026-08-31).
|
|
73
|
-
sealer: 'admitir aparatos y cambiar permisos él solo
|
|
74
|
-
unattended: 'llevarse claves privadas SIN preguntarte. Apagado, cada vez que arranque tienes que aprobarlo desde un aparato con «Aprobar»'
|
|
78
|
+
sealer: 'admitir aparatos y cambiar permisos él solo cuando esta bóveda no está. Solo sirve en otra BÓVEDA',
|
|
79
|
+
unattended: 'llevarse claves privadas SIN preguntarte. Apagado, cada vez que arranque tienes que aprobarlo desde un aparato con «Aprobar»',
|
|
80
|
+
// El replicador reparte y no puede nada más: no abre lo que entrega ni cambia nada.
|
|
81
|
+
replica: 'las entrega cerradas, tal como salieron de aquí: no puede abrirlas ni cambiar nada. Solo sirve en otra máquina'
|
|
75
82
|
},
|
|
76
83
|
confirmAdmin: (id) => `¿Dejar que ${id} conecte y quite dispositivos sin venir aquí?`,
|
|
77
84
|
capGiven: (n) => `Concedido: ${n}`,
|
|
78
85
|
capTaken: (n) => `Quitado: ${n}`,
|
|
79
86
|
applyingCaps: 'Aplicando…',
|
|
80
|
-
loadingMembers: 'Cargando
|
|
87
|
+
loadingMembers: 'Cargando los dispositivos…',
|
|
81
88
|
helpCaps: ['↑↓', 'Enter marcar', 'G guardar', 'F5 refrescar', 'Esc dispositivos', 'l English', 'q salir'],
|
|
82
89
|
// Perfil del usuario (lo que sincronizan los dispositivos). Solo lectura: se edita en
|
|
83
90
|
// el aparato, no en la máquina donde vive la bóveda.
|
|
@@ -152,7 +159,7 @@ const es = {
|
|
|
152
159
|
noDevices: ' (sin dispositivos enrolados — pulsa P para emparejar uno)',
|
|
153
160
|
noLabel: '(sin etiqueta)',
|
|
154
161
|
// Está en el acta y no puede entrar: o le retiraron el certificado, o se le venció.
|
|
155
|
-
deviceNoAccess: 'SIN ACCESO — está en el
|
|
162
|
+
deviceNoAccess: 'SIN ACCESO — está en el perfil pero no puede entrar',
|
|
156
163
|
deviceDebt: (n) => `no abre ${n}`,
|
|
157
164
|
thisVault: 'esta bóveda (manda ella)',
|
|
158
165
|
cantRemoveMaster: 'Esta bóveda es la que manda: no se quita a sí misma.',
|
|
@@ -165,7 +172,7 @@ const es = {
|
|
|
165
172
|
noPendingToReject: 'No hay ningún dispositivo pendiente para rechazar',
|
|
166
173
|
rejecting: 'Rechazando…',
|
|
167
174
|
deviceRejected: 'Dispositivo rechazado',
|
|
168
|
-
revokeConfirm: (id) => `¿Quitar ${id} del perfil?
|
|
175
|
+
revokeConfirm: (id) => `¿Quitar ${id} del perfil? Pierde el acceso ahora mismo. Lo que tenga guardado se le borra cuando se conecte: si está apagado, sigue ahí hasta entonces.`,
|
|
169
176
|
revoking: 'Revocando…',
|
|
170
177
|
deviceRevoked: (id) => `Revocado ${id}`,
|
|
171
178
|
approveLabel: (id) => `Código que MUESTRA el dispositivo ${id}`,
|
|
@@ -213,11 +220,15 @@ const es = {
|
|
|
213
220
|
},
|
|
214
221
|
loadNothing: 'No se cargó nada:',
|
|
215
222
|
varPublic: 'pública',
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
223
|
+
// LA PREGUNTA ES LA PREGUNTA, sin explicar la consecuencia (dueño, 2026-09-02:
|
|
224
|
+
// «debe decir: ¿la variable es privada? y punto»). Antes decía «¿que su valor se pueda
|
|
225
|
+
// VER desde la consola remota?», que además ya era FALSO: desde el 2026-09-02 las dos se
|
|
226
|
+
// guardan cifradas igual y `pública` solo dice que se entrega sin pedirte permiso.
|
|
227
|
+
newVarPrivateAsk: '¿Es privada?',
|
|
228
|
+
makePublicConfirm: '¿Hacerla pública?',
|
|
229
|
+
changingVisibility: 'Cambiando…',
|
|
230
|
+
nowPublic: 'Pública: se entrega sin pedirte permiso',
|
|
231
|
+
nowPrivate: 'Privada: para entregarla te pide permiso',
|
|
221
232
|
|
|
222
233
|
// variables de UN aparato (Dispositivos → tecla e). Las del scope las comparten todos
|
|
223
234
|
// los aparatos que sirven ese namespace; estas las lee solo él, y le ganan.
|
|
@@ -332,11 +343,11 @@ const es = {
|
|
|
332
343
|
// El código NO es la contraseña de la bóveda: son los seis dígitos que el aparato
|
|
333
344
|
// enseña en su pantalla. Si no coincide, la bóveda no firma nada y el aparato sigue
|
|
334
345
|
// esperando ahí, así que se puede volver a intentar con A.
|
|
335
|
-
errWrongCode: 'El código no coincide con el que muestra el dispositivo: no
|
|
346
|
+
errWrongCode: 'El código no coincide con el que muestra el dispositivo: no ha entrado nadie. Míralo otra vez y pulsa A.',
|
|
336
347
|
errProfileLocked: 'Bóveda bloqueada: ábrela con su contraseña',
|
|
337
348
|
errWrongPassword: (n) => `Contraseña incorrecta${n ? ` — van ${n} intentos fallidos` : ''}`,
|
|
338
349
|
errTooManyTries: (s) => `Demasiados intentos: espera ${s || '?'} s antes de volver a probar`,
|
|
339
|
-
errMasterWithMembers: 'Esta bóveda es
|
|
350
|
+
errMasterWithMembers: 'Esta bóveda es la que manda en esta cuenta y hay otros dispositivos: pásale primero el mando a uno que esté conectado. Si la borras así, se quedan con su llave y sin nadie que pueda volver a darles permisos.'
|
|
340
351
|
}
|
|
341
352
|
|
|
342
353
|
// ---------------------------------- inglés ----------------------------------
|
|
@@ -362,35 +373,37 @@ const en = {
|
|
|
362
373
|
capsNoMember: ' (this device is no longer in the record)',
|
|
363
374
|
capsApplyHint: 'Enter ticks or unticks · G saves · Esc leaves',
|
|
364
375
|
capsPending: (n) => `${n} unsaved change(s) (*) · G to save them in ONE record · Esc to discard`,
|
|
365
|
-
capsSaved: (n) => `${n} change(s) saved in
|
|
376
|
+
capsSaved: (n) => `${n} change(s) saved in one go`,
|
|
366
377
|
capsNothing: 'You have not changed anything',
|
|
367
378
|
capsDiscard: 'Discard the unsaved changes?',
|
|
368
379
|
capName: {
|
|
369
380
|
secrets: (cn) => `Read the «${cn}» keys`,
|
|
370
|
-
sign: 'Sign as you',
|
|
381
|
+
sign: 'Sign in to apps as you',
|
|
371
382
|
store: 'Save your data',
|
|
372
383
|
read: 'Read your data',
|
|
373
|
-
admin: '
|
|
384
|
+
admin: 'Connect and remove devices',
|
|
374
385
|
passwords: 'Ask for your passwords',
|
|
375
|
-
approve: '
|
|
376
|
-
sealer: '
|
|
377
|
-
unattended: 'Take keys without
|
|
386
|
+
approve: 'Authorise other devices',
|
|
387
|
+
sealer: 'Admit devices without this vault',
|
|
388
|
+
unattended: 'Take keys without asking you',
|
|
389
|
+
replica: 'Hand out your keys while the vault is off'
|
|
378
390
|
},
|
|
379
391
|
capHint: {
|
|
380
392
|
sign: 'use your identity across the ecosystem apps',
|
|
381
393
|
store: 'write to your vault (profile, content, sensitive data)',
|
|
382
394
|
read: 'see what you saved',
|
|
383
|
-
admin: '
|
|
395
|
+
admin: 'do it from that device, without coming here. It cannot change permissions',
|
|
384
396
|
passwords: 'the manager: ask for one credential per site, never the whole list',
|
|
385
397
|
approve: 'give the go-ahead when another device asks for private keys',
|
|
386
|
-
sealer: 'admit devices and change permissions on its own
|
|
387
|
-
unattended: 'take private keys WITHOUT asking you. Off, you must approve it from a device with «Approve» every time it starts'
|
|
398
|
+
sealer: 'admit devices and change permissions on its own when this vault is away. Only useful on another VAULT',
|
|
399
|
+
unattended: 'take private keys WITHOUT asking you. Off, you must approve it from a device with «Approve» every time it starts',
|
|
400
|
+
replica: 'it hands them over sealed, exactly as they left here: it cannot open them or change anything. Only useful on another machine'
|
|
388
401
|
},
|
|
389
402
|
confirmAdmin: (id) => `Let ${id} connect and remove devices without coming here?`,
|
|
390
403
|
capGiven: (n) => `Granted: ${n}`,
|
|
391
404
|
capTaken: (n) => `Removed: ${n}`,
|
|
392
405
|
applyingCaps: 'Applying…',
|
|
393
|
-
loadingMembers: 'Loading the
|
|
406
|
+
loadingMembers: 'Loading the devices…',
|
|
394
407
|
helpCaps: ['↑↓', 'Enter tick', 'G save', 'F5 refresh', 'Esc devices', 'l Español', 'q quit'],
|
|
395
408
|
loadingProfile: 'Loading profile…',
|
|
396
409
|
noProfile: ' (this vault has no profile yet)',
|
|
@@ -474,7 +487,7 @@ const en = {
|
|
|
474
487
|
noPendingToReject: 'No device is waiting to be rejected',
|
|
475
488
|
rejecting: 'Rejecting…',
|
|
476
489
|
deviceRejected: 'Device rejected',
|
|
477
|
-
revokeConfirm: (id) => `Remove ${id} from the profile? It
|
|
490
|
+
revokeConfirm: (id) => `Remove ${id} from the profile? It loses access right away. What it has stored is erased when it connects: if it is switched off, it stays there until then.`,
|
|
478
491
|
revoking: 'Revoking…',
|
|
479
492
|
deviceRevoked: (id) => `Revoked ${id}`,
|
|
480
493
|
approveLabel: (id) => `Code SHOWN by device ${id}`,
|
|
@@ -519,11 +532,11 @@ const en = {
|
|
|
519
532
|
},
|
|
520
533
|
loadNothing: 'Nothing was loaded:',
|
|
521
534
|
varPublic: 'public',
|
|
522
|
-
|
|
523
|
-
makePublicConfirm: '
|
|
524
|
-
changingVisibility: 'Changing
|
|
525
|
-
nowPublic: '
|
|
526
|
-
nowPrivate: '
|
|
535
|
+
newVarPrivateAsk: 'Is it private?',
|
|
536
|
+
makePublicConfirm: 'Make it public?',
|
|
537
|
+
changingVisibility: 'Changing…',
|
|
538
|
+
nowPublic: 'Public: handed over without asking you',
|
|
539
|
+
nowPrivate: 'Private: handing it over asks you first',
|
|
527
540
|
|
|
528
541
|
// variables de UN aparato (Dispositivos → tecla e)
|
|
529
542
|
titleDevVars: 'Device variables',
|
|
@@ -618,7 +631,7 @@ const en = {
|
|
|
618
631
|
errNotApplied: 'The daemon did not apply the change (check the service logs).',
|
|
619
632
|
errNotDeleted: 'The daemon did not delete the variable (check the service logs).',
|
|
620
633
|
errPairFailed: 'The daemon did not start the pairing.',
|
|
621
|
-
errWrongCode: 'The code does not match the one shown by the device:
|
|
634
|
+
errWrongCode: 'The code does not match the one shown by the device: nobody got in. Check it and press A again.',
|
|
622
635
|
errProfileLocked: 'Vault locked: open it with its password',
|
|
623
636
|
errWrongPassword: (n) => `Wrong password${n ? ` — ${n} failed attempts so far` : ''}`,
|
|
624
637
|
errTooManyTries: (s) => `Too many attempts: wait ${s || '?'} s before trying again`,
|
package/src/vault.js
CHANGED
|
@@ -1161,6 +1161,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1161
1161
|
if (payload.type === MSG.ADMIN) return await handleAdmin(from, payload)
|
|
1162
1162
|
if (payload.type === MSG.RENOUNCE) return await handleRenounce(from, payload)
|
|
1163
1163
|
if (payload.type === MSG.ADMIN_EVENT) return await handleAdminEvent(payload)
|
|
1164
|
+
if (payload.type === MSG.REPLICA_ACK) return await handleReplicaAck(from, payload)
|
|
1164
1165
|
} catch (e) {
|
|
1165
1166
|
reply(from, { type: MSG.ERROR, error: e.message })
|
|
1166
1167
|
}
|
|
@@ -1355,7 +1356,15 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1355
1356
|
* ventana. La CLAVE distingue los dos cajones (`<ns>` y `dev:<pub>`) para que tocar
|
|
1356
1357
|
* lo de un aparato no cancele el aviso pendiente de todo su namespace.
|
|
1357
1358
|
*/
|
|
1358
|
-
function scheduleNotice (ns) {
|
|
1359
|
+
function scheduleNotice (ns) {
|
|
1360
|
+
schedule(ns, async () => {
|
|
1361
|
+
await notifyNsChange(ns)
|
|
1362
|
+
// Y a los replicadores el sobre nuevo. Sin esto repartirían el valor de ayer: el
|
|
1363
|
+
// aviso de cambio le dice al servicio que se reinicie, y al reiniciar volvería a
|
|
1364
|
+
// pedir — y si la bóveda está apagada, el replicador le daría lo viejo.
|
|
1365
|
+
await pushToReplicas(`secret:${ns}`)
|
|
1366
|
+
})
|
|
1367
|
+
}
|
|
1359
1368
|
function scheduleDeviceNotice (pub) { schedule('dev:' + pub, () => notifyDeviceChange(pub)) }
|
|
1360
1369
|
|
|
1361
1370
|
function schedule (key, fn) {
|
|
@@ -1432,6 +1441,80 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1432
1441
|
for (const d of issued || []) avisar(d.sub)
|
|
1433
1442
|
log(`[vault] notified ${seen.size} member(s) of "${ev}" (record #${acta?.seq ?? '?'})`)
|
|
1434
1443
|
} catch (e) { log('[vault] could not notify members of the change:', e.message) }
|
|
1444
|
+
// Y a los replicadores, que no quieren un aviso sino el CONTENIDO: sin esto se
|
|
1445
|
+
// quedarían con el acta de ayer y repartiendo sobres de un aparato ya revocado.
|
|
1446
|
+
await pushToReplicas(ev)
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* EL ARRASTRE AL ARRANCAR (`docs/replicas.md` §6). Un replicador dice por dónde va en
|
|
1451
|
+
* cuanto se conecta, y si va por detrás se le empuja.
|
|
1452
|
+
*
|
|
1453
|
+
* Sin esto, la bóveda solo empujaba al cambiar algo: un replicador que se enciende
|
|
1454
|
+
* después se quedaba vacío hasta el siguiente cambio, que puede no llegar en semanas.
|
|
1455
|
+
* Y el empujón de un enrolamiento se manda a un aparato que todavía no está conectado.
|
|
1456
|
+
* Lo destapó el smoke y era un hueco del diseño, no del código.
|
|
1457
|
+
*
|
|
1458
|
+
* El acuse va FIRMADO por el replicador y se comprueba contra el acta: sin eso, cualquiera
|
|
1459
|
+
* podría pedirle a la bóveda que reparta el acta y los sobres mandando un acuse inventado.
|
|
1460
|
+
*/
|
|
1461
|
+
async function handleReplicaAck (from, p) {
|
|
1462
|
+
const body = p?.body
|
|
1463
|
+
if (!body || body.op !== 'replica.ack') return
|
|
1464
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
1465
|
+
if (!record) return
|
|
1466
|
+
// LA LLAVE VA DENTRO DEL MENSAJE FIRMADO, no se saca de `from`: `from` es el
|
|
1467
|
+
// identificador de la conexión en el proxio, no la llave del miembro. Compararlo con
|
|
1468
|
+
// el acta rechazaba a todo el mundo, y en el caso contrario habría sido peor.
|
|
1469
|
+
const quien = body.publickey
|
|
1470
|
+
if (typeof quien !== 'string' || !Acta.memberCan(record, quien, 'replica')) {
|
|
1471
|
+
return log('[vault] a replica ack arrived from someone the record does not allow to serve: ignored')
|
|
1472
|
+
}
|
|
1473
|
+
if (!(await verifyDeviceSig({ publickey: quien, data: body, signature: p.signature }))) {
|
|
1474
|
+
return log('[vault] a replica ack does not check out against its own key: ignored')
|
|
1475
|
+
}
|
|
1476
|
+
if (typeof body.seq === 'number' && body.seq >= record.seq) return
|
|
1477
|
+
log(`[vault] a replica is at #${body.seq} and we are at #${record.seq}: catching it up`)
|
|
1478
|
+
await pushToReplicas('catch-up')
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
/**
|
|
1482
|
+
* EMPUJAR A LOS REPLICADORES lo que tienen que poder repartir: el acta y los sobres.
|
|
1483
|
+
*
|
|
1484
|
+
* Se manda TODO firmado de antes —el acta la selló la maestra, cada sobre lo firmó la
|
|
1485
|
+
* llave de sellado— así que un replicador no puede falsificar nada de lo que le damos.
|
|
1486
|
+
* Y va cerrado a su destinatario, no a él: reparte sobres que no puede abrir. Es lo que
|
|
1487
|
+
* permite ponerlo en una máquina en la que no confías (`docs/replicas.md` §8.bis).
|
|
1488
|
+
*
|
|
1489
|
+
* Se empuja después de cada cambio, y no se espera acuse: si un replicador está apagado
|
|
1490
|
+
* se queda atrás y lo alcanza el siguiente empujón. Un replicador atrasado no es un
|
|
1491
|
+
* peligro —el cliente rechaza un acta con `seq` menor del que ya vio— sino menos
|
|
1492
|
+
* disponibilidad, que es exactamente lo que un replicador arriesga.
|
|
1493
|
+
*/
|
|
1494
|
+
async function pushToReplicas (motivo = 'change') {
|
|
1495
|
+
try {
|
|
1496
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
1497
|
+
if (!record) return
|
|
1498
|
+
const replicas = (record.members || []).filter((m) => Acta.memberCan(record, m.pub, 'replica'))
|
|
1499
|
+
if (!replicas.length) return
|
|
1500
|
+
|
|
1501
|
+
// UN SOBRE POR (CAJÓN, APARATO), porque `bundleFor` lo talla a quien lo pide: lleva
|
|
1502
|
+
// solo las envolturas de ESE aparato. Mandar uno genérico no serviría de nada.
|
|
1503
|
+
const bundles = {}
|
|
1504
|
+
for (const m of record.members || []) {
|
|
1505
|
+
if (!m.cn) continue
|
|
1506
|
+
try { bundles[`${m.cn} ${m.pub}`] = secrets.bundleFor(m.cn, m.pub) } catch (_) {}
|
|
1507
|
+
}
|
|
1508
|
+
const body = { op: 'replica.push', seq: record.seq, acta: record, bundles, ts: Date.now() }
|
|
1509
|
+
const seal = await signSeal(body)
|
|
1510
|
+
if (!seal) return log('[vault] cannot push to the replicas: the record names no sealing key of ours')
|
|
1511
|
+
let n = 0
|
|
1512
|
+
for (const r of replicas) {
|
|
1513
|
+
try { client.sendByPubkey(r.pub, { type: MSG.REPLICA_PUSH, body, seal }); n++ }
|
|
1514
|
+
catch (e) { log(`[vault] could not push to a replica: ${e.message}`) }
|
|
1515
|
+
}
|
|
1516
|
+
log(`[vault] pushed record #${record.seq} and ${Object.keys(bundles).length} bundle(s) to ${n} replica(s) on ${motivo}`)
|
|
1517
|
+
} catch (e) { log('[vault] could not push to the replicas: ' + e.message) }
|
|
1435
1518
|
}
|
|
1436
1519
|
|
|
1437
1520
|
/**
|
|
@@ -2291,7 +2374,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2291
2374
|
// ANTES de reenvolver nada: si la copia de recuperación se quedó bajo la llave de la
|
|
2292
2375
|
// máquina, se pasa a la frase. Sin esto, todo lo de abajo falla con «wrong password».
|
|
2293
2376
|
if (adminKey) await migrarRecuperacionALaFrase(adminKey)
|
|
2294
|
-
const out = { drawers: 0, wrapped: 0, dropped: 0, sinLlave: [], failed: [] }
|
|
2377
|
+
const out = { drawers: 0, wrapped: 0, sealed: 0, dropped: 0, sinLlave: [], failed: [] }
|
|
2295
2378
|
for (const owner of secrets.owners?.() || []) {
|
|
2296
2379
|
const before = new Set(secrets.recipientsIn(owner))
|
|
2297
2380
|
// POR VISIBILIDAD, no una lista fija: una pública lleva además la envoltura de quien
|
|
@@ -2303,6 +2386,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2303
2386
|
const after = new Set(secrets.recipientsIn(owner))
|
|
2304
2387
|
out.drawers++
|
|
2305
2388
|
out.wrapped += r?.wrapped || 0
|
|
2389
|
+
out.sealed += r?.sealed || 0
|
|
2306
2390
|
out.dropped += [...before].filter((p) => !after.has(p)).length
|
|
2307
2391
|
for (const p of r?.sinLlave || []) out.sinLlave.push(p)
|
|
2308
2392
|
} catch (e) {
|
|
@@ -2310,7 +2394,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2310
2394
|
out.failed.push({ owner, error: e.message })
|
|
2311
2395
|
}
|
|
2312
2396
|
}
|
|
2313
|
-
if (out.dropped) audit('secret.reseal', { drawers: out.drawers, dropped: out.dropped })
|
|
2397
|
+
if (out.dropped || out.sealed) audit('secret.reseal', { drawers: out.drawers, dropped: out.dropped, sealed: out.sealed })
|
|
2314
2398
|
if (out.failed.length) audit('secret.reseal-failed', { drawers: out.failed.map((f) => f.owner) })
|
|
2315
2399
|
return out
|
|
2316
2400
|
}
|
|
@@ -2603,6 +2687,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2603
2687
|
if (r?.locked === false) { try { await ensureCommKeyInActa() } catch (_) {} }
|
|
2604
2688
|
return { locked: r?.locked !== false }
|
|
2605
2689
|
},
|
|
2690
|
+
// Forzar un empujón: lo usa `replica push` de la CLI y el smoke, para no depender
|
|
2691
|
+
// de que justo acabe de cambiar algo.
|
|
2692
|
+
pushToReplicas,
|
|
2606
2693
|
startPairing: desk.startPairing,
|
|
2607
2694
|
stopPairing: desk.stopPairing,
|
|
2608
2695
|
listPending: desk.listPending,
|