@dotrino/vaultd 0.97.0 → 0.105.1
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 +29 -3
- package/lib/src/enroll.js +12 -1
- package/lib/src/index.js +4 -1
- package/lib/src/localdesk.js +96 -0
- package/lib/src/service.js +185 -34
- package/package.json +6 -3
- package/src/approvals.js +7 -2
- package/src/client.js +4 -1
- package/src/ctl.js +105 -3
- package/src/daemon.js +17 -1
- package/src/replica.js +232 -0
- package/src/secretsStore.js +136 -0
- package/src/threadStore.js +10 -0
- package/src/transport.js +27 -4
- package/src/tui/app.js +76 -9
- package/src/tui/i18n.js +85 -55
- package/src/vault.js +298 -15
- package/src/vaultControl.js +24 -0
package/lib/src/admin.js
CHANGED
|
@@ -56,7 +56,12 @@ export const ADMIN_OPS = Object.freeze([
|
|
|
56
56
|
// aparato que administra no tiene sobres de lo privado (solo el servicio dueño y la
|
|
57
57
|
// recuperación). Eso lo hace la bóveda en su máquina (`secret show/history/revert`).
|
|
58
58
|
// No lo cablees.
|
|
59
|
-
|
|
59
|
+
// `var.recipients` es solo LEER, y hace falta para poder escribir: la consola sella el
|
|
60
|
+
// valor ella misma (§8.1) y para eso necesita saber a quién envolverle la llave. Estaba
|
|
61
|
+
// documentada en `buildSealedVar` y la consola la llamaba, pero no existía — así que
|
|
62
|
+
// crear una variable desde la consola contestaba «admin: invalid operation». No expone
|
|
63
|
+
// nada nuevo: son llaves PÚBLICAS, las mismas que ya viajan en el acta.
|
|
64
|
+
'vars', 'var.set', 'var.setMany', 'var.recipients'
|
|
60
65
|
])
|
|
61
66
|
|
|
62
67
|
/**
|
|
@@ -85,7 +90,7 @@ export const ADMIN_OPS = Object.freeze([
|
|
|
85
90
|
* Se dejan las de LEER a propósito: si se cortaran todas, la consola no podría ni
|
|
86
91
|
* enterarse de por qué no funciona y parecería rota. Así puede decir «está cerrada».
|
|
87
92
|
*/
|
|
88
|
-
export const ADMIN_OPS_WHILE_LOCKED = Object.freeze(['audit', 'vars', 'var.set', 'var.setMany'])
|
|
93
|
+
export const ADMIN_OPS_WHILE_LOCKED = Object.freeze(['audit', 'vars', 'var.set', 'var.setMany', 'var.recipients'])
|
|
89
94
|
|
|
90
95
|
/** Cuánto se recuerda un nonce ya usado (el doble de la ventana de frescura). */
|
|
91
96
|
export const ADMIN_NONCE_TTL_MS = 10 * 60 * 1000
|
|
@@ -195,7 +200,7 @@ export function createAdminDesk ({
|
|
|
195
200
|
|
|
196
201
|
// VARIABLES DE ENTORNO. El módulo solo enruta y audita: qué valor puede salir y con
|
|
197
202
|
// qué se cifra lo decide la bóveda (`vars`), que es la que tiene la clave y el disco.
|
|
198
|
-
if (data.op === 'vars' || data.op === 'var.set' || data.op === 'var.setMany') {
|
|
203
|
+
if (data.op === 'vars' || data.op === 'var.set' || data.op === 'var.setMany' || data.op === 'var.recipients') {
|
|
199
204
|
if (!vars) return { ok: false, error: 'admin: this vault does not serve environment variables' }
|
|
200
205
|
// Un destino y solo uno: o un scope, o un aparato. Sin esto, mandar los dos dejaría
|
|
201
206
|
// que quien llama adivine dónde acabó su variable.
|
|
@@ -212,6 +217,27 @@ export function createAdminDesk ({
|
|
|
212
217
|
}
|
|
213
218
|
if (toScope === toDevice) return { ok: false, error: 'admin: var.set needs exactly one target (ns or pub)' }
|
|
214
219
|
|
|
220
|
+
// A QUIÉN HAY QUE ENVOLVERLE. Se pide ANTES de sellar, porque sellar lo hace la
|
|
221
|
+
// consola: envolver solo necesita públicas, así que puede hacerlo un navegador sin
|
|
222
|
+
// que ninguna privada ande suelta (§8.1). La lista sale del ACTA y la sabe la
|
|
223
|
+
// bóveda, no la consola — así conceder un permiso a un aparato nuevo basta, sin que
|
|
224
|
+
// nadie tenga que acordarse de actualizar una lista en otro sitio.
|
|
225
|
+
//
|
|
226
|
+
// Depende de la VISIBILIDAD: una pública se envuelve además para quien administra,
|
|
227
|
+
// así que quien pregunta dice cuál va a escribir.
|
|
228
|
+
if (data.op === 'var.recipients') {
|
|
229
|
+
if (typeof vars.recipients !== 'function') {
|
|
230
|
+
return { ok: false, error: 'admin: this vault cannot say who to seal for (update it)' }
|
|
231
|
+
}
|
|
232
|
+
const result = await vars.recipients({
|
|
233
|
+
ns: toScope ? data.ns : null,
|
|
234
|
+
pub: toDevice ? data.pub : null,
|
|
235
|
+
public: data.public === true
|
|
236
|
+
})
|
|
237
|
+
audit('admin.var.recipients', { by, ns: toScope ? data.ns : null })
|
|
238
|
+
return { ok: true, result }
|
|
239
|
+
}
|
|
240
|
+
|
|
215
241
|
// VARIAS DE UNA VEZ. Mismo permiso y misma frontera que una sola: lo que cambia es
|
|
216
242
|
// que la bóveda las guarda juntas y manda UN aviso de cambio en vez de uno por
|
|
217
243
|
// variable — o sea, el servicio se reinicia una vez, con la configuración entera,
|
package/lib/src/enroll.js
CHANGED
|
@@ -52,7 +52,18 @@ export const MSG_REVOKED = 'vault.revoked'
|
|
|
52
52
|
export const MSG_ERROR = 'vault.error'
|
|
53
53
|
|
|
54
54
|
/** Los scopes del cert se corresponden 1:1 con las capacidades del acta (§D7). */
|
|
55
|
-
const SCOPE_TO_CAP = {
|
|
55
|
+
const SCOPE_TO_CAP = {
|
|
56
|
+
'vault:sign': 'sign',
|
|
57
|
+
'vault:store': 'store',
|
|
58
|
+
'vault:read': 'read',
|
|
59
|
+
'vault:admin': 'admin',
|
|
60
|
+
'vault:passwords': 'passwords',
|
|
61
|
+
// `replica` SÍ se empareja, al revés que `sealer` y `admin`. La razón es la misma que
|
|
62
|
+
// hace estrecho al permiso: un replicador reparte sobres que no puede abrir y no cambia
|
|
63
|
+
// nada. Y se despliega sin teclado —un contenedor, una máquina ajena—, que es justo
|
|
64
|
+
// donde obligar a un segundo paso a mano es el paso que nadie da.
|
|
65
|
+
'vault:replica': 'replica'
|
|
66
|
+
}
|
|
56
67
|
export const scopeToCaps = (scope) =>
|
|
57
68
|
(Array.isArray(scope) ? scope : [scope]).map((s) => SCOPE_TO_CAP[s]).filter(Boolean)
|
|
58
69
|
|
package/lib/src/index.js
CHANGED
|
@@ -84,7 +84,10 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
84
84
|
const client = injectedClient || await (async () => {
|
|
85
85
|
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
86
86
|
const c = new WebSocketProxyClient({
|
|
87
|
-
|
|
87
|
+
// WEBRTC SOLO DONDE EXISTE (ver `lib/src/service.js`): en Node no hay
|
|
88
|
+
// `RTCPeerConnection` y encenderlo reventaría al negociar; en un navegador es nativo y es
|
|
89
|
+
// el camino directo que hay que preferir. Se mira, en vez de apagarlo para siempre.
|
|
90
|
+
url: proxy, enableWebRTC: typeof globalThis.RTCPeerConnection === 'function', autoReconnect: true,
|
|
88
91
|
maxReconnectAttempts: 100000, reconnectDelay: 4000
|
|
89
92
|
})
|
|
90
93
|
await c.connect()
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* localdesk.js — HABLARLE A LA BÓVEDA SIN SALIR DE LA MÁQUINA.
|
|
3
|
+
*
|
|
4
|
+
* Un servicio en el mismo equipo que la bóveda daba la vuelta por `proxy.dotrino.com`:
|
|
5
|
+
* salía a internet, volvía, y si el proxio tenía un mal momento el arranque se retrasaba
|
|
6
|
+
* cinco segundos por nada. Absurdo cuando los dos procesos comparten disco (dueño,
|
|
7
|
+
* 2026-09-03: «estando en la misma máquina debería ser inmediato»).
|
|
8
|
+
*
|
|
9
|
+
* Es EL MISMO PROTOCOLO. Lo que viaja es idéntico —`{type, data, signature, cert}`— y la
|
|
10
|
+
* bóveda lo comprueba igual, contra el certificado y contra el acta. **Alcanzar el socket
|
|
11
|
+
* no autoriza nada**: es la misma puerta por un camino más corto, no una puerta de atrás.
|
|
12
|
+
*
|
|
13
|
+
* Se hace pasar por un cliente del proxio (`send`, `sendByPubkey`, `on('message')`,
|
|
14
|
+
* `close`) para que quien lo use no tenga que saber por dónde va. `sendByPubkey` ignora a
|
|
15
|
+
* quién se dirige, y es correcto: al otro lado del socket solo hay una bóveda, la de esta
|
|
16
|
+
* máquina — si hubiera que elegir destinatario, este no sería el camino.
|
|
17
|
+
*/
|
|
18
|
+
import fs from 'node:fs'
|
|
19
|
+
import net from 'node:net'
|
|
20
|
+
import path from 'node:path'
|
|
21
|
+
import os from 'node:os'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* DÓNDE ESTÁ EL SOCKET, y por qué se nombra por la LLAVE de la bóveda.
|
|
25
|
+
*
|
|
26
|
+
* Una máquina puede tener varias bóvedas (varios perfiles), y un servicio no sabe nada de
|
|
27
|
+
* perfiles: lo único que conoce de la suya es su llave maestra, que lleva pineada desde
|
|
28
|
+
* que se enroló. Así que el nombre sale de ahí y los dos lados lo calculan igual, sin
|
|
29
|
+
* mirar ningún índice ni depender de cómo estén repartidos los datos en el disco.
|
|
30
|
+
*
|
|
31
|
+
* Y va en el directorio de EJECUCIÓN del usuario, no entre los datos: un socket es algo
|
|
32
|
+
* vivo, no algo guardado. Se lo lleva el reinicio, que es lo correcto.
|
|
33
|
+
*/
|
|
34
|
+
export function socketDir () {
|
|
35
|
+
const base = process.env.XDG_RUNTIME_DIR || os.tmpdir()
|
|
36
|
+
return path.join(base, 'dotrino-vault')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function localSocketPath (masterId) {
|
|
40
|
+
if (process.env.DOTRINO_VAULT_SOCKET) return process.env.DOTRINO_VAULT_SOCKET
|
|
41
|
+
return path.join(socketDir(), String(masterId) + '.sock')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** ¿Hay una bóveda escuchando aquí mismo? Mirar el archivo es barato y no conecta nada. */
|
|
45
|
+
export const hasLocalVault = (masterId) => {
|
|
46
|
+
try { return fs.statSync(localSocketPath(masterId)).isSocket() } catch (_) { return false }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Se conecta al mostrador local y devuelve algo con la forma de un cliente del proxio.
|
|
51
|
+
* Si no hay socket, o no acepta, LANZA — no se queda callado: quien llama decide si sale
|
|
52
|
+
* por el proxio, y esa decisión tiene que ser explícita y no un silencio.
|
|
53
|
+
*/
|
|
54
|
+
export async function connectLocal ({ masterId, timeoutMs = 3000 } = {}) {
|
|
55
|
+
const socketPath = localSocketPath(masterId)
|
|
56
|
+
const sock = await new Promise((resolve, reject) => {
|
|
57
|
+
const s = net.createConnection(socketPath)
|
|
58
|
+
const t = setTimeout(() => { s.destroy(); reject(new Error('the local vault desk did not answer')) }, timeoutMs)
|
|
59
|
+
s.once('connect', () => { clearTimeout(t); resolve(s) })
|
|
60
|
+
s.once('error', (e) => { clearTimeout(t); reject(new Error('no local vault desk at ' + socketPath + ': ' + e.message)) })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const oyentes = new Set()
|
|
64
|
+
let buf = ''
|
|
65
|
+
sock.setEncoding('utf8')
|
|
66
|
+
sock.on('data', (chunk) => {
|
|
67
|
+
buf += chunk
|
|
68
|
+
let i
|
|
69
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
70
|
+
const linea = buf.slice(0, i); buf = buf.slice(i + 1)
|
|
71
|
+
if (!linea.trim()) continue
|
|
72
|
+
let p = null
|
|
73
|
+
try { p = JSON.parse(linea) } catch (_) { continue }
|
|
74
|
+
// `from` es el mismo para todo: al otro lado solo hay una bóveda.
|
|
75
|
+
for (const fn of oyentes) { try { fn('local', p) } catch (_) {} }
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const enviar = (obj) => { sock.write(JSON.stringify(obj) + '\n') }
|
|
80
|
+
return {
|
|
81
|
+
local: true,
|
|
82
|
+
token: 'local',
|
|
83
|
+
send: (_to, obj) => enviar(obj),
|
|
84
|
+
sendByPubkey: (_pub, obj) => enviar(obj),
|
|
85
|
+
// El mostrador local no pide identificarse: la puerta la abre el certificado que va
|
|
86
|
+
// dentro de cada mensaje, igual que por el proxio. Identificarse ahí sirve para ser
|
|
87
|
+
// DIRECCIONABLE, y aquí no hay a quién direccionar.
|
|
88
|
+
identify: async () => ({ ok: true }),
|
|
89
|
+
on: (ev, fn) => {
|
|
90
|
+
if (ev !== 'message') return () => {}
|
|
91
|
+
oyentes.add(fn)
|
|
92
|
+
return () => oyentes.delete(fn)
|
|
93
|
+
},
|
|
94
|
+
close: () => { try { sock.destroy() } catch (_) {} }
|
|
95
|
+
}
|
|
96
|
+
}
|
package/lib/src/service.js
CHANGED
|
@@ -25,11 +25,12 @@ import {
|
|
|
25
25
|
makeDeviceKey, makeDeviceEncKey, importDeviceEncKey, signWithDevice, verifyDelegation,
|
|
26
26
|
verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from '@dotrino/identity/capabilities'
|
|
27
27
|
import { openWrap, wrapForMember, decryptWithCek } from '@dotrino/identity/content'
|
|
28
|
-
import { verifyActa, sealKeyAt, sealersOf } from '@dotrino/identity/acta'
|
|
28
|
+
import { verifyActa, sealKeyAt, sealersOf, memberCan } from '@dotrino/identity/acta'
|
|
29
29
|
import { MSG, secretsScope, isValidSecretsNs } from './protocol.js'
|
|
30
30
|
import { makeEphemeralKey, openSealed } from './sealed.js'
|
|
31
31
|
import { parseInvite } from './invite.js'
|
|
32
32
|
import { atRestFor } from './atrest.js'
|
|
33
|
+
import { connectLocal, hasLocalVault } from './localdesk.js'
|
|
33
34
|
|
|
34
35
|
const IDENTITY_FILE = 'service-identity.json'
|
|
35
36
|
const FRESH_WINDOW_MS = 5 * 60 * 1000
|
|
@@ -126,10 +127,33 @@ async function resolveAppointment (client, code) {
|
|
|
126
127
|
return r.instance
|
|
127
128
|
}
|
|
128
129
|
|
|
129
|
-
|
|
130
|
+
/**
|
|
131
|
+
* EL MÁS CORTO PRIMERO: si la bóveda está en ESTA máquina, se le habla por su socket y no
|
|
132
|
+
* se sale a internet (dueño, 2026-09-03: «estando en la misma máquina debería ser
|
|
133
|
+
* inmediato»). Es el mismo protocolo y las mismas comprobaciones; lo único que cambia es
|
|
134
|
+
* por dónde van los bytes.
|
|
135
|
+
*
|
|
136
|
+
* No es un repliegue que tape nada: el socket o está o no está, y no estar es el caso
|
|
137
|
+
* normal —la bóveda suele vivir en otra máquina—. Lo que sí sería un repliegue es callar
|
|
138
|
+
* un fallo del socket y salir por el proxio como si nada, así que se dice.
|
|
139
|
+
*/
|
|
140
|
+
async function freshClient (proxyUrl, connectTimeoutMs = 20000, master = null) {
|
|
130
141
|
installNodeGlobals()
|
|
142
|
+
// El id sale de la LLAVE de la bóveda, que es lo único que quien llama sabe seguro de
|
|
143
|
+
// ella. Sin llave no se busca socket: no habría forma de saber cuál es el suyo.
|
|
144
|
+
const masterId = master ? (await pubkeyId(master)).slice(0, 16) : null
|
|
145
|
+
if (masterId && hasLocalVault(masterId)) {
|
|
146
|
+
try { return await connectLocal({ masterId }) } catch (e) {
|
|
147
|
+
console.error('[vault] the local desk is there but did not answer (%s): going out through the proxy', e.message)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
131
150
|
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
132
|
-
|
|
151
|
+
// WEBRTC SOLO DONDE EXISTE. En Node no hay `RTCPeerConnection`, así que encenderlo
|
|
152
|
+
// reventaría al negociar; en un navegador es nativo y es el camino directo que hay que
|
|
153
|
+
// preferir. Se mira si está, en vez de apagarlo a mano para siempre: el día que este
|
|
154
|
+
// código corra en un navegador —o que Node lo traiga— se enciende solo.
|
|
155
|
+
const hayWebRTC = typeof globalThis.RTCPeerConnection === 'function'
|
|
156
|
+
const client = new WebSocketProxyClient({ url: proxyUrl, enableWebRTC: hayWebRTC, autoReconnect: false })
|
|
133
157
|
// connect() del cliente solo se resuelve con 'connected' y solo rechaza en el
|
|
134
158
|
// evento 'error' de transporte: si el socket cierra LIMPIO antes de 'connected'
|
|
135
159
|
// (p.ej. el proxy banea la IP y envía close 1008), la promesa quedaría colgada
|
|
@@ -241,7 +265,54 @@ function writeServiceIdentity (dir, obj) {
|
|
|
241
265
|
* @param {number} [opts.approveTimeoutMs]
|
|
242
266
|
* @returns {Promise<{device, enc:{publickey:string, privateJwk:object}, cert, iss:string, proxy:string}>}
|
|
243
267
|
*/
|
|
244
|
-
|
|
268
|
+
/**
|
|
269
|
+
* @param {boolean} [opts.withEncKey] Estrenar llave de CIFRADO y registrarla en el acta.
|
|
270
|
+
* Por defecto sí: sin ella el aparato entra pero no le llega ningún secreto.
|
|
271
|
+
*
|
|
272
|
+
* Un REPLICADOR entra con `false`, y no es una optimización: es su garantía. Regla del
|
|
273
|
+
* dueño (2026-09-02), *«recibirá todos los sobres que se generen, ningún sobre firmado
|
|
274
|
+
* para él»*. Sin `encPub` en el acta no hay a dónde envolverle nada, así que repartir no
|
|
275
|
+
* puede convertirse en leer ni por descuido ni por un cambio futuro. Es el mismo cerrojo
|
|
276
|
+
* que tiene la llave de comunicación.
|
|
277
|
+
*/
|
|
278
|
+
/**
|
|
279
|
+
* QR CORTO: se le pregunta a la bóveda QUIÉN ES, punto a punto, presentando el `sn`.
|
|
280
|
+
*
|
|
281
|
+
* Sale de dentro de `enrollWithVault` para poder preguntarlo ANTES de enrolar: quien
|
|
282
|
+
* enrola necesita el nombre de la cuenta para saber dónde guardar (la etiqueta de bóveda
|
|
283
|
+
* es una carpeta con ese nombre), y pedírselo al usuario cuando la bóveda ya lo sabe es
|
|
284
|
+
* fricción sin nada a cambio.
|
|
285
|
+
*
|
|
286
|
+
* Devuelve el mismo `qr` con `iss`, `proxy` y `acct` puestos. Con un QR largo no pregunta
|
|
287
|
+
* nada: ya los trae.
|
|
288
|
+
*/
|
|
289
|
+
export async function sayHello (client, qr) {
|
|
290
|
+
if (qr.iss) return qr
|
|
291
|
+
const target = await resolveAppointment(client, qr.conn)
|
|
292
|
+
const hello = await new Promise((resolve, reject) => {
|
|
293
|
+
const off = client.on('message', (_f, p) => {
|
|
294
|
+
if (p?.type === MSG.HELLO_OK) { finish(); verifyHello(p, qr.sn).then(resolve, reject) }
|
|
295
|
+
else if (p?.type === MSG.ERROR) { finish(); reject(new Error(p.error)) }
|
|
296
|
+
})
|
|
297
|
+
const t = setTimeout(() => { finish(); reject(new Error('the vault did not answer: that code may have expired')) }, 15000)
|
|
298
|
+
const finish = () => { off(); clearTimeout(t) }
|
|
299
|
+
try { client.send(target, { type: MSG.HELLO, sn: qr.sn }) } catch (e) { finish(); reject(e) }
|
|
300
|
+
})
|
|
301
|
+
return { ...qr, iss: hello.iss, proxy: hello.proxy || qr.proxy, ...(hello.acct ? { acct: hello.acct } : {}) }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Cómo se llama la cuenta de esa invitación, sin enrolar nada. Una conexión y una pregunta.
|
|
306
|
+
*/
|
|
307
|
+
export async function vaultAccountName (qr) {
|
|
308
|
+
if (typeof qr === 'string') qr = parseInvite(qr)
|
|
309
|
+
if (!qr?.sn) throw new Error('that does not look like a vault invitation')
|
|
310
|
+
if (qr.acct) return qr.acct
|
|
311
|
+
const client = await freshClient(qr.proxy || 'wss://proxy.dotrino.com')
|
|
312
|
+
try { return (await sayHello(client, qr)).acct || '' } finally { try { client.close() } catch (_) {} }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export async function enrollWithVault ({ qr, label = 'agent', expectedScope = null, onCode, approveTimeoutMs = 180000, withEncKey = true } = {}) {
|
|
245
316
|
// `parseInvite` y NO `JSON.parse`: el vault no imprime JSON desde hace rato.
|
|
246
317
|
// `dotrino-vault pair` emite la URL del QR y el código compacto (`c…`/`t…`, ver
|
|
247
318
|
// invite.js); `parseInvite` acepta todas las formas, incluida la vieja.
|
|
@@ -254,31 +325,19 @@ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = nu
|
|
|
254
325
|
rejectAdoption(qr.m)
|
|
255
326
|
|
|
256
327
|
const client = await freshClient(qr.proxy || 'wss://proxy.dotrino.com')
|
|
257
|
-
|
|
258
|
-
if (!qr.iss) {
|
|
259
|
-
const target = await resolveAppointment(client, qr.conn)
|
|
260
|
-
const hello = await new Promise((resolve, reject) => {
|
|
261
|
-
const off = client.on('message', (_f, p) => {
|
|
262
|
-
if (p?.type === MSG.HELLO_OK) { finish(); verifyHello(p, qr.sn).then(resolve, reject) }
|
|
263
|
-
else if (p?.type === MSG.ERROR) { finish(); reject(new Error(p.error)) }
|
|
264
|
-
})
|
|
265
|
-
const t = setTimeout(() => { finish(); reject(new Error('the vault did not answer: that code may have expired')) }, 15000)
|
|
266
|
-
const finish = () => { off(); clearTimeout(t) }
|
|
267
|
-
try { client.send(target, { type: MSG.HELLO, sn: qr.sn }) } catch (e) { finish(); reject(e) }
|
|
268
|
-
})
|
|
269
|
-
qr = { ...qr, iss: hello.iss, proxy: hello.proxy || qr.proxy }
|
|
270
|
-
}
|
|
328
|
+
qr = await sayHello(client, qr)
|
|
271
329
|
try {
|
|
272
330
|
const device = await makeDeviceKey({ label })
|
|
273
331
|
// La llave de CIFRADO: es a la que la bóveda sella cada variable. Sin ella el
|
|
274
|
-
// aparato entra al acta pero no le llega ningún secreto, y no da error.
|
|
275
|
-
|
|
332
|
+
// aparato entra al acta pero no le llega ningún secreto, y no da error. Un replicador
|
|
333
|
+
// entra así A PROPÓSITO (ver `withEncKey`): reparte sobres cerrados y no abre ninguno.
|
|
334
|
+
const enc = withEncKey ? await makeDeviceEncKey() : null
|
|
276
335
|
const deviceId = (await pubkeyId(device.publickey)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
|
|
277
336
|
// Código de emparejamiento ALEATORIO: se muestra y NO se envía. La bóveda lo
|
|
278
337
|
// aprende solo cuando un humano lo tipea → aprobar exige TENER esta máquina.
|
|
279
338
|
const code = makePairingCode()
|
|
280
339
|
const commit = await commitCode({ code, dpub: device.publickey, sn: qr.sn })
|
|
281
|
-
const data = { op: 'enroll', intent: 'join', dpub: device.publickey, encPub: enc
|
|
340
|
+
const data = { op: 'enroll', intent: 'join', dpub: device.publickey, encPub: enc?.encPublickey || null, token: qr.token || qr.sn, sn: qr.sn, commit, label, ts: Date.now() }
|
|
282
341
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
283
342
|
|
|
284
343
|
const enrolled = new Promise((resolve, reject) => {
|
|
@@ -313,7 +372,7 @@ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = nu
|
|
|
313
372
|
})
|
|
314
373
|
if (!v.ok) throw new Error('invalid cert: ' + v.reason)
|
|
315
374
|
|
|
316
|
-
return { device, enc: { publickey: enc.encPublickey, privateJwk: enc.encPrivateJwk }, cert: res.cert, iss: qr.iss, proxy: qr.proxy || 'wss://proxy.dotrino.com' }
|
|
375
|
+
return { device, enc: enc ? { publickey: enc.encPublickey, privateJwk: enc.encPrivateJwk } : null, cert: res.cert, iss: qr.iss, proxy: qr.proxy || 'wss://proxy.dotrino.com' }
|
|
317
376
|
} finally { client.close() }
|
|
318
377
|
}
|
|
319
378
|
|
|
@@ -402,7 +461,7 @@ export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device,
|
|
|
402
461
|
cert = cert || saved?.cert
|
|
403
462
|
if (!proxyUrl || !masterPubkey || !device || !cert) throw new Error('service not enrolled')
|
|
404
463
|
|
|
405
|
-
const client = await freshClient(proxyUrl)
|
|
464
|
+
const client = await freshClient(proxyUrl, 20000, masterPubkey)
|
|
406
465
|
try {
|
|
407
466
|
await identifyAsService(client, device)
|
|
408
467
|
const data = { op: 'enckey', ns, encPub, publickey: device.publickey, ts: Date.now() }
|
|
@@ -438,7 +497,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
438
497
|
throw new Error('service not enrolled: run enrollService() first (service-identity.json missing)')
|
|
439
498
|
}
|
|
440
499
|
|
|
441
|
-
const client = await freshClient(proxyUrl)
|
|
500
|
+
const client = await freshClient(proxyUrl, 20000, masterPubkey)
|
|
442
501
|
try {
|
|
443
502
|
await identifyAsService(client, device)
|
|
444
503
|
|
|
@@ -459,10 +518,23 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
459
518
|
saved = { ...saved, ...patch }
|
|
460
519
|
if (dir) { try { writeServiceIdentity(dir, saved) } catch (_) {} }
|
|
461
520
|
}
|
|
462
|
-
/**
|
|
521
|
+
/**
|
|
522
|
+
* Hasta qué acta hemos visto. Dispara la renovación la próxima vez, y desde que hay
|
|
523
|
+
* replicadores es además el freno anti-rollback (`verifyResponder`).
|
|
524
|
+
*
|
|
525
|
+
* SOLO SUBE. Antes escribía cualquier `seq` que llegara, también uno MENOR: bastaba que
|
|
526
|
+
* alguien contestara con un acta vieja para que el pin bajara y dejara de proteger de
|
|
527
|
+
* ahí en adelante. Un pin que retrocede no es un pin — se llama `maxSeq` en el diseño
|
|
528
|
+
* justo por esto (`acta-de-perfil.md` §2.3).
|
|
529
|
+
*/
|
|
463
530
|
const anotarActa = (acta) => {
|
|
464
|
-
if (typeof acta?.seq !== 'number'
|
|
465
|
-
|
|
531
|
+
if (typeof acta?.seq !== 'number') return
|
|
532
|
+
if (typeof saved?.actaSeq === 'number' && acta.seq <= saved.actaSeq) return
|
|
533
|
+
// Y QUIÉNES REPARTEN. Sale del acta, que llega firmada en cada respuesta, así que no
|
|
534
|
+
// hay una lista que mantener a mano ni un `.env` que se olvide: darle `replica` a un
|
|
535
|
+
// aparato basta para que sus servicios sepan preguntarle cuando la bóveda no esté.
|
|
536
|
+
const replicas = (acta.members || []).filter((m) => memberCan(acta, m.pub, 'replica')).map((m) => m.pub)
|
|
537
|
+
recordar({ actaSeq: acta.seq, replicas })
|
|
466
538
|
}
|
|
467
539
|
|
|
468
540
|
// DOS MOTIVOS PARA PEDIR PAPEL NUEVO:
|
|
@@ -503,16 +575,35 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
503
575
|
// poner por el camino. Quien decide qué se manda es la bóveda; esto solo es la petición.
|
|
504
576
|
const data = { op: 'secrets', ns, ek: eph.ek, publickey: device.publickey, ts: Date.now(), ...(publicOnly ? { publicOnly: true } : {}) }
|
|
505
577
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
578
|
+
// A QUIÉN SE LE PREGUNTA: primero a la bóveda, y si no contesta, a los REPLICADORES
|
|
579
|
+
// que el acta nombraba la última vez que la vimos (`docs/replicas.md` §8: «el servicio
|
|
580
|
+
// lleva una lista de bóvedas y las prueba en orden»).
|
|
581
|
+
//
|
|
582
|
+
// Sin esto, un replicador no servía absolutamente para nada: el mensaje se mandaba a
|
|
583
|
+
// la llave de la bóveda y, con la bóveda apagada, no llegaba a ninguna parte. Lo
|
|
584
|
+
// destapó el smoke con las dos máquinas encendidas y la bóveda muerta, que es
|
|
585
|
+
// exactamente el caso que un replicador existe para cubrir.
|
|
586
|
+
//
|
|
587
|
+
// La bóveda va PRIMERO siempre: un replicador solo puede estar igual de al día o menos.
|
|
588
|
+
const destinos = [masterPubkey, ...(saved?.replicas || []).filter((k) => k !== masterPubkey)]
|
|
589
|
+
// El plazo se reparte: no tiene sentido esperar el timeout entero a una bóveda apagada
|
|
590
|
+
// y quedarse sin tiempo para preguntarle a quien sí está.
|
|
591
|
+
const porDestino = Math.max(4000, Math.round(timeoutMs / destinos.length))
|
|
592
|
+
let res = null
|
|
593
|
+
let ultimo = null
|
|
594
|
+
for (const destino of destinos) {
|
|
595
|
+
const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, porDestino)
|
|
596
|
+
client.sendByPubkey(destino, { type: MSG.SECRETS, data, signature, cert })
|
|
597
|
+
try { res = await pending; break } catch (e) { ultimo = e }
|
|
598
|
+
}
|
|
599
|
+
if (!res) throw ultimo || new Error('nobody answered: neither the vault nor any replica')
|
|
509
600
|
if (res.type === MSG.ERROR) throw new Error(res.error)
|
|
510
601
|
|
|
511
602
|
// CAJÓN CON APROBACIÓN: la bóveda contesta «pendiente» (firmado) y la respuesta de
|
|
512
603
|
// verdad llega cuando el aparato que aprueba firme — por esta misma conexión, que
|
|
513
604
|
// sigue identificada. Se espera lo que dura el pedido; denegado o vencido es error.
|
|
514
605
|
if (res.body?.op === 'secrets.pending' && res.body.ns === ns) {
|
|
515
|
-
await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey })
|
|
606
|
+
await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
|
|
516
607
|
anotarActa(res.acta)
|
|
517
608
|
try { onPending?.({ id: res.body.id, ns, exp: res.body.exp }) } catch (_) {}
|
|
518
609
|
const until = typeof res.body.exp === 'number' ? Math.max(5000, res.body.exp - Date.now() + 5000) : approvalTimeoutMs
|
|
@@ -534,7 +625,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
534
625
|
// confía en nadie — solo saca el acta con la que SÍ se comprueba. Nada de lo que hay
|
|
535
626
|
// dentro se usa hasta después de verificar.
|
|
536
627
|
const payload = await openSealed({ privateKey: eph.privateKey, enc: body.enc })
|
|
537
|
-
await verifyResponder({ body, seal: res.seal, acta: payload?.acta, masterPubkey })
|
|
628
|
+
await verifyResponder({ body, seal: res.seal, acta: payload?.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
|
|
538
629
|
anotarActa(payload?.acta)
|
|
539
630
|
|
|
540
631
|
// DOS CAPAS DE SOBRE, y hacen cosas distintas:
|
|
@@ -578,8 +669,32 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
578
669
|
* Sin el paso 1 el paso 2 no vale nada: un acta cualquiera nombraría la llave que
|
|
579
670
|
* quisiera.
|
|
580
671
|
*/
|
|
581
|
-
|
|
582
|
-
|
|
672
|
+
/**
|
|
673
|
+
* ¿QUIÉN PUEDE HABERME CONTESTADO ESTO? Hay dos respuestas legítimas y una condición.
|
|
674
|
+
*
|
|
675
|
+
* · **La bóveda.** Firma con la llave de sellado que el acta nombra para ese `seq`. Es
|
|
676
|
+
* el camino de siempre y no cambia.
|
|
677
|
+
* · **Un REPLICADOR** (`seal.by`). No tiene esa llave —no tiene maestra— así que firma
|
|
678
|
+
* con la suya de aparato, y lo que la autoriza es que el acta le reconozca `replica`.
|
|
679
|
+
* Reparte lo que la bóveda ya selló: los sobres de dentro se siguen comprobando contra
|
|
680
|
+
* la llave de sellado (`makeSealCheck`), así que un replicador no puede inventarse
|
|
681
|
+
* contenido, solo entregarlo.
|
|
682
|
+
*
|
|
683
|
+
* LA CONDICIÓN, y es la que cierra el agujero que `replicas.md` §6.1 llama «construir el
|
|
684
|
+
* ataque»: **un replicador solo le contesta a quien YA conoce la cuenta.** Un replicador
|
|
685
|
+
* atrasado presenta un acta donde un aparato revocado sigue siendo miembro; contra eso, a
|
|
686
|
+
* quien tiene `maxSeq` pineado lo protege el pin, pero a un aparato que llega nuevo no hay
|
|
687
|
+
* con qué compararlo. Así que sin pin no se le cree: que conteste la bóveda.
|
|
688
|
+
*
|
|
689
|
+
* El precio, dicho en voz alta: un aparato nuevo necesita la bóveda encendida una vez. El
|
|
690
|
+
* oráculo de frescura (§2.4.2 del acta) es lo que quitaría ese precio; mientras no exista,
|
|
691
|
+
* esto es el freno, y es un freno de verdad y no un aviso.
|
|
692
|
+
*
|
|
693
|
+
* @param {number|null} [knownSeq] El `seq` más alto que este agente ya vio de esta cuenta.
|
|
694
|
+
* `null`/ausente = no conoce la cuenta, y entonces un replicador no vale.
|
|
695
|
+
*/
|
|
696
|
+
export async function verifyResponder ({ body, seal, acta, masterPubkey, knownSeq = null }) {
|
|
697
|
+
if (!seal?.sig) {
|
|
583
698
|
throw new Error('the reply carries no sealing signature: this vault is too old to serve (update it)')
|
|
584
699
|
}
|
|
585
700
|
if (!acta) throw new Error('the reply carries no record: cannot tell which key was allowed to sign it')
|
|
@@ -587,6 +702,33 @@ export async function verifyResponder ({ body, seal, acta, masterPubkey }) {
|
|
|
587
702
|
throw new Error('the record was not sealed by the master this agent knows: refusing the reply')
|
|
588
703
|
}
|
|
589
704
|
if (!(await verifyActa({ acta })).ok) throw new Error('the record does not verify')
|
|
705
|
+
|
|
706
|
+
// NUNCA HACIA ATRÁS. Vale para los dos caminos: un acta con `seq` menor del que ya
|
|
707
|
+
// vimos es un rollback, venga de donde venga.
|
|
708
|
+
if (typeof knownSeq === 'number' && typeof acta.seq === 'number' && acta.seq < knownSeq) {
|
|
709
|
+
throw Object.assign(
|
|
710
|
+
new Error(`the record went backwards (#${acta.seq} after #${knownSeq}): refusing the reply`),
|
|
711
|
+
{ code: 'stale-record' })
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (seal.by) {
|
|
715
|
+
if (typeof knownSeq !== 'number') {
|
|
716
|
+
throw Object.assign(
|
|
717
|
+
new Error('a replica answered, and this device has never seen the account: ask the vault itself the first time'),
|
|
718
|
+
{ code: 'replica-unknown-account' })
|
|
719
|
+
}
|
|
720
|
+
if (!memberCan(acta, seal.by, 'replica')) {
|
|
721
|
+
throw new Error('the reply comes from a device the record does not allow to serve as a replica')
|
|
722
|
+
}
|
|
723
|
+
if (!(await verifyDeviceSig({ publickey: seal.by, data: body, signature: seal.sig }))) {
|
|
724
|
+
throw new Error('the replica signature does not check out')
|
|
725
|
+
}
|
|
726
|
+
return
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (typeof seal.seq !== 'number') {
|
|
730
|
+
throw new Error('the reply carries no sealing signature: this vault is too old to serve (update it)')
|
|
731
|
+
}
|
|
590
732
|
const pub = sealKeyAt(acta, seal.seq)
|
|
591
733
|
if (!pub) throw new Error(`the record names no sealing key for #${seal.seq}`)
|
|
592
734
|
if (!(await verifyDeviceSig({ publickey: pub, data: body, signature: seal.sig }))) {
|
|
@@ -968,6 +1110,15 @@ export async function watchSecretsChanges ({
|
|
|
968
1110
|
const connect = async () => {
|
|
969
1111
|
if (stopped) return
|
|
970
1112
|
try {
|
|
1113
|
+
// POR EL PROXIO, A PROPÓSITO, aunque la bóveda esté en esta misma máquina.
|
|
1114
|
+
//
|
|
1115
|
+
// El mostrador local es pregunta y respuesta. Los avisos que la bóveda manda POR SU
|
|
1116
|
+
// CUENTA —«tu configuración cambió», una revocación— salen por el proxio, así que un
|
|
1117
|
+
// oyente conectado solo en local no se enteraría de nada y se quedaría corriendo con
|
|
1118
|
+
// la configuración vieja para siempre. Lo cazaron los E2E de avisos.
|
|
1119
|
+
//
|
|
1120
|
+
// El atajo local es para el ARRANQUE, que es donde se nota la latencia: pedir las
|
|
1121
|
+
// claves una vez. Escuchar es otra cosa y necesita que la bóveda pueda alcanzarte.
|
|
971
1122
|
client = await freshClient(saved.proxy)
|
|
972
1123
|
await identifyAsService(client, saved.device)
|
|
973
1124
|
client.on('message', (_from, p) => { handleMessage(p).catch(() => {}) })
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.105.1",
|
|
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,9 +20,9 @@
|
|
|
20
20
|
"node": ">=20"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@dotrino/identity": "0.
|
|
23
|
+
"@dotrino/identity": "0.79.0",
|
|
24
24
|
"@dotrino/passmanager": "^0.10.1",
|
|
25
|
-
"@dotrino/proxy-client": "
|
|
25
|
+
"@dotrino/proxy-client": "0.15.0",
|
|
26
26
|
"ws": "^8.18.0"
|
|
27
27
|
},
|
|
28
28
|
"license": "MIT",
|
|
@@ -47,5 +47,8 @@
|
|
|
47
47
|
"@dotrino/remote-agent": "0.6.1",
|
|
48
48
|
"@types/node": "^22.0.0",
|
|
49
49
|
"typescript": "5.9.3"
|
|
50
|
+
},
|
|
51
|
+
"optionalDependencies": {
|
|
52
|
+
"@dotrino/webrtc": "0.1.0"
|
|
50
53
|
}
|
|
51
54
|
}
|
package/src/approvals.js
CHANGED
|
@@ -21,10 +21,15 @@ export function createApprovals ({ now = Date.now, pendingTtlMs = PENDING_TTL_MS
|
|
|
21
21
|
|
|
22
22
|
return {
|
|
23
23
|
/** Apunta un pedido. Uno por cajón y aparato: pedir otra vez reemplaza al anterior. */
|
|
24
|
-
|
|
24
|
+
/**
|
|
25
|
+
* `from` es POR DÓNDE ENTRÓ la pregunta. La respuesta llega más tarde —cuando alguien
|
|
26
|
+
* apruebe— y tiene que volver por el mismo sitio: desde que la bóveda atiende también
|
|
27
|
+
* por un socket local, mandarla siempre por el proxio la dejaba en el vacío.
|
|
28
|
+
*/
|
|
29
|
+
request ({ ns, device, deviceId, label = '', ek, from = null }) {
|
|
25
30
|
for (const [id, p] of pending) if (p.ns === ns && p.device === device) pending.delete(id)
|
|
26
31
|
const ts = now()
|
|
27
|
-
const p = { id: rnd(), ns, device, deviceId, label, ek, ts, exp: ts + pendingTtlMs }
|
|
32
|
+
const p = { id: rnd(), ns, device, deviceId, label, ek, ts, exp: ts + pendingTtlMs, from }
|
|
28
33
|
pending.set(p.id, p)
|
|
29
34
|
return publicOf(p)
|
|
30
35
|
},
|
package/src/client.js
CHANGED
|
@@ -29,7 +29,10 @@ import { MSG } from './protocol.js'
|
|
|
29
29
|
async function freshClient ({ proxyUrl, dir = '.dotrino-vault-device' }) {
|
|
30
30
|
installNodeGlobals(dir)
|
|
31
31
|
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
32
|
-
|
|
32
|
+
// WEBRTC SOLO DONDE EXISTE (ver `lib/src/service.js`): en Node no hay
|
|
33
|
+
// `RTCPeerConnection` y encenderlo reventaría al negociar; en un navegador es nativo y es
|
|
34
|
+
// el camino directo que hay que preferir. Se mira, en vez de apagarlo para siempre.
|
|
35
|
+
const client = new WebSocketProxyClient({ url: proxyUrl, enableWebRTC: typeof globalThis.RTCPeerConnection === 'function', autoReconnect: false })
|
|
33
36
|
await client.connect()
|
|
34
37
|
return client
|
|
35
38
|
}
|