@dotrino/vault 0.79.0 → 0.81.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/README.md +28 -0
- package/package.json +1 -1
- package/src/admin.js +15 -0
- package/src/protocol.js +7 -0
- package/src/service.js +113 -0
package/README.md
CHANGED
|
@@ -254,6 +254,34 @@ firmada por la llave del servicio + cert, respuesta **sellada** (ECDH efímero +
|
|
|
254
254
|
el proxy no ve los valores) y **firmada por la maestra**, verificada contra la `iss`
|
|
255
255
|
pineada en el enrolamiento.
|
|
256
256
|
|
|
257
|
+
### Actualizarse: preguntar y avisar (`@dotrino/vault/service`)
|
|
258
|
+
|
|
259
|
+
Un aparato enrolado se actualiza solo (CONVENCIONES §15). Si tiene encendido **su propio**
|
|
260
|
+
interruptor de «pedir aprobación», le pregunta a su bóveda antes de instalar; si tiene
|
|
261
|
+
encendido el de avisos, se lo cuenta después. Son interruptores de cada pieza, independientes
|
|
262
|
+
del de la bóveda (`dotrino-vault update --approval`, que vale solo para ella).
|
|
263
|
+
|
|
264
|
+
```js
|
|
265
|
+
import { askUpdateApproval, reportUpdated } from '@dotrino/vault/service'
|
|
266
|
+
|
|
267
|
+
// Antes de instalar. Con `dir` (identidad de enrollService) o con
|
|
268
|
+
// { proxyUrl, masterPubkey, device, cert }, como el resto de helpers.
|
|
269
|
+
const r = await askUpdateApproval({ product: '@dotrino/terminal-agent', version: '1.3.0', from: '1.2.9', dir })
|
|
270
|
+
// → { ok: true, asked: false } nadie aprueba en la cuenta: adelante
|
|
271
|
+
// → { ok: true, asked: true } alguien lo aprobó
|
|
272
|
+
// → { ok: false, asked: true } lo denegaron, o pasó un día sin respuesta
|
|
273
|
+
if (r.ok) { /* instalar */ }
|
|
274
|
+
|
|
275
|
+
// Después de instalar: la bóveda avisa a quien aprueba, y dice qué aparato fue.
|
|
276
|
+
await reportUpdated({ product: '@dotrino/terminal-agent', version: '1.3.0', from: '1.2.9', dir }) // → { ok: true }
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
- El pedido dura **un día**. Mientras vive, volver a preguntar por lo mismo no vuelve a
|
|
280
|
+
timbrar. No repetir la pregunta por una versión ya denegada es cosa de quien llama.
|
|
281
|
+
- `askUpdateApproval` **lanza** si la bóveda no contesta (`code: 'vault-no-reply'`) o si quedó
|
|
282
|
+
pendiente y el resultado no llegó (`code: 'unanswered'`). Que nadie conteste nunca es un sí.
|
|
283
|
+
- `reportUpdated` lanza con `code: 'vault-no-reply'` si la bóveda no contesta.
|
|
284
|
+
|
|
257
285
|
## Modelo de aprobación (seguro por diseño)
|
|
258
286
|
|
|
259
287
|
- El **dispositivo** que se enrola genera un **código aleatorio** (`makePairingCode`) y
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vault",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.81.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/admin.js
CHANGED
|
@@ -47,6 +47,9 @@ export const ADMIN_OPS = Object.freeze([
|
|
|
47
47
|
// `pending`, `pair`, `approve` y `reject` NO están, y no es un olvido: agregar aparatos
|
|
48
48
|
// a distancia se quitó (ver la cabecera). Lo que queda es mirar, quitar y configurar.
|
|
49
49
|
'revoke', 'audit',
|
|
50
|
+
// Los BLOQUEADOS por un aprobador (`src/blocked.js`): verlos y desbloquearlos. Bloquear a
|
|
51
|
+
// distancia también, por si el administrador llega antes que el teléfono. No toca el acta.
|
|
52
|
+
'blocked', 'block', 'unblock',
|
|
50
53
|
// Variables de entorno: verlas (nombres siempre; valor solo de las públicas) y
|
|
51
54
|
// ponerles valor (de las dos). Borrar NO está, y es a propósito: un aparato robado
|
|
52
55
|
// no debe poder dejar sin configuración a los servicios.
|
|
@@ -96,6 +99,8 @@ export const ADMIN_OPS = Object.freeze([
|
|
|
96
99
|
*/
|
|
97
100
|
export const ADMIN_OPS_WHILE_LOCKED = Object.freeze([
|
|
98
101
|
'audit', 'vars', 'var.set', 'var.setMany', 'var.recipients',
|
|
102
|
+
// Bloquear no sella nada: la lista es de la bóveda, no del acta.
|
|
103
|
+
'blocked', 'block', 'unblock',
|
|
99
104
|
// Las dos de abrir, obviamente: son las únicas que solo tienen sentido con el candado
|
|
100
105
|
// echado. Que estén aquí NO las deja pasar sin más — siguen exigiendo cert, acta, nonce
|
|
101
106
|
// y frescura como todas, y además la contraseña dentro del sobre.
|
|
@@ -147,6 +152,8 @@ export function createAdminDesk ({
|
|
|
147
152
|
// este módulo es puro a propósito. `null` en una bóveda que no lo soporte: entonces la
|
|
148
153
|
// operación existe y contesta que no, en vez de romper con un TypeError.
|
|
149
154
|
unlockDesk = null,
|
|
155
|
+
// La lista de bloqueados, inyectada como `vars`: `{ list(), set(pub, on, { by }) }`.
|
|
156
|
+
blocked = null,
|
|
150
157
|
now = () => Date.now()
|
|
151
158
|
}) {
|
|
152
159
|
const ops = new Set(ADMIN_OPS)
|
|
@@ -333,6 +340,14 @@ export function createAdminDesk ({
|
|
|
333
340
|
// usarlo para quitar dejaba un miembro sin certificados al que la bóveda ya nunca le
|
|
334
341
|
// mandaba el aviso de expulsión (mientras siga en el acta, un papel retirado
|
|
335
342
|
// significa «renueva»). Ese era el dispositivo fantasma.
|
|
343
|
+
if (data.op === 'blocked' || data.op === 'block' || data.op === 'unblock') {
|
|
344
|
+
if (!blocked) return { ok: false, error: 'admin: this vault cannot block devices' }
|
|
345
|
+
if (data.op === 'blocked') return { ok: true, result: { items: blocked.list() } }
|
|
346
|
+
if (typeof data.sub !== 'string' || !data.sub) return { ok: false, error: `admin: ${data.op} needs the device key (sub)` }
|
|
347
|
+
const r = await blocked.set(data.sub, data.op === 'block', { by })
|
|
348
|
+
audit(`admin.${data.op}`, { by, device: await deviceIdOf(data.sub).catch(() => null) })
|
|
349
|
+
return { ok: true, result: { ok: true, was: r } }
|
|
350
|
+
}
|
|
336
351
|
if (data.op === 'revoke') {
|
|
337
352
|
if (data.sub) {
|
|
338
353
|
const deviceId = await deviceIdOf(String(data.sub)).catch(() => null)
|
package/src/protocol.js
CHANGED
|
@@ -103,6 +103,13 @@ export const MSG = Object.freeze({
|
|
|
103
103
|
// una forma de echar a alguien de su cuenta.
|
|
104
104
|
LOGIN_CLOSE: 'vault.login.close', // aparato → vault: { data:{op,user,sid,publickey,ts}, signature }
|
|
105
105
|
LOGIN_CLOSED: 'vault.login.closed', // vault → aparato: { ok }
|
|
106
|
+
// INCIDENTE: un agente de la cuenta (la terminal) avisa de que OTRO aparato de la cuenta
|
|
107
|
+
// se equivocó de clave tres veces seguidas. La bóveda lo apunta como un pedido más
|
|
108
|
+
// (`kind: 'incident'`), timbra a quien aprueba, y el aprobador bloquea o ignora. Bloquear
|
|
109
|
+
// no toca el acta: es una lista de la bóveda (`blocked.json`) que viaja a los agentes con
|
|
110
|
+
// `vault.devices.result` (`blocked`) y se deshace con `dotrino-vault unblock <ID>`.
|
|
111
|
+
INCIDENT: 'vault.incident', // agente → vault: { data:{op:'incident',kind,about,tries,publickey,ts}, signature, cert }
|
|
112
|
+
INCIDENT_RESULT: 'vault.incident.result', // vault → agente: { id, approvers, blocked }
|
|
106
113
|
ERROR: 'vault.error' // vault → dispositivo: { error }
|
|
107
114
|
})
|
|
108
115
|
|
package/src/service.js
CHANGED
|
@@ -620,6 +620,119 @@ export async function fetchDigest ({ dir, ns, proxyUrl, masterPubkey, device, ce
|
|
|
620
620
|
} finally { client.close() }
|
|
621
621
|
}
|
|
622
622
|
|
|
623
|
+
/** Cuánto espera un aparato a que alguien apruebe su actualización (el pedido vive un día). */
|
|
624
|
+
export const UPDATE_APPROVAL_TIMEOUT_MS = 24 * 60 * 60 * 1000 + 10 * 1000
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* ¿PUEDO ACTUALIZARME? Lo pregunta un aparato a su bóveda ANTES de instalar una versión
|
|
628
|
+
* nueva, y SOLO si tiene encendido su propio interruptor de «pedir aprobación» (CONVENCIONES
|
|
629
|
+
* §15, dueño 2026-10-08: todo se actualiza solo; pedir aprobación es un ajuste de cada pieza,
|
|
630
|
+
* independiente del de la bóveda). Quien no lo tiene encendido no llama a esto.
|
|
631
|
+
*
|
|
632
|
+
* · nadie aprueba en la cuenta → `{ ok: true, asked: false }`, ya.
|
|
633
|
+
* · hay aprobadores → la bóveda contesta «pendiente», timbra a quien aprueba y el resultado
|
|
634
|
+
* llega por esta misma conexión cuando alguien decide: `{ ok, asked: true }`. El pedido
|
|
635
|
+
* dura un día; vencido es no.
|
|
636
|
+
*
|
|
637
|
+
* LANZA si la bóveda no contesta (`code: 'vault-no-reply'`) o si quedó «pendiente» y el
|
|
638
|
+
* resultado no llegó (`code: 'unanswered'`): quien llama decide qué hacer. Que nadie conteste
|
|
639
|
+
* NUNCA es un sí. Preguntar otra vez por lo mismo mientras el pedido vive no vuelve a timbrar;
|
|
640
|
+
* no repetir la pregunta por la misma versión es cosa de quien llama.
|
|
641
|
+
*
|
|
642
|
+
* @param {object} o
|
|
643
|
+
* @param {string} o.product nombre npm de lo que se actualiza, p. ej. `@dotrino/terminal-agent`
|
|
644
|
+
* @param {string} o.version la versión que se quiere instalar
|
|
645
|
+
* @param {string} [o.from] la que corre ahora
|
|
646
|
+
* @param {string} [o.dir] identidad guardada por `enrollService`; o, en su lugar, la
|
|
647
|
+
* identidad por parámetros (`proxyUrl`, `masterPubkey`, `device`, `cert`), como en los demás.
|
|
648
|
+
* @param {(p: { id: string, exp: number }) => void} [o.onPending]
|
|
649
|
+
* @returns {Promise<{ ok: boolean, asked: boolean }>}
|
|
650
|
+
*/
|
|
651
|
+
export async function askUpdateApproval ({ product, version, from = null, dir, proxyUrl, masterPubkey, device, cert, timeoutMs = 30000, approvalTimeoutMs = UPDATE_APPROVAL_TIMEOUT_MS, onPending } = {}) {
|
|
652
|
+
const saved = dir ? readServiceIdentity(dir) : null
|
|
653
|
+
proxyUrl = proxyUrl || saved?.proxy
|
|
654
|
+
masterPubkey = masterPubkey || saved?.iss
|
|
655
|
+
device = device || saved?.device
|
|
656
|
+
cert = cert || saved?.cert
|
|
657
|
+
if (typeof product !== 'string' || !product || typeof version !== 'string' || !version) throw new Error('askUpdateApproval: needs product and version')
|
|
658
|
+
if (!proxyUrl || !masterPubkey || !device || !cert) throw new Error('service not enrolled')
|
|
659
|
+
const client = await freshClient(proxyUrl, 20000, masterPubkey)
|
|
660
|
+
try {
|
|
661
|
+
await identifyAsService(client, device)
|
|
662
|
+
const data = { op: 'update-ask', product, version, ...(typeof from === 'string' && from ? { from } : {}), publickey: device.publickey, ts: Date.now() }
|
|
663
|
+
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
664
|
+
const mine = (p) => p.type === MSG.ERROR || (p.type === MSG.SECRETS_RESULT && p.body?.op === 'update-ask' && p.body.product === product && p.body.version === version)
|
|
665
|
+
const check = async (res) => {
|
|
666
|
+
if (res.type === MSG.ERROR) throw vaultError(res)
|
|
667
|
+
await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
|
|
668
|
+
if (typeof res.body.ts !== 'number' || Math.abs(Date.now() - res.body.ts) > FRESH_WINDOW_MS) throw new Error('stale update-ask reply')
|
|
669
|
+
return res.body
|
|
670
|
+
}
|
|
671
|
+
// UNA COLA, no dos esperas seguidas: el resultado puede llegar mientras todavía se
|
|
672
|
+
// comprueba el «pendiente», y una escucha puesta después lo perdería (y se esperaría un
|
|
673
|
+
// día por algo que ya se contestó).
|
|
674
|
+
const queue = []
|
|
675
|
+
let wake = null
|
|
676
|
+
const off = client.on('message', (_from, p) => { if (p && typeof p === 'object' && mine(p)) { queue.push(p); wake?.() } })
|
|
677
|
+
const next = (ms, code, message) => new Promise((resolve, reject) => {
|
|
678
|
+
if (queue.length) return resolve(queue.shift())
|
|
679
|
+
const t = setTimeout(() => { wake = null; reject(Object.assign(new Error(message), { code })) }, ms)
|
|
680
|
+
wake = () => { clearTimeout(t); wake = null; resolve(queue.shift()) }
|
|
681
|
+
})
|
|
682
|
+
let body
|
|
683
|
+
try {
|
|
684
|
+
client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
|
|
685
|
+
body = await check(await next(timeoutMs, 'vault-no-reply', 'update-ask: the vault did not answer'))
|
|
686
|
+
if (body.pending === true) {
|
|
687
|
+
try { onPending?.({ id: body.id, exp: body.exp }) } catch (_) {}
|
|
688
|
+
const until = typeof body.exp === 'number' ? Math.max(5000, body.exp - Date.now() + 5000) : approvalTimeoutMs
|
|
689
|
+
do {
|
|
690
|
+
body = await check(await next(Math.min(until, approvalTimeoutMs), 'unanswered', 'update-ask: nobody answered the request in time'))
|
|
691
|
+
} while (body.pending === true)
|
|
692
|
+
}
|
|
693
|
+
} finally { off() }
|
|
694
|
+
if (typeof body.ok !== 'boolean') throw new Error('malformed update-ask reply')
|
|
695
|
+
return { ok: body.ok, asked: body.asked === true }
|
|
696
|
+
} finally { client.close() }
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* ME ACTUALICÉ: un aparato se lo dice a su bóveda DESPUÉS de instalar, y ella se lo cuenta a
|
|
701
|
+
* quien aprueba en la cuenta (un aviso, y queda 24 h en la lista de avisos). Se llama solo
|
|
702
|
+
* si el aparato tiene encendido su interruptor de avisos de actualización.
|
|
703
|
+
*
|
|
704
|
+
* Quién es el aparato lo pone la bóveda desde el acta, no lo que él diga.
|
|
705
|
+
* LANZA si la bóveda no contesta (`code: 'vault-no-reply'`) o no pudo avisar.
|
|
706
|
+
*
|
|
707
|
+
* @param {object} o `product`, `version`, `from` y la conexión, como en `askUpdateApproval`
|
|
708
|
+
* @returns {Promise<{ ok: true }>}
|
|
709
|
+
*/
|
|
710
|
+
export async function reportUpdated ({ product, version, from = null, dir, proxyUrl, masterPubkey, device, cert, timeoutMs = 30000 } = {}) {
|
|
711
|
+
const saved = dir ? readServiceIdentity(dir) : null
|
|
712
|
+
proxyUrl = proxyUrl || saved?.proxy
|
|
713
|
+
masterPubkey = masterPubkey || saved?.iss
|
|
714
|
+
device = device || saved?.device
|
|
715
|
+
cert = cert || saved?.cert
|
|
716
|
+
if (typeof product !== 'string' || !product || typeof version !== 'string' || !version) throw new Error('reportUpdated: needs product and version')
|
|
717
|
+
if (!proxyUrl || !masterPubkey || !device || !cert) throw new Error('service not enrolled')
|
|
718
|
+
const client = await freshClient(proxyUrl, 20000, masterPubkey)
|
|
719
|
+
try {
|
|
720
|
+
await identifyAsService(client, device)
|
|
721
|
+
const data = { op: 'update-done', product, version, ...(typeof from === 'string' && from ? { from } : {}), publickey: device.publickey, ts: Date.now() }
|
|
722
|
+
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
723
|
+
const pending = waitForMsg(client, (p) => p.type === MSG.ERROR || (p.type === MSG.SECRETS_RESULT && p.body?.op === 'update-done' && p.body.product === product && p.body.version === version), timeoutMs)
|
|
724
|
+
client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
|
|
725
|
+
const res = await pending.catch((e) => {
|
|
726
|
+
if (/timeout/.test(e.message)) throw Object.assign(new Error('update-done: the vault did not answer'), { code: 'vault-no-reply' })
|
|
727
|
+
throw e
|
|
728
|
+
})
|
|
729
|
+
if (res.type === MSG.ERROR) throw vaultError(res)
|
|
730
|
+
await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
|
|
731
|
+
if (res.body.ok !== true) throw new Error('malformed update-done reply')
|
|
732
|
+
return { ok: true }
|
|
733
|
+
} finally { client.close() }
|
|
734
|
+
}
|
|
735
|
+
|
|
623
736
|
/**
|
|
624
737
|
* Pide los secretos del ns al vault (una petición puntual; lanza si falla).
|
|
625
738
|
* Usa la identidad persistida por `enrollService` salvo que se pase explícita.
|