@dotrino/vault 0.53.0 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/enroll.js +12 -1
- package/src/service.js +110 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vault",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.54.0",
|
|
4
4
|
"description": "Usa ESTE dispositivo (navegador) como bóveda/CA del ecosistema Dotrino: atiende enrolamientos por el proxy y firma certificados de delegación a tus máquinas. Incluye el cliente de SERVICIO (Node): un proyecto se enrola una vez y jala sus credenciales del vault en vez del .env (`import '@dotrino/vault/config'`).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/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/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 }))) {
|