@dotrino/vaultd 0.53.0 → 0.58.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/bin/dotrino-vaultd.js +27 -1
- package/lib/src/atrest.js +116 -2
- package/lib/src/enroll.js +1 -1
- package/lib/src/kek.js +293 -0
- package/lib/src/lock.js +117 -0
- package/lib/src/protocol.js +9 -1
- package/lib/src/service.js +47 -9
- package/package.json +4 -4
- package/src/client.js +3 -3
- package/src/ctl.js +331 -13
- package/src/daemon.js +179 -15
- package/src/keyowner.js +83 -0
- package/src/manager.js +33 -4
- package/src/passwords.js +15 -4
- package/src/profiles.js +173 -16
- package/src/selfupdate.js +86 -0
- package/src/vault.js +137 -19
package/lib/src/service.js
CHANGED
|
@@ -23,8 +23,7 @@ import path from 'node:path'
|
|
|
23
23
|
import { createHash } from 'node:crypto'
|
|
24
24
|
import {
|
|
25
25
|
makeDeviceKey, makeDeviceEncKey, importDeviceEncKey, signWithDevice, verifyDelegation,
|
|
26
|
-
verifyDeviceSig, makePairingCode, commitCode, pubkeyId
|
|
27
|
-
} from '@dotrino/identity/capabilities'
|
|
26
|
+
verifyDeviceSig, makePairingCode, commitCode, pubkeyId, PEER_SKEW_MS } from '@dotrino/identity/capabilities'
|
|
28
27
|
import { openWrap, wrapForMember, decryptWithCek } from '@dotrino/identity/content'
|
|
29
28
|
import { verifyActa, sealKeyAt } from '@dotrino/identity/acta'
|
|
30
29
|
import { MSG, secretsScope, isValidSecretsNs } from './protocol.js'
|
|
@@ -298,7 +297,7 @@ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = nu
|
|
|
298
297
|
const res = await enrolled
|
|
299
298
|
|
|
300
299
|
if (res.code !== code) throw new Error('the vault echoed a code other than the one shown (possible malicious relay)')
|
|
301
|
-
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, ...(expectedScope ? { expectedScope } : {}) })
|
|
300
|
+
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, skewMs: PEER_SKEW_MS, ...(expectedScope ? { expectedScope } : {}) })
|
|
302
301
|
if (!v.ok) throw new Error('invalid cert: ' + v.reason)
|
|
303
302
|
if (res.cert.iss !== qr.iss) throw new Error('cert signed by a master other than the one in the QR')
|
|
304
303
|
|
|
@@ -440,7 +439,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
440
439
|
client.sendByPubkey(masterPubkey, { type: MSG.RENEW, data, signature, cert })
|
|
441
440
|
const res = await pending
|
|
442
441
|
if (res.type === MSG.RENEWED && res.cert?.sub === device.publickey) {
|
|
443
|
-
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, expectedScope: secretsScope(ns) })
|
|
442
|
+
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, skewMs: PEER_SKEW_MS, expectedScope: secretsScope(ns) })
|
|
444
443
|
if (v.ok) { cert = res.cert; if (dir && saved) writeServiceIdentity(dir, { ...saved, cert }) }
|
|
445
444
|
}
|
|
446
445
|
} catch (_) { /* la renovación no bloquea el fetch */ }
|
|
@@ -458,8 +457,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
458
457
|
// verdad llega cuando el aparato que aprueba firme — por esta misma conexión, que
|
|
459
458
|
// sigue identificada. Se espera lo que dura el pedido; denegado o vencido es error.
|
|
460
459
|
if (res.body?.op === 'secrets.pending' && res.body.ns === ns) {
|
|
461
|
-
|
|
462
|
-
if (!okPending) throw new Error('invalid master signature on the pending reply')
|
|
460
|
+
await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey })
|
|
463
461
|
try { onPending?.({ id: res.body.id, ns, exp: res.body.exp }) } catch (_) {}
|
|
464
462
|
const until = typeof res.body.exp === 'number' ? Math.max(5000, res.body.exp - Date.now() + 5000) : approvalTimeoutMs
|
|
465
463
|
res = await waitForMsg(client, (p) => (p.type === MSG.SECRETS_RESULT && p.body?.op === 'secrets.result') || p.type === MSG.ERROR, Math.min(until, approvalTimeoutMs))
|
|
@@ -467,14 +465,20 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
467
465
|
if (res.type === MSG.ERROR) throw new Error(res.error)
|
|
468
466
|
}
|
|
469
467
|
|
|
470
|
-
//
|
|
468
|
+
// AUTENTICIDAD: la firma NO es de la maestra. El acta dice qué llave vale para esto
|
|
469
|
+
// (`sealPub`, buscada por `seq`), que es la misma regla que ya gobierna los sobres.
|
|
470
|
+
// La maestra solo sella el acta y reenvuelve; servir no es cosa suya, y por eso puede
|
|
471
|
+
// quedarse cerrada.
|
|
471
472
|
const body = res.body
|
|
472
473
|
if (!body || body.op !== 'secrets.result' || body.ns !== ns) throw new Error('malformed secrets reply')
|
|
473
474
|
if (typeof body.ts !== 'number' || Math.abs(Date.now() - body.ts) > FRESH_WINDOW_MS) throw new Error('stale secrets reply')
|
|
474
|
-
const ok = await verifyDeviceSig({ publickey: masterPubkey, data: body, signature: res.signature })
|
|
475
|
-
if (!ok) throw new Error('invalid master signature on the secrets reply')
|
|
476
475
|
|
|
476
|
+
// Se abre ANTES de comprobar la firma, y no es un descuido: el sobre de fuera va
|
|
477
|
+
// sellado a la efímera que acabamos de estrenar, así que abrirlo no prueba nada ni
|
|
478
|
+
// confía en nadie — solo saca el acta con la que SÍ se comprueba. Nada de lo que hay
|
|
479
|
+
// dentro se usa hasta después de verificar.
|
|
477
480
|
const payload = await openSealed({ privateKey: eph.privateKey, enc: body.enc })
|
|
481
|
+
await verifyResponder({ body, seal: res.seal, acta: payload?.acta, masterPubkey })
|
|
478
482
|
|
|
479
483
|
// DOS CAPAS DE SOBRE, y hacen cosas distintas:
|
|
480
484
|
// · la de fuera (`ek` efímera, recién abierta) tapa el TRAMO — el proxio no ve
|
|
@@ -499,6 +503,40 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
499
503
|
* omitido: silenciarlo convertiría una rotación mal sellada en «el servicio sigue
|
|
500
504
|
* con el valor viejo y nadie se entera», que es el peor modo de fallo de todo esto.
|
|
501
505
|
*/
|
|
506
|
+
/**
|
|
507
|
+
* ¿QUIÉN CONTESTÓ, Y PUEDE? La misma regla que gobierna los sobres, aplicada al
|
|
508
|
+
* transporte: **el acta dice qué llave firma qué**.
|
|
509
|
+
*
|
|
510
|
+
* La maestra no entra aquí. Sus dos trabajos son sellar el acta y reenvolver los sobres
|
|
511
|
+
* de todos los aparatos; servir una petición no es ninguno de los dos, así que exigir su
|
|
512
|
+
* firma la obligaba a estar despierta todo el tiempo — y de paso hacía imposible que
|
|
513
|
+
* sirviera nadie más.
|
|
514
|
+
*
|
|
515
|
+
* Lo que se comprueba, en orden:
|
|
516
|
+
* 1. el acta que llega va firmada por la maestra que este agente lleva pineada desde
|
|
517
|
+
* que se enroló (o por la que aquella nombró al traspasar);
|
|
518
|
+
* 2. la firma del cuerpo cuadra con la llave de sellado que ESA acta nombra para el
|
|
519
|
+
* `seq` con el que se firmó.
|
|
520
|
+
*
|
|
521
|
+
* Sin el paso 1 el paso 2 no vale nada: un acta cualquiera nombraría la llave que
|
|
522
|
+
* quisiera.
|
|
523
|
+
*/
|
|
524
|
+
export async function verifyResponder ({ body, seal, acta, masterPubkey }) {
|
|
525
|
+
if (!seal?.sig || typeof seal.seq !== 'number') {
|
|
526
|
+
throw new Error('the reply carries no sealing signature: this vault is too old to serve (update it)')
|
|
527
|
+
}
|
|
528
|
+
if (!acta) throw new Error('the reply carries no record: cannot tell which key was allowed to sign it')
|
|
529
|
+
if (acta.sealedBy !== masterPubkey) {
|
|
530
|
+
throw new Error('the record was not sealed by the master this agent knows: refusing the reply')
|
|
531
|
+
}
|
|
532
|
+
if (!(await verifyActa({ acta })).ok) throw new Error('the record does not verify')
|
|
533
|
+
const pub = sealKeyAt(acta, seal.seq)
|
|
534
|
+
if (!pub) throw new Error(`the record names no sealing key for #${seal.seq}`)
|
|
535
|
+
if (!(await verifyDeviceSig({ publickey: pub, data: body, signature: seal.sig }))) {
|
|
536
|
+
throw new Error('the reply signature does not check out against the key the record names')
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
502
540
|
/**
|
|
503
541
|
* ¿SALIÓ ESTE SOBRE DE MI BÓVEDA? (§8.8 de `dotrino-vault/docs/secretos-sellados.md`)
|
|
504
542
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.58.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,8 +20,8 @@
|
|
|
20
20
|
"node": ">=20"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@dotrino/identity": "^0.
|
|
24
|
-
"@dotrino/passmanager": "^0.
|
|
23
|
+
"@dotrino/identity": "^0.64.0",
|
|
24
|
+
"@dotrino/passmanager": "^0.9.0",
|
|
25
25
|
"@dotrino/proxy-client": "^0.13.1",
|
|
26
26
|
"ws": "^8.18.0"
|
|
27
27
|
},
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"!lib/src/types.d.ts"
|
|
45
45
|
],
|
|
46
46
|
"devDependencies": {
|
|
47
|
-
"@dotrino/remote-agent": "^0.3
|
|
47
|
+
"@dotrino/remote-agent": "^0.5.3",
|
|
48
48
|
"@types/node": "^22.0.0",
|
|
49
49
|
"typescript": "5.9.3"
|
|
50
50
|
}
|
package/src/client.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* estrictamente (firmado por la maestra que vio en el QR, y para SU clave) antes de
|
|
12
12
|
* guardarlo. La maestra nunca sale del vault.
|
|
13
13
|
*/
|
|
14
|
-
import { makeDeviceKey, signWithDevice, verifyDelegation, verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from '@dotrino/identity/capabilities'
|
|
14
|
+
import { makeDeviceKey, signWithDevice, verifyDelegation, verifyDeviceSig, makePairingCode, commitCode, pubkeyId, PEER_SKEW_MS } from '@dotrino/identity/capabilities'
|
|
15
15
|
import { installNodeGlobals } from './node-globals.js'
|
|
16
16
|
import { MSG } from './protocol.js'
|
|
17
17
|
|
|
@@ -84,7 +84,7 @@ export async function enroll ({ qr, label = '', dir, onChallenge, approveTimeout
|
|
|
84
84
|
const res = await enrolled
|
|
85
85
|
|
|
86
86
|
// VALIDACIÓN ESTRICTA antes de persistir (cierra inyección de cert / sustitución de maestra).
|
|
87
|
-
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey })
|
|
87
|
+
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, skewMs: PEER_SKEW_MS })
|
|
88
88
|
if (!v.ok) throw new Error('invalid cert: ' + v.reason)
|
|
89
89
|
if (res.cert.iss !== qr.iss) throw new Error('cert signed by a master other than the one you saw (possible malicious proxy)')
|
|
90
90
|
if (res.cert.sub !== device.publickey) throw new Error('cert issued for a different device')
|
|
@@ -151,7 +151,7 @@ export async function requestRenew ({ masterPubkey, proxyUrl, device, cert, dir
|
|
|
151
151
|
if (res.type === MSG.ERROR) throw new Error(res.error)
|
|
152
152
|
// Se valida antes de devolverlo, igual que en el enrolamiento: un cert que no está
|
|
153
153
|
// firmado por la maestra que ya conocemos, o que no es para esta llave, no se guarda.
|
|
154
|
-
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey })
|
|
154
|
+
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, skewMs: PEER_SKEW_MS })
|
|
155
155
|
if (!v.ok) throw new Error('invalid cert: ' + v.reason)
|
|
156
156
|
if (res.cert.iss !== masterPubkey) throw new Error('cert signed by a master other than the pinned one')
|
|
157
157
|
return { cert: res.cert }
|