@dotrino/vaultd 0.97.0 → 0.105.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/src/admin.js +29 -3
- package/lib/src/enroll.js +12 -1
- package/lib/src/index.js +4 -1
- package/lib/src/localdesk.js +96 -0
- package/lib/src/service.js +185 -34
- package/package.json +6 -3
- package/src/approvals.js +7 -2
- package/src/client.js +4 -1
- package/src/ctl.js +105 -3
- package/src/daemon.js +17 -1
- package/src/replica.js +232 -0
- package/src/secretsStore.js +136 -0
- package/src/threadStore.js +10 -0
- package/src/transport.js +27 -4
- package/src/tui/app.js +76 -9
- package/src/tui/i18n.js +85 -55
- package/src/vault.js +298 -15
- package/src/vaultControl.js +24 -0
package/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)
|
|
@@ -1161,10 +1273,64 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1161
1273
|
if (payload.type === MSG.ADMIN) return await handleAdmin(from, payload)
|
|
1162
1274
|
if (payload.type === MSG.RENOUNCE) return await handleRenounce(from, payload)
|
|
1163
1275
|
if (payload.type === MSG.ADMIN_EVENT) return await handleAdminEvent(payload)
|
|
1276
|
+
if (payload.type === MSG.REPLICA_ACK) return await handleReplicaAck(from, payload)
|
|
1164
1277
|
} catch (e) {
|
|
1165
1278
|
reply(from, { type: MSG.ERROR, error: e.message })
|
|
1166
1279
|
}
|
|
1167
|
-
}
|
|
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
|
+
}
|
|
1168
1334
|
|
|
1169
1335
|
/**
|
|
1170
1336
|
* AVISO DE OTRA BÓVEDA de la misma cuenta (multivault). Trae el acta nueva y aquí se
|
|
@@ -1355,7 +1521,15 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1355
1521
|
* ventana. La CLAVE distingue los dos cajones (`<ns>` y `dev:<pub>`) para que tocar
|
|
1356
1522
|
* lo de un aparato no cancele el aviso pendiente de todo su namespace.
|
|
1357
1523
|
*/
|
|
1358
|
-
function scheduleNotice (ns) {
|
|
1524
|
+
function scheduleNotice (ns) {
|
|
1525
|
+
schedule(ns, async () => {
|
|
1526
|
+
await notifyNsChange(ns)
|
|
1527
|
+
// Y a los replicadores el sobre nuevo. Sin esto repartirían el valor de ayer: el
|
|
1528
|
+
// aviso de cambio le dice al servicio que se reinicie, y al reiniciar volvería a
|
|
1529
|
+
// pedir — y si la bóveda está apagada, el replicador le daría lo viejo.
|
|
1530
|
+
await pushToReplicas(`secret:${ns}`)
|
|
1531
|
+
})
|
|
1532
|
+
}
|
|
1359
1533
|
function scheduleDeviceNotice (pub) { schedule('dev:' + pub, () => notifyDeviceChange(pub)) }
|
|
1360
1534
|
|
|
1361
1535
|
function schedule (key, fn) {
|
|
@@ -1432,6 +1606,80 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1432
1606
|
for (const d of issued || []) avisar(d.sub)
|
|
1433
1607
|
log(`[vault] notified ${seen.size} member(s) of "${ev}" (record #${acta?.seq ?? '?'})`)
|
|
1434
1608
|
} catch (e) { log('[vault] could not notify members of the change:', e.message) }
|
|
1609
|
+
// Y a los replicadores, que no quieren un aviso sino el CONTENIDO: sin esto se
|
|
1610
|
+
// quedarían con el acta de ayer y repartiendo sobres de un aparato ya revocado.
|
|
1611
|
+
await pushToReplicas(ev)
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/**
|
|
1615
|
+
* EL ARRASTRE AL ARRANCAR (`docs/replicas.md` §6). Un replicador dice por dónde va en
|
|
1616
|
+
* cuanto se conecta, y si va por detrás se le empuja.
|
|
1617
|
+
*
|
|
1618
|
+
* Sin esto, la bóveda solo empujaba al cambiar algo: un replicador que se enciende
|
|
1619
|
+
* después se quedaba vacío hasta el siguiente cambio, que puede no llegar en semanas.
|
|
1620
|
+
* Y el empujón de un enrolamiento se manda a un aparato que todavía no está conectado.
|
|
1621
|
+
* Lo destapó el smoke y era un hueco del diseño, no del código.
|
|
1622
|
+
*
|
|
1623
|
+
* El acuse va FIRMADO por el replicador y se comprueba contra el acta: sin eso, cualquiera
|
|
1624
|
+
* podría pedirle a la bóveda que reparta el acta y los sobres mandando un acuse inventado.
|
|
1625
|
+
*/
|
|
1626
|
+
async function handleReplicaAck (from, p) {
|
|
1627
|
+
const body = p?.body
|
|
1628
|
+
if (!body || body.op !== 'replica.ack') return
|
|
1629
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
1630
|
+
if (!record) return
|
|
1631
|
+
// LA LLAVE VA DENTRO DEL MENSAJE FIRMADO, no se saca de `from`: `from` es el
|
|
1632
|
+
// identificador de la conexión en el proxio, no la llave del miembro. Compararlo con
|
|
1633
|
+
// el acta rechazaba a todo el mundo, y en el caso contrario habría sido peor.
|
|
1634
|
+
const quien = body.publickey
|
|
1635
|
+
if (typeof quien !== 'string' || !Acta.memberCan(record, quien, 'replica')) {
|
|
1636
|
+
return log('[vault] a replica ack arrived from someone the record does not allow to serve: ignored')
|
|
1637
|
+
}
|
|
1638
|
+
if (!(await verifyDeviceSig({ publickey: quien, data: body, signature: p.signature }))) {
|
|
1639
|
+
return log('[vault] a replica ack does not check out against its own key: ignored')
|
|
1640
|
+
}
|
|
1641
|
+
if (typeof body.seq === 'number' && body.seq >= record.seq) return
|
|
1642
|
+
log(`[vault] a replica is at #${body.seq} and we are at #${record.seq}: catching it up`)
|
|
1643
|
+
await pushToReplicas('catch-up')
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
/**
|
|
1647
|
+
* EMPUJAR A LOS REPLICADORES lo que tienen que poder repartir: el acta y los sobres.
|
|
1648
|
+
*
|
|
1649
|
+
* Se manda TODO firmado de antes —el acta la selló la maestra, cada sobre lo firmó la
|
|
1650
|
+
* llave de sellado— así que un replicador no puede falsificar nada de lo que le damos.
|
|
1651
|
+
* Y va cerrado a su destinatario, no a él: reparte sobres que no puede abrir. Es lo que
|
|
1652
|
+
* permite ponerlo en una máquina en la que no confías (`docs/replicas.md` §8.bis).
|
|
1653
|
+
*
|
|
1654
|
+
* Se empuja después de cada cambio, y no se espera acuse: si un replicador está apagado
|
|
1655
|
+
* se queda atrás y lo alcanza el siguiente empujón. Un replicador atrasado no es un
|
|
1656
|
+
* peligro —el cliente rechaza un acta con `seq` menor del que ya vio— sino menos
|
|
1657
|
+
* disponibilidad, que es exactamente lo que un replicador arriesga.
|
|
1658
|
+
*/
|
|
1659
|
+
async function pushToReplicas (motivo = 'change') {
|
|
1660
|
+
try {
|
|
1661
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
1662
|
+
if (!record) return
|
|
1663
|
+
const replicas = (record.members || []).filter((m) => Acta.memberCan(record, m.pub, 'replica'))
|
|
1664
|
+
if (!replicas.length) return
|
|
1665
|
+
|
|
1666
|
+
// UN SOBRE POR (CAJÓN, APARATO), porque `bundleFor` lo talla a quien lo pide: lleva
|
|
1667
|
+
// solo las envolturas de ESE aparato. Mandar uno genérico no serviría de nada.
|
|
1668
|
+
const bundles = {}
|
|
1669
|
+
for (const m of record.members || []) {
|
|
1670
|
+
if (!m.cn) continue
|
|
1671
|
+
try { bundles[`${m.cn} ${m.pub}`] = secrets.bundleFor(m.cn, m.pub) } catch (_) {}
|
|
1672
|
+
}
|
|
1673
|
+
const body = { op: 'replica.push', seq: record.seq, acta: record, bundles, ts: Date.now() }
|
|
1674
|
+
const seal = await signSeal(body)
|
|
1675
|
+
if (!seal) return log('[vault] cannot push to the replicas: the record names no sealing key of ours')
|
|
1676
|
+
let n = 0
|
|
1677
|
+
for (const r of replicas) {
|
|
1678
|
+
try { client.sendByPubkey(r.pub, { type: MSG.REPLICA_PUSH, body, seal }); n++ }
|
|
1679
|
+
catch (e) { log(`[vault] could not push to a replica: ${e.message}`) }
|
|
1680
|
+
}
|
|
1681
|
+
log(`[vault] pushed record #${record.seq} and ${Object.keys(bundles).length} bundle(s) to ${n} replica(s) on ${motivo}`)
|
|
1682
|
+
} catch (e) { log('[vault] could not push to the replicas: ' + e.message) }
|
|
1435
1683
|
}
|
|
1436
1684
|
|
|
1437
1685
|
/**
|
|
@@ -1814,6 +2062,19 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1814
2062
|
// abrirse desde un navegador. Lo que cambia es que «pública» ya sí quiere decir algo
|
|
1815
2063
|
// para quien administra — puede leerla— además de que se despacha sin aprobación.
|
|
1816
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
|
+
}
|
|
1817
2078
|
if (owner.startsWith('ns:')) {
|
|
1818
2079
|
const owned = await nsMembers(owner.slice(3))
|
|
1819
2080
|
// Sin dueño (un cajón personal, o uno cuyo servicio ya no está) entra quien administra
|
|
@@ -2369,6 +2630,25 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2369
2630
|
}
|
|
2370
2631
|
async function deleteSecret (ns, key) { const ok = await secrets.delete(ns, key); if (ok) { audit('secret.rm', { ns, key }); scheduleNotice(ns) } return ok }
|
|
2371
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
|
+
|
|
2372
2652
|
/**
|
|
2373
2653
|
* CARGAR CONFIGURACIÓN ES UNA TRANSACCIÓN: muchas variables, UN aviso.
|
|
2374
2654
|
*
|
|
@@ -2604,6 +2884,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2604
2884
|
if (r?.locked === false) { try { await ensureCommKeyInActa() } catch (_) {} }
|
|
2605
2885
|
return { locked: r?.locked !== false }
|
|
2606
2886
|
},
|
|
2887
|
+
// Forzar un empujón: lo usa `replica push` de la CLI y el smoke, para no depender
|
|
2888
|
+
// de que justo acabe de cambiar algo.
|
|
2889
|
+
pushToReplicas,
|
|
2607
2890
|
startPairing: desk.startPairing,
|
|
2608
2891
|
stopPairing: desk.stopPairing,
|
|
2609
2892
|
listPending: desk.listPending,
|
|
@@ -2652,7 +2935,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2652
2935
|
// El mostrador que atiende a la consola remota. Se expone para poder probar la
|
|
2653
2936
|
// frontera de verdad (que el valor de una privada no salga ni dentro del sobre).
|
|
2654
2937
|
vars: varsDesk,
|
|
2655
|
-
setSecret, deleteSecret, listSecrets, setSecretVisibility, openSecrets,
|
|
2938
|
+
setSecret, deleteSecret, renameSecret, renameDeviceSecret, listSecrets, setSecretVisibility, openSecrets,
|
|
2656
2939
|
// Sella un `secrets.json` v3 entero. Es el punto de no retorno del despliegue y
|
|
2657
2940
|
// por eso es una operación con nombre propio, no algo que ocurra de refilón al
|
|
2658
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).
|