@dotrino/vaultd 0.98.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/index.js +4 -1
- package/lib/src/localdesk.js +96 -0
- package/lib/src/service.js +75 -18
- package/package.json +5 -2
- 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,43 @@ 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
|
+
* 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
|
+
|
|
254
315
|
export async function enrollWithVault ({ qr, label = 'agent', expectedScope = null, onCode, approveTimeoutMs = 180000, withEncKey = true } = {}) {
|
|
255
316
|
// `parseInvite` y NO `JSON.parse`: el vault no imprime JSON desde hace rato.
|
|
256
317
|
// `dotrino-vault pair` emite la URL del QR y el código compacto (`c…`/`t…`, ver
|
|
@@ -264,20 +325,7 @@ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = nu
|
|
|
264
325
|
rejectAdoption(qr.m)
|
|
265
326
|
|
|
266
327
|
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
|
-
}
|
|
328
|
+
qr = await sayHello(client, qr)
|
|
281
329
|
try {
|
|
282
330
|
const device = await makeDeviceKey({ label })
|
|
283
331
|
// La llave de CIFRADO: es a la que la bóveda sella cada variable. Sin ella el
|
|
@@ -413,7 +461,7 @@ export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device,
|
|
|
413
461
|
cert = cert || saved?.cert
|
|
414
462
|
if (!proxyUrl || !masterPubkey || !device || !cert) throw new Error('service not enrolled')
|
|
415
463
|
|
|
416
|
-
const client = await freshClient(proxyUrl)
|
|
464
|
+
const client = await freshClient(proxyUrl, 20000, masterPubkey)
|
|
417
465
|
try {
|
|
418
466
|
await identifyAsService(client, device)
|
|
419
467
|
const data = { op: 'enckey', ns, encPub, publickey: device.publickey, ts: Date.now() }
|
|
@@ -449,7 +497,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
449
497
|
throw new Error('service not enrolled: run enrollService() first (service-identity.json missing)')
|
|
450
498
|
}
|
|
451
499
|
|
|
452
|
-
const client = await freshClient(proxyUrl)
|
|
500
|
+
const client = await freshClient(proxyUrl, 20000, masterPubkey)
|
|
453
501
|
try {
|
|
454
502
|
await identifyAsService(client, device)
|
|
455
503
|
|
|
@@ -1062,6 +1110,15 @@ export async function watchSecretsChanges ({
|
|
|
1062
1110
|
const connect = async () => {
|
|
1063
1111
|
if (stopped) return
|
|
1064
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.
|
|
1065
1122
|
client = await freshClient(saved.proxy)
|
|
1066
1123
|
await identifyAsService(client, saved.device)
|
|
1067
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": {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"dependencies": {
|
|
23
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
|
}
|
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
|
}
|
package/src/secretsStore.js
CHANGED
|
@@ -72,6 +72,32 @@ const KEY_RE = /^[A-Z0-9_]{1,64}$/
|
|
|
72
72
|
*/
|
|
73
73
|
export const RECOVERY = '#recovery'
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* EL CAJÓN DEL PERFIL. Los datos de tu identidad se guardan como cualquier variable —
|
|
77
|
+
* mismo sobre, mismo llavero, mismo histórico— porque son lo mismo: un valor que solo
|
|
78
|
+
* pueden leer tus aparatos. Diseño: `docs/datos-del-perfil.md`.
|
|
79
|
+
*
|
|
80
|
+
* SE LLAMA `@me` A PROPÓSITO, y es la garantía entera: un nombre de cajón válido es
|
|
81
|
+
* `[a-z0-9-]{1,32}`, así que **ningún servicio puede llamarse `@me`**. Por lo tanto
|
|
82
|
+
* `memberCanReadSecrets(acta, quien, '@me')` no puede ser cierto nunca y no hay forma de
|
|
83
|
+
* pedir el perfil por el camino de los secretos. No es una comprobación que alguien pueda
|
|
84
|
+
* olvidarse de hacer: es un nombre que el validador no deja existir.
|
|
85
|
+
*/
|
|
86
|
+
export const PROFILE_NS = '@me'
|
|
87
|
+
export const PROFILE_OWNER = 'ns:' + PROFILE_NS
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* El nombre de un dato del perfil. NO vale `assertVar`: aquélla exige MAYÚSCULAS porque
|
|
91
|
+
* describe una variable de entorno, y aquí los campos son `nickname`, `telefono`,
|
|
92
|
+
* `apellidos`. Mismo espíritu —un nombre corto y previsible, sin sorpresas al serializar—
|
|
93
|
+
* y distinto alfabeto.
|
|
94
|
+
*/
|
|
95
|
+
export function assertProfileKey (key) {
|
|
96
|
+
if (typeof key !== 'string' || !/^[a-z][a-z0-9_]{0,31}$/.test(key)) {
|
|
97
|
+
throw new Error('invalid profile field (use lowercase_with_underscores, e.g. "nickname")')
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
75
101
|
/** Cuántas versiones anteriores se conservan, en total. Ver «EL HISTÓRICO» arriba. */
|
|
76
102
|
const MAX_HISTORY = 500
|
|
77
103
|
|
|
@@ -818,6 +844,75 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
818
844
|
owners () {
|
|
819
845
|
return [...Object.keys(data.ns).map((k) => `ns:${k}`), ...Object.keys(data.dev).map((k) => `dev:${k}`)]
|
|
820
846
|
},
|
|
847
|
+
// ---------------- EL PERFIL (`docs/datos-del-perfil.md`) ----------------
|
|
848
|
+
//
|
|
849
|
+
// Escribir un dato PRIVADO no necesita nada de aquí: es `putSealed(PROFILE_OWNER, …)`,
|
|
850
|
+
// que ya guarda un sobre hecho sin abrirlo y funciona con la bóveda cerrada. Lo que
|
|
851
|
+
// falta es leerlo, y el camino de lo PÚBLICO, que no se parece a ninguna variable.
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Los sobres del perfil TALLADOS a un aparato: sus entradas y las envolturas suyas.
|
|
855
|
+
* Igual que `bundleFor`, sin `assertNs` — `@me` no es un nombre de cajón válido, y esa
|
|
856
|
+
* es justamente su garantía.
|
|
857
|
+
*
|
|
858
|
+
* El PAQUETE LO ARMA EL APARATO (dueño, 2026-09-03): aquí solo se entrega lo que hay.
|
|
859
|
+
*/
|
|
860
|
+
profileBundleFor (devicePub) {
|
|
861
|
+
const bag = data.ns
|
|
862
|
+
const k = PROFILE_NS
|
|
863
|
+
if (isLegacy() || !bag[k]) return { entries: {}, wraps: [] }
|
|
864
|
+
const wraps = (bag[k].keyring || [])
|
|
865
|
+
.filter((g) => g.wraps?.[devicePub])
|
|
866
|
+
.map((g) => ({ gen: g.gen, wrap: g.wraps[devicePub] }))
|
|
867
|
+
return { entries: { ...varsOf(bag, k) }, wraps }
|
|
868
|
+
},
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* LO PÚBLICO DE VERDAD: en claro, y firmado. Es lo que recibe quien pregunta desde
|
|
872
|
+
* fuera y no tiene ninguna llave tuya — no hay a quién sellárselo (dueño, 2026-09-03).
|
|
873
|
+
*
|
|
874
|
+
* Va firmado igual que lo demás: un dato público sin firma es un dato que cualquiera
|
|
875
|
+
* puede inventar en tu nombre, que es peor que no tenerlo.
|
|
876
|
+
*/
|
|
877
|
+
profilePublic () {
|
|
878
|
+
const bag = data.ns[PROFILE_NS]
|
|
879
|
+
if (!bag?.vars) return {}
|
|
880
|
+
const out = {}
|
|
881
|
+
for (const [key, e] of Object.entries(bag.vars)) {
|
|
882
|
+
if (e?.cls !== 'public' || typeof e.pubv !== 'string') continue
|
|
883
|
+
out[key] = { v: e.pubv, gen: e.gen, seal: e.seal || null, at: e.at || null, by: e.by || null }
|
|
884
|
+
}
|
|
885
|
+
return out
|
|
886
|
+
},
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Guarda un dato PÚBLICO del perfil: en claro y con su firma. No lleva sobre ni
|
|
890
|
+
* envoltura porque no se le sella a nadie.
|
|
891
|
+
*
|
|
892
|
+
* `pubv` y no `v`: un `v` suelto dentro de un paquete de variables es un error duro
|
|
893
|
+
* desde el 2026-09-02 (`plaintext-var`), y con razón. Aquí es lo correcto, así que se
|
|
894
|
+
* escribe con otro nombre — el paquete del perfil lo arma otro código y esa invariante
|
|
895
|
+
* de los secretos se queda intacta.
|
|
896
|
+
*/
|
|
897
|
+
async profilePutPublic (key, value, { by = null } = {}) {
|
|
898
|
+
return enFila(async () => {
|
|
899
|
+
if (isLegacy()) throw new Error('profile: this store is still v3 (run `migrate` first)')
|
|
900
|
+
if (needsMigration()) throw new NeedsMigration()
|
|
901
|
+
assertProfileKey(key)
|
|
902
|
+
if (typeof value !== 'string' || value.length > 200000) {
|
|
903
|
+
throw new Error('a public profile field must be a string of at most 200 kB')
|
|
904
|
+
}
|
|
905
|
+
const vars = ensureBag(data.ns, PROFILE_NS)
|
|
906
|
+
const before = vars[key]
|
|
907
|
+
const gen = (topGen(data.ns, PROFILE_NS)?.gen || 0) + 1
|
|
908
|
+
const seal = signer ? await signer({ owner: PROFILE_OWNER, key, gen, iv: '', ct: value }) : null
|
|
909
|
+
pushHistory(PROFILE_OWNER, key, before, by)
|
|
910
|
+
vars[key] = { cls: 'public', pub: true, owner: PROFILE_OWNER, gen, pubv: value, seal, at: Date.now(), by: by || null }
|
|
911
|
+
save()
|
|
912
|
+
return { gen }
|
|
913
|
+
})
|
|
914
|
+
},
|
|
915
|
+
|
|
821
916
|
/** @private */
|
|
822
917
|
/**
|
|
823
918
|
* @param {Array|((isPublic:boolean)=>Promise<Array>)} members A quién envolver. Puede ser
|
|
@@ -895,6 +990,47 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
895
990
|
return { wrapped, sealed, sinLlave: [...sinLlave] }
|
|
896
991
|
},
|
|
897
992
|
|
|
993
|
+
/**
|
|
994
|
+
* RENOMBRAR UNA VARIABLE. Cambia el nombre y NADA MÁS: el mismo sobre, la misma
|
|
995
|
+
* generación, las mismas envolturas. El valor no se toca ni se mira.
|
|
996
|
+
*
|
|
997
|
+
* Lo único que hay que rehacer es la FIRMA, porque el nombre va dentro de lo firmado
|
|
998
|
+
* (§8.8: `{owner, key, gen, iv, ct}`). Sin eso el sobre quedaría diciendo que se llama
|
|
999
|
+
* de otra manera y el agente lo rechazaría al abrirlo — un fallo que aparecería lejos
|
|
1000
|
+
* de aquí y sin pista.
|
|
1001
|
+
*
|
|
1002
|
+
* Y firmar de nuevo NO pide la frase: la llave de sellado existe justo para esto, no
|
|
1003
|
+
* abre nada. Así que renombrar funciona con la bóveda cerrada, como escribir.
|
|
1004
|
+
*
|
|
1005
|
+
* El HISTÓRICO se muda con ella: es la misma variable, y dejarlo bajo el nombre viejo
|
|
1006
|
+
* lo volvería inalcanzable — se vería como un hueco, no como una decisión.
|
|
1007
|
+
*/
|
|
1008
|
+
async rename (owner, from, to) { return enFila(() => this._renameRaw(owner, from, to)) },
|
|
1009
|
+
/** @private */
|
|
1010
|
+
async _renameRaw (owner, from, to) {
|
|
1011
|
+
if (needsMigration()) throw new NeedsMigration()
|
|
1012
|
+
assertKeyValue(to, 'x')
|
|
1013
|
+
const [kind, k] = splitOwner(owner)
|
|
1014
|
+
const bag = kind === 'ns' ? data.ns : data.dev
|
|
1015
|
+
const vars = varsOf(bag, k)
|
|
1016
|
+
const e = vars[from]
|
|
1017
|
+
if (!e) throw new Error(`there is no variable called "${from}" in ${owner}`)
|
|
1018
|
+
if (from === to) return { renamed: false }
|
|
1019
|
+
if (vars[to]) throw new Error(`"${to}" already exists in ${owner}: pick another name or remove it first`)
|
|
1020
|
+
|
|
1021
|
+
// La firma lleva el nombre dentro, así que se rehace. Si esta bóveda no puede firmar,
|
|
1022
|
+
// se para: dejar el sobre con la firma vieja es dejarlo roto para quien lo abra.
|
|
1023
|
+
if (e.e) {
|
|
1024
|
+
if (!signer) throw new Error('this vault cannot sign right now: renaming would leave the envelope signed with the old name')
|
|
1025
|
+
e.seal = await signer({ owner, key: to, gen: e.gen, iv: e.e.iv, ct: e.e.ct })
|
|
1026
|
+
}
|
|
1027
|
+
delete vars[from]
|
|
1028
|
+
vars[to] = e
|
|
1029
|
+
for (const h of data.history) if (h.owner === owner && h.key === from) h.key = to
|
|
1030
|
+
save()
|
|
1031
|
+
return { renamed: true }
|
|
1032
|
+
},
|
|
1033
|
+
|
|
898
1034
|
/**
|
|
899
1035
|
* ROTA de verdad: vuelve a cifrar las variables privadas del cajón con llaves nuevas
|
|
900
1036
|
* y solo para los miembros dados. Es lo que corta el acceso de quien salió —
|
package/src/threadStore.js
CHANGED
|
@@ -103,6 +103,16 @@ export function openThreadStore (dir) {
|
|
|
103
103
|
},
|
|
104
104
|
profileGet () { return { me: data.profile || null } },
|
|
105
105
|
|
|
106
|
+
// LOS DATOS DEL PERFIL EN SOBRES (`docs/datos-del-perfil.md`). Están aquí solo para
|
|
107
|
+
// que el filtro de métodos los reconozca: los atiende `handleProfile` en `vault.js`,
|
|
108
|
+
// antes de llegar a este store, porque su puerta es otra —`firma` y sin candado—.
|
|
109
|
+
// Si alguna vez alguien los llamara por este camino, contestan que no en vez de
|
|
110
|
+
// hacer algo a medias.
|
|
111
|
+
profilePut () { throw new Error('profilePut is handled before the store: see handleProfile') },
|
|
112
|
+
profileBundle () { throw new Error('profileBundle is handled before the store: see handleProfile') },
|
|
113
|
+
profilePublic () { throw new Error('profilePublic is handled before the store: see handleProfile') },
|
|
114
|
+
profileRecipients () { throw new Error('profileRecipients is handled before the store: see handleProfile') },
|
|
115
|
+
|
|
106
116
|
// ----- DATOS SENSIBLES del usuario (F4, docs/consola-remota.md §6) -----
|
|
107
117
|
//
|
|
108
118
|
// Contraseñas, notas, documentos: van al contenido del perfil, cifrados con la
|
package/src/transport.js
CHANGED
|
@@ -46,11 +46,33 @@ export async function createTransport ({ identity, dir, url = DEFAULT_PROXY, com
|
|
|
46
46
|
// su `identify` pisaba al anterior. Sigue siendo el cliente oficial del paquete.
|
|
47
47
|
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
/**
|
|
50
|
+
* EL CANAL DIRECTO, ENCENDIDO Y CON LA PUERTA CERRADA.
|
|
51
|
+
*
|
|
52
|
+
* Encendido porque es el escalón 2 de la regla del transporte (`CLAUDE.md`) y en Node ya
|
|
53
|
+
* hay con qué (`werift`, que el pilar carga solo). Si no lo hubiera, negociar falla y se
|
|
54
|
+
* sigue por el proxio — el escalón 4 siempre está.
|
|
55
|
+
*
|
|
56
|
+
* Y con la puerta cerrada desde el primer día, que es lo que hace defendible encenderlo:
|
|
57
|
+
* **solo se le negocia a quien está en el acta.** Aceptar una señal arranca DTLS, ICE y
|
|
58
|
+
* SCTP, o sea código que parsea red no confiable, dentro del proceso que guarda la
|
|
59
|
+
* maestra. Quien decide si eso corre no puede ser el que llama a la puerta.
|
|
60
|
+
*
|
|
61
|
+
* Reconexión prácticamente ilimitada: un daemon de larga duración no debe rendirse tras
|
|
62
|
+
* unos intentos.
|
|
63
|
+
*/
|
|
64
|
+
// La puerta tiene que decidir AL INSTANTE (la señal llega y o se atiende o no), y pedir
|
|
65
|
+
// el acta es asíncrono. Así que se guarda una copia y se refresca donde ya se refresca
|
|
66
|
+
// todo: en `identify`, que es lo que corre cada vez que el acta cambia o se reconecta.
|
|
67
|
+
let actaVista = null
|
|
68
|
+
const enElActa = (token) => {
|
|
69
|
+
// SIN ACTA NO SE NEGOCIA. No se sabe quién es nadie, y en la duda no se arranca a
|
|
70
|
+
// parsear red no confiable. Se sigue por el proxio, que es el escalón que siempre está.
|
|
71
|
+
if (!actaVista) return false
|
|
72
|
+
return (actaVista.members || []).some((m) => m?.pub === token)
|
|
73
|
+
}
|
|
52
74
|
const client = new WebSocketProxyClient({
|
|
53
|
-
url, enableWebRTC:
|
|
75
|
+
url, enableWebRTC: true, acceptDirectFrom: enElActa, autoReconnect: true,
|
|
54
76
|
maxReconnectAttempts: 100000, reconnectDelay: 4000
|
|
55
77
|
})
|
|
56
78
|
|
|
@@ -59,6 +81,7 @@ export async function createTransport ({ identity, dir, url = DEFAULT_PROXY, com
|
|
|
59
81
|
const identify = async () => {
|
|
60
82
|
if (!client.token) return
|
|
61
83
|
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
84
|
+
actaVista = record // la copia que mira la puerta del canal directo
|
|
62
85
|
const comm = commKey?.pub?.() || null
|
|
63
86
|
// ¿Nos nombra el acta? Con `cn` o sin él, lo que el proxio mira es la pertenencia.
|
|
64
87
|
const esMiembro = comm && (record?.members || []).some((m) => m?.pub === comm)
|
package/src/tui/app.js
CHANGED
|
@@ -390,6 +390,8 @@ function capsRows (st, t) {
|
|
|
390
390
|
// qué vas a firmar — que es justo lo que hace peligroso acumular cambios.
|
|
391
391
|
const cambia = has.has(cap) !== real.has(cap)
|
|
392
392
|
if (cambia) tocados++
|
|
393
|
+
// El título ES el nombre del permiso —el mismo que se teclea en `caps <ID> +administra`—
|
|
394
|
+
// así que no hay que repetirlo al lado. Lo que hace va debajo.
|
|
393
395
|
const line = ` ${mark}${cambia ? t.bold('*') : ' '} ${cap === 'admin' ? t.bold(name) : name}`
|
|
394
396
|
rows.push({ text: line, sel: true, meta: { cap } })
|
|
395
397
|
const hint = typeof i.capHint[cap] === 'function' ? i.capHint[cap](member.cn) : i.capHint[cap]
|
|
@@ -1312,12 +1314,44 @@ async function onKeySecrets (term, st, key) {
|
|
|
1312
1314
|
await revealValue(term, st, `ns:${cur.ns}`, cur.key, cur.public)
|
|
1313
1315
|
} else if (ch === 't' && cur?.key) {
|
|
1314
1316
|
await toggleVisibility(term, st, cur.public, () => vc.setSecretVisibility(cur.ns, cur.key, !cur.public, activeId(st), activePwd(st)))
|
|
1317
|
+
} else if (ch === 'r' && cur?.key) {
|
|
1318
|
+
await promptRenameVariable(term, st, cur)
|
|
1315
1319
|
} else if (key.name === 'f5') {
|
|
1316
1320
|
await refreshSecrets(term, st)
|
|
1317
1321
|
}
|
|
1318
1322
|
return true
|
|
1319
1323
|
}
|
|
1320
1324
|
|
|
1325
|
+
/**
|
|
1326
|
+
* RENOMBRAR: el mismo sobre con otro nombre. No pide la frase, porque lo único que hay que
|
|
1327
|
+
* rehacer es la firma —el nombre va DENTRO de lo firmado— y de eso se encarga la llave de
|
|
1328
|
+
* sellado, que trabaja con la bóveda cerrada.
|
|
1329
|
+
*
|
|
1330
|
+
* Se avisa de lo que de verdad importa y no se ve: para el servicio que la lee esto es un
|
|
1331
|
+
* cambio de configuración, así que se va a reiniciar. Si su código busca el nombre viejo,
|
|
1332
|
+
* dejará de encontrarlo — y eso no lo puede saber la bóveda.
|
|
1333
|
+
*/
|
|
1334
|
+
function promptRenameVariable (term, st, cur) {
|
|
1335
|
+
const i = L(st)
|
|
1336
|
+
return new Promise((listo) => {
|
|
1337
|
+
setInput(st, {
|
|
1338
|
+
label: i.renameLabel(cur.key),
|
|
1339
|
+
hint: i.renameHint,
|
|
1340
|
+
value: cur.key,
|
|
1341
|
+
onSubmit: async (nuevo) => {
|
|
1342
|
+
const nv = nuevo.trim()
|
|
1343
|
+
st.input = null
|
|
1344
|
+
if (!nv || nv === cur.key) { listo(); return }
|
|
1345
|
+
if (!KEY_RE.test(nv)) { flash(st, i.keyInvalid, 'danger'); listo(); return }
|
|
1346
|
+
const r = await guard(term, st, i.renaming, () => vc.renameSecret(cur.ns, cur.key, nv, activeId(st)))
|
|
1347
|
+
if (r.ok) { flash(st, i.renamed(cur.key, nv)); st.secrets = r.v }
|
|
1348
|
+
listo()
|
|
1349
|
+
},
|
|
1350
|
+
onCancel: () => { st.input = null; listo() }
|
|
1351
|
+
})
|
|
1352
|
+
})
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1321
1355
|
/**
|
|
1322
1356
|
* Hacer pública una variable es dejar que su valor SALGA de esta máquina, así que se
|
|
1323
1357
|
* pregunta; volverla privada no expone nada y se aplica directo.
|
|
@@ -1420,11 +1454,17 @@ async function onKeyDevVars (term, st, key) {
|
|
|
1420
1454
|
*/
|
|
1421
1455
|
function askVisibility (term, st, done) {
|
|
1422
1456
|
const i = L(st)
|
|
1457
|
+
// SE DEVUELVE LA PROMESA. `onConfirmKey` hace `await f?.()` para SERIALIZAR las
|
|
1458
|
+
// operaciones contra el daemon —lo dice el comentario de `onInputKey`—, y soltarla aquí
|
|
1459
|
+
// dejaba la escritura corriendo por fuera de esa serialización: se solapaba con el
|
|
1460
|
+
// refresco periódico, los dos usan el mismo archivo de respuesta, el refresco lo borraba
|
|
1461
|
+
// antes de que la escritura lo leyera y la pantalla se quedaba en «Guardando…» para
|
|
1462
|
+
// siempre. El valor SÍ se había guardado, que es lo que lo hacía tan confuso.
|
|
1423
1463
|
setConfirm(st, {
|
|
1424
1464
|
text: i.newVarPrivateAsk,
|
|
1425
1465
|
defaultYes: true,
|
|
1426
|
-
onYes: () => { st.confirm = null; done(false) }, // privada
|
|
1427
|
-
onNo: () => { st.confirm = null; done(true) } // pública
|
|
1466
|
+
onYes: () => { st.confirm = null; return done(false) }, // privada
|
|
1467
|
+
onNo: () => { st.confirm = null; return done(true) } // pública
|
|
1428
1468
|
})
|
|
1429
1469
|
}
|
|
1430
1470
|
|
package/src/tui/i18n.js
CHANGED
|
@@ -49,36 +49,41 @@ const es = {
|
|
|
49
49
|
// lo que hacen y no lo que implican»). «Sellar el acta» y «Administrar el perfil» eran
|
|
50
50
|
// lo segundo: hay que saberse el modelo para traducirlos. Lo que hacen es admitir
|
|
51
51
|
// aparatos y conectar o quitar dispositivos, y eso es lo que se lee ahora.
|
|
52
|
+
// EL TÍTULO ES EL NOMBRE DEL PERMISO, no una descripción (dueño, 2026-09-03: «deben
|
|
53
|
+
// decir siempre lo que SON»). Antes puse frases —«Conectar y quitar dispositivos»— y el
|
|
54
|
+
// resultado fue una lista donde no se sabía qué permiso era cuál ni cómo nombrarlo para
|
|
55
|
+
// el CLI. Lo que hace cada uno va DEBAJO, que es su sitio.
|
|
52
56
|
capName: {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
unattended: 'Llevarse claves sin pedirte permiso',
|
|
64
|
-
replica: 'Entregar tus claves con la bóveda apagada'
|
|
57
|
+
secrets: (cn) => `secretos «${cn}»`,
|
|
58
|
+
sign: 'firma',
|
|
59
|
+
store: 'guarda',
|
|
60
|
+
read: 'lee',
|
|
61
|
+
admin: 'administra',
|
|
62
|
+
passwords: 'contrasenas',
|
|
63
|
+
approve: 'aprueba',
|
|
64
|
+
sealer: 'sella',
|
|
65
|
+
unattended: 'desatendido',
|
|
66
|
+
replica: 'replica'
|
|
65
67
|
},
|
|
68
|
+
// CADA PISTA SE SOSTIENE SOLA. Antes colgaban del título —«hacerlo desde ese aparato»
|
|
69
|
+
// continuaba a «Conectar y quitar dispositivos»—; ahora el título es la palabra que se
|
|
70
|
+
// teclea, así que la pista tiene que decir la frase entera.
|
|
66
71
|
capHint: {
|
|
67
|
-
secrets: (cn) => `abrir
|
|
72
|
+
secrets: (cn) => `abrir el cajón de «${cn}» y ningún otro`,
|
|
68
73
|
sign: 'usar tu identidad en las apps del ecosistema',
|
|
69
|
-
store: 'escribir en tu bóveda
|
|
74
|
+
store: 'escribir en tu bóveda: perfil, contenido, datos',
|
|
70
75
|
read: 'ver lo que guardaste',
|
|
71
|
-
admin: '
|
|
72
|
-
passwords: '
|
|
76
|
+
admin: 'conectar y quitar aparatos desde ahí, sin venir aquí. No puede cambiar permisos',
|
|
77
|
+
passwords: 'pedir una contraseña por sitio, nunca la lista entera',
|
|
73
78
|
approve: 'dar el visto bueno cuando otro aparato pide claves privadas',
|
|
74
79
|
// Sellar solo significa algo en un aparato que HAGA de bóveda (otra máquina con el
|
|
75
80
|
// daemon, o un navegador que se puso de bóveda). En un teléfono normal se concede y no
|
|
76
81
|
// lo usa nadie. Se enseña igual, y se dice: verlo desactivado explica el modelo;
|
|
77
82
|
// esconderlo hacía creer que no existe (dueño, 2026-08-31).
|
|
78
83
|
sealer: 'admitir aparatos y cambiar permisos él solo cuando esta bóveda no está. Solo sirve en otra BÓVEDA',
|
|
79
|
-
unattended: 'llevarse claves privadas
|
|
84
|
+
unattended: 'llevarse claves privadas sin preguntarte. Apagado, hay que aprobarlo desde un aparato con «aprueba» cada vez que arranque',
|
|
80
85
|
// El replicador reparte y no puede nada más: no abre lo que entrega ni cambia nada.
|
|
81
|
-
replica: '
|
|
86
|
+
replica: 'repartir los sobres que esta bóveda ya firmó, tal como salieron: no puede abrirlos ni cambiar nada. Solo sirve en otra máquina'
|
|
82
87
|
},
|
|
83
88
|
confirmAdmin: (id) => `¿Dejar que ${id} conecte y quite dispositivos sin venir aquí?`,
|
|
84
89
|
capGiven: (n) => `Concedido: ${n}`,
|
|
@@ -227,6 +232,10 @@ const es = {
|
|
|
227
232
|
newVarPrivateAsk: '¿Es privada?',
|
|
228
233
|
makePublicConfirm: '¿Hacerla pública?',
|
|
229
234
|
changingVisibility: 'Cambiando…',
|
|
235
|
+
renameLabel: (k) => `Nuevo nombre para ${k}`,
|
|
236
|
+
renameHint: 'MAYUSCULAS_CON_GUION_BAJO · el valor no se toca · Esc cancela',
|
|
237
|
+
renaming: 'Renombrando…',
|
|
238
|
+
renamed: (a, b) => `${a} ahora se llama ${b} — el servicio que la lee se reinicia; si busca el nombre viejo, ya no lo encuentra`,
|
|
230
239
|
nowPublic: 'Pública: se entrega sin pedirte permiso',
|
|
231
240
|
nowPrivate: 'Privada: para entregarla te pide permiso',
|
|
232
241
|
|
|
@@ -299,7 +308,7 @@ const es = {
|
|
|
299
308
|
deviceRenamed: (n) => `Ahora se llama «${n}»`,
|
|
300
309
|
helpSecrets: ({ hasSecrets } = {}) => [
|
|
301
310
|
'←→ pestaña', '↑↓', 'n nueva variable', 'i cargar varias',
|
|
302
|
-
...(hasSecrets ? ['v ver valor', 't pública/privada', 'x quitar (variable/scope)'] : []),
|
|
311
|
+
...(hasSecrets ? ['v ver valor', 't pública/privada', 'r renombrar', 'x quitar (variable/scope)'] : []),
|
|
303
312
|
'F5 refrescar', 'Esc bóvedas', 'l English', 'q salir'
|
|
304
313
|
],
|
|
305
314
|
helpDevVars: ({ hasVars } = {}) => [
|
|
@@ -376,28 +385,32 @@ const en = {
|
|
|
376
385
|
capsSaved: (n) => `${n} change(s) saved in one go`,
|
|
377
386
|
capsNothing: 'You have not changed anything',
|
|
378
387
|
capsDiscard: 'Discard the unsaved changes?',
|
|
388
|
+
// El título es el NOMBRE del permiso, el mismo que se teclea. Ver la nota en el bloque
|
|
389
|
+
// español: describirlo dejaba una lista donde no se sabía qué permiso era cuál.
|
|
379
390
|
capName: {
|
|
380
|
-
secrets: (cn) => `
|
|
381
|
-
sign: '
|
|
382
|
-
store: '
|
|
383
|
-
read: '
|
|
384
|
-
admin: '
|
|
385
|
-
passwords: '
|
|
386
|
-
approve: '
|
|
387
|
-
sealer: '
|
|
388
|
-
unattended: '
|
|
389
|
-
replica: '
|
|
391
|
+
secrets: (cn) => `secrets «${cn}»`,
|
|
392
|
+
sign: 'sign',
|
|
393
|
+
store: 'store',
|
|
394
|
+
read: 'read',
|
|
395
|
+
admin: 'admin',
|
|
396
|
+
passwords: 'passwords',
|
|
397
|
+
approve: 'approve',
|
|
398
|
+
sealer: 'sealer',
|
|
399
|
+
unattended: 'unattended',
|
|
400
|
+
replica: 'replica'
|
|
390
401
|
},
|
|
402
|
+
// Cada pista se sostiene sola: el título es la palabra, no una frase que continuar.
|
|
391
403
|
capHint: {
|
|
404
|
+
secrets: (cn) => `open the «${cn}» drawer and no other`,
|
|
392
405
|
sign: 'use your identity across the ecosystem apps',
|
|
393
|
-
store: 'write to your vault
|
|
406
|
+
store: 'write to your vault: profile, content, data',
|
|
394
407
|
read: 'see what you saved',
|
|
395
|
-
admin: '
|
|
396
|
-
passwords: '
|
|
408
|
+
admin: 'connect and remove devices from there, without coming here. It cannot change permissions',
|
|
409
|
+
passwords: 'ask for one password per site, never the whole list',
|
|
397
410
|
approve: 'give the go-ahead when another device asks for private keys',
|
|
398
411
|
sealer: 'admit devices and change permissions on its own when this vault is away. Only useful on another VAULT',
|
|
399
|
-
unattended: 'take private keys
|
|
400
|
-
replica: '
|
|
412
|
+
unattended: 'take private keys without asking you. Off, it must be approved from a device with «approve» every time it starts',
|
|
413
|
+
replica: 'hand out the envelopes this vault already signed, exactly as they left: it cannot open them or change anything. Only useful on another machine'
|
|
401
414
|
},
|
|
402
415
|
confirmAdmin: (id) => `Let ${id} connect and remove devices without coming here?`,
|
|
403
416
|
capGiven: (n) => `Granted: ${n}`,
|
|
@@ -535,6 +548,10 @@ const en = {
|
|
|
535
548
|
newVarPrivateAsk: 'Is it private?',
|
|
536
549
|
makePublicConfirm: 'Make it public?',
|
|
537
550
|
changingVisibility: 'Changing…',
|
|
551
|
+
renameLabel: (k) => `New name for ${k}`,
|
|
552
|
+
renameHint: 'UPPERCASE_WITH_UNDERSCORES · the value is not touched · Esc cancels',
|
|
553
|
+
renaming: 'Renaming…',
|
|
554
|
+
renamed: (a, b) => `${a} is now ${b} — the service that reads it restarts; if it looks for the old name, it will not find it`,
|
|
538
555
|
nowPublic: 'Public: handed over without asking you',
|
|
539
556
|
nowPrivate: 'Private: handing it over asks you first',
|
|
540
557
|
|
|
@@ -593,7 +610,7 @@ const en = {
|
|
|
593
610
|
deviceRenamed: (n) => `Now called "${n}"`,
|
|
594
611
|
helpSecrets: ({ hasSecrets } = {}) => [
|
|
595
612
|
'←→ tab', '↑↓', 'n new variable', 'i load several',
|
|
596
|
-
...(hasSecrets ? ['v show value', 't public/private', 'x remove (variable/scope)'] : []),
|
|
613
|
+
...(hasSecrets ? ['v show value', 't public/private', 'r rename', 'x remove (variable/scope)'] : []),
|
|
597
614
|
'F5 refresh', 'Esc vaults', 'l Español', 'q quit'
|
|
598
615
|
],
|
|
599
616
|
helpDevVars: ({ hasVars } = {}) => [
|
package/src/vault.js
CHANGED
|
@@ -26,7 +26,7 @@ import { startSealersPublisher } from './sealers.js'
|
|
|
26
26
|
import { assertKeyOwnsDir } from './keyowner.js'
|
|
27
27
|
import { openStore } from './store.js'
|
|
28
28
|
import { openThreadStore, STORE_READ_METHODS, PROFILE_EDIT_METHODS } from './threadStore.js'
|
|
29
|
-
import { openSecretsStore, assertVar, RECOVERY as RECOVERY_WRAP } from './secretsStore.js'
|
|
29
|
+
import { openSecretsStore, assertVar, RECOVERY as RECOVERY_WRAP, PROFILE_OWNER } from './secretsStore.js'
|
|
30
30
|
// `PENDING_TTL_MS` se usa abajo, al esperar la firma del aprobador: sin importarlo, esa
|
|
31
31
|
// espera reventaba con un ReferenceError y la aprobación del mostrador de contraseñas no
|
|
32
32
|
// llegaba a existir. Solo se veía por ese camino —el único que lo usa—, y no había prueba
|
|
@@ -36,7 +36,9 @@ import { makeSealer } from './sealer.js'
|
|
|
36
36
|
import { openSealKeys } from './sealKey.js'
|
|
37
37
|
import { openCommKey, COMM_CN, COMM_CAPS } from './commKey.js'
|
|
38
38
|
import { seal } from '../lib/src/sealed.js'
|
|
39
|
+
import { localSocketPath, socketDir } from '../lib/src/localdesk.js'
|
|
39
40
|
import { dataDir, ensureDir } from './paths.js'
|
|
41
|
+
import net from 'node:net'
|
|
40
42
|
import { atRestFor, kekFor, migrateFile, encryptText, decryptText } from './atrest.js'
|
|
41
43
|
import { MSG, SCOPE, secretsScope, isValidSecretsNs } from './protocol.js'
|
|
42
44
|
|
|
@@ -321,15 +323,26 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
321
323
|
* Así que una respuesta que no quepa no se manda: se sustituye por un error, que sí cabe.
|
|
322
324
|
* Una bóveda que dice «no cabe» se arregla; una muda no se puede ni diagnosticar.
|
|
323
325
|
*/
|
|
326
|
+
/**
|
|
327
|
+
* QUIÉN CONTESTA A QUIÉN. Por defecto el proxio; un remitente LOCAL (el socket de esta
|
|
328
|
+
* máquina) se apunta aquí con su propia forma de contestar.
|
|
329
|
+
*
|
|
330
|
+
* Que haya dos transportes no cambia NADA de quién puede pedir qué: la autorización sale
|
|
331
|
+
* del certificado y del acta, igual por los dos caminos. Alcanzar el socket no autoriza
|
|
332
|
+
* nada — es la misma puerta, con el camino más corto.
|
|
333
|
+
*/
|
|
334
|
+
const canales = new Map()
|
|
335
|
+
|
|
324
336
|
const reply = (to, obj) => {
|
|
325
337
|
try {
|
|
338
|
+
const enviar = canales.get(to) || ((o) => client.send(to, o))
|
|
326
339
|
const bytes = Buffer.byteLength(JSON.stringify(obj))
|
|
327
340
|
if (bytes > MAX_REPLY_BYTES) {
|
|
328
341
|
log(`[vault] the reply to ${obj?.type} does not fit (${bytes} bytes > ${MAX_REPLY_BYTES}): sending an error instead of killing the connection`)
|
|
329
342
|
audit('reply-too-big', { type: obj?.type || null, bytes })
|
|
330
|
-
return
|
|
343
|
+
return enviar({ type: MSG.ERROR, error: `reply too big (${bytes} bytes): ask for less at a time` })
|
|
331
344
|
}
|
|
332
|
-
|
|
345
|
+
enviar(obj)
|
|
333
346
|
} catch (e) { log('[vault] could not reply:', e.message) }
|
|
334
347
|
}
|
|
335
348
|
|
|
@@ -610,6 +623,69 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
610
623
|
|
|
611
624
|
// Store de hilos+aperturas (Fase 3): escrituras requieren vault:store; lecturas
|
|
612
625
|
// aceptan vault:store o vault:read. Cada op va firmada por D + cert (cadena D←maestra).
|
|
626
|
+
/**
|
|
627
|
+
* LOS DATOS DEL PERFIL (`docs/datos-del-perfil.md`). Tres métodos y ninguno más:
|
|
628
|
+
*
|
|
629
|
+
* · `profilePut` — guarda un dato. Privado = sobre ya sellado; público = en claro.
|
|
630
|
+
* · `profileBundle` — los sobres de ESTE aparato, para que arme su perfil.
|
|
631
|
+
* · `profilePublic` — lo público en claro, que puede pedir cualquiera.
|
|
632
|
+
*
|
|
633
|
+
* Lo que NO se comprueba aquí: el candado. Un sobre sellado no lo puede leer ni fabricar
|
|
634
|
+
* la bóveda, así que guardarlo no es una decisión suya y no necesita su maestra. Ese era
|
|
635
|
+
* el motivo real por el que editar el perfil exigía abrirla, y es el síntoma que abrió
|
|
636
|
+
* todo esto: se cerraba sola a los cinco minutos y editar dejaba de funcionar.
|
|
637
|
+
*/
|
|
638
|
+
const PROFILE_METHODS = new Set(['profilePut', 'profileBundle', 'profilePublic', 'profileRecipients'])
|
|
639
|
+
|
|
640
|
+
async function handleProfile (from, p, d) {
|
|
641
|
+
// LO PÚBLICO NO PIDE NADA. Es lo que ve quien pregunta desde fuera y no tiene ninguna
|
|
642
|
+
// llave tuya: pedir un papel para leerlo sería contradecir lo que significa «público».
|
|
643
|
+
// Va firmado, así que quien lo recibe comprueba que es tuyo sin fiarse de nadie.
|
|
644
|
+
if (d.method === 'profilePublic') {
|
|
645
|
+
return reply(from, { type: MSG.STORE_RESULT, method: d.method, result: secrets.profilePublic() })
|
|
646
|
+
}
|
|
647
|
+
// A QUIÉN HAY QUE ENVOLVERLE. Lo pide el aparato ANTES de sellar, porque sellar lo hace
|
|
648
|
+
// él: envolver solo necesita públicas, así que puede hacerlo un navegador sin que
|
|
649
|
+
// ninguna privada ande suelta. Sale de aquí y no de una lista suya para que conceder
|
|
650
|
+
// `firma` a un aparato nuevo baste — sin que nadie tenga que acordarse de nada.
|
|
651
|
+
if (d.method === 'profileRecipients') {
|
|
652
|
+
const members = await recipientsOf(PROFILE_OWNER)
|
|
653
|
+
return reply(from, {
|
|
654
|
+
type: MSG.STORE_RESULT,
|
|
655
|
+
method: d.method,
|
|
656
|
+
result: { recoveryPub: secrets.recoveryPub(), members: members.map((m) => ({ pub: m.pub, encPub: m.encPub })) }
|
|
657
|
+
})
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const revoked = await revocationSet()
|
|
661
|
+
// `vault:sign` — hablar por ti. No `vault:store`, que es guardar cosas.
|
|
662
|
+
const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.SIGN, ...(await contextoActa()), revoked })
|
|
663
|
+
if (!chk.ok) return denyChain(from, chk, p, 'profile')
|
|
664
|
+
if (!await actaAllows(from, chk, SCOPE.SIGN, 'profile')) return
|
|
665
|
+
|
|
666
|
+
try {
|
|
667
|
+
if (d.method === 'profileBundle') {
|
|
668
|
+
const b = secrets.profileBundleFor(chk.device)
|
|
669
|
+
return reply(from, { type: MSG.STORE_RESULT, method: d.method, result: b })
|
|
670
|
+
}
|
|
671
|
+
// profilePut
|
|
672
|
+
const { key, cls, sobre, value } = d.args || {}
|
|
673
|
+
const quien = await deviceIdOf(chk.device).catch(() => null)
|
|
674
|
+
let r
|
|
675
|
+
if (cls === 'public') {
|
|
676
|
+
r = await secrets.profilePutPublic(key, value, { by: quien })
|
|
677
|
+
} else {
|
|
678
|
+
r = await secrets.putSealed(PROFILE_OWNER, key, sobre, { by: quien })
|
|
679
|
+
}
|
|
680
|
+
audit('profile.put', { key, cls: cls || 'private', by: quien, gen: r?.gen ?? null })
|
|
681
|
+
// Y a los demás aparatos, que si no se enteran al arrancar y vuelve la divergencia.
|
|
682
|
+
await notifyMembers('profile', { key, by: quien })
|
|
683
|
+
return reply(from, { type: MSG.STORE_RESULT, method: d.method, result: r })
|
|
684
|
+
} catch (e) {
|
|
685
|
+
return reply(from, { type: MSG.ERROR, error: 'profile: ' + e.message })
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
613
689
|
async function handleStore (from, p) {
|
|
614
690
|
const d = p.data
|
|
615
691
|
// `Object.hasOwn` y no `threads.methods[d.method]`: con la comprobación laxa,
|
|
@@ -619,9 +695,19 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
619
695
|
return reply(from, { type: MSG.ERROR, error: 'store: invalid method' })
|
|
620
696
|
}
|
|
621
697
|
if (!isFresh(d)) return staleReply(from)
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
//
|
|
698
|
+
// EL PERFIL VA POR OTRO CAMINO, y por eso se atiende antes que nada
|
|
699
|
+
// (`docs/datos-del-perfil.md`). Dos diferencias, y las dos son el arreglo:
|
|
700
|
+
//
|
|
701
|
+
// · **No hace falta abrir la bóveda.** Lo que llega es un sobre ya sellado y
|
|
702
|
+
// firmado, que ella no puede leer ni falsificar: aceptarlo no es una decisión
|
|
703
|
+
// suya. El candado estaba ahí porque guardaba el perfil en claro; ya no.
|
|
704
|
+
// · **Lo gatea `firma`, no `guarda`.** Escribir tu identidad es hablar por ti, no
|
|
705
|
+
// guardar un archivo en tu bóveda (dueño, 2026-09-03). El proxio tiene `guarda`
|
|
706
|
+
// para lo suyo y no debe poder cambiar quién eres.
|
|
707
|
+
if (PROFILE_METHODS.has(d.method)) return await handleProfile(from, p, d)
|
|
708
|
+
// CANDADO del perfil (contraseña opcional): solo frena EDITAR el perfil heredado
|
|
709
|
+
// (`profileSet`, el `me` plano). Un dispositivo enrolado puede seguir firmando,
|
|
710
|
+
// leyendo y guardando contenido.
|
|
625
711
|
if (PROFILE_EDIT_METHODS.has(d.method) && isLocked()) {
|
|
626
712
|
audit('rejected', { what: 'store', method: d.method, reason: 'locked' })
|
|
627
713
|
return reply(from, { type: MSG.ERROR, error: 'profile locked: unlock it on the vault machine (dotrino-vault unlock) to edit it' })
|
|
@@ -981,7 +1067,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
981
1067
|
if (!publicOnly && needsApproval(chk.device, record)) {
|
|
982
1068
|
const deviceId = await deviceIdOf(chk.device).catch(() => null)
|
|
983
1069
|
const label = (record?.members || []).find((m) => m.pub === chk.device)?.label || ''
|
|
984
|
-
|
|
1070
|
+
// `from` SE GUARDA: la respuesta llega más tarde —cuando alguien apruebe— y tiene que
|
|
1071
|
+
// volver POR DONDE VINO. Si la pregunta entró por el mostrador local, mandarla por el
|
|
1072
|
+
// proxio la deja en el vacío: quien preguntó está escuchando en el socket, no ahí.
|
|
1073
|
+
// Lo cazó el E2E de aprobación, que se quedó esperando cinco minutos.
|
|
1074
|
+
const pend = approvals.request({ ns, device: chk.device, deviceId, label, ek: p.data.ek, from })
|
|
985
1075
|
audit('secrets.pending', { device: deviceId, ns, id: pend.id })
|
|
986
1076
|
log(`[vault] ${ns}: ${deviceId || '????-????'} is waiting for approval (${pend.id})`)
|
|
987
1077
|
const body = { op: 'secrets.pending', ns, id: pend.id, exp: pend.exp, ts: Date.now() }
|
|
@@ -1093,8 +1183,12 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1093
1183
|
if (op === 'deny') {
|
|
1094
1184
|
audit('secrets.denied', { device: pend.deviceId, ns: pend.ns, id, by })
|
|
1095
1185
|
log(`[vault] ${pend.ns}: request of ${pend.deviceId} DENIED by ${by}`)
|
|
1096
|
-
try {
|
|
1097
|
-
|
|
1186
|
+
try {
|
|
1187
|
+
const err = { type: MSG.ERROR, error: `unauthorized: denied — the "${pend.ns}" request was denied from ${by}` }
|
|
1188
|
+
const porLocal = pend.from && canales.get(pend.from)
|
|
1189
|
+
if (porLocal) porLocal(err)
|
|
1190
|
+
else client.sendByPubkey(pend.device, err)
|
|
1191
|
+
} catch (e) { log(`[vault] ${pend.ns}: could not tell ${pend.deviceId} it was denied: ${e.message}`) }
|
|
1098
1192
|
return answer({ op: 'deny.result', id, ok: true })
|
|
1099
1193
|
}
|
|
1100
1194
|
if (op !== 'approve') return reply(from, { type: MSG.ERROR, error: 'approval: unknown op' })
|
|
@@ -1108,8 +1202,13 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1108
1202
|
// EL FALLO NO SE TRAGA. Tragarlo era lo peor de este mostrador: la bóveda apuntaba
|
|
1109
1203
|
// «aprobado», el que pedía no recibía nada, y no quedaba una sola línea que mirar —
|
|
1110
1204
|
// así que el dueño aprobaba una y otra vez creyendo que el teléfono no llegaba.
|
|
1111
|
-
|
|
1112
|
-
|
|
1205
|
+
// Por el canal por el que preguntó. Si fue el proxio, `sendByPubkey`: así lo recoge al
|
|
1206
|
+
// volver aunque ahora esté apagado. Si fue el socket local, por el socket.
|
|
1207
|
+
try {
|
|
1208
|
+
const porLocal = pend.from && canales.get(pend.from)
|
|
1209
|
+
if (porLocal) porLocal({ type: MSG.SECRETS_RESULT, ...res })
|
|
1210
|
+
else client.sendByPubkey(pend.device, { type: MSG.SECRETS_RESULT, ...res })
|
|
1211
|
+
} catch (e) { log(`[vault] ${pend.ns}: approved, but the reply did not reach ${pend.deviceId}: ${e.message}`) }
|
|
1113
1212
|
return answer({ op: 'approve.result', id, ok: true })
|
|
1114
1213
|
}
|
|
1115
1214
|
|
|
@@ -1144,7 +1243,20 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1144
1243
|
} catch (e) { log('[vault] could not notify approvers:', e.message) }
|
|
1145
1244
|
}
|
|
1146
1245
|
|
|
1147
|
-
client.on('message',
|
|
1246
|
+
client.on('message', (from, payload) => onMessage(from, payload))
|
|
1247
|
+
|
|
1248
|
+
// EL MOSTRADOR LOCAL, siempre que se pueda. Si no se puede abrir (un sistema sin sockets
|
|
1249
|
+
// de archivo, un directorio de solo lectura) se dice y se sigue: los servicios usan el
|
|
1250
|
+
// proxio como hasta ahora. Lo que NO se hace es callarlo — un atajo que no está y nadie
|
|
1251
|
+
// lo dice es media hora buscando por qué algo va lento.
|
|
1252
|
+
// El socket se nombra por la LLAVE de esta bóveda: una máquina puede tener varias, y un
|
|
1253
|
+
// servicio solo conoce la maestra que lleva pineada. Así los dos lados calculan la misma
|
|
1254
|
+
// ruta sin mirar ningún índice.
|
|
1255
|
+
if (identity.me?.publickey) {
|
|
1256
|
+
abrirMostradorLocal(localSocketPath((await pubkeyId(identity.me.publickey)).slice(0, 16)))
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
async function onMessage (from, payload) {
|
|
1148
1260
|
if (!payload || typeof payload !== 'object') return
|
|
1149
1261
|
try {
|
|
1150
1262
|
if (payload.type === MSG.HELLO) return desk.handleHello(from, payload)
|
|
@@ -1165,7 +1277,60 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1165
1277
|
} catch (e) {
|
|
1166
1278
|
reply(from, { type: MSG.ERROR, error: e.message })
|
|
1167
1279
|
}
|
|
1168
|
-
}
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* EL MOSTRADOR LOCAL: el mismo protocolo, sin salir de la máquina.
|
|
1284
|
+
*
|
|
1285
|
+
* Un servicio en ESTA máquina hablaba con la bóveda dando la vuelta por
|
|
1286
|
+
* `proxy.dotrino.com`: sale a internet, vuelve, y si el proxio tiene un mal momento el
|
|
1287
|
+
* arranque se retrasa cinco segundos por nada. Absurdo cuando los dos procesos comparten
|
|
1288
|
+
* disco (dueño, 2026-09-03: «estando en la misma máquina debería ser inmediato»).
|
|
1289
|
+
*
|
|
1290
|
+
* Es EL MISMO PROTOCOLO y el mismo enrutador: llega `{type, data, signature, cert}` y se
|
|
1291
|
+
* comprueba igual, contra el certificado y contra el acta. **Alcanzar el socket no
|
|
1292
|
+
* autoriza nada** — es la misma puerta por un camino más corto, no una puerta de atrás.
|
|
1293
|
+
* Por eso no hay un solo `if` de «viene de local» en ningún handler, y no debe haberlo.
|
|
1294
|
+
*
|
|
1295
|
+
* Y no es un puerto: es un socket de archivo, en modo 0600 y bajo el directorio del
|
|
1296
|
+
* usuario. No se alcanza desde otra máquina ni desde otro usuario, así que la promesa
|
|
1297
|
+
* del `docker-compose` —«la bóveda no escucha nada»— sigue siendo cierta donde importa.
|
|
1298
|
+
*/
|
|
1299
|
+
function abrirMostradorLocal (socketPath) {
|
|
1300
|
+
let n = 0
|
|
1301
|
+
const server = net.createServer((sock) => {
|
|
1302
|
+
const id = 'local:' + (++n)
|
|
1303
|
+
let buf = ''
|
|
1304
|
+
const enviar = (obj) => { try { sock.write(JSON.stringify(obj) + '\n') } catch (_) {} }
|
|
1305
|
+
canales.set(id, enviar)
|
|
1306
|
+
sock.on('data', (chunk) => {
|
|
1307
|
+
buf += chunk
|
|
1308
|
+
// UN TOPE, y por lo mismo que en las respuestas: sin él, quien se conecte puede
|
|
1309
|
+
// hacer crecer este buffer hasta tumbar la bóveda mandando y sin saltos de línea.
|
|
1310
|
+
if (buf.length > MAX_REPLY_BYTES) { buf = ''; enviar({ type: MSG.ERROR, error: 'request too big' }); return }
|
|
1311
|
+
let i
|
|
1312
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
1313
|
+
const linea = buf.slice(0, i); buf = buf.slice(i + 1)
|
|
1314
|
+
if (!linea.trim()) continue
|
|
1315
|
+
let p = null
|
|
1316
|
+
try { p = JSON.parse(linea) } catch (_) { enviar({ type: MSG.ERROR, error: 'malformed request' }); continue }
|
|
1317
|
+
onMessage(id, p).catch((e) => enviar({ type: MSG.ERROR, error: e.message }))
|
|
1318
|
+
}
|
|
1319
|
+
})
|
|
1320
|
+
const cerrar = () => canales.delete(id)
|
|
1321
|
+
sock.on('close', cerrar)
|
|
1322
|
+
sock.on('error', cerrar)
|
|
1323
|
+
})
|
|
1324
|
+
server.on('error', (e) => log('[vault] the local desk could not open (' + e.message + '): services will use the proxy'))
|
|
1325
|
+
try { fs.mkdirSync(socketDir(), { recursive: true, mode: 0o700 }) } catch (_) {}
|
|
1326
|
+
try { fs.rmSync(socketPath, { force: true }) } catch (_) {}
|
|
1327
|
+
server.listen(socketPath, () => {
|
|
1328
|
+
try { fs.chmodSync(socketPath, 0o600) } catch (_) {}
|
|
1329
|
+
log('[vault] local desk at ' + socketPath + ' (same machine, no round trip)')
|
|
1330
|
+
})
|
|
1331
|
+
server.unref?.()
|
|
1332
|
+
return server
|
|
1333
|
+
}
|
|
1169
1334
|
|
|
1170
1335
|
/**
|
|
1171
1336
|
* AVISO DE OTRA BÓVEDA de la misma cuenta (multivault). Trae el acta nueva y aquí se
|
|
@@ -1897,6 +2062,19 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1897
2062
|
// abrirse desde un navegador. Lo que cambia es que «pública» ya sí quiere decir algo
|
|
1898
2063
|
// para quien administra — puede leerla— además de que se despacha sin aprobación.
|
|
1899
2064
|
const admins = isPublic ? await adminDevices() : []
|
|
2065
|
+
// EL PERFIL SE LE ENVUELVE A QUIEN PUEDE HABLAR POR TI (`docs/datos-del-perfil.md`).
|
|
2066
|
+
//
|
|
2067
|
+
// Va explícito y antes que nada porque si no cae en la rama de «cajón sin dueño» y
|
|
2068
|
+
// acabaría envuelto solo para quien ADMINISTRA — que no es lo que se decidió: leer y
|
|
2069
|
+
// escribir tu identidad es `firma`, y no todos los que firman administran. Un teléfono
|
|
2070
|
+
// normal firma y no administra, y tiene que ver tu perfil.
|
|
2071
|
+
if (owner === PROFILE_OWNER) {
|
|
2072
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
2073
|
+
const firman = (record?.members || [])
|
|
2074
|
+
.filter((m) => m.encPub && Acta.memberCan(record, m.pub, 'sign'))
|
|
2075
|
+
.map((m) => ({ pub: m.pub, encPub: m.encPub }))
|
|
2076
|
+
return conCoselladores(dedup(firman), co)
|
|
2077
|
+
}
|
|
1900
2078
|
if (owner.startsWith('ns:')) {
|
|
1901
2079
|
const owned = await nsMembers(owner.slice(3))
|
|
1902
2080
|
// Sin dueño (un cajón personal, o uno cuyo servicio ya no está) entra quien administra
|
|
@@ -2452,6 +2630,25 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2452
2630
|
}
|
|
2453
2631
|
async function deleteSecret (ns, key) { const ok = await secrets.delete(ns, key); if (ok) { audit('secret.rm', { ns, key }); scheduleNotice(ns) } return ok }
|
|
2454
2632
|
|
|
2633
|
+
/**
|
|
2634
|
+
* RENOMBRAR una variable. Para el servicio que la lee es un cambio de configuración como
|
|
2635
|
+
* cualquier otro —la de antes desaparece y aparece otra—, así que se avisa igual: si no,
|
|
2636
|
+
* seguiría arrancando con el nombre viejo hasta el siguiente reinicio.
|
|
2637
|
+
*/
|
|
2638
|
+
async function renameSecret (ns, from, to) {
|
|
2639
|
+
const r = await secrets.rename(`ns:${ns}`, from, to)
|
|
2640
|
+
if (r?.renamed) { audit('secret.rename', { ns, from, to }); scheduleNotice(ns) }
|
|
2641
|
+
return r
|
|
2642
|
+
}
|
|
2643
|
+
async function renameDeviceSecret (pub, from, to) {
|
|
2644
|
+
const r = await secrets.rename(`dev:${pub}`, from, to)
|
|
2645
|
+
if (r?.renamed) {
|
|
2646
|
+
audit('secret.rename', { device: await deviceIdOf(pub).catch(() => null), from, to, scope: 'device' })
|
|
2647
|
+
scheduleDeviceNotice(pub)
|
|
2648
|
+
}
|
|
2649
|
+
return r
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2455
2652
|
/**
|
|
2456
2653
|
* CARGAR CONFIGURACIÓN ES UNA TRANSACCIÓN: muchas variables, UN aviso.
|
|
2457
2654
|
*
|
|
@@ -2738,7 +2935,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2738
2935
|
// El mostrador que atiende a la consola remota. Se expone para poder probar la
|
|
2739
2936
|
// frontera de verdad (que el valor de una privada no salga ni dentro del sobre).
|
|
2740
2937
|
vars: varsDesk,
|
|
2741
|
-
setSecret, deleteSecret, listSecrets, setSecretVisibility, openSecrets,
|
|
2938
|
+
setSecret, deleteSecret, renameSecret, renameDeviceSecret, listSecrets, setSecretVisibility, openSecrets,
|
|
2742
2939
|
// Sella un `secrets.json` v3 entero. Es el punto de no retorno del despliegue y
|
|
2743
2940
|
// por eso es una operación con nombre propio, no algo que ocurra de refilón al
|
|
2744
2941
|
// desbloquear: deja `secrets.json.v3.bak` para poder volver.
|
package/src/vaultControl.js
CHANGED
|
@@ -513,6 +513,30 @@ export function deleteSecret (ns, key, profile) {
|
|
|
513
513
|
})
|
|
514
514
|
}
|
|
515
515
|
|
|
516
|
+
/**
|
|
517
|
+
* Renombra una variable: el mismo sobre con otro nombre. No pide la frase — lo único que
|
|
518
|
+
* hay que rehacer es la firma, y de eso se encarga la llave de sellado.
|
|
519
|
+
*
|
|
520
|
+
* Se comprueban LAS DOS cosas: que la nueva esté y que la vieja ya no. Mirar solo una
|
|
521
|
+
* dejaría pasar una copia a medias, que es peor que un fallo.
|
|
522
|
+
*/
|
|
523
|
+
export function renameSecret (ns, key, to, profile) {
|
|
524
|
+
return secretOp({ op: 'rename', ns, key, to }, profile, (out) => {
|
|
525
|
+
if (!has(out.ns[ns], to) || has(out.ns[ns], key)) {
|
|
526
|
+
throw coded('the daemon did not rename the variable (check the service logs)', 'NOT_RENAMED')
|
|
527
|
+
}
|
|
528
|
+
})
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Lo mismo para una variable de UN aparato. */
|
|
532
|
+
export function renameDeviceSecret (pub, key, to, profile) {
|
|
533
|
+
return secretOp({ op: 'dev-rename', pub, key, to }, profile, (out) => {
|
|
534
|
+
if (!has(keysOf(out, pub), to) || has(keysOf(out, pub), key)) {
|
|
535
|
+
throw coded('the daemon did not rename the variable (check the service logs)', 'NOT_RENAMED')
|
|
536
|
+
}
|
|
537
|
+
})
|
|
538
|
+
}
|
|
539
|
+
|
|
516
540
|
/**
|
|
517
541
|
* Cambia SOLO quién puede ver el valor: `public` deja que la consola remota lo vea,
|
|
518
542
|
* `private` lo encierra en esta máquina. No toca el valor (ni hace falta conocerlo).
|