@dotrino/vault 0.84.0 → 0.86.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 +32 -32
- package/package.json +16 -4
- package/src/browser.js +167 -0
- package/src/core/approvals.js +204 -0
- package/src/core/blocked.js +68 -0
- package/src/core/browserDisk.js +109 -0
- package/src/core/commKey.js +68 -0
- package/src/core/disk.js +65 -0
- package/src/core/libVersion.js +8 -0
- package/src/core/machines.js +66 -0
- package/src/core/notices.js +71 -0
- package/src/core/notifiable.js +73 -0
- package/src/core/passwords.js +125 -0
- package/src/core/sealKey.js +77 -0
- package/src/core/sealer.js +172 -0
- package/src/core/sealers.js +159 -0
- package/src/core/secretsStore.js +1326 -0
- package/src/core/sha256.js +69 -0
- package/src/core/store.js +68 -0
- package/src/core/subacta.js +101 -0
- package/src/core/threadStore.js +273 -0
- package/src/core/transport.js +270 -0
- package/src/core/vault.js +4414 -0
- package/src/enroll.js +8 -4
- package/src/proc.js +12 -82
- package/src/procContext.js +97 -0
- package/src/service.js +3 -3
package/README.md
CHANGED
|
@@ -1,42 +1,42 @@
|
|
|
1
1
|
# @dotrino/vault
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
La bóveda de Dotrino como librería. **El mismo núcleo que el binario `dotrino-vault`**
|
|
4
|
+
(`src/core/vault.js`) corre en el navegador: la pestaña de `vault.dotrino.com` y la extensión
|
|
5
|
+
del gestor de contraseñas son la misma bóveda que el demonio, con el disco en IndexedDB
|
|
6
|
+
(regla de las tres versiones, `docs/three-versions.md` del repo).
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
Incluye además el cliente de SERVICIO (Node): un proyecto se enrola una vez y jala sus
|
|
9
|
+
credenciales del vault en vez del `.env` (abajo).
|
|
10
10
|
|
|
11
|
-
##
|
|
11
|
+
## La bóveda en el navegador
|
|
12
12
|
|
|
13
13
|
```js
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const vault = await
|
|
19
|
-
|
|
20
|
-
//
|
|
21
|
-
const { qr } = vault.startPairing({ label: 'mi-agente' })
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
// 2) Cuando el dispositivo pide acceso, aparece en la lista de pendientes:
|
|
26
|
-
vault.onPendingChange(() => {
|
|
27
|
-
for (const { deviceId } of vault.listPending()) {
|
|
28
|
-
// Un humano LEE el código del dispositivo y lo TIPEA aquí:
|
|
29
|
-
// await vault.approve(deviceId, codigoTipeado)
|
|
30
|
-
}
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
// 3) Máquinas ya enroladas / revocar:
|
|
34
|
-
const machines = await vault.listMachines() // [{ sub, deviceId, label, exp, nonce, scope }]
|
|
35
|
-
// await vault.revoke(nonce)
|
|
36
|
-
|
|
37
|
-
vault.close()
|
|
14
|
+
import { startBrowserVault } from '@dotrino/vault/browser'
|
|
15
|
+
|
|
16
|
+
// `core`: el núcleo de identidad ya abierto (`createIdentityCore` de @dotrino/identity),
|
|
17
|
+
// el del iframe de id.dotrino.com o el de la extensión.
|
|
18
|
+
const vault = await startBrowserVault({ core, onPendingChange: () => pintar(vault.listPending()) })
|
|
19
|
+
|
|
20
|
+
// Emparejar: abrir una invitación y aprobar TECLEANDO el código que muestra el aparato.
|
|
21
|
+
const { qr } = await vault.startPairing({ label: 'mi-agente' })
|
|
22
|
+
// await vault.approve(deviceId, codigoTipeado)
|
|
23
|
+
|
|
24
|
+
await vault.close()
|
|
38
25
|
```
|
|
39
26
|
|
|
27
|
+
Devuelve el mismo objeto que el núcleo del binario (cajones, aprobaciones, inicios de
|
|
28
|
+
sesión, bloqueados, revocar…) más `approve(deviceId, code)`, `reject`, `listMachines`,
|
|
29
|
+
`revokeDevice(sub)` y `close()`. La consola que corre en ese mismo navegador la administra
|
|
30
|
+
con el protocolo de la consola remota, por un camino local (`core.setSelfVault`).
|
|
31
|
+
|
|
32
|
+
Opciones: `proxyUrl`, `makeClient` (quién crea el cliente del proxio; obligatorio en un
|
|
33
|
+
contexto sin `import()`), `opaque` (el servidor de OPAQUE si el del paquete no carga),
|
|
34
|
+
`servePasswords: false` (las contraseñas las atiende quien monta la bóveda, con la política
|
|
35
|
+
del núcleo: `passwordsNeedApproval`, `approvePasswordRequest`), `legacyLogins`, `log`.
|
|
36
|
+
|
|
37
|
+
`startDeviceVault(identity, opts)` (el mostrador anterior, la mitad del protocolo) se
|
|
38
|
+
conserva solo para `dotrino-passmanager serve` y desaparecerá.
|
|
39
|
+
|
|
40
40
|
## Credenciales del vault en vez del `.env` (Node)
|
|
41
41
|
|
|
42
42
|
La cara "dotenv" del paquete: **cualquier proyecto Node** jala sus credenciales del
|
|
@@ -315,7 +315,7 @@ quien llama. Lo preferible es instalar los servicios como usuario, que no necesi
|
|
|
315
315
|
|
|
316
316
|
## API
|
|
317
317
|
|
|
318
|
-
`startDeviceVault(identity, { proxyUrl? }) → Promise<handle>`
|
|
318
|
+
`startDeviceVault(identity, { proxyUrl? }) → Promise<handle>` — el mostrador anterior, solo para `dotrino-passmanager serve`. Para una bóveda de navegador, `startBrowserVault` (arriba).
|
|
319
319
|
|
|
320
320
|
- `startPairing({ scope?, ttlMs?, label?, mode?, account? }) → { qr, expiresInMs }`
|
|
321
321
|
- `stopPairing(token)`
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vault",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.86.0",
|
|
4
|
+
"description": "La bóveda de Dotrino como librería: el MISMO núcleo que el binario corriendo en el navegador (`@dotrino/vault/browser`), el mostrador de emparejamiento y 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",
|
|
7
7
|
"module": "src/index.js",
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
".": {
|
|
14
14
|
"import": "./src/index.js"
|
|
15
15
|
},
|
|
16
|
+
"./browser": {
|
|
17
|
+
"import": "./src/browser.js"
|
|
18
|
+
},
|
|
16
19
|
"./service": {
|
|
17
20
|
"import": "./src/service.js"
|
|
18
21
|
},
|
|
@@ -59,8 +62,9 @@
|
|
|
59
62
|
"pairing"
|
|
60
63
|
],
|
|
61
64
|
"peerDependencies": {
|
|
62
|
-
"@dotrino/identity": ">=0.
|
|
63
|
-
"@dotrino/proxy-client": ">=0.23.1"
|
|
65
|
+
"@dotrino/identity": ">=0.116.0",
|
|
66
|
+
"@dotrino/proxy-client": ">=0.23.1",
|
|
67
|
+
"@dotrino/store": ">=0.11.0"
|
|
64
68
|
},
|
|
65
69
|
"license": "MIT",
|
|
66
70
|
"repository": {
|
|
@@ -76,7 +80,15 @@
|
|
|
76
80
|
"type-check": "tsc --noEmit"
|
|
77
81
|
},
|
|
78
82
|
"dependencies": {
|
|
83
|
+
"@dotrino/compat": "0.4.0",
|
|
79
84
|
"@dotrino/opaque": "0.1.0",
|
|
85
|
+
"@dotrino/passmanager": "0.17.0",
|
|
86
|
+
"@dotrino/roadmap": "0.4.13",
|
|
80
87
|
"@dotrino/update": "0.5.1"
|
|
88
|
+
},
|
|
89
|
+
"peerDependenciesMeta": {
|
|
90
|
+
"@dotrino/store": {
|
|
91
|
+
"optional": true
|
|
92
|
+
}
|
|
81
93
|
}
|
|
82
94
|
}
|
package/src/browser.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LA BÓVEDA EN EL NAVEGADOR: la pestaña (`vault.dotrino.com`, dentro del iframe de
|
|
3
|
+
* identidad) y la extensión del gestor de contraseñas corren el MISMO núcleo que el
|
|
4
|
+
* binario (`./core/vault.js`). Lo único distinto es lo que pone la plataforma:
|
|
5
|
+
*
|
|
6
|
+
* · el disco es IndexedDB cifrado (`./core/browserDisk.js`), uno por perfil;
|
|
7
|
+
* · la identidad es la que ya tiene abierta la pestaña o la extensión;
|
|
8
|
+
* · no hay `/proc` ni socket de archivo: lo que declara quien pide se queda como
|
|
9
|
+
* declarado, y los servicios de la misma máquina llegan por el proxio.
|
|
10
|
+
*
|
|
11
|
+
* Y lo que sí suma: la consola que corre en este mismo navegador administra esta bóveda
|
|
12
|
+
* por un canal local (`core.setSelfVault`), con el MISMO protocolo y las mismas
|
|
13
|
+
* comprobaciones que contra el binario.
|
|
14
|
+
*
|
|
15
|
+
* Lo que el binario tiene y el navegador no, por su contexto: actualizarse solo (la pestaña
|
|
16
|
+
* se sirve siempre al día y la extensión la actualiza su tienda), las llaves de hardware
|
|
17
|
+
* como puerta del perfil y el candado que cifra la maestra con la contraseña. Ver
|
|
18
|
+
* `docs/three-versions.md`.
|
|
19
|
+
*/
|
|
20
|
+
import { startVault } from './core/vault.js'
|
|
21
|
+
import { openBrowserDisk } from './core/browserDisk.js'
|
|
22
|
+
import { LIB_VERSION } from './core/libVersion.js'
|
|
23
|
+
import { IdentityFacade } from '@dotrino/identity/facade'
|
|
24
|
+
import { memberScopes } from '@dotrino/identity/acta'
|
|
25
|
+
import { deviceIdOf } from './enroll.js'
|
|
26
|
+
|
|
27
|
+
const DEFAULT_PROXY = 'wss://proxy.dotrino.com'
|
|
28
|
+
/** Cada cuánto se rehace el papel con el que la consola local administra esta bóveda. */
|
|
29
|
+
const SELF_CERT_EVERY_MS = 60_000
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {object} opts
|
|
33
|
+
* @param {object} opts.core el núcleo de identidad ya abierto (`createIdentityCore`)
|
|
34
|
+
* @param {string} [opts.proxyUrl]
|
|
35
|
+
* @param {Function} [opts.makeClient] quién crea el cliente del proxio. La extensión lo
|
|
36
|
+
* pasa siempre: un service worker no puede hacer `import()`.
|
|
37
|
+
* @param {Function} [opts.legacyLogins] los inicios de sesión que esta pestaña guardaba
|
|
38
|
+
* antes de correr el núcleo (`{ state, drop }` o `null`). Se traen UNA vez.
|
|
39
|
+
* @param {Function} [opts.onPendingChange] un aparato espera aprobación, o dejó de esperar
|
|
40
|
+
* @param {Function} [opts.onAdopted]
|
|
41
|
+
* @param {Function} [opts.log]
|
|
42
|
+
* @param {object} [opts.opaque] el servidor de OPAQUE, si el del paquete no se puede cargar aquí
|
|
43
|
+
* (la extensión: su CSP no deja WASM en la página de la bóveda).
|
|
44
|
+
* @param {boolean} [opts.servePasswords] `false` si las contraseñas las atiende quien monta
|
|
45
|
+
* la bóveda (la extensión, con su worker); decide con `passwordsNeedApproval`/`approvePasswordRequest`.
|
|
46
|
+
* @param {Function} [opts.openDisk] `(name) => disk`. Solo para las pruebas (Node no tiene
|
|
47
|
+
* IndexedDB); por defecto, `openBrowserDisk`.
|
|
48
|
+
*/
|
|
49
|
+
export async function startBrowserVault ({ core, proxyUrl = DEFAULT_PROXY, makeClient = null, legacyLogins = null, onPendingChange = () => {}, onAdopted = () => {}, log = console.log, openDisk = openBrowserDisk, opaque = null, servePasswords = true } = {}) {
|
|
50
|
+
if (!core?.handlers) throw Object.assign(new Error('startBrowserVault: the identity core is required'), { code: 'no-identity' })
|
|
51
|
+
const identity = new IdentityFacade(core)
|
|
52
|
+
if (!identity.me?.publickey) throw Object.assign(new Error('no identity: create or unlock your profile before using this browser as a vault'), { code: 'no-identity' })
|
|
53
|
+
const { id: pid } = await identity.currentProfile()
|
|
54
|
+
if (!pid) throw Object.assign(new Error('no profile: the vault belongs to an account'), { code: 'no-profile' })
|
|
55
|
+
|
|
56
|
+
const disk = await openDisk(`dotrino-vault.${pid}`)
|
|
57
|
+
|
|
58
|
+
// LOS INICIOS DE SESIÓN QUE YA HABÍA. Antes de correr el núcleo, la pestaña guardaba los
|
|
59
|
+
// usuarios y contraseñas de la cuenta en su propio almacén. Se traen una vez —si el disco
|
|
60
|
+
// ya tiene los suyos, mandan esos— y después se borra lo viejo: dos copias acaban diciendo
|
|
61
|
+
// cosas distintas.
|
|
62
|
+
if (legacyLogins && !disk.exists('logins.json')) {
|
|
63
|
+
const old = await legacyLogins()
|
|
64
|
+
if (old?.state) {
|
|
65
|
+
disk.write('logins.json', disk.atRest.encrypt(JSON.stringify(old.state)))
|
|
66
|
+
await disk.flush()
|
|
67
|
+
try { await old.drop?.() } catch (_) {}
|
|
68
|
+
log('[vault] password logins moved into the vault storage')
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const avisar = () => { try { onPendingChange() } catch (_) {} }
|
|
73
|
+
const vault = await startVault({
|
|
74
|
+
platform: {
|
|
75
|
+
disk,
|
|
76
|
+
product: 'vault',
|
|
77
|
+
version: LIB_VERSION,
|
|
78
|
+
openIdentity: async () => identity,
|
|
79
|
+
makeClient,
|
|
80
|
+
noticeGroupMs: 3000,
|
|
81
|
+
...(opaque ? { opaque } : {}),
|
|
82
|
+
servePasswords
|
|
83
|
+
},
|
|
84
|
+
proxyUrl,
|
|
85
|
+
log,
|
|
86
|
+
isLocked: () => !!core.masterLocked,
|
|
87
|
+
// El navegador no tiene la contraseña del perfil con la que el binario cierra la copia
|
|
88
|
+
// de recuperación: se cierra con la llave de este almacén, y el núcleo lo dice.
|
|
89
|
+
hasPassword: () => false,
|
|
90
|
+
onEnrollChallenge: avisar,
|
|
91
|
+
onAdopted: (info) => { try { onAdopted(info) } catch (_) {} }
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// EL PAPEL CON EL QUE LA CONSOLA DE ESTE NAVEGADOR ADMINISTRA ESTA BÓVEDA: la llave del
|
|
95
|
+
// perfil firmándose a sí misma con lo que el acta le deja hoy. Se rehace cada minuto
|
|
96
|
+
// porque el acta cambia (un permiso que te quitas, un sellado nuevo).
|
|
97
|
+
const master = identity.me.publickey
|
|
98
|
+
const selfCert = async () => {
|
|
99
|
+
const acta = (await identity.profileActa().catch(() => null))?.acta || null
|
|
100
|
+
if (!acta) return null
|
|
101
|
+
const scope = memberScopes(acta, master)
|
|
102
|
+
if (!scope.length) return null
|
|
103
|
+
return (await identity.signDelegation(master, scope, { label: 'self' })).cert
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* EL DUEÑO EN SU MÁQUINA ADMINISTRA. En el binario eso es la TUI, que no necesita ningún
|
|
107
|
+
* permiso del acta; aquí la consola habla el protocolo de la consola remota, que exige
|
|
108
|
+
* `admin`. Así que la llave de la cuenta se lo da a sí misma, una vez y a la vista en el
|
|
109
|
+
* acta: es verdad que este navegador administra la cuenta. `aprueba` NO, a propósito: con
|
|
110
|
+
* él los servicios pasarían a esperar el visto bueno de una pestaña que puede no estar.
|
|
111
|
+
*/
|
|
112
|
+
const asegurarAdmin = async () => {
|
|
113
|
+
if (core.masterLocked) return
|
|
114
|
+
const acta = (await identity.profileActa().catch(() => null))?.acta || null
|
|
115
|
+
const yo = (acta?.members || []).find((m) => m.pub === master)
|
|
116
|
+
if (!yo || (yo.caps || []).includes('admin')) return
|
|
117
|
+
if (!(await identity.isMaster().catch(() => false))) return
|
|
118
|
+
await identity.setCaps(master, [...yo.caps, 'admin'])
|
|
119
|
+
log('[vault] this browser administers the account: admin added to its own key')
|
|
120
|
+
}
|
|
121
|
+
const open = (onReply) => vault.localChannel(onReply)
|
|
122
|
+
const presentar = async () => {
|
|
123
|
+
try {
|
|
124
|
+
await asegurarAdmin()
|
|
125
|
+
const cert = await selfCert()
|
|
126
|
+
core.setSelfVault?.(cert ? { cert, proxy: proxyUrl, open } : null)
|
|
127
|
+
} catch (e) { log('[vault] the console in this browser cannot administer this vault yet: ' + (e?.message || e)) }
|
|
128
|
+
}
|
|
129
|
+
await presentar()
|
|
130
|
+
const refresco = setInterval(presentar, SELF_CERT_EVERY_MS)
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
...vault,
|
|
134
|
+
iss: master,
|
|
135
|
+
proxy: proxyUrl,
|
|
136
|
+
disk,
|
|
137
|
+
/** Aprueba TIPEANDO el código que muestra el aparato (el núcleo comprueba el compromiso). */
|
|
138
|
+
approve: async (_deviceId, code) => { try { return await vault.approveDevice(code) } finally { avisar() } },
|
|
139
|
+
reject: (deviceId) => { try { return vault.rejectDevice(deviceId) } finally { avisar() } },
|
|
140
|
+
/**
|
|
141
|
+
* Las máquinas con papel de firma de esta cuenta (excluye los navegadores enrolados con
|
|
142
|
+
* etiqueta `cli`, que no atienden pedidos). Se queda el papel del acta más nueva.
|
|
143
|
+
*/
|
|
144
|
+
async listMachines () {
|
|
145
|
+
const { issued } = await identity.listDelegations()
|
|
146
|
+
const bySub = new Map()
|
|
147
|
+
for (const x of (issued || [])) {
|
|
148
|
+
if (!x.sub || x.revokedAt) continue
|
|
149
|
+
if (!Array.isArray(x.scope) || !x.scope.includes('vault:sign')) continue
|
|
150
|
+
if (!x.label || x.label === 'cli' || x.label === 'self') continue
|
|
151
|
+
if (!bySub.has(x.sub) || (x.seq || 0) > (bySub.get(x.sub).seq || 0)) bySub.set(x.sub, x)
|
|
152
|
+
}
|
|
153
|
+
return Promise.all([...bySub.values()].map(async (x) => ({ ...x, deviceId: await deviceIdOf(x.sub) })))
|
|
154
|
+
},
|
|
155
|
+
revokeDevice: (sub) => vault.revokeDevice({ sub }),
|
|
156
|
+
revoke: (nonce) => vault.revokeDevice(nonce),
|
|
157
|
+
async close () {
|
|
158
|
+
clearInterval(refresco)
|
|
159
|
+
try { core.setSelfVault?.(null) } catch (_) {}
|
|
160
|
+
vault.close()
|
|
161
|
+
try { await disk.flush() } catch (e) { log('[vault] some changes could not be saved: ' + (e?.message || e)) }
|
|
162
|
+
disk.close()
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export default { startBrowserVault }
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PEDIDOS DE APROBACIÓN Y CONCESIONES (puro: sin red ni disco).
|
|
3
|
+
*
|
|
4
|
+
* Un aparato marcado con `approval on` no recibe claves privadas solo por tener el cert: el
|
|
5
|
+
* vault apunta el pedido, avisa a los aparatos con `approve` (el teléfono), y solo su firma
|
|
6
|
+
* libera los secretos. Lo que nadie aprueba vence solo a los 5 min.
|
|
7
|
+
*
|
|
8
|
+
* Lo que se guarda de cada pedido es lo justo para contestar después: el cajón, quién pide,
|
|
9
|
+
* su llave efímera `ek` (a la que se sella la respuesta) y **qué comando está corriendo y
|
|
10
|
+
* desde dónde**. Nunca un valor.
|
|
11
|
+
*
|
|
12
|
+
* LA CONCESIÓN POR COMANDO (dueño, 2026-09-11). Antes no había ventana: cada petición era un
|
|
13
|
+
* pedido, y para un servicio bien hecho eso era una por arranque. Dejó de alcanzar en cuanto
|
|
14
|
+
* los pedidos empezaron a decir QUÉ comando piden: si el dueño ya dijo que sí a ese comando
|
|
15
|
+
* exacto desde esa carpeta exacta, volver a timbrarle por lo mismo es ruido. Así que aprobar
|
|
16
|
+
* concede **una hora, contada desde el último uso** — o sea que un servicio que sigue
|
|
17
|
+
* pidiendo la mantiene viva indefinidamente, que es lo que se pidió. Lo que la corta: que
|
|
18
|
+
* cambie el comando o la carpeta (otra huella, otro pedido), una hora entera sin usarla,
|
|
19
|
+
* revocarla a mano, o reiniciar la bóveda (viven en memoria y no se escriben en ningún
|
|
20
|
+
* sitio: un permiso que sobrevive a un reinicio es un permiso que nadie recuerda haber dado).
|
|
21
|
+
*
|
|
22
|
+
* SIN COMANDO NO HAY CONCESIÓN. Un pedido que no dice qué está corriendo —porque vino de
|
|
23
|
+
* otra máquina por el proxio, donde mandarlo en claro filtraría las rutas del dueño— no
|
|
24
|
+
* tiene huella con la que emparejarse, y entonces se pide aprobación cada vez. No se
|
|
25
|
+
* inventa una huella «vacía» que valdría para todo: eso sería exactamente un repliegue.
|
|
26
|
+
*
|
|
27
|
+
* Y PARA EL GESTOR DE CONTRASEÑAS ESO ES LO QUE SE QUIERE, no una carencia que arreglar
|
|
28
|
+
* (dueño, 2026-09-11: «para el gestor de contraseñas es perfecto»). La extensión es un
|
|
29
|
+
* navegador hablando por el proxio, no un proceso con línea de comandos, así que nunca va a
|
|
30
|
+
* traer huella — y así cada acceso a una credencial vuelve a pasar por tu mano, que es la
|
|
31
|
+
* protección por la que esto existe. No se le busque un sustituto del comando para que
|
|
32
|
+
* entre en la ventana.
|
|
33
|
+
*/
|
|
34
|
+
import { commandFingerprint } from '../procContext.js'
|
|
35
|
+
|
|
36
|
+
const rnd = () => [...crypto.getRandomValues(new Uint8Array(8))].map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
37
|
+
|
|
38
|
+
/** Separador de la llave compuesta. Es un byte que no puede aparecer en un cajón ni en una pubkey. */
|
|
39
|
+
const SEP = String.fromCharCode(0)
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* LO QUE DURA UN PEDIDO SIN CONTESTAR, y por qué son dos números y no uno.
|
|
43
|
+
*
|
|
44
|
+
* CINCO MINUTOS es lo correcto para un COMANDO: hay un proceso bloqueado esperando, y
|
|
45
|
+
* aprobar media hora más tarde entregaría claves a algo cuyo contexto ya cambió — la
|
|
46
|
+
* carpeta, el comando o la máquina pueden ser otros. Que caduque rápido es la protección.
|
|
47
|
+
*
|
|
48
|
+
* UNA ACTUALIZACIÓN no se parece en nada (dueño, 2026-09-20): no hay nadie esperando, y
|
|
49
|
+
* caducar no protege de nada — solo significa que mañana se vuelve a pedir y se vuelve a
|
|
50
|
+
* perder, porque mirar el teléfono puede tardar horas. Un pedido que nunca llega a tiempo
|
|
51
|
+
* es una función que no existe.
|
|
52
|
+
*
|
|
53
|
+
* Por eso el plazo es DEL PEDIDO y no de la mesa. Quien pide dice cuánto aguanta lo suyo.
|
|
54
|
+
*/
|
|
55
|
+
export const PENDING_TTL_MS = 5 * 60 * 1000
|
|
56
|
+
/**
|
|
57
|
+
* Y el de una actualización: un día. Cuadra con que la bóveda mire una vez al día — pedir
|
|
58
|
+
* otra vez reemplaza el pedido anterior del mismo cajón y aparato, así que siempre hay
|
|
59
|
+
* exactamente uno vivo, en vez de una cola de avisos caducados.
|
|
60
|
+
*/
|
|
61
|
+
export const UPDATE_TTL_MS = 24 * 60 * 60 * 1000
|
|
62
|
+
/** Lo que dura una concesión SIN USARSE. Cada uso la vuelve a poner en una hora. */
|
|
63
|
+
export const GRANT_TTL_MS = 60 * 60 * 1000
|
|
64
|
+
|
|
65
|
+
export function createApprovals ({ now = Date.now, pendingTtlMs = PENDING_TTL_MS } = {}) {
|
|
66
|
+
/** id → { id, ns, device, deviceId, label, ek, ctx, ts, exp, from } */
|
|
67
|
+
const pending = new Map()
|
|
68
|
+
/**
|
|
69
|
+
* EL CONTEXTO VA EN LA LISTA, Y QUIEN LA SACA POR LA RED LO ENVUELVE.
|
|
70
|
+
*
|
|
71
|
+
* `ctx` es el comando y el path del dueño: por el mostrador local y en la consola de su
|
|
72
|
+
* propia máquina se enseña tal cual, pero al teléfono viaja por el proxio y ahí va dentro
|
|
73
|
+
* de un sobre (`handleApproval`). Si mañana aparece otro sitio que publique esta lista,
|
|
74
|
+
* esto es lo que hay que recordar.
|
|
75
|
+
*/
|
|
76
|
+
// `kind` dice QUÉ se pide: `read` (llevarse las claves, lo de siempre) o `write` (guardar
|
|
77
|
+
// variables). La pantalla del teléfono no puede decir «pide tus claves» a una escritura.
|
|
78
|
+
const publicOf = (p) => ({ id: p.id, ns: p.ns, kind: p.kind, deviceId: p.deviceId, label: p.label, ts: p.ts, exp: p.exp, ctx: p.ctx || null })
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
/** Apunta un pedido. Uno por cajón y aparato: pedir otra vez reemplaza al anterior. */
|
|
82
|
+
/**
|
|
83
|
+
* `from` es POR DÓNDE ENTRÓ la pregunta. La respuesta llega más tarde —cuando alguien
|
|
84
|
+
* apruebe— y tiene que volver por el mismo sitio: desde que la bóveda atiende también
|
|
85
|
+
* por un socket local, mandarla siempre por el proxio la dejaba en el vacío.
|
|
86
|
+
*/
|
|
87
|
+
request ({ ns, device, deviceId, label = '', ek, ctx = null, from = null, ttlMs = null, kind = 'read' }) {
|
|
88
|
+
// Pedir las claves otra vez reemplaza al pedido anterior: es la misma pregunta. Una
|
|
89
|
+
// ESCRITURA no: cada una lleva su propio valor, y reemplazarla tiraría la anterior
|
|
90
|
+
// sin que nadie dijera que no.
|
|
91
|
+
if (kind !== 'write') for (const [id, p] of pending) if (p.kind !== 'write' && p.ns === ns && p.device === device) pending.delete(id)
|
|
92
|
+
const ts = now()
|
|
93
|
+
// `ttlMs` lo pone quien pide: una actualización aguanta un día y un comando cinco
|
|
94
|
+
// minutos, y la mesa no tiene por qué saber cuál es cuál (ver `PENDING_TTL_MS`).
|
|
95
|
+
const p = { id: rnd(), ns, kind, device, deviceId, label, ek, ctx, ts, exp: ts + (ttlMs || pendingTtlMs), from }
|
|
96
|
+
pending.set(p.id, p)
|
|
97
|
+
return publicOf(p)
|
|
98
|
+
},
|
|
99
|
+
/** Los pedidos vivos, sin la `ek` (no hace falta fuera de aquí). */
|
|
100
|
+
list () { this.sweep(); return [...pending.values()].map(publicOf) },
|
|
101
|
+
/** Saca un pedido para resolverlo (aprobar o denegar). `null` si no existe o venció. */
|
|
102
|
+
take (id) {
|
|
103
|
+
this.sweep()
|
|
104
|
+
const p = pending.get(id)
|
|
105
|
+
if (p) pending.delete(id)
|
|
106
|
+
return p || null
|
|
107
|
+
},
|
|
108
|
+
/** Tira lo vencido; devuelve los pedidos que vencieron, para anotarlos. */
|
|
109
|
+
sweep () {
|
|
110
|
+
const t = now(); const gone = []
|
|
111
|
+
for (const [id, p] of pending) if (p.exp <= t) { pending.delete(id); gone.push(publicOf(p)) }
|
|
112
|
+
return gone
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* LAS CONCESIONES VIVAS: «este comando, desde esta carpeta, ya está aprobado».
|
|
119
|
+
*
|
|
120
|
+
* La llave es cajón + aparato + huella del comando. Nada de esto se escribe en disco.
|
|
121
|
+
*/
|
|
122
|
+
export function createGrants ({ now = Date.now, ttlMs = GRANT_TTL_MS } = {}) {
|
|
123
|
+
/** key → { id, ns, device, deviceId, fp, ctx, by, grantedAt, lastAt, exp, uses } */
|
|
124
|
+
const grants = new Map()
|
|
125
|
+
const keyOf = (ns, device, fp) => ns + SEP + device + SEP + fp
|
|
126
|
+
const publicOf = (g) => ({
|
|
127
|
+
id: g.id, ns: g.ns, deviceId: g.deviceId, fp: g.fp, ctx: g.ctx,
|
|
128
|
+
by: g.by, grantedAt: g.grantedAt, lastAt: g.lastAt, exp: g.exp, uses: g.uses
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const api = {
|
|
132
|
+
/**
|
|
133
|
+
* Anota que este comando quedó aprobado. Devuelve la concesión, o `null` si el pedido no
|
|
134
|
+
* traía comando: sin huella no hay nada que recordar, y el próximo pedido volverá a
|
|
135
|
+
* timbrar. Que eso se diga en el log es cosa de quien llama.
|
|
136
|
+
*/
|
|
137
|
+
grant ({ ns, device, deviceId = null, ctx = null, by = null }) {
|
|
138
|
+
const fp = commandFingerprint(ctx)
|
|
139
|
+
if (!fp) return null
|
|
140
|
+
const t = now()
|
|
141
|
+
const g = { id: rnd(), ns, device, deviceId, fp, ctx, by, grantedAt: t, lastAt: t, exp: t + ttlMs, uses: 0 }
|
|
142
|
+
grants.set(keyOf(ns, device, fp), g)
|
|
143
|
+
return publicOf(g)
|
|
144
|
+
},
|
|
145
|
+
/**
|
|
146
|
+
* ¿Está este comando ya aprobado? Si lo está, **la concesión se estira**: la hora cuenta
|
|
147
|
+
* desde el último uso, no desde que se dio. Devuelve la concesión (ya refrescada) o `null`.
|
|
148
|
+
*/
|
|
149
|
+
allows ({ ns, device, ctx }) {
|
|
150
|
+
api.sweep()
|
|
151
|
+
const fp = commandFingerprint(ctx)
|
|
152
|
+
if (!fp) return null
|
|
153
|
+
const g = grants.get(keyOf(ns, device, fp))
|
|
154
|
+
if (!g) return null
|
|
155
|
+
const t = now()
|
|
156
|
+
g.lastAt = t
|
|
157
|
+
g.exp = t + ttlMs
|
|
158
|
+
g.uses += 1
|
|
159
|
+
return publicOf(g)
|
|
160
|
+
},
|
|
161
|
+
/** Las concesiones vivas. */
|
|
162
|
+
list () { api.sweep(); return [...grants.values()].map(publicOf) },
|
|
163
|
+
/** Corta una por su id. `true` si existía. */
|
|
164
|
+
revoke (id) {
|
|
165
|
+
for (const [k, g] of grants) if (g.id === id) { grants.delete(k); return true }
|
|
166
|
+
return false
|
|
167
|
+
},
|
|
168
|
+
/** Corta todas, o las de un cajón / un aparato. Devuelve cuántas se fueron. */
|
|
169
|
+
revokeAll ({ ns = null, device = null } = {}) {
|
|
170
|
+
let n = 0
|
|
171
|
+
for (const [k, g] of grants) {
|
|
172
|
+
if (ns && g.ns !== ns) continue
|
|
173
|
+
if (device && g.device !== device) continue
|
|
174
|
+
grants.delete(k); n++
|
|
175
|
+
}
|
|
176
|
+
return n
|
|
177
|
+
},
|
|
178
|
+
/** Tira lo vencido; devuelve lo que se fue, para anotarlo. */
|
|
179
|
+
sweep () {
|
|
180
|
+
const t = now(); const gone = []
|
|
181
|
+
for (const [k, g] of grants) if (g.exp <= t) { grants.delete(k); gone.push(publicOf(g)) }
|
|
182
|
+
return gone
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return api
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* ¿SE LLEVÓ UNA LIMPIEZA AL ÚLTIMO QUE APROBABA? Devuelve los aprobadores que se fueron si
|
|
190
|
+
* antes había alguno y ahora no queda ninguno; si no, una lista vacía.
|
|
191
|
+
*
|
|
192
|
+
* Una cuenta SIN aprobadores puede ser legítima (todo desatendido, se actualiza sola), así
|
|
193
|
+
* que el aviso no es «no hay aprobadores»: es «LOS HABÍA y acabas de perder el último».
|
|
194
|
+
*
|
|
195
|
+
* @param {string[]} before llaves con `approve` antes
|
|
196
|
+
* @param {string[]} after llaves con `approve` después
|
|
197
|
+
* @param {string[]} removed llaves que salieron del acta
|
|
198
|
+
*/
|
|
199
|
+
export function lastApproverGone (before, after, removed) {
|
|
200
|
+
if (!before.length || after.length) return []
|
|
201
|
+
return removed.filter((pub) => before.includes(pub))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export default { createApprovals, createGrants, lastApproverGone, PENDING_TTL_MS, UPDATE_TTL_MS, GRANT_TTL_MS }
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LOS APARATOS BLOQUEADOS: la lista que un aprobador decide y solo la bóveda deshace.
|
|
3
|
+
*
|
|
4
|
+
* Un agente (la terminal, por ahora) reporta un INCIDENTE cuando un aparato de la cuenta se
|
|
5
|
+
* equivoca de clave tres veces seguidas. El aprobador lo ve en su teléfono y elige: bloquear
|
|
6
|
+
* o ignorar. Bloquear NO es revocar: el aparato sigue en el acta —no hace falta la maestra, y
|
|
7
|
+
* la bóveda vive cerrada—, pero la bóveda deja de atenderle y los agentes, que reciben esta
|
|
8
|
+
* lista con el acta, le cierran la puerta. Se deshace aquí (`dotrino-vault unblock <ID>`) o
|
|
9
|
+
* desde la consola de administración; un aprobador solo bloquea.
|
|
10
|
+
*
|
|
11
|
+
* Va en el disco cifrado en reposo como las contraseñas: es parte del mapa de la cuenta.
|
|
12
|
+
* Se carga UNA vez: un archivo que existe y no se puede leer NO es «nadie bloqueado» — se
|
|
13
|
+
* lanza, y la bóveda no arranca con una lista que no puede saber.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const BLOCKED_FILE = 'blocked.json'
|
|
17
|
+
|
|
18
|
+
const fail = (code, message) => Object.assign(new Error(message), { code })
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {{ disk: object, now?: () => number }} opts
|
|
22
|
+
*/
|
|
23
|
+
export function openBlocked ({ disk, now = Date.now }) {
|
|
24
|
+
const file = BLOCKED_FILE
|
|
25
|
+
const atRest = disk.atRest
|
|
26
|
+
/** pub → { deviceId, label, since, by, reason } */
|
|
27
|
+
let items = load()
|
|
28
|
+
|
|
29
|
+
function load () {
|
|
30
|
+
let raw
|
|
31
|
+
try { raw = disk.read(file) } catch (e) {
|
|
32
|
+
throw fail('blocked-unreadable', `cannot read ${BLOCKED_FILE}: ${e.message}`)
|
|
33
|
+
}
|
|
34
|
+
if (raw == null) return new Map()
|
|
35
|
+
let d
|
|
36
|
+
try { d = JSON.parse(atRest.decrypt(raw)) } catch (e) { throw fail('blocked-unreadable', `${BLOCKED_FILE} cannot be opened: ${e.message}`) }
|
|
37
|
+
if (d?.v !== 1 || !d.items || typeof d.items !== 'object') throw fail('blocked-unreadable', `${BLOCKED_FILE} has an unknown shape`)
|
|
38
|
+
return new Map(Object.entries(d.items))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function save () {
|
|
42
|
+
disk.write(file, atRest.encrypt(JSON.stringify({ v: 1, items: Object.fromEntries(items) })))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const publicOf = (pub, b) => ({ pub, deviceId: b.deviceId || null, label: b.label || '', since: b.since, by: b.by || null, reason: b.reason || null })
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
has: (pub) => items.has(pub),
|
|
49
|
+
pubs: () => [...items.keys()],
|
|
50
|
+
list: () => [...items].map(([pub, b]) => publicOf(pub, b)),
|
|
51
|
+
/** Bloquea. Devuelve la entrada; si ya estaba, la que había (no se pisa el «desde cuándo»). */
|
|
52
|
+
block ({ pub, deviceId = null, label = '', by = null, reason = null }) {
|
|
53
|
+
if (typeof pub !== 'string' || !pub) throw fail('bad-pub', 'block: a device key is required')
|
|
54
|
+
if (items.has(pub)) return publicOf(pub, items.get(pub))
|
|
55
|
+
const b = { deviceId, label, since: now(), by, reason }
|
|
56
|
+
items.set(pub, b)
|
|
57
|
+
save()
|
|
58
|
+
return publicOf(pub, b)
|
|
59
|
+
},
|
|
60
|
+
/** Desbloquea. `true` si estaba. */
|
|
61
|
+
unblock (pub) {
|
|
62
|
+
if (!items.has(pub)) return false
|
|
63
|
+
items.delete(pub)
|
|
64
|
+
save()
|
|
65
|
+
return true
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|