@dotrino/vaultd 0.65.0 → 0.73.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/admin.js +63 -46
- package/lib/src/index.js +34 -13
- package/lib/src/service.js +51 -9
- package/package.json +3 -3
- package/src/client.js +16 -5
- package/src/commKey.js +71 -0
- package/src/daemon.js +4 -2
- package/src/manager.js +37 -2
- package/src/profiles.js +42 -1
- package/src/sealers.js +159 -0
- package/src/transport.js +32 -8
- package/src/vault.js +152 -31
package/lib/src/admin.js
CHANGED
|
@@ -7,12 +7,14 @@
|
|
|
7
7
|
*
|
|
8
8
|
* QUÉ SE DELEGA Y QUÉ NO — esto es el módulo entero, el resto es plomería:
|
|
9
9
|
*
|
|
10
|
-
* sí · ver el acta y la bitácora ·
|
|
11
|
-
* · APROBAR o rechazar a quien entra · REVOCAR a un miembro
|
|
10
|
+
* sí · ver el acta y la bitácora · REVOCAR a un miembro
|
|
12
11
|
* · VARIABLES DE ENTORNO: crearlas y darles valor (de un scope o de un aparato),
|
|
13
12
|
* y ver el valor de las marcadas PÚBLICAS
|
|
14
|
-
* no ·
|
|
13
|
+
* no · AGREGAR UN APARATO (quitado 2026-08-31, ver abajo)
|
|
14
|
+
* · cambiar permisos · traspasar el mando · conceder `admin`
|
|
15
15
|
* · ver el valor de una variable PRIVADA · borrar variables
|
|
16
|
+
* · NADA que cambie algo mientras el perfil está CERRADO (ver
|
|
17
|
+
* `ADMIN_OPS_WHILE_LOCKED`): el candado es de la consola, y esto es la consola.
|
|
16
18
|
*
|
|
17
19
|
* SOBRE LAS VARIABLES, que es la rendija más nueva: lo que cruza la frontera no es «los
|
|
18
20
|
* secretos» sino los que su dueño MARCÓ como mostrables. Una privada se puede reescribir
|
|
@@ -26,16 +28,25 @@
|
|
|
26
28
|
* fuera de su propia cuenta, que no tiene vuelta atrás. Las operaciones que no se
|
|
27
29
|
* delegan **no existen como mensaje**: no hay nada que autorizar mal.
|
|
28
30
|
*
|
|
29
|
-
* POR QUÉ
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
31
|
+
* POR QUÉ AGREGAR APARATOS YA NO SE HACE DESDE AQUÍ (dueño, 2026-08-31). Existía para
|
|
32
|
+
* ahorrarse ir hasta la máquina de la bóveda. El **multivault** quita esa fricción de raíz
|
|
33
|
+
* —cualquier otra selladora abierta admite el aparato— así que esto dejó de comprar nada y
|
|
34
|
+
* se quedaba pidiendo lo caro: que la maestra pudiera firmar a distancia.
|
|
35
|
+
*
|
|
36
|
+
* Y era incompatible con la regla dura del acta: la maestra solo sella el acta y reenvuelve
|
|
37
|
+
* sobres, y con la bóveda cerrada no firma nada. Admitir a alguien es emitir un certificado
|
|
38
|
+
* Y sellar el acta: las dos cosas son de una selladora abierta, y esta consola no lo es.
|
|
39
|
+
*
|
|
40
|
+
* **Quitado de raíz, no escondido**: no existe la operación, ni el mensaje, ni el botón.
|
|
41
|
+
* Una funcionalidad escondida sigue siendo superficie de ataque y vuelve sola en el
|
|
42
|
+
* siguiente refactor.
|
|
34
43
|
*/
|
|
35
44
|
|
|
36
45
|
/** Las únicas operaciones que existen a distancia. Lista cerrada, como las capacidades. */
|
|
37
46
|
export const ADMIN_OPS = Object.freeze([
|
|
38
|
-
|
|
47
|
+
// `pending`, `pair`, `approve` y `reject` NO están, y no es un olvido: agregar aparatos
|
|
48
|
+
// a distancia se quitó (ver la cabecera). Lo que queda es mirar, quitar y configurar.
|
|
49
|
+
'status', 'revoke', 'audit',
|
|
39
50
|
// Variables de entorno: verlas (nombres siempre; valor solo de las públicas) y
|
|
40
51
|
// ponerles valor (de las dos). Borrar NO está, y es a propósito: un aparato robado
|
|
41
52
|
// no debe poder dejar sin configuración a los servicios.
|
|
@@ -48,6 +59,21 @@ export const ADMIN_OPS = Object.freeze([
|
|
|
48
59
|
'vars', 'var.set', 'var.setMany'
|
|
49
60
|
])
|
|
50
61
|
|
|
62
|
+
/**
|
|
63
|
+
* LO ÚNICO QUE SE ATIENDE CON EL CANDADO ECHADO: mirar. Ni una operación que cambie algo.
|
|
64
|
+
*
|
|
65
|
+
* El candado ES de la consola —los aparatos ya emparejados siguen leyendo, guardando y
|
|
66
|
+
* firmando con SU llave— así que cerrar el perfil tiene que cerrar exactamente esto. No
|
|
67
|
+
* estaba: `admin.pair` y `admin.approve` pasaban con el perfil bloqueado, y `approve`
|
|
68
|
+
* firmaba un certificado de 30 días CON LA MAESTRA. Esas dos ya no existen —agregar
|
|
69
|
+
* aparatos se quitó de aquí—, pero la regla se queda para las que quedan: revocar reescribe
|
|
70
|
+
* el acta y configurar toca los secretos.
|
|
71
|
+
*
|
|
72
|
+
* Se dejan las de LEER a propósito: si se cortaran todas, la consola no podría ni
|
|
73
|
+
* enterarse de por qué no funciona y parecería rota. Así puede decir «está cerrada».
|
|
74
|
+
*/
|
|
75
|
+
export const ADMIN_OPS_WHILE_LOCKED = Object.freeze(['status', 'audit', 'vars'])
|
|
76
|
+
|
|
51
77
|
/** Cuánto se recuerda un nonce ya usado (el doble de la ventana de frescura). */
|
|
52
78
|
export const ADMIN_NONCE_TTL_MS = 10 * 60 * 1000
|
|
53
79
|
|
|
@@ -56,7 +82,8 @@ export const AUDIT_MAX = 500
|
|
|
56
82
|
|
|
57
83
|
/**
|
|
58
84
|
* @param {Object} o
|
|
59
|
-
* @param {Object} o.desk
|
|
85
|
+
* @param {Object} o.desk `createEnrollDesk`, del que aquí solo se usa REVOCAR
|
|
86
|
+
* (`revoke`/`revokeDevice`). Emparejar y aprobar ya no se atienden a distancia.
|
|
60
87
|
* @param {(scope:string[])=>Promise<any>} o.verify verifica cadena+cert; devuelve `{ok, device, reason}`.
|
|
61
88
|
* @param {(limit:number)=>any[]} o.readActivity últimas entradas de la bitácora.
|
|
62
89
|
* @param {(pub:string)=>Promise<string>} o.deviceIdOf
|
|
@@ -67,19 +94,19 @@ export const AUDIT_MAX = 500
|
|
|
67
94
|
* bóveda no las atiende, en vez de fingir que se aplicaron.
|
|
68
95
|
* @param {(ev:string, info?:object)=>Promise<void>} [o.notify] aviso a todos los miembros.
|
|
69
96
|
* @param {(op:string, info?:object)=>void} [o.audit]
|
|
70
|
-
* @param {
|
|
71
|
-
*
|
|
97
|
+
* @param {()=>boolean} [o.isLocked] candado del perfil. Cerrado, solo se atiende
|
|
98
|
+
* `ADMIN_OPS_WHILE_LOCKED`; lo demás responde con el código `vault-locked`.
|
|
72
99
|
*/
|
|
73
100
|
export function createAdminDesk ({
|
|
74
101
|
desk, verify, readActivity = () => [], deviceIdOf, vars = null,
|
|
75
|
-
notify = async () => {}, audit = () => {},
|
|
76
|
-
|
|
77
|
-
ttlMs, now = () => Date.now()
|
|
102
|
+
notify = async () => {}, audit = () => {}, isLocked = () => false,
|
|
103
|
+
now = () => Date.now()
|
|
78
104
|
}) {
|
|
79
105
|
const ops = new Set(ADMIN_OPS)
|
|
106
|
+
const mientrasCerrada = new Set(ADMIN_OPS_WHILE_LOCKED)
|
|
80
107
|
|
|
81
108
|
// NONCE de un solo uso. `sign`/`get` son idempotentes y les basta la ventana de ±5
|
|
82
|
-
// min; `
|
|
109
|
+
// min; `revoke` y `var.set` CAMBIAN ESTADO, así que reproducir uno dentro de esa
|
|
83
110
|
// ventana sí importa (re-aprobar un enrolamiento que el dueño ya rechazó, por
|
|
84
111
|
// ejemplo). Por eso el nonce, y por eso es obligatorio en todas las ops: una lista
|
|
85
112
|
// de excepciones es una invitación a equivocarse.
|
|
@@ -109,6 +136,21 @@ export function createAdminDesk ({
|
|
|
109
136
|
}
|
|
110
137
|
const by = await deviceIdOf(chk.device).catch(() => null)
|
|
111
138
|
|
|
139
|
+
// CERRADA NO SE ADMINISTRA. Va después de autorizar (el candado es estado del perfil,
|
|
140
|
+
// y a un desconocido no se le cuenta) y ANTES de quemar el nonce: quien reintente
|
|
141
|
+
// tras abrir la bóveda no tiene por qué generar otro.
|
|
142
|
+
//
|
|
143
|
+
// El código va aparte del texto porque el texto se traduce y el código no: quien
|
|
144
|
+
// llama distingue «está cerrada, ábrela» de «no tienes permiso» sin leer la frase.
|
|
145
|
+
if (isLocked() && !mientrasCerrada.has(data.op)) {
|
|
146
|
+
audit('rejected', { what: 'admin', op: data.op, by, reason: 'locked' })
|
|
147
|
+
return {
|
|
148
|
+
ok: false,
|
|
149
|
+
code: 'vault-locked',
|
|
150
|
+
error: 'vault locked: unlock the profile on the vault machine (dotrino-vault unlock) to administer it'
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
112
154
|
// El nonce se marca DESPUÉS de autorizar: si no, cualquiera podría quemarle los
|
|
113
155
|
// nonces a un admin legítimo mandando basura firmada por nadie.
|
|
114
156
|
if (nonceAlreadyUsed(data.nonce)) {
|
|
@@ -117,41 +159,16 @@ export function createAdminDesk ({
|
|
|
117
159
|
}
|
|
118
160
|
|
|
119
161
|
try {
|
|
120
|
-
|
|
162
|
+
// Lo mínimo que la consola necesita saber de la bóveda para explicarse: si está
|
|
163
|
+
// cerrada, lo que queda (revocar, configurar) no se puede, y hay que decir por qué en
|
|
164
|
+
// vez de dejar pulsar un botón para recibir un error.
|
|
165
|
+
if (data.op === 'status') return { ok: true, result: { locked: !!isLocked() } }
|
|
121
166
|
|
|
122
167
|
if (data.op === 'audit') {
|
|
123
168
|
const limit = Math.min(Math.max(Number(data.limit) || 100, 1), AUDIT_MAX)
|
|
124
169
|
return { ok: true, result: { entries: readActivity(limit) } }
|
|
125
170
|
}
|
|
126
171
|
|
|
127
|
-
if (data.op === 'pair') {
|
|
128
|
-
// Un admin NO empareja servicios ni crea otros admins. Se corta aquí, en la
|
|
129
|
-
// bóveda, no en la interfaz: una pantalla no es un control de seguridad.
|
|
130
|
-
const scope = Array.isArray(data.scope) && data.scope.length ? data.scope : defaultScope
|
|
131
|
-
const forbidden = scope.find((s) => s === 'vault:admin' || String(s).startsWith('vault:secrets:'))
|
|
132
|
-
if (forbidden) {
|
|
133
|
-
audit('rejected', { what: 'admin', op: 'pair', by, reason: 'forbidden-scope', scope: forbidden })
|
|
134
|
-
return { ok: false, error: 'admin: cannot grant admin or service secrets from here; do that on the vault machine' }
|
|
135
|
-
}
|
|
136
|
-
const label = String(data.label || '').slice(0, 60) || 'remoto'
|
|
137
|
-
const r = await desk.startPairing({ scope, label, ...(ttlMs ? { ttlMs } : {}) })
|
|
138
|
-
audit('admin.pair', { by })
|
|
139
|
-
return { ok: true, result: r }
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
if (data.op === 'approve') {
|
|
143
|
-
const r = await desk.approve(String(data.code || ''), { deviceId: data.deviceId })
|
|
144
|
-
audit('admin.approve', { by, device: data.deviceId || null })
|
|
145
|
-
await notify('enrolled', { deviceId: r?.deviceId || data.deviceId || null, by })
|
|
146
|
-
return { ok: true, result: r || { ok: true } }
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
if (data.op === 'reject') {
|
|
150
|
-
desk.reject(data.deviceId)
|
|
151
|
-
audit('admin.reject', { by, device: data.deviceId || null })
|
|
152
|
-
return { ok: true, result: { ok: true } }
|
|
153
|
-
}
|
|
154
|
-
|
|
155
172
|
// VARIABLES DE ENTORNO. El módulo solo enruta y audita: qué valor puede salir y con
|
|
156
173
|
// qué se cifra lo decide la bóveda (`vars`), que es la que tiene la clave y el disco.
|
|
157
174
|
if (data.op === 'vars' || data.op === 'var.set' || data.op === 'var.setMany') {
|
|
@@ -249,4 +266,4 @@ export function createAdminDesk ({
|
|
|
249
266
|
return { handle, get nonceCount () { return seen.size } }
|
|
250
267
|
}
|
|
251
268
|
|
|
252
|
-
export default { createAdminDesk, ADMIN_OPS, ADMIN_NONCE_TTL_MS }
|
|
269
|
+
export default { createAdminDesk, ADMIN_OPS, ADMIN_OPS_WHILE_LOCKED, ADMIN_NONCE_TTL_MS }
|
package/lib/src/index.js
CHANGED
|
@@ -26,14 +26,13 @@
|
|
|
26
26
|
* No reimplementa nada del ecosistema.
|
|
27
27
|
*/
|
|
28
28
|
import { verifyChain, verifyDeviceSig } from '@dotrino/identity/capabilities'
|
|
29
|
-
import { memberCanScope } from '@dotrino/identity/acta'
|
|
29
|
+
import { memberCanScope, sealersOf, memberScopes } from '@dotrino/identity/acta'
|
|
30
30
|
import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS, FRESH_WINDOW_MS } from './enroll.js'
|
|
31
31
|
// Las constantes del protocolo salen del MISMO módulo que usa el daemon: si la lista
|
|
32
32
|
// local se queda corta, el dispositivo deja de handle mensajes sin que nadie lo note.
|
|
33
33
|
import { MSG, SCOPE } from './protocol.js'
|
|
34
34
|
|
|
35
35
|
const SIGN_SCOPE = SCOPE.SIGN
|
|
36
|
-
const SELFCERT_TTL_MS = 24 * 60 * 60 * 1000 // el self-cert P←P se regenera cada 24 h
|
|
37
36
|
const RENEW_TTL_MS = DEVICE_TTL_MS // la renovación extiende la misma ventana (30 días)
|
|
38
37
|
|
|
39
38
|
/** deviceId legible (p. ej. `C440-AC0E`) desde una pubkey JWK. */
|
|
@@ -58,10 +57,24 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
58
57
|
|
|
59
58
|
// ----- self-cert P ← P (para que este dispositivo pueda además actuar de cliente
|
|
60
59
|
// de sus propias máquinas: lo firma la propia P y verifyChain lo acepta) -----
|
|
60
|
+
/**
|
|
61
|
+
* Con qué se juzga un papel: el acta que tiene esta bóveda. Sustituye a `trustedIssuer`,
|
|
62
|
+
* que fijaba UNA llave y por eso los papeles de una segunda selladora no valían.
|
|
63
|
+
* Sin acta van nulos y `verifyDelegation` contesta `no-acta`: no hay con qué decidir, así
|
|
64
|
+
* que no se decide que sí.
|
|
65
|
+
*/
|
|
66
|
+
async function contextoActa () {
|
|
67
|
+
const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
68
|
+
if (!acta) return { actaSeq: null, sealers: null }
|
|
69
|
+
return { actaSeq: acta.seq, sealers: sealersOf(acta) }
|
|
70
|
+
}
|
|
71
|
+
|
|
61
72
|
let _selfCert = null
|
|
62
73
|
const getSelfCert = async () => {
|
|
63
|
-
|
|
64
|
-
const {
|
|
74
|
+
// Se rehace cuando el acta cambia, no cuando pasa el tiempo: el papel ya no caduca.
|
|
75
|
+
const { actaSeq } = await contextoActa()
|
|
76
|
+
if (_selfCert && _selfCert.seq === actaSeq) return _selfCert
|
|
77
|
+
const { cert } = await identity.signDelegation(iss, SIGN_SCOPE)
|
|
65
78
|
_selfCert = cert
|
|
66
79
|
return cert
|
|
67
80
|
}
|
|
@@ -138,13 +151,20 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
138
151
|
if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) {
|
|
139
152
|
return send(from, { type: MSG.ERROR, error: 'stale request: ts outside the ±5 min window (possible replay, or the device clock is off)' })
|
|
140
153
|
}
|
|
141
|
-
const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert,
|
|
154
|
+
const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, ...(await contextoActa()), revoked: await revocationSet() })
|
|
142
155
|
if (!chk.ok) return send(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
|
|
143
156
|
// Reusar el label del cert original (si sigue registrado en delegations).
|
|
144
157
|
const { issued } = await identity.listDelegations()
|
|
145
158
|
const prev = (issued || []).find((x) => x.nonce === p.cert.nonce)
|
|
146
|
-
|
|
147
|
-
|
|
159
|
+
// EL SCOPE SALE DEL ACTA, no del papel viejo: el papel dice a qué se comprometió esta
|
|
160
|
+
// bóveda al conectar el aparato; el acta, lo que puede HOY.
|
|
161
|
+
const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
162
|
+
if (!acta) return send(from, { type: MSG.ERROR, error: 'unauthorized: this vault has no record to decide with' })
|
|
163
|
+
const scope = memberScopes(acta, p.cert.sub)
|
|
164
|
+
if (!scope.length) return send(from, { type: MSG.ERROR, error: 'unauthorized: the record no longer lists this device' })
|
|
165
|
+
const { cert } = await identity.signDelegation(p.cert.sub, scope, { label: prev?.label || '' })
|
|
166
|
+
// El acta viaja con el papel: sin ella quien lo recibe no puede comprobar quién lo firmó.
|
|
167
|
+
send(from, { type: MSG.RENEWED, cert, acta })
|
|
148
168
|
}
|
|
149
169
|
|
|
150
170
|
// Consulta de revocaciones (igual que `vault.devices` del daemon): responde la lista
|
|
@@ -154,12 +174,12 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
154
174
|
const d = p?.data
|
|
155
175
|
if (!d || !p.signature || !p.cert) return send(from, { type: MSG.ERROR, error: 'invalid request' })
|
|
156
176
|
if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) return
|
|
157
|
-
const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert,
|
|
177
|
+
const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, ...(await contextoActa()) })
|
|
158
178
|
if (!chk.ok) return send(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
|
|
159
179
|
const { issued, revoked, revokedCerts } = await identity.listDelegations()
|
|
160
180
|
const devices = await Promise.all((issued || []).map(async (x) => ({
|
|
161
181
|
deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null,
|
|
162
|
-
label: x.label || '', scope: x.scope,
|
|
182
|
+
label: x.label || '', scope: x.scope, seq: x.seq, nonce: x.nonce
|
|
163
183
|
})))
|
|
164
184
|
send(from, { type: MSG.DEVICES_RESULT, devices, revoked: (revoked || []).map((r) => r.nonce || r) })
|
|
165
185
|
// ¿el que consulta es una máquina revocada que reapareció? → re-emite el REVOKED firmado.
|
|
@@ -189,7 +209,7 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
189
209
|
const chk = await verifyChain({
|
|
190
210
|
data: d, signature: p.signature, cert: p.cert,
|
|
191
211
|
...(expectedScope ? { expectedScope } : {}),
|
|
192
|
-
|
|
212
|
+
...(await contextoActa()), revoked: await revocationSet(),
|
|
193
213
|
})
|
|
194
214
|
if (!chk.ok) {
|
|
195
215
|
send(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
|
|
@@ -314,13 +334,14 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
314
334
|
*/
|
|
315
335
|
async function listMachines () {
|
|
316
336
|
const { issued } = await identity.listDelegations()
|
|
317
|
-
const now = Date.now()
|
|
318
337
|
const bySub = new Map()
|
|
319
338
|
for (const x of (issued || [])) {
|
|
320
|
-
if (!x.sub || x.revokedAt
|
|
339
|
+
if (!x.sub || x.revokedAt) continue // revocada = fuera de la lista
|
|
321
340
|
if (!Array.isArray(x.scope) || !x.scope.includes(SIGN_SCOPE)) continue
|
|
322
341
|
if (!x.label || x.label === 'cli') continue
|
|
323
|
-
|
|
342
|
+
// Se queda el papel del acta MÁS NUEVA de esa llave: ya no hay «el que vence más
|
|
343
|
+
// tarde», porque ninguno vence.
|
|
344
|
+
if (!bySub.has(x.sub) || (x.seq || 0) > (bySub.get(x.sub).seq || 0)) bySub.set(x.sub, x)
|
|
324
345
|
}
|
|
325
346
|
return Promise.all([...bySub.values()].map(async (x) => ({ ...x, deviceId: await deviceIdOf(x.sub) })))
|
|
326
347
|
}
|
package/lib/src/service.js
CHANGED
|
@@ -23,9 +23,9 @@ 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
|
|
26
|
+
verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from '@dotrino/identity/capabilities'
|
|
27
27
|
import { openWrap, wrapForMember, decryptWithCek } from '@dotrino/identity/content'
|
|
28
|
-
import { verifyActa, sealKeyAt } from '@dotrino/identity/acta'
|
|
28
|
+
import { verifyActa, sealKeyAt, sealersOf } 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'
|
|
@@ -33,7 +33,6 @@ import { atRestFor } from './atrest.js'
|
|
|
33
33
|
|
|
34
34
|
const IDENTITY_FILE = 'service-identity.json'
|
|
35
35
|
const FRESH_WINDOW_MS = 5 * 60 * 1000
|
|
36
|
-
const RENEW_BEFORE_MS = 7 * 24 * 60 * 60 * 1000 // renovar el cert si vence en <7 días
|
|
37
36
|
|
|
38
37
|
let _globalsInstalled = false
|
|
39
38
|
function installNodeGlobals () {
|
|
@@ -297,9 +296,22 @@ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = nu
|
|
|
297
296
|
const res = await enrolled
|
|
298
297
|
|
|
299
298
|
if (res.code !== code) throw new Error('the vault echoed a code other than the one shown (possible malicious relay)')
|
|
300
|
-
|
|
299
|
+
// EL ACTA VIAJA CON EL PAPEL, y hace falta para juzgarlo: el certificado ya no vence
|
|
300
|
+
// por reloj, dice el `seq` del acta con el que se emitió, y quien lo emitió tiene que
|
|
301
|
+
// ser SELLADORA de ese acta. Sin acta no se puede comprobar nada de eso, y entrar a
|
|
302
|
+
// ciegas en un perfil es justo lo que no se hace.
|
|
303
|
+
const acta = res.acta
|
|
304
|
+
if (!acta) throw new Error('the vault did not send its record: cannot check who signed this cert')
|
|
305
|
+
// Y el acta tiene que ser la DEL PERFIL DEL QR. Antes esto se comprobaba mirando que el
|
|
306
|
+
// cert lo firmara `qr.iss`; con varias selladoras el emisor puede ser otra llave del
|
|
307
|
+
// mismo perfil, así que lo que se fija es el perfil, no la llave.
|
|
308
|
+
if (acta.profileId !== qr.iss) throw new Error('the record is from a profile other than the one in the QR')
|
|
309
|
+
const v = await verifyDelegation({
|
|
310
|
+
cert: res.cert, expectedSub: device.publickey,
|
|
311
|
+
actaSeq: acta.seq, sealers: sealersOf(acta),
|
|
312
|
+
...(expectedScope ? { expectedScope } : {})
|
|
313
|
+
})
|
|
301
314
|
if (!v.ok) throw new Error('invalid cert: ' + v.reason)
|
|
302
|
-
if (res.cert.iss !== qr.iss) throw new Error('cert signed by a master other than the one in the QR')
|
|
303
315
|
|
|
304
316
|
return { device, enc: { publickey: enc.encPublickey, privateJwk: enc.encPrivateJwk }, cert: res.cert, iss: qr.iss, proxy: qr.proxy || 'wss://proxy.dotrino.com' }
|
|
305
317
|
} finally { client.close() }
|
|
@@ -430,8 +442,36 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
430
442
|
try {
|
|
431
443
|
await identifyAsService(client, device)
|
|
432
444
|
|
|
433
|
-
//
|
|
434
|
-
|
|
445
|
+
// ¿Nos quedamos atrás? Lo sabemos porque la bóveda manda su acta con cada respuesta y
|
|
446
|
+
// apuntamos su `seq` al guardarla. Si esa acta es más nueva que el papel, puede que el
|
|
447
|
+
// dueño nos haya cambiado los permisos: se pide uno al día siguiente de enterarnos, no
|
|
448
|
+
// por calendario.
|
|
449
|
+
/**
|
|
450
|
+
* Guarda en disco lo que hemos aprendido, SIN pisar lo demás.
|
|
451
|
+
*
|
|
452
|
+
* `saved` se lee una vez al entrar, así que hay que actualizarlo en memoria además de
|
|
453
|
+
* escribirlo: si no, la segunda escritura parte de la foto vieja y deshace la primera.
|
|
454
|
+
* Eso pasó — renovar guardaba el papel nuevo y acto seguido apuntar el acta lo pisaba con
|
|
455
|
+
* el viejo, que ya estaba revocado. Al siguiente arranque: «unauthorized: revoked».
|
|
456
|
+
*/
|
|
457
|
+
const recordar = (patch) => {
|
|
458
|
+
if (!saved) return
|
|
459
|
+
saved = { ...saved, ...patch }
|
|
460
|
+
if (dir) { try { writeServiceIdentity(dir, saved) } catch (_) {} }
|
|
461
|
+
}
|
|
462
|
+
/** Hasta qué acta hemos visto: es lo que dispara la renovación la próxima vez. */
|
|
463
|
+
const anotarActa = (acta) => {
|
|
464
|
+
if (typeof acta?.seq !== 'number' || saved?.actaSeq === acta.seq) return
|
|
465
|
+
recordar({ actaSeq: acta.seq })
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const renovar = typeof saved?.actaSeq === 'number' && typeof cert?.seq === 'number' && saved.actaSeq > cert.seq
|
|
469
|
+
|
|
470
|
+
// RENOVAR YA NO ES UNA TAREA DE CALENDARIO. El papel no vence: lo único que obliga a
|
|
471
|
+
// pedir otro es que la bóveda diga que el nuestro se quedó atrás respecto del acta, y
|
|
472
|
+
// eso lo contesta ella cuando lo pide (`acta-vieja`). Aquí se intenta una vez y no se
|
|
473
|
+
// insiste: si falla, se sigue con el papel que hay — que probablemente valga.
|
|
474
|
+
if (renovar) {
|
|
435
475
|
try {
|
|
436
476
|
const data = { op: 'renew', publickey: device.publickey, ts: Date.now() }
|
|
437
477
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
@@ -439,8 +479,8 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
439
479
|
client.sendByPubkey(masterPubkey, { type: MSG.RENEW, data, signature, cert })
|
|
440
480
|
const res = await pending
|
|
441
481
|
if (res.type === MSG.RENEWED && res.cert?.sub === device.publickey) {
|
|
442
|
-
|
|
443
|
-
|
|
482
|
+
cert = res.cert
|
|
483
|
+
recordar({ cert })
|
|
444
484
|
}
|
|
445
485
|
} catch (_) { /* la renovación no bloquea el fetch */ }
|
|
446
486
|
}
|
|
@@ -458,6 +498,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
458
498
|
// sigue identificada. Se espera lo que dura el pedido; denegado o vencido es error.
|
|
459
499
|
if (res.body?.op === 'secrets.pending' && res.body.ns === ns) {
|
|
460
500
|
await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey })
|
|
501
|
+
anotarActa(res.acta)
|
|
461
502
|
try { onPending?.({ id: res.body.id, ns, exp: res.body.exp }) } catch (_) {}
|
|
462
503
|
const until = typeof res.body.exp === 'number' ? Math.max(5000, res.body.exp - Date.now() + 5000) : approvalTimeoutMs
|
|
463
504
|
res = await waitForMsg(client, (p) => (p.type === MSG.SECRETS_RESULT && p.body?.op === 'secrets.result') || p.type === MSG.ERROR, Math.min(until, approvalTimeoutMs))
|
|
@@ -479,6 +520,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
479
520
|
// dentro se usa hasta después de verificar.
|
|
480
521
|
const payload = await openSealed({ privateKey: eph.privateKey, enc: body.enc })
|
|
481
522
|
await verifyResponder({ body, seal: res.seal, acta: payload?.acta, masterPubkey })
|
|
523
|
+
anotarActa(payload?.acta)
|
|
482
524
|
|
|
483
525
|
// DOS CAPAS DE SOBRE, y hacen cosas distintas:
|
|
484
526
|
// · la de fuera (`ek` efímera, recién abierta) tapa el TRAMO — el proxio no ve
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.73.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": "
|
|
23
|
+
"@dotrino/identity": "0.73.0",
|
|
24
24
|
"@dotrino/passmanager": "^0.9.0",
|
|
25
25
|
"@dotrino/proxy-client": "^0.13.1",
|
|
26
26
|
"ws": "^8.18.0"
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"!lib/src/types.d.ts"
|
|
45
45
|
],
|
|
46
46
|
"devDependencies": {
|
|
47
|
-
"@dotrino/remote-agent": "
|
|
47
|
+
"@dotrino/remote-agent": "0.6.0",
|
|
48
48
|
"@types/node": "^22.0.0",
|
|
49
49
|
"typescript": "5.9.3"
|
|
50
50
|
}
|
package/src/client.js
CHANGED
|
@@ -11,7 +11,18 @@
|
|
|
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
|
|
14
|
+
import { makeDeviceKey, signWithDevice, verifyDelegation, verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from '@dotrino/identity/capabilities'
|
|
15
|
+
import { sealersOf } from '@dotrino/identity/acta'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Con qué se juzga un papel: el ACTA que la bóveda manda junto a él. Sustituye a comparar
|
|
19
|
+
* `cert.iss` con la maestra — con varias selladoras el emisor puede ser otra llave del mismo
|
|
20
|
+
* perfil, así que lo que se fija es el PERFIL, no la llave.
|
|
21
|
+
*/
|
|
22
|
+
function contexto (acta) {
|
|
23
|
+
if (!acta) throw new Error('the vault did not send its record: cannot check who signed this cert')
|
|
24
|
+
return { actaSeq: acta.seq, sealers: sealersOf(acta) }
|
|
25
|
+
}
|
|
15
26
|
import { installNodeGlobals } from './node-globals.js'
|
|
16
27
|
import { MSG } from './protocol.js'
|
|
17
28
|
|
|
@@ -84,9 +95,9 @@ export async function enroll ({ qr, label = '', dir, onChallenge, approveTimeout
|
|
|
84
95
|
const res = await enrolled
|
|
85
96
|
|
|
86
97
|
// VALIDACIÓN ESTRICTA antes de persistir (cierra inyección de cert / sustitución de maestra).
|
|
87
|
-
|
|
98
|
+
if (res.acta?.profileId !== qr.iss) throw new Error('the record is from a profile other than the one you saw (possible malicious proxy)')
|
|
99
|
+
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, ...contexto(res.acta) })
|
|
88
100
|
if (!v.ok) throw new Error('invalid cert: ' + v.reason)
|
|
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
101
|
if (res.cert.sub !== device.publickey) throw new Error('cert issued for a different device')
|
|
91
102
|
// OJO: devolvemos qr.iss (la maestra que el usuario VIO), NO res.iss.
|
|
92
103
|
return { device, cert: res.cert, iss: qr.iss }
|
|
@@ -151,9 +162,9 @@ export async function requestRenew ({ masterPubkey, proxyUrl, device, cert, dir
|
|
|
151
162
|
if (res.type === MSG.ERROR) throw new Error(res.error)
|
|
152
163
|
// Se valida antes de devolverlo, igual que en el enrolamiento: un cert que no está
|
|
153
164
|
// firmado por la maestra que ya conocemos, o que no es para esta llave, no se guarda.
|
|
154
|
-
|
|
165
|
+
if (res.acta?.profileId !== masterPubkey) throw new Error('the record is from a profile other than the pinned one')
|
|
166
|
+
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, ...contexto(res.acta) })
|
|
155
167
|
if (!v.ok) throw new Error('invalid cert: ' + v.reason)
|
|
156
|
-
if (res.cert.iss !== masterPubkey) throw new Error('cert signed by a master other than the pinned one')
|
|
157
168
|
return { cert: res.cert }
|
|
158
169
|
} finally { client.close() }
|
|
159
170
|
}
|
package/src/commKey.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commKey.js — LA LLAVE DE COMUNICACIÓN de esta bóveda.
|
|
3
|
+
*
|
|
4
|
+
* La maestra tiene dos trabajos y ninguno más: **sellar el acta y reenvolver los sobres**
|
|
5
|
+
* (dueño, 2026-08-31). Hablar por la red no es suyo. Todo lo demás —identificarse ante el
|
|
6
|
+
* proxio, firmar lo que se sirve— lo hace esta llave, y **su autoridad la dice el acta**,
|
|
7
|
+
* donde entra como un miembro más.
|
|
8
|
+
*
|
|
9
|
+
* Por qué eso importa y no es un rodeo: mientras la maestra fuera la que se identificaba,
|
|
10
|
+
* no podía vivir bajo llave — `identify` firma en cada conexión y en cada reconexión, así
|
|
11
|
+
* que una bóveda cerrada no habría existido en la red. Sacándola de ahí, la maestra solo
|
|
12
|
+
* sale con el perfil abierto y el candado deja de ser una bandera.
|
|
13
|
+
*
|
|
14
|
+
* Tres propiedades, y las tres son a propósito:
|
|
15
|
+
*
|
|
16
|
+
* · **Firmar no es leer.** No abre ningún valor. Por eso puede vivir cifrada en reposo
|
|
17
|
+
* con la llave de la máquina y usarse SIN la frase — que es lo que hace que la bóveda
|
|
18
|
+
* siga sirviendo con el candado echado.
|
|
19
|
+
* · **ESTABLE.** No rota con cada acta, al revés que la vieja `sealPub`: es un miembro,
|
|
20
|
+
* y un miembro no cambia de llave cada vez que se toca el acta. Rota cuando se decide,
|
|
21
|
+
* como cualquier aparato.
|
|
22
|
+
* · **Acotada por `cn`.** Entra con `cn: 'vault'`, así que el acta la trata como un
|
|
23
|
+
* SERVICIO: `memberCanSign` sin cajón le dice que no. Puede hablar por la bóveda, no
|
|
24
|
+
* firmar por la persona. Si alguien se lleva el disco, se lleva eso y nada más.
|
|
25
|
+
*/
|
|
26
|
+
import path from 'node:path'
|
|
27
|
+
import { readJson, writeJson } from './paths.js'
|
|
28
|
+
import { atRestFor } from './atrest.js'
|
|
29
|
+
import { signWithDevice } from '@dotrino/identity/capabilities'
|
|
30
|
+
|
|
31
|
+
/** El `cn` con el que entra en el acta. Es un servicio del perfil, no un aparato tuyo. */
|
|
32
|
+
export const COMM_CN = 'vault'
|
|
33
|
+
/** Lo que puede: firmar (acotado por su `cn`). Nada de leer, guardar ni sellar. */
|
|
34
|
+
export const COMM_CAPS = Object.freeze(['sign'])
|
|
35
|
+
|
|
36
|
+
/** Abre (o estrena) la llave de comunicación del perfil. */
|
|
37
|
+
export function openCommKey (dir) {
|
|
38
|
+
const file = path.join(dir, 'commkey.json')
|
|
39
|
+
const atRest = atRestFor(dir)
|
|
40
|
+
let data = readJson(file, null, atRest)
|
|
41
|
+
if (!data || typeof data !== 'object' || !data.pub) data = null
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
/** La pública, o `null` si todavía no se ha estrenado. */
|
|
45
|
+
pub: () => data?.pub || null,
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Estrena la llave si no existe y devuelve su pública. Idempotente: llamarla dos veces
|
|
49
|
+
* NO cambia de llave — cambiarla dejaría al acta nombrando una que ya no tenemos y a la
|
|
50
|
+
* bóveda sin poder identificarse hasta el siguiente sellado.
|
|
51
|
+
*/
|
|
52
|
+
async ensure () {
|
|
53
|
+
if (data?.pub) return data.pub
|
|
54
|
+
const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'])
|
|
55
|
+
const pub = JSON.stringify(await crypto.subtle.exportKey('jwk', pair.publicKey))
|
|
56
|
+
const priv = await crypto.subtle.exportKey('jwk', pair.privateKey)
|
|
57
|
+
data = { v: 1, pub, priv, createdAt: Date.now() }
|
|
58
|
+
writeJson(file, data, atRest)
|
|
59
|
+
return pub
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
/** Firma un cuerpo. `null` si aún no hay llave (no se estrena una al vuelo: ver `ensure`). */
|
|
63
|
+
async sign (body) {
|
|
64
|
+
if (!data?.priv) return null
|
|
65
|
+
const { signature } = await signWithDevice({ privateJwk: data.priv, publickey: data.pub, data: body })
|
|
66
|
+
return signature
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export default { openCommKey, COMM_CN, COMM_CAPS }
|
package/src/daemon.js
CHANGED
|
@@ -323,7 +323,7 @@ export async function runDaemon () {
|
|
|
323
323
|
// paga; el dueño no tiene que acordarse de un comando aparte.
|
|
324
324
|
case 'unlock': {
|
|
325
325
|
const id = ref()
|
|
326
|
-
await mgr.
|
|
326
|
+
await mgr.unlock(id, req.password) // por el manager: mueve la maestra a memoria y la sella
|
|
327
327
|
let note = ''
|
|
328
328
|
let ak = null
|
|
329
329
|
try {
|
|
@@ -345,7 +345,9 @@ export async function runDaemon () {
|
|
|
345
345
|
// los 5 minutos de ABRIRLO aunque estuvieras delante trabajando. `ref()` ya estira
|
|
346
346
|
// el plazo; esta op no hace nada más, y por eso no devuelve ningún volcado.
|
|
347
347
|
case 'touch': { ref(); return { done: 'ok' } }
|
|
348
|
-
|
|
348
|
+
// Por el manager, no por `profiles`: cerrar tiene que SOLTAR la maestra, no solo
|
|
349
|
+
// apuntar que está cerrada.
|
|
350
|
+
case 'lock': { await mgr.lock(ref()); return { done: 'perfil bloqueado' } }
|
|
349
351
|
// PONER contraseña: los secretos pasan de abrirse con la llave de la máquina a
|
|
350
352
|
// abrirse con la frase. Hay que volver a cerrar la copia maestra con la nueva, o
|
|
351
353
|
// quedarían ilegibles. Si el perfil YA tenía contraseña, hace falta la vieja para
|
package/src/manager.js
CHANGED
|
@@ -51,8 +51,14 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
|
|
|
51
51
|
...(autoLockMs === undefined ? {} : { autoLockMs }),
|
|
52
52
|
// Que se cierre solo tiene que VERSE: si no, quien vuelve y encuentra la consola
|
|
53
53
|
// pidiendo la contraseña otra vez cree que algo se rompió.
|
|
54
|
-
onAutoLock: (id) =>
|
|
55
|
-
|
|
54
|
+
onAutoLock: (id) => {
|
|
55
|
+
log('[vault] profile %s locked itself after %d min idle (the console; devices keep working)',
|
|
56
|
+
id, Math.round(profiles.autoLockMs / 60000))
|
|
57
|
+
// Y la maestra se va con el candado. Cerrarse solo tiene que soltarla igual que
|
|
58
|
+
// cerrarla a mano: si no, bastaba con esperar a que se cerrara sola para tenerla
|
|
59
|
+
// descifrada en memoria sin candado que valga.
|
|
60
|
+
running.get(id)?.dropMasterKey?.().catch(() => {})
|
|
61
|
+
}
|
|
56
62
|
})
|
|
57
63
|
/**
|
|
58
64
|
* ACUÑAR (o simplemente LEER) la llave de una carpeta, y cerrarla enseguida.
|
|
@@ -92,6 +98,10 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
|
|
|
92
98
|
// Para la consola remota: la contraseña llega dentro del sobre firmado y hay que
|
|
93
99
|
// convertirla en la llave que abre la copia maestra de los secretos.
|
|
94
100
|
deriveAdminKey: (password) => profiles.adminKey(id, password),
|
|
101
|
+
// La llave del perfil MIENTRAS esté abierto, o null. Con ella, enrolar un servicio
|
|
102
|
+
// con la bóveda abierta le envuelve su cajón en el acto en vez de dejarlo entrar
|
|
103
|
+
// sin poder leer nada (ver `profiles.js`, `llaves`).
|
|
104
|
+
openKey: () => profiles.openKey(id),
|
|
95
105
|
// Camino A: nació para adoptar la cuenta de un aparato (ver profiles.add).
|
|
96
106
|
forAdoption: !!p?.adopt,
|
|
97
107
|
// Ya adoptó: la marca se consume (no vuelve a estar «a la espera»).
|
|
@@ -120,6 +130,27 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
|
|
|
120
130
|
return v
|
|
121
131
|
}
|
|
122
132
|
|
|
133
|
+
/**
|
|
134
|
+
* ABRIR Y CERRAR EL PERFIL, con la maestra siguiendo al candado.
|
|
135
|
+
*
|
|
136
|
+
* Va aquí y no en `profiles.js` porque el registro de perfiles no sabe de bóvedas
|
|
137
|
+
* corriendo: sabe de contraseñas. Quien tiene las dos cosas delante es esto.
|
|
138
|
+
*/
|
|
139
|
+
async function unlock (id, password) {
|
|
140
|
+
const r = await profiles.unlock(id, password)
|
|
141
|
+
// `takeMasterKey` además la deja SELLADA la primera vez: un perfil que ya tenía
|
|
142
|
+
// contraseña guardaba la maestra bajo la llave de máquina, y se migra aquí, que es el
|
|
143
|
+
// único momento en que la llave del perfil está en la mano.
|
|
144
|
+
try { await running.get(id)?.takeMasterKey?.() } catch (e) { log('[vault] could not take the master key: %s', e.message) }
|
|
145
|
+
return r
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function lock (id) {
|
|
149
|
+
const r = profiles.lock(id)
|
|
150
|
+
try { await running.get(id)?.dropMasterKey?.() } catch (_) {}
|
|
151
|
+
return r
|
|
152
|
+
}
|
|
153
|
+
|
|
123
154
|
/** Resumen para state.json / `profile ls`: identidad + candado de cada perfil. */
|
|
124
155
|
const summary = () => profiles.list().map((p) => {
|
|
125
156
|
const v = running.get(p.id)
|
|
@@ -128,6 +159,10 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
|
|
|
128
159
|
|
|
129
160
|
return {
|
|
130
161
|
profiles,
|
|
162
|
+
// Abrir y cerrar pasan por AQUÍ, no por `profiles`: son las que mueven la maestra
|
|
163
|
+
// dentro y fuera de la memoria. Llamar a `profiles.lock` a secas deja la llave puesta.
|
|
164
|
+
unlock,
|
|
165
|
+
lock,
|
|
131
166
|
running,
|
|
132
167
|
get,
|
|
133
168
|
list: () => profiles.list(),
|