@dotrino/vaultd 0.98.0 → 0.105.2
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/index.js +4 -1
- package/lib/src/localdesk.js +96 -0
- package/lib/src/service.js +95 -18
- package/package.json +7 -3
- package/src/approvals.js +7 -2
- package/src/client.js +4 -1
- package/src/ctl.js +19 -2
- package/src/daemon.js +4 -0
- package/src/replica.js +34 -1
- package/src/secretsStore.js +136 -0
- package/src/threadStore.js +10 -0
- package/src/transport.js +27 -4
- package/src/tui/app.js +42 -2
- package/src/tui/i18n.js +52 -35
- package/src/vault.js +211 -14
- 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/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
|
@@ -30,6 +30,7 @@ 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
|
|
@@ -251,6 +275,63 @@ function writeServiceIdentity (dir, obj) {
|
|
|
251
275
|
* puede convertirse en leer ni por descuido ni por un cambio futuro. Es el mismo cerrojo
|
|
252
276
|
* que tiene la llave de comunicación.
|
|
253
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
|
+
* El hello de esa invitación, sin enrolar nada: devuelve el `qr` ENRIQUECIDO con
|
|
306
|
+
* `iss`, `proxy` y `acct`.
|
|
307
|
+
*
|
|
308
|
+
* Devuelve el qr y no solo el nombre PORQUE LA CITA SE QUEMA AL CANJEARLA. Quien
|
|
309
|
+
* pregunta el nombre de la cuenta y después enrola estaría canjeando dos veces la
|
|
310
|
+
* misma cita, y el segundo canje falla con «código no válido o ya usado» — un error
|
|
311
|
+
* que señala a la invitación cuando el problema es haberla gastado uno mismo.
|
|
312
|
+
* Con el qr enriquecido, `sayHello` corta por su `if (qr.iss) return qr` y el
|
|
313
|
+
* enrolamiento no vuelve a salir a preguntar.
|
|
314
|
+
*/
|
|
315
|
+
export async function vaultHello (qr) {
|
|
316
|
+
if (typeof qr === 'string') qr = parseInvite(qr)
|
|
317
|
+
if (!qr?.sn) throw new Error('that does not look like a vault invitation')
|
|
318
|
+
if (qr.iss) return qr
|
|
319
|
+
const client = await freshClient(qr.proxy || 'wss://proxy.dotrino.com')
|
|
320
|
+
try { return await sayHello(client, qr) } finally { try { client.close() } catch (_) {} }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Cómo se llama la cuenta de esa invitación, sin enrolar nada.
|
|
325
|
+
*
|
|
326
|
+
* OJO: gasta la cita. Si además vas a enrolar, usá `vaultHello` y pasale al enroll el
|
|
327
|
+
* qr que devuelve; si no, el enrolamiento se encuentra la cita quemada.
|
|
328
|
+
*/
|
|
329
|
+
export async function vaultAccountName (qr) {
|
|
330
|
+
if (typeof qr === 'string') qr = parseInvite(qr)
|
|
331
|
+
if (qr?.acct) return qr.acct
|
|
332
|
+
return (await vaultHello(qr)).acct || ''
|
|
333
|
+
}
|
|
334
|
+
|
|
254
335
|
export async function enrollWithVault ({ qr, label = 'agent', expectedScope = null, onCode, approveTimeoutMs = 180000, withEncKey = true } = {}) {
|
|
255
336
|
// `parseInvite` y NO `JSON.parse`: el vault no imprime JSON desde hace rato.
|
|
256
337
|
// `dotrino-vault pair` emite la URL del QR y el código compacto (`c…`/`t…`, ver
|
|
@@ -264,20 +345,7 @@ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = nu
|
|
|
264
345
|
rejectAdoption(qr.m)
|
|
265
346
|
|
|
266
347
|
const client = await freshClient(qr.proxy || 'wss://proxy.dotrino.com')
|
|
267
|
-
|
|
268
|
-
if (!qr.iss) {
|
|
269
|
-
const target = await resolveAppointment(client, qr.conn)
|
|
270
|
-
const hello = await new Promise((resolve, reject) => {
|
|
271
|
-
const off = client.on('message', (_f, p) => {
|
|
272
|
-
if (p?.type === MSG.HELLO_OK) { finish(); verifyHello(p, qr.sn).then(resolve, reject) }
|
|
273
|
-
else if (p?.type === MSG.ERROR) { finish(); reject(new Error(p.error)) }
|
|
274
|
-
})
|
|
275
|
-
const t = setTimeout(() => { finish(); reject(new Error('the vault did not answer: that code may have expired')) }, 15000)
|
|
276
|
-
const finish = () => { off(); clearTimeout(t) }
|
|
277
|
-
try { client.send(target, { type: MSG.HELLO, sn: qr.sn }) } catch (e) { finish(); reject(e) }
|
|
278
|
-
})
|
|
279
|
-
qr = { ...qr, iss: hello.iss, proxy: hello.proxy || qr.proxy }
|
|
280
|
-
}
|
|
348
|
+
qr = await sayHello(client, qr)
|
|
281
349
|
try {
|
|
282
350
|
const device = await makeDeviceKey({ label })
|
|
283
351
|
// La llave de CIFRADO: es a la que la bóveda sella cada variable. Sin ella el
|
|
@@ -413,7 +481,7 @@ export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device,
|
|
|
413
481
|
cert = cert || saved?.cert
|
|
414
482
|
if (!proxyUrl || !masterPubkey || !device || !cert) throw new Error('service not enrolled')
|
|
415
483
|
|
|
416
|
-
const client = await freshClient(proxyUrl)
|
|
484
|
+
const client = await freshClient(proxyUrl, 20000, masterPubkey)
|
|
417
485
|
try {
|
|
418
486
|
await identifyAsService(client, device)
|
|
419
487
|
const data = { op: 'enckey', ns, encPub, publickey: device.publickey, ts: Date.now() }
|
|
@@ -449,7 +517,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
449
517
|
throw new Error('service not enrolled: run enrollService() first (service-identity.json missing)')
|
|
450
518
|
}
|
|
451
519
|
|
|
452
|
-
const client = await freshClient(proxyUrl)
|
|
520
|
+
const client = await freshClient(proxyUrl, 20000, masterPubkey)
|
|
453
521
|
try {
|
|
454
522
|
await identifyAsService(client, device)
|
|
455
523
|
|
|
@@ -1062,6 +1130,15 @@ export async function watchSecretsChanges ({
|
|
|
1062
1130
|
const connect = async () => {
|
|
1063
1131
|
if (stopped) return
|
|
1064
1132
|
try {
|
|
1133
|
+
// POR EL PROXIO, A PROPÓSITO, aunque la bóveda esté en esta misma máquina.
|
|
1134
|
+
//
|
|
1135
|
+
// El mostrador local es pregunta y respuesta. Los avisos que la bóveda manda POR SU
|
|
1136
|
+
// CUENTA —«tu configuración cambió», una revocación— salen por el proxio, así que un
|
|
1137
|
+
// oyente conectado solo en local no se enteraría de nada y se quedaría corriendo con
|
|
1138
|
+
// la configuración vieja para siempre. Lo cazaron los E2E de avisos.
|
|
1139
|
+
//
|
|
1140
|
+
// El atajo local es para el ARRANQUE, que es donde se nota la latencia: pedir las
|
|
1141
|
+
// claves una vez. Escuchar es otra cosa y necesita que la bóveda pueda alcanzarte.
|
|
1065
1142
|
client = await freshClient(saved.proxy)
|
|
1066
1143
|
await identifyAsService(client, saved.device)
|
|
1067
1144
|
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.2",
|
|
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": {
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"pair": "node bin/dotrino-vaultd.js --pair",
|
|
15
15
|
"tui": "node bin/dotrino-vault-tui.js",
|
|
16
16
|
"test": "node --test test/*.test.mjs",
|
|
17
|
-
"type-check": "tsc --noEmit"
|
|
17
|
+
"type-check": "tsc --noEmit",
|
|
18
|
+
"release:env": "node packaging/release-env.mjs"
|
|
18
19
|
},
|
|
19
20
|
"engines": {
|
|
20
21
|
"node": ">=20"
|
|
@@ -22,7 +23,7 @@
|
|
|
22
23
|
"dependencies": {
|
|
23
24
|
"@dotrino/identity": "0.79.0",
|
|
24
25
|
"@dotrino/passmanager": "^0.10.1",
|
|
25
|
-
"@dotrino/proxy-client": "
|
|
26
|
+
"@dotrino/proxy-client": "0.15.0",
|
|
26
27
|
"ws": "^8.18.0"
|
|
27
28
|
},
|
|
28
29
|
"license": "MIT",
|
|
@@ -47,5 +48,8 @@
|
|
|
47
48
|
"@dotrino/remote-agent": "0.6.1",
|
|
48
49
|
"@types/node": "^22.0.0",
|
|
49
50
|
"typescript": "5.9.3"
|
|
51
|
+
},
|
|
52
|
+
"optionalDependencies": {
|
|
53
|
+
"@dotrino/webrtc": "0.1.0"
|
|
50
54
|
}
|
|
51
55
|
}
|
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
|
}
|
package/src/ctl.js
CHANGED
|
@@ -208,6 +208,17 @@ async function cmdPair (args = []) {
|
|
|
208
208
|
// Sin esto el nombre lo ponía el propio aparato, que por defecto usa el apodo del
|
|
209
209
|
// PERFIL: acababas con varios dispositivos llamados igual que tú, y para distinguirlos
|
|
210
210
|
// había que renombrarlos después —cuando ya no sabías cuál era cuál—.
|
|
211
|
+
// UNA BANDERA QUE NO EXISTE SE DICE, no se ignora. Escribir `--label` en vez de `--name`
|
|
212
|
+
// emparejaba igual y en silencio, y el aparato entraba llamándose «cli»; el error se
|
|
213
|
+
// veía media hora después mirando el acta, si es que se miraba. Una opción que tecleas
|
|
214
|
+
// y no hace nada es un fallo mudo, que es justo lo que aquí no se acepta.
|
|
215
|
+
const CONOCIDAS = new Set(['--admin', '--adopt', '--kms', '--name', '--new-account', '--quiet', '--scope', '--service', '--save', '--profile'])
|
|
216
|
+
const rara = args.find((a) => a.startsWith('--') && !CONOCIDAS.has(a))
|
|
217
|
+
if (rara) {
|
|
218
|
+
console.error('no existe la opción %s', rara)
|
|
219
|
+
console.error('las de `pair` son: %s', [...CONOCIDAS].join(' '))
|
|
220
|
+
process.exit(2)
|
|
221
|
+
}
|
|
211
222
|
const nIdx = args.indexOf('--name')
|
|
212
223
|
let label = null
|
|
213
224
|
if (nIdx >= 0) {
|
|
@@ -552,7 +563,10 @@ async function cmdMembers () {
|
|
|
552
563
|
// Resaltado como los otros cuatro: dice si esa máquina se lleva tus claves privadas
|
|
553
564
|
// sola o si te lo pregunta. Sin traducción salía el identificador en crudo
|
|
554
565
|
// (`unattended`) en una lista que lee un humano.
|
|
555
|
-
unattended: `${B}recibe claves sin aprobación${Z}
|
|
566
|
+
unattended: `${B}recibe claves sin aprobación${Z}`,
|
|
567
|
+
// Y el que reparte cuando esta bóveda no está. Se resalta como los demás que cambian
|
|
568
|
+
// lo que ese aparato puede hacerle a la cuenta.
|
|
569
|
+
replica: `${B}entrega tus claves con la bóveda apagada${Z}`
|
|
556
570
|
}
|
|
557
571
|
// El nombre del perfil es una pubkey JWK. Recortarla no la hace legible: la deja
|
|
558
572
|
// pareciendo un error (`{"key_ops":["verify"],"e…`). Se muestra su huella corta, la
|
|
@@ -582,7 +596,7 @@ async function cmdMembers () {
|
|
|
582
596
|
// se mira quién es quién — y en la lista de variables ya seria tarde.
|
|
583
597
|
if (m.cn && !m.canSeal) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
|
|
584
598
|
}
|
|
585
|
-
console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba | +contrasenas | +sella | +desatendido')
|
|
599
|
+
console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba | +contrasenas | +sella | +desatendido | +replica')
|
|
586
600
|
console.log(' «Permiso»: ese aparato solo recibe claves privadas cuando lo apruebas desde un aparato con «aprueba» (en cada arranque).')
|
|
587
601
|
console.log(' «Administra» deja conectar y quitar dispositivos desde ese aparato, sin venir aquí.')
|
|
588
602
|
console.log(' No deja cambiar permisos ni traspasar el mando: eso solo se hace en esta máquina.')
|
|
@@ -648,6 +662,9 @@ async function cmdCaps (args = []) {
|
|
|
648
662
|
// podrá admitir aparatos y cambiar permisos si esta se pierde. No es un traspaso:
|
|
649
663
|
// quien manda sigue mandando. Como `administra`, no se empareja: se concede aquí.
|
|
650
664
|
sella: 'sealer', sealer: 'sealer',
|
|
665
|
+
// `replica`: reparte los sobres que esta bóveda ya firmó, cuando ella no está. No
|
|
666
|
+
// tiene maestra y no puede abrir nada. A diferencia de `sella`, SÍ se empareja.
|
|
667
|
+
replica: 'replica', replicador: 'replica',
|
|
651
668
|
// `contraseñas`: el gestor (la extensión, la app del teléfono) puede PEDIR
|
|
652
669
|
// credenciales de a una. Se acepta con y sin tilde: nadie escribe la ñ en una CLI.
|
|
653
670
|
contrasenas: 'passwords', 'contraseñas': 'passwords',
|
package/src/daemon.js
CHANGED
|
@@ -567,6 +567,10 @@ export async function runDaemon () {
|
|
|
567
567
|
console.log('[vault] %d secret(s) applied in one go: %s', changed.length, sec.pub ? 'device' : sec.ns)
|
|
568
568
|
} else if (sec.op === 'set') { await vault.setSecret(sec.ns, sec.key, sec.value, sec.public); console.log('[vault] secret saved: %s/%s', sec.ns, sec.key) }
|
|
569
569
|
else if (sec.op === 'rm') { await vault.deleteSecret(sec.ns, sec.key); console.log('[vault] secret deleted: %s/%s', sec.ns, sec.key) }
|
|
570
|
+
// Renombrar NO pide la frase: solo hay que volver a firmar el sobre —el nombre va
|
|
571
|
+
// dentro de lo firmado— y eso lo hace la llave de sellado con la bóveda cerrada.
|
|
572
|
+
else if (sec.op === 'rename') { await vault.renameSecret(sec.ns, sec.key, sec.to); console.log('[vault] secret renamed: %s/%s → %s', sec.ns, sec.key, sec.to) }
|
|
573
|
+
else if (sec.op === 'dev-rename') { await vault.renameDeviceSecret(sec.pub, sec.key, sec.to); console.log('[vault] device secret renamed: %s → %s', sec.key, sec.to) }
|
|
570
574
|
else if (sec.op === 'dev-set') { await vault.setDeviceSecret(sec.pub, sec.key, sec.value, sec.public); console.log('[vault] device secret saved: %s', sec.key) }
|
|
571
575
|
else if (sec.op === 'dev-rm') { await vault.deleteDeviceSecret(sec.pub, sec.key); console.log('[vault] device secret deleted: %s', sec.key) }
|
|
572
576
|
// Saldar lo que quedó a deber: heredarle a un aparato nuevo lo ya guardado y
|
package/src/replica.js
CHANGED
|
@@ -39,6 +39,37 @@ export const replicaDir = () =>
|
|
|
39
39
|
const LINK = 'link.json'
|
|
40
40
|
const STATE = 'state.json'
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* MEZCLAR DOS VERSIONES DEL MISMO PAQUETE, DATO A DATO.
|
|
44
|
+
*
|
|
45
|
+
* Antes se reemplazaba el paquete entero, y eso pierde datos en un caso concreto: una
|
|
46
|
+
* bóveda restaurada de un respaldo empuja un paquete donde un dato está más atrasado que
|
|
47
|
+
* el que ya tenemos. El `seq` del acta no lo caza —el acta puede ir por delante mientras
|
|
48
|
+
* un dato va por detrás—, así que la comparación tiene que ser POR DATO.
|
|
49
|
+
*
|
|
50
|
+
* Gana la GENERACIÓN más alta, que es la que sube en cada escritura. Un empate se rompe
|
|
51
|
+
* igual en todas partes —gana el `ct` menor— y no es justicia, es determinismo: dos
|
|
52
|
+
* replicadores que reciban lo mismo en distinto orden acaban idénticos.
|
|
53
|
+
*
|
|
54
|
+
* Las envolturas se SUMAN: son de aparatos distintos y ninguna estorba a otra.
|
|
55
|
+
*/
|
|
56
|
+
export function mergeBundle (viejo, nuevo) {
|
|
57
|
+
if (!viejo) return nuevo
|
|
58
|
+
if (!nuevo) return viejo
|
|
59
|
+
const entries = { ...(viejo.entries || {}) }
|
|
60
|
+
for (const [k, e] of Object.entries(nuevo.entries || {})) {
|
|
61
|
+
const a = entries[k]
|
|
62
|
+
if (!a) { entries[k] = e; continue }
|
|
63
|
+
const ga = a.gen || 0
|
|
64
|
+
const gb = e.gen || 0
|
|
65
|
+
if (gb > ga) entries[k] = e
|
|
66
|
+
else if (gb === ga && String(e.e?.ct ?? e.pubv ?? '') < String(a.e?.ct ?? a.pubv ?? '')) entries[k] = e
|
|
67
|
+
}
|
|
68
|
+
const porGen = new Map()
|
|
69
|
+
for (const w of [...(viejo.wraps || []), ...(nuevo.wraps || [])]) porGen.set(w.gen, w)
|
|
70
|
+
return { ...viejo, ...nuevo, entries, wraps: [...porGen.values()].sort((x, y) => x.gen - y.gen) }
|
|
71
|
+
}
|
|
72
|
+
|
|
42
73
|
/** La clave de un sobre: es POR (cajón, aparato), porque va tallado a quien lo pide. */
|
|
43
74
|
export const bundleKey = (ns, devicePub) => `${ns} ${devicePub}`
|
|
44
75
|
|
|
@@ -67,7 +98,9 @@ export function openReplicaStore (dir) {
|
|
|
67
98
|
*/
|
|
68
99
|
apply ({ seq, acta, bundles }) {
|
|
69
100
|
if (typeof seq !== 'number' || seq < state.seq) return false
|
|
70
|
-
|
|
101
|
+
const mezclados = { ...state.bundles }
|
|
102
|
+
for (const [k, nuevo] of Object.entries(bundles || {})) mezclados[k] = mergeBundle(mezclados[k], nuevo)
|
|
103
|
+
state = { seq, acta: acta || state.acta, bundles: mezclados }
|
|
71
104
|
escribir(STATE, state)
|
|
72
105
|
return true
|
|
73
106
|
}
|