@dotrino/vaultd 0.141.0 → 0.142.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/lib/src/admin.js +15 -0
- package/lib/src/protocol.js +7 -0
- package/package.json +2 -2
- package/src/blocked.js +71 -0
- package/src/ctl.js +70 -2
- package/src/daemon.js +20 -1
- package/src/vault.js +122 -3
package/README.md
CHANGED
|
@@ -237,6 +237,7 @@ dotrino-vault devices # lista dispositivos enrolados / revocados
|
|
|
237
237
|
dotrino-vault members # el acta del perfil: qué llaves son tuyas y qué puede cada una
|
|
238
238
|
dotrino-vault caps <ID> ±permiso # cambia lo que puede un dispositivo (+firma -guarda +lee +administra +aprueba …)
|
|
239
239
|
dotrino-vault revoke <nonce> # revoca un dispositivo (le ordena autoborrarse)
|
|
240
|
+
dotrino-vault block <ID> # lo deja fuera sin quitarlo del acta; unblock <ID> lo deshace (solo aquí o por el admin)
|
|
240
241
|
dotrino-vault activity [n] # bitácora de seguridad: firmas, renovaciones, enrolados, rechazos
|
|
241
242
|
dotrino-vault pair --service <ns> # empareja un SERVICIO (proxy, geo…) con acceso SOLO a sus secretos
|
|
242
243
|
dotrino-vault pair --scope <lista> # los PERMISOS con los que entra (cualquiera de caps: firma,lee,guarda,administra,aprueba…
|
package/lib/src/admin.js
CHANGED
|
@@ -47,6 +47,9 @@ export const ADMIN_OPS = Object.freeze([
|
|
|
47
47
|
// `pending`, `pair`, `approve` y `reject` NO están, y no es un olvido: agregar aparatos
|
|
48
48
|
// a distancia se quitó (ver la cabecera). Lo que queda es mirar, quitar y configurar.
|
|
49
49
|
'revoke', 'audit',
|
|
50
|
+
// Los BLOQUEADOS por un aprobador (`src/blocked.js`): verlos y desbloquearlos. Bloquear a
|
|
51
|
+
// distancia también, por si el administrador llega antes que el teléfono. No toca el acta.
|
|
52
|
+
'blocked', 'block', 'unblock',
|
|
50
53
|
// Variables de entorno: verlas (nombres siempre; valor solo de las públicas) y
|
|
51
54
|
// ponerles valor (de las dos). Borrar NO está, y es a propósito: un aparato robado
|
|
52
55
|
// no debe poder dejar sin configuración a los servicios.
|
|
@@ -96,6 +99,8 @@ export const ADMIN_OPS = Object.freeze([
|
|
|
96
99
|
*/
|
|
97
100
|
export const ADMIN_OPS_WHILE_LOCKED = Object.freeze([
|
|
98
101
|
'audit', 'vars', 'var.set', 'var.setMany', 'var.recipients',
|
|
102
|
+
// Bloquear no sella nada: la lista es de la bóveda, no del acta.
|
|
103
|
+
'blocked', 'block', 'unblock',
|
|
99
104
|
// Las dos de abrir, obviamente: son las únicas que solo tienen sentido con el candado
|
|
100
105
|
// echado. Que estén aquí NO las deja pasar sin más — siguen exigiendo cert, acta, nonce
|
|
101
106
|
// y frescura como todas, y además la contraseña dentro del sobre.
|
|
@@ -147,6 +152,8 @@ export function createAdminDesk ({
|
|
|
147
152
|
// este módulo es puro a propósito. `null` en una bóveda que no lo soporte: entonces la
|
|
148
153
|
// operación existe y contesta que no, en vez de romper con un TypeError.
|
|
149
154
|
unlockDesk = null,
|
|
155
|
+
// La lista de bloqueados, inyectada como `vars`: `{ list(), set(pub, on, { by }) }`.
|
|
156
|
+
blocked = null,
|
|
150
157
|
now = () => Date.now()
|
|
151
158
|
}) {
|
|
152
159
|
const ops = new Set(ADMIN_OPS)
|
|
@@ -333,6 +340,14 @@ export function createAdminDesk ({
|
|
|
333
340
|
// usarlo para quitar dejaba un miembro sin certificados al que la bóveda ya nunca le
|
|
334
341
|
// mandaba el aviso de expulsión (mientras siga en el acta, un papel retirado
|
|
335
342
|
// significa «renueva»). Ese era el dispositivo fantasma.
|
|
343
|
+
if (data.op === 'blocked' || data.op === 'block' || data.op === 'unblock') {
|
|
344
|
+
if (!blocked) return { ok: false, error: 'admin: this vault cannot block devices' }
|
|
345
|
+
if (data.op === 'blocked') return { ok: true, result: { items: blocked.list() } }
|
|
346
|
+
if (typeof data.sub !== 'string' || !data.sub) return { ok: false, error: `admin: ${data.op} needs the device key (sub)` }
|
|
347
|
+
const r = await blocked.set(data.sub, data.op === 'block', { by })
|
|
348
|
+
audit(`admin.${data.op}`, { by, device: await deviceIdOf(data.sub).catch(() => null) })
|
|
349
|
+
return { ok: true, result: { ok: true, was: r } }
|
|
350
|
+
}
|
|
336
351
|
if (data.op === 'revoke') {
|
|
337
352
|
if (data.sub) {
|
|
338
353
|
const deviceId = await deviceIdOf(String(data.sub)).catch(() => null)
|
package/lib/src/protocol.js
CHANGED
|
@@ -103,6 +103,13 @@ export const MSG = Object.freeze({
|
|
|
103
103
|
// una forma de echar a alguien de su cuenta.
|
|
104
104
|
LOGIN_CLOSE: 'vault.login.close', // aparato → vault: { data:{op,user,sid,publickey,ts}, signature }
|
|
105
105
|
LOGIN_CLOSED: 'vault.login.closed', // vault → aparato: { ok }
|
|
106
|
+
// INCIDENTE: un agente de la cuenta (la terminal) avisa de que OTRO aparato de la cuenta
|
|
107
|
+
// se equivocó de clave tres veces seguidas. La bóveda lo apunta como un pedido más
|
|
108
|
+
// (`kind: 'incident'`), timbra a quien aprueba, y el aprobador bloquea o ignora. Bloquear
|
|
109
|
+
// no toca el acta: es una lista de la bóveda (`blocked.json`) que viaja a los agentes con
|
|
110
|
+
// `vault.devices.result` (`blocked`) y se deshace con `dotrino-vault unblock <ID>`.
|
|
111
|
+
INCIDENT: 'vault.incident', // agente → vault: { data:{op:'incident',kind,about,tries,publickey,ts}, signature, cert }
|
|
112
|
+
INCIDENT_RESULT: 'vault.incident.result', // vault → agente: { id, approvers, blocked }
|
|
106
113
|
ERROR: 'vault.error' // vault → dispositivo: { error }
|
|
107
114
|
})
|
|
108
115
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.142.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
|
|
6
6
|
"bin": {
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"!lib/src/types.d.ts"
|
|
51
51
|
],
|
|
52
52
|
"devDependencies": {
|
|
53
|
-
"@dotrino/remote-agent": "0.
|
|
53
|
+
"@dotrino/remote-agent": "0.13.0",
|
|
54
54
|
"@types/node": "^22.0.0",
|
|
55
55
|
"typescript": "5.9.3"
|
|
56
56
|
},
|
package/src/blocked.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LOS APARATOS BLOQUEADOS: la lista que un aprobador decide y solo la bóveda deshace.
|
|
3
|
+
*
|
|
4
|
+
* Un agente (la terminal, por ahora) reporta un INCIDENTE cuando un aparato de la cuenta se
|
|
5
|
+
* equivoca de clave tres veces seguidas. El aprobador lo ve en su teléfono y elige: bloquear
|
|
6
|
+
* o ignorar. Bloquear NO es revocar: el aparato sigue en el acta —no hace falta la maestra, y
|
|
7
|
+
* la bóveda vive cerrada—, pero la bóveda deja de atenderle y los agentes, que reciben esta
|
|
8
|
+
* lista con el acta, le cierran la puerta. Se deshace aquí (`dotrino-vault unblock <ID>`) o
|
|
9
|
+
* desde la consola de administración; un aprobador solo bloquea.
|
|
10
|
+
*
|
|
11
|
+
* Va en el disco cifrado en reposo como las contraseñas: es parte del mapa de la cuenta.
|
|
12
|
+
* Se carga UNA vez: un archivo que existe y no se puede leer NO es «nadie bloqueado» — se
|
|
13
|
+
* lanza, y la bóveda no arranca con una lista que no puede saber.
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs'
|
|
16
|
+
import path from 'node:path'
|
|
17
|
+
|
|
18
|
+
export const BLOCKED_FILE = 'blocked.json'
|
|
19
|
+
|
|
20
|
+
const fail = (code, message) => Object.assign(new Error(message), { code })
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {{ dir: string, atRest: { encrypt: (s: string) => string, decrypt: (s: string) => string }, now?: () => number }} opts
|
|
24
|
+
*/
|
|
25
|
+
export function openBlocked ({ dir, atRest, now = Date.now }) {
|
|
26
|
+
const file = path.join(dir, BLOCKED_FILE)
|
|
27
|
+
/** pub → { deviceId, label, since, by, reason } */
|
|
28
|
+
let items = load()
|
|
29
|
+
|
|
30
|
+
function load () {
|
|
31
|
+
let raw
|
|
32
|
+
try { raw = fs.readFileSync(file, 'utf8') } catch (e) {
|
|
33
|
+
if (e.code === 'ENOENT') return new Map()
|
|
34
|
+
throw fail('blocked-unreadable', `cannot read ${BLOCKED_FILE}: ${e.message}`)
|
|
35
|
+
}
|
|
36
|
+
let d
|
|
37
|
+
try { d = JSON.parse(atRest.decrypt(raw)) } catch (e) { throw fail('blocked-unreadable', `${BLOCKED_FILE} cannot be opened: ${e.message}`) }
|
|
38
|
+
if (d?.v !== 1 || !d.items || typeof d.items !== 'object') throw fail('blocked-unreadable', `${BLOCKED_FILE} has an unknown shape`)
|
|
39
|
+
return new Map(Object.entries(d.items))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function save () {
|
|
43
|
+
const tmp = file + '.tmp'
|
|
44
|
+
fs.writeFileSync(tmp, atRest.encrypt(JSON.stringify({ v: 1, items: Object.fromEntries(items) })), { mode: 0o600 })
|
|
45
|
+
fs.renameSync(tmp, file)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const publicOf = (pub, b) => ({ pub, deviceId: b.deviceId || null, label: b.label || '', since: b.since, by: b.by || null, reason: b.reason || null })
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
has: (pub) => items.has(pub),
|
|
52
|
+
pubs: () => [...items.keys()],
|
|
53
|
+
list: () => [...items].map(([pub, b]) => publicOf(pub, b)),
|
|
54
|
+
/** Bloquea. Devuelve la entrada; si ya estaba, la que había (no se pisa el «desde cuándo»). */
|
|
55
|
+
block ({ pub, deviceId = null, label = '', by = null, reason = null }) {
|
|
56
|
+
if (typeof pub !== 'string' || !pub) throw fail('bad-pub', 'block: a device key is required')
|
|
57
|
+
if (items.has(pub)) return publicOf(pub, items.get(pub))
|
|
58
|
+
const b = { deviceId, label, since: now(), by, reason }
|
|
59
|
+
items.set(pub, b)
|
|
60
|
+
save()
|
|
61
|
+
return publicOf(pub, b)
|
|
62
|
+
},
|
|
63
|
+
/** Desbloquea. `true` si estaba. */
|
|
64
|
+
unblock (pub) {
|
|
65
|
+
if (!items.has(pub)) return false
|
|
66
|
+
items.delete(pub)
|
|
67
|
+
save()
|
|
68
|
+
return true
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/ctl.js
CHANGED
|
@@ -140,7 +140,7 @@ function cmdStatus () {
|
|
|
140
140
|
// corriendo es más viejo que el CLI, avisar (nos mordió 3 veces).
|
|
141
141
|
if (s.version && s.version !== VERSION) {
|
|
142
142
|
console.log(' ⚠ el servicio corre la versión %s (binario instalado: %s).', s.version, VERSION)
|
|
143
|
-
console.log('
|
|
143
|
+
console.log(' Si acabas de actualizar, se pone al día solo en uno o dos minutos. Si no: %s', RESTART_HINT)
|
|
144
144
|
}
|
|
145
145
|
console.log(' fingerprint : %s', s.fingerprint)
|
|
146
146
|
console.log(' proxy : %s', s.proxy)
|
|
@@ -598,7 +598,11 @@ async function cmdMembers () {
|
|
|
598
598
|
console.log('\n%sPerfil%s %s · acta #%d\n', B, Z, profileId, record.seq)
|
|
599
599
|
for (const m of record.members) {
|
|
600
600
|
const who = m.label || m.id
|
|
601
|
+
const bloqueo = (record.blocked || []).find((b) => b.pub === m.pub)
|
|
601
602
|
const marks = [
|
|
603
|
+
// BLOQUEADO va primero y resaltado: es lo que hay que ver antes que ningún permiso,
|
|
604
|
+
// porque mientras dure ningún permiso cuenta.
|
|
605
|
+
bloqueo ? `${B}BLOQUEADO${Z} desde el ${fechaCorta(bloqueo.since)} (dotrino-vault unblock ${m.id})` : null,
|
|
602
606
|
// Ya no hay UN master: la marca dice quién puede sellar, y pueden ser varios. El
|
|
603
607
|
// permiso también sale abajo en la lista, pero aquí se ve de un vistazo — que es
|
|
604
608
|
// justo lo que se busca al mirar quién es quién.
|
|
@@ -667,6 +671,33 @@ async function cmdLabel (args = []) {
|
|
|
667
671
|
console.log('Listo: %s ahora se llama «%s». Compruébalo con: dotrino-vault members', m.id, name.slice(0, 60))
|
|
668
672
|
}
|
|
669
673
|
|
|
674
|
+
/**
|
|
675
|
+
* BLOQUEAR / DESBLOQUEAR un aparato de la cuenta. Lo normal es que bloquee el aprobador desde
|
|
676
|
+
* el teléfono, al recibir el incidente de un agente (la terminal: tres claves mal seguidas);
|
|
677
|
+
* desbloquear SOLO se hace aquí o desde la consola de administración (dueño, 2026-10-07).
|
|
678
|
+
* No toca el acta: el aparato sigue siendo miembro, pero la bóveda y los agentes no le
|
|
679
|
+
* atienden hasta que se le quite el bloqueo.
|
|
680
|
+
*/
|
|
681
|
+
async function cmdBlock (on, args = []) {
|
|
682
|
+
const [id] = args
|
|
683
|
+
const verb = on ? 'block' : 'unblock'
|
|
684
|
+
if (!id) { console.error(`uso: dotrino-vault ${verb} <ID>`); process.exit(2) }
|
|
685
|
+
const s = requireDaemon()
|
|
686
|
+
const m = await findMember(id)
|
|
687
|
+
const resFile = path.join(dataDir(), 'block.json')
|
|
688
|
+
try { fs.rmSync(resFile, { force: true }) } catch (_) {}
|
|
689
|
+
const reqId = Math.random().toString(36).slice(2)
|
|
690
|
+
writeReq('block-request.json', { id: reqId, pub: m.pub, on })
|
|
691
|
+
sendSignal(s.pid, 'SIGUSR2')
|
|
692
|
+
let r = null
|
|
693
|
+
for (let i = 0; i < 50; i++) { await sleep(100); const a = ipcRead(resFile, null); if (a?.at && a.req === reqId) { r = a; break } }
|
|
694
|
+
if (!r) { console.error('El daemon no respondió.'); process.exit(1) }
|
|
695
|
+
if (!r.ok) { console.error('No se pudo %s: %s', on ? 'bloquear' : 'desbloquear', r.error); process.exit(1) }
|
|
696
|
+
if (on) console.log('Bloqueado %s. La bóveda y tus agentes no le atienden hasta: dotrino-vault unblock %s', m.id, m.id)
|
|
697
|
+
else if (r.was) console.log('Desbloqueado %s. Compruébalo con: dotrino-vault members', m.id)
|
|
698
|
+
else console.log('%s no estaba bloqueado.', m.id)
|
|
699
|
+
}
|
|
700
|
+
|
|
670
701
|
/** Busca un miembro del acta por su identificador (AB12-CD34) o se rinde con un mensaje claro. */
|
|
671
702
|
async function findMember (id) {
|
|
672
703
|
const s = requireDaemon()
|
|
@@ -1732,6 +1763,30 @@ async function cmdLogins (rest) {
|
|
|
1732
1763
|
* Instalar no reinicia nada a mano: el daemon vigila su propio binario y, al cambiar, se
|
|
1733
1764
|
* va para que systemd lo levante con el nuevo (`src/selfupdate.js`).
|
|
1734
1765
|
*/
|
|
1766
|
+
/**
|
|
1767
|
+
* ESPERA A QUE EL SERVICIO CORRA LA VERSIÓN INSTALADA, y lo dice. El servicio se reinicia
|
|
1768
|
+
* solo cuando ve el binario nuevo (`selfupdate.js`), pero espera a verlo quieto dos minutos
|
|
1769
|
+
* seguidos: decir «se reinicia solo, compruébalo con status» y que `status` contestara un
|
|
1770
|
+
* minuto después «reinicia para actualizarlo» era mandar a hacer a mano lo que ya iba a pasar.
|
|
1771
|
+
*/
|
|
1772
|
+
async function waitForService (version, { timeoutMs = 4 * 60_000 } = {}) {
|
|
1773
|
+
const s0 = ipcRead(stateFile, null)
|
|
1774
|
+
if (!s0 || !alive(s0.pid)) {
|
|
1775
|
+
console.log('El servicio no está corriendo: arráncalo y ya será la %s. %s', version, START_HINT)
|
|
1776
|
+
return false
|
|
1777
|
+
}
|
|
1778
|
+
process.stdout.write('El servicio la arranca solo en uno o dos minutos. Esperando… ')
|
|
1779
|
+
const t0 = Date.now()
|
|
1780
|
+
while (Date.now() - t0 < timeoutMs) {
|
|
1781
|
+
const s = ipcRead(stateFile, null)
|
|
1782
|
+
if (s?.version === version && alive(s.pid)) { console.log('✓ ya corre la %s', version); return true }
|
|
1783
|
+
await sleep(3000)
|
|
1784
|
+
}
|
|
1785
|
+
console.log('')
|
|
1786
|
+
console.error('El servicio sigue sin correr la %s después de %d min. Reinícialo: %s', version, Math.round(timeoutMs / 60000), RESTART_HINT)
|
|
1787
|
+
return false
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1735
1790
|
async function cmdUpdate (args = []) {
|
|
1736
1791
|
const { latestRelease, assetFor, download, verifyArtifact, isNewer, isUserInstall, installUserRelease } = await import('./update.js')
|
|
1737
1792
|
const soloMirar = args.includes('--check')
|
|
@@ -1763,7 +1818,8 @@ async function cmdUpdate (args = []) {
|
|
|
1763
1818
|
process.exit(1)
|
|
1764
1819
|
}
|
|
1765
1820
|
console.log('firmada por el release de %s ✓', 'imdotrino/dotrino-vault')
|
|
1766
|
-
console.log('Instalada la %s.
|
|
1821
|
+
console.log('Instalada la %s.', res.version)
|
|
1822
|
+
if (!args.includes('--no-wait')) await waitForService(res.version)
|
|
1767
1823
|
return
|
|
1768
1824
|
}
|
|
1769
1825
|
|
|
@@ -2110,6 +2166,8 @@ function help () {
|
|
|
2110
2166
|
|
|
2111
2167
|
tui interfaz de terminal a pantalla completa (bóvedas, pares, secretos)
|
|
2112
2168
|
status | info estado del servicio + fingerprint + el id de cada perfil
|
|
2169
|
+
update [--check] trae la versión nueva (verificando la firma) y espera a que el
|
|
2170
|
+
servicio la arranque · --check solo mira · --no-wait no espera
|
|
2113
2171
|
pair [--save <f>] inicia un emparejamiento (QR + espera); --save escribe la invitación (.dpair)
|
|
2114
2172
|
pair --kms <config.json>
|
|
2115
2173
|
el sitio que se cree (--adopt o --new-account) NACE con su clave
|
|
@@ -2182,6 +2240,10 @@ function help () {
|
|
|
2182
2240
|
+sella = OTRA BÓVEDA que puede sellar el acta de esta cuenta, para
|
|
2183
2241
|
que perder una máquina no se la lleve. No es un traspaso
|
|
2184
2242
|
revoke <ID|nonce> quita un dispositivo (con el ID, todos sus certificados)
|
|
2243
|
+
block <ID> bloquea un aparato sin quitarlo: la bóveda y tus agentes dejan de
|
|
2244
|
+
atenderle. Lo normal es bloquear desde el teléfono al recibir el
|
|
2245
|
+
incidente (la terminal: tres claves mal seguidas)
|
|
2246
|
+
unblock <ID> le quita el bloqueo (solo desde aquí o desde la consola de admin)
|
|
2185
2247
|
logins los aparatos que se abren con usuario y contraseña (para un equipo
|
|
2186
2248
|
prestado, donde no puedes emparejar nada)
|
|
2187
2249
|
logins add <usuario> [nombre del equipo] [±permiso …]
|
|
@@ -2320,6 +2382,10 @@ async function cmdReplica (args) {
|
|
|
2320
2382
|
|
|
2321
2383
|
export async function runCtl (argv) {
|
|
2322
2384
|
const [cmd, ...rest] = takeProfileFlag(argv)
|
|
2385
|
+
// `--help` EN CUALQUIER COMANDO ENSEÑA LA AYUDA Y NO HACE NADA. Ningún subcomando lo miraba,
|
|
2386
|
+
// así que se ejecutaban igual: `pair --help` lanzaba una invitación real y `update --help`
|
|
2387
|
+
// instalaba la versión nueva (2026-10-05). Mirar cómo se usa algo no puede cambiar nada.
|
|
2388
|
+
if (rest.includes('--help') || rest.includes('-h')) return help()
|
|
2323
2389
|
switch (cmd) {
|
|
2324
2390
|
case 'tui': return cmdTui()
|
|
2325
2391
|
case 'profile': return cmdProfile(rest)
|
|
@@ -2340,6 +2406,8 @@ export async function runCtl (argv) {
|
|
|
2340
2406
|
case 'label': return cmdLabel(rest)
|
|
2341
2407
|
case 'caps': return cmdCaps(rest)
|
|
2342
2408
|
case 'revoke': return cmdRevoke(rest[0])
|
|
2409
|
+
case 'block': return cmdBlock(true, rest)
|
|
2410
|
+
case 'unblock': return cmdBlock(false, rest)
|
|
2343
2411
|
case 'logins': return cmdLogins(rest)
|
|
2344
2412
|
case 'update': return cmdUpdate(rest)
|
|
2345
2413
|
case 'secret': return cmdSecret(rest)
|
package/src/daemon.js
CHANGED
|
@@ -638,6 +638,25 @@ export async function runDaemon () {
|
|
|
638
638
|
console.log('[vault] device renamed: %s', labelReq.label || '(no name)')
|
|
639
639
|
} catch (e) { console.error('[vault] could not rename the device:', e.message) }
|
|
640
640
|
}
|
|
641
|
+
// BLOQUEAR / DESBLOQUEAR un aparato (`dotrino-vault block|unblock <ID>`). No toca el
|
|
642
|
+
// acta, así que funciona con el perfil cerrado; se contesta en `block.json`.
|
|
643
|
+
const blockReq = readJsonSafe(path.join(dir, 'block-request.json'))
|
|
644
|
+
if (blockReq?.pub && typeof blockReq.on === 'boolean') {
|
|
645
|
+
rm(path.join(dir, 'block-request.json'))
|
|
646
|
+
const answer = (extra) => ipcWrite(path.join(dir, 'block.json'), { v: 1, at: Date.now(), req: blockReq.id || null, ...extra })
|
|
647
|
+
try {
|
|
648
|
+
// Con el perfil CERRADO también: bloquear no sella nada, y un aparato que está
|
|
649
|
+
// probando claves no espera a que el dueño abra la bóveda.
|
|
650
|
+
const vault = resolveTarget(blockReq)?.vault
|
|
651
|
+
if (!vault) throw Object.assign(new Error('no vault for that profile'), { code: 'NO_PROFILE' })
|
|
652
|
+
const r = await vault.setBlocked(blockReq.pub, blockReq.on, { by: 'vault' })
|
|
653
|
+
console.log('[vault] device %s', blockReq.on ? 'blocked' : (r ? 'unblocked' : 'was not blocked'))
|
|
654
|
+
answer({ ok: true, on: blockReq.on, was: r })
|
|
655
|
+
} catch (e) {
|
|
656
|
+
console.error('[vault] could not %s the device: %s', blockReq.on ? 'block' : 'unblock', e.message)
|
|
657
|
+
answer({ ok: false, error: e.message, code: e.code || 'BLOCK_FAILED' })
|
|
658
|
+
}
|
|
659
|
+
}
|
|
641
660
|
const capsReq = readJsonSafe(path.join(dir, 'caps-request.json'))
|
|
642
661
|
// VARIOS APARATOS, UNA ACTA (el borrador de la TUI). Se contesta con el `id` de la
|
|
643
662
|
// petición, para que quien espera sepa si se firmó de verdad.
|
|
@@ -894,7 +913,7 @@ export async function runDaemon () {
|
|
|
894
913
|
lastSecretHistory = null
|
|
895
914
|
ipcWrite(devFile, { v: 1, at: Date.now(), req: reqId, profile: t.id, ...(await t.vault.listDevices()) })
|
|
896
915
|
// Acta del perfil: quién es del perfil y qué puede hacer cada uno (`members`/`caps`).
|
|
897
|
-
try { ipcWrite(path.join(dir, 'acta.json'), { v: 1, at: Date.now(), req: reqId, profile: t.id, ...(await t.vault.profileMembers()) }) } catch (_) {}
|
|
916
|
+
try { ipcWrite(path.join(dir, 'acta.json'), { v: 1, at: Date.now(), req: reqId, profile: t.id, ...(await t.vault.profileMembers()), blocked: t.vault.listBlocked?.() || [] }) } catch (_) {}
|
|
898
917
|
}
|
|
899
918
|
|
|
900
919
|
// PERFIL del usuario (apodo, foto, datos) tal como lo tiene la bóveda: `dotrino-vault me`.
|
package/src/vault.js
CHANGED
|
@@ -29,6 +29,7 @@ import { openStore } from './store.js'
|
|
|
29
29
|
import { openThreadStore, STORE_READ_METHODS, PROFILE_EDIT_METHODS } from './threadStore.js'
|
|
30
30
|
import { openSecretsStore, assertVar, RECOVERY as RECOVERY_WRAP, PROFILE_OWNER } from './secretsStore.js'
|
|
31
31
|
import { openSubacta } from './subacta.js'
|
|
32
|
+
import { openBlocked } from './blocked.js'
|
|
32
33
|
import { makeEphemeralKey, openSealed } from '../lib/src/sealed.js'
|
|
33
34
|
import { VERSION } from './version.js'
|
|
34
35
|
import { declare as compatDeclare, check as compatCheck, annotate as compatAnnotate } from '@dotrino/compat'
|
|
@@ -259,6 +260,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
259
260
|
// además un emparejamiento paralelo al del ecosistema.
|
|
260
261
|
const passwordsFile = path.join(dir, 'passwords.json')
|
|
261
262
|
const passwordsAtRest = atRestFor(dir)
|
|
263
|
+
// Los aparatos BLOQUEADOS por un aprobador (`src/blocked.js`). Cifrado en reposo como lo demás.
|
|
264
|
+
const blocked = openBlocked({ dir, atRest: passwordsAtRest })
|
|
262
265
|
const readPasswordsFile = () => {
|
|
263
266
|
try { return JSON.parse(passwordsAtRest.decrypt(fs.readFileSync(passwordsFile, 'utf8'))) } catch (_) { return null }
|
|
264
267
|
}
|
|
@@ -603,6 +606,12 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
603
606
|
const verifyChain = async (args) => {
|
|
604
607
|
const chk = await verifyChainRaw(args)
|
|
605
608
|
if (chk.ok && args?.data?.v) apuntarVersion(chk.device, args.data.v)
|
|
609
|
+
// UN BLOQUEADO NO PASA POR NINGÚN MOSTRADOR. Sigue en el acta —bloquear no la toca,
|
|
610
|
+
// porque la bóveda vive cerrada y eso lo decide un aprobador desde el teléfono—, así que
|
|
611
|
+
// el papel verifica; la puerta se cierra aquí, que es por donde entra todo, y con su
|
|
612
|
+
// propio `reason` para que el otro lado sepa qué le pasa y no lo confunda con un papel
|
|
613
|
+
// viejo (que se arregla renovando) ni con una revocación (que se arregla emparejando).
|
|
614
|
+
if (chk.ok && blocked.has(chk.device)) return { ok: false, reason: 'blocked', device: chk.device }
|
|
606
615
|
return chk
|
|
607
616
|
}
|
|
608
617
|
|
|
@@ -1281,7 +1290,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1281
1290
|
return reply(from, { type: MSG.ERROR, error: 'unauthorized: this vault has no record to decide with' })
|
|
1282
1291
|
}
|
|
1283
1292
|
audit('devices', { device: await deviceIdOf(chk.device).catch(() => null), solo: 'revocaciones' })
|
|
1284
|
-
return reply(from, { type: MSG.DEVICES_RESULT, devices: [], revoked, acta: record })
|
|
1293
|
+
return reply(from, { type: MSG.DEVICES_RESULT, devices: [], revoked, blocked: blocked.pubs(), acta: record })
|
|
1285
1294
|
}
|
|
1286
1295
|
// El acta viaja con la lista: así cada dispositivo se entera de los cambios de
|
|
1287
1296
|
// política (quién manda, quién puede qué) sin un canal aparte.
|
|
@@ -1313,7 +1322,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1313
1322
|
// LA SUBACTA VIAJA CON EL ACTA. Es la mitad de «se comparte entre todos los
|
|
1314
1323
|
// dispositivos»: sin esto, una renuncia sin sellar solo la respeta esta máquina.
|
|
1315
1324
|
// Cada entrada va firmada por su sujeto, así que quien la recibe la verifica sola.
|
|
1316
|
-
reply(from, fitChain({ type: MSG.DEVICES_RESULT, devices, revoked, acta: record, chain, vault: MI_VERSION, subacta: subacta.entries() }))
|
|
1325
|
+
reply(from, fitChain({ type: MSG.DEVICES_RESULT, devices, revoked, blocked: blocked.pubs(), acta: record, chain, vault: MI_VERSION, subacta: subacta.entries() }))
|
|
1317
1326
|
}
|
|
1318
1327
|
|
|
1319
1328
|
/**
|
|
@@ -1580,7 +1589,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1580
1589
|
if (p.data?.op === 'enckey') return handleEncKey(from, p)
|
|
1581
1590
|
if (p.data?.op === 'store.recipients' || p.data?.op === 'store') return handleVarStore(from, p)
|
|
1582
1591
|
if (p.data?.op === 'digest') return handleDigest(from, p)
|
|
1583
|
-
if (['approvals', 'approve', 'deny', 'grants', 'grant-revoke'].includes(p.data?.op)) return handleApproval(from, p)
|
|
1592
|
+
if (['approvals', 'approve', 'deny', 'grants', 'grant-revoke', 'block', 'blocked'].includes(p.data?.op)) return handleApproval(from, p)
|
|
1584
1593
|
const ns = p.data?.ns
|
|
1585
1594
|
if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: invalid namespace' })
|
|
1586
1595
|
if (typeof p.data?.ek !== 'string') return reply(from, { type: MSG.ERROR, error: 'secrets: missing ek (requester ephemeral key)' })
|
|
@@ -1759,6 +1768,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1759
1768
|
// renueva con cada uso puede durar indefinidamente: si no se puede mirar ni quitar desde
|
|
1760
1769
|
// el mismo sitio donde se dio, es un permiso invisible, y eso no lo damos por bueno.
|
|
1761
1770
|
if (op === 'grants') return answer({ op: 'grants', items: await conContextoSellado(grants.list(), chk.device, record), ttlMs: GRANT_TTL_MS })
|
|
1771
|
+
// LOS BLOQUEADOS SE VEN desde donde se bloquean. Desbloquear no: eso es de la bóveda
|
|
1772
|
+
// (`dotrino-vault unblock <ID>`) o del administrador, que es lo que pidió el dueño.
|
|
1773
|
+
if (op === 'blocked') return answer({ op: 'blocked', items: blocked.list() })
|
|
1762
1774
|
if (op === 'grant-revoke') {
|
|
1763
1775
|
const gid = typeof p.data?.id === 'string' ? p.data.id : ''
|
|
1764
1776
|
const ok = gid ? grants.revoke(gid) : false
|
|
@@ -1771,6 +1783,31 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1771
1783
|
const id = typeof p.data?.id === 'string' ? p.data.id : ''
|
|
1772
1784
|
const pend = approvals.take(id)
|
|
1773
1785
|
if (!pend) return reply(from, { type: MSG.ERROR, error: 'approval: unknown or expired request' })
|
|
1786
|
+
// UN INCIDENTE no entrega nada: se contesta BLOQUEAR (`block`) o IGNORAR (`deny`).
|
|
1787
|
+
// `approve` no significa nada aquí y se rechaza en voz alta: el pedido vuelve a la mesa
|
|
1788
|
+
// para que lo conteste con la palabra correcta.
|
|
1789
|
+
if (pend.kind === 'incident') {
|
|
1790
|
+
if (op !== 'block' && op !== 'deny') {
|
|
1791
|
+
approvals.request({ ...pend, ttlMs: Math.max(1000, pend.exp - Date.now()) })
|
|
1792
|
+
return reply(from, { type: MSG.ERROR, error: 'approval: an incident is answered with block or deny' })
|
|
1793
|
+
}
|
|
1794
|
+
if (op === 'deny') {
|
|
1795
|
+
audit('incident.ignored', { device: pend.deviceId, kind: pend.ctx?.kind || null, id, by })
|
|
1796
|
+
log(`[vault] incident ${id} about ${pend.deviceId} ignored by ${by}`)
|
|
1797
|
+
return answer({ op: 'deny.result', id, ok: true })
|
|
1798
|
+
}
|
|
1799
|
+
const b = blocked.block({ pub: pend.device, deviceId: pend.deviceId, label: pend.label, by, reason: pend.ctx?.kind || 'incident' })
|
|
1800
|
+
// Lo que ya tenía concedido se corta en el acto: bloquear y dejarle pasar la hora que
|
|
1801
|
+
// le quedaba sería un bloqueo de mentira.
|
|
1802
|
+
const cut = grants.revokeAll({ device: pend.device })
|
|
1803
|
+
audit('blocked', { device: pend.deviceId, kind: pend.ctx?.kind || null, id, by, grants: cut })
|
|
1804
|
+
log(`[vault] BLOCKED ${pend.deviceId}${pend.label ? ` (${pend.label})` : ''} by ${by} after incident ${id} · unblock with: dotrino-vault unblock ${pend.deviceId}`)
|
|
1805
|
+
// Se avisa a todos: los agentes (la terminal) traen la lista con el acta al recibirlo
|
|
1806
|
+
// y le cierran la sesión que tenga abierta. Sin esto el bloqueo esperaba al tic de 5 min.
|
|
1807
|
+
await notifyMembers('blocked', { deviceId: pend.deviceId, by })
|
|
1808
|
+
return answer({ op: 'block.result', id, ok: true, deviceId: pend.deviceId, since: b.since })
|
|
1809
|
+
}
|
|
1810
|
+
if (op === 'block') return reply(from, { type: MSG.ERROR, error: 'approval: only an incident can be blocked' })
|
|
1774
1811
|
// PEDIDOS QUE NO ENTREGAN UN CAJÓN (la bóveda de contraseñas, guardar variables). Se
|
|
1775
1812
|
// resuelven ANTES de tocar `resultFor`, que asume un cajón y una `ek`: aquí no hay nada
|
|
1776
1813
|
// que sellar, solo una promesa esperando un sí o un no.
|
|
@@ -1903,6 +1940,57 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1903
1940
|
} catch (e) { log('[vault] could not notify approvers:', e.message) }
|
|
1904
1941
|
}
|
|
1905
1942
|
|
|
1943
|
+
/**
|
|
1944
|
+
* UN INCIDENTE: un agente de la cuenta dice que OTRO aparato de la cuenta se equivocó de
|
|
1945
|
+
* clave tres veces seguidas (la terminal, `dotrino-terminal lock`). No se decide aquí
|
|
1946
|
+
* nada: se apunta como un pedido más, de tipo `incident`, y se timbra a quien aprueba,
|
|
1947
|
+
* que es quien bloquea o ignora desde el teléfono. Sin nadie que apruebe no hay a quién
|
|
1948
|
+
* preguntar: se anota en la bitácora y se dice, y el agente se queda con su freno local.
|
|
1949
|
+
*
|
|
1950
|
+
* Quien reporta tiene que ser un miembro con `sign` (es lo que lo hace agente), y el
|
|
1951
|
+
* señalado tiene que estar en el acta: un incidente sobre un desconocido no es un incidente.
|
|
1952
|
+
* Y si el señalado ya está bloqueado no se vuelve a timbrar: se contesta que lo está.
|
|
1953
|
+
*/
|
|
1954
|
+
async function handleIncident (from, p) {
|
|
1955
|
+
if (!isFresh(p.data)) return staleReply(from)
|
|
1956
|
+
const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, ...(await contextoActa()), revoked: await revocationSet() })
|
|
1957
|
+
if (!chk.ok) return denyChain(from, chk, p, 'incident')
|
|
1958
|
+
const record = await refreshActa()
|
|
1959
|
+
if (!record || !Acta.memberCan(record, chk.device, 'sign')) {
|
|
1960
|
+
audit('rejected', { what: 'incident', reason: record ? 'acta' : 'sin-acta' })
|
|
1961
|
+
return reply(from, { type: MSG.ERROR, error: 'unauthorized: acta — this member cannot report incidents' })
|
|
1962
|
+
}
|
|
1963
|
+
const d = p.data || {}
|
|
1964
|
+
const kind = typeof d.kind === 'string' ? d.kind.slice(0, 32) : ''
|
|
1965
|
+
const about = typeof d.about === 'string' ? d.about : ''
|
|
1966
|
+
const tries = Number.isInteger(d.tries) ? d.tries : null
|
|
1967
|
+
if (d.op !== 'incident' || !kind || !about) return reply(from, { type: MSG.ERROR, error: 'incident: op, kind and about are required' })
|
|
1968
|
+
const target = (record.members || []).find((m) => m.pub === about)
|
|
1969
|
+
if (!target) return reply(from, { type: MSG.ERROR, code: 'unknown-device', error: 'incident: that device is not in the record' })
|
|
1970
|
+
const by = await deviceIdOf(chk.device).catch(() => null)
|
|
1971
|
+
const byLabel = (record.members || []).find((m) => m.pub === chk.device)?.label || ''
|
|
1972
|
+
const deviceId = await deviceIdOf(about).catch(() => null)
|
|
1973
|
+
audit('incident', { device: deviceId, by, kind, tries })
|
|
1974
|
+
if (blocked.has(about)) {
|
|
1975
|
+
log(`[vault] incident (${kind}) about ${deviceId} reported by ${by}: already blocked`)
|
|
1976
|
+
return reply(from, { type: MSG.INCIDENT_RESULT, id: null, approvers: 0, blocked: true })
|
|
1977
|
+
}
|
|
1978
|
+
const approvers = (record.members || []).filter((m) => Acta.memberCan(record, m.pub, 'approve')).length
|
|
1979
|
+
if (!approvers) {
|
|
1980
|
+
log(`[vault] incident (${kind}) about ${deviceId} reported by ${by}: nobody can approve, so nobody is asked (grant it with: dotrino-vault caps <ID> +aprueba)`)
|
|
1981
|
+
return reply(from, { type: MSG.INCIDENT_RESULT, id: null, approvers: 0, blocked: false })
|
|
1982
|
+
}
|
|
1983
|
+
// `ns` es el que reporta: la pantalla dice «X falló la clave de la terminal de Y». El
|
|
1984
|
+
// contexto va sellado al aprobador como el de cualquier pedido.
|
|
1985
|
+
const pend = approvals.request({
|
|
1986
|
+
ns: by || 'incident', kind: 'incident', device: about, deviceId, label: target.label || '', ek: '',
|
|
1987
|
+
ctx: { kind, tries, reporter: by, reporterLabel: byLabel }, from, ttlMs: UPDATE_TTL_MS
|
|
1988
|
+
})
|
|
1989
|
+
log(`[vault] incident (${kind}) about ${deviceId}${target.label ? ` (${target.label})` : ''} reported by ${by}: asking ${approvers} approver(s) (${pend.id})`)
|
|
1990
|
+
await notifyApprovers(pend, record)
|
|
1991
|
+
reply(from, { type: MSG.INCIDENT_RESULT, id: pend.id, approvers, blocked: false })
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1906
1994
|
client.on('message', (from, payload) => onMessage(from, payload))
|
|
1907
1995
|
|
|
1908
1996
|
// EL MOSTRADOR LOCAL, siempre que se pueda. Si no se puede abrir (un sistema sin sockets
|
|
@@ -1933,6 +2021,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1933
2021
|
if (payload.type === MSG.ADMIN) return await handleAdmin(from, payload)
|
|
1934
2022
|
if (payload.type === MSG.RENOUNCE) return await handleRenounce(from, payload)
|
|
1935
2023
|
if (payload.type === MSG.ADMIN_EVENT) return await handleAdminEvent(payload)
|
|
2024
|
+
if (payload.type === MSG.INCIDENT) return await handleIncident(from, payload)
|
|
1936
2025
|
if (payload.type === MSG.REPLICA_ACK) return await handleReplicaAck(from, payload)
|
|
1937
2026
|
if (payload.type === MSG.LOGIN_START) return await handleLoginStart(from, payload)
|
|
1938
2027
|
if (payload.type === MSG.LOGIN_FINISH) return await handleLoginFinish(from, payload)
|
|
@@ -2611,10 +2700,37 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2611
2700
|
return pend.id
|
|
2612
2701
|
}
|
|
2613
2702
|
|
|
2703
|
+
/**
|
|
2704
|
+
* BLOQUEAR O DESBLOQUEAR A MANO, desde la bóveda (CLI/TUI) o desde la consola de
|
|
2705
|
+
* administración. El camino normal para bloquear es el aprobador (`handleApproval`,
|
|
2706
|
+
* `block`); desbloquear solo pasa por aquí, que es lo que pidió el dueño.
|
|
2707
|
+
*/
|
|
2708
|
+
async function setBlocked (pub, on, { by = 'vault' } = {}) {
|
|
2709
|
+
const deviceId = await deviceIdOf(pub).catch(() => null)
|
|
2710
|
+
if (on) {
|
|
2711
|
+
const record = await refreshActa()
|
|
2712
|
+
const label = (record?.members || []).find((m) => m.pub === pub)?.label || ''
|
|
2713
|
+
const b = blocked.block({ pub, deviceId, label, by, reason: 'manual' })
|
|
2714
|
+
grants.revokeAll({ device: pub })
|
|
2715
|
+
audit('blocked', { device: deviceId, by, kind: 'manual' })
|
|
2716
|
+
log(`[vault] BLOCKED ${deviceId} by ${by}`)
|
|
2717
|
+
await notifyMembers('blocked', { deviceId, by })
|
|
2718
|
+
return b
|
|
2719
|
+
}
|
|
2720
|
+
const was = blocked.unblock(pub)
|
|
2721
|
+
if (was) {
|
|
2722
|
+
audit('unblocked', { device: deviceId, by })
|
|
2723
|
+
log(`[vault] unblocked ${deviceId} by ${by}`)
|
|
2724
|
+
await notifyMembers('unblocked', { deviceId, by })
|
|
2725
|
+
}
|
|
2726
|
+
return was
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2614
2729
|
const admin = createAdminDesk({
|
|
2615
2730
|
desk,
|
|
2616
2731
|
deviceIdOf,
|
|
2617
2732
|
audit,
|
|
2733
|
+
blocked: { list: () => blocked.list(), set: setBlocked },
|
|
2618
2734
|
// Abrir a distancia. Va inyectado porque `admin.js` es puro y esto necesita cripto y
|
|
2619
2735
|
// disco; `null` si este perfil no lo soporta, y entonces la operación contesta que no.
|
|
2620
2736
|
unlockDesk,
|
|
@@ -4058,6 +4174,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
4058
4174
|
// rotar su llave). Lo enseñan `secret list` y la consola: si no se ve, no se salda.
|
|
4059
4175
|
rotationsDue,
|
|
4060
4176
|
secretDebts,
|
|
4177
|
+
// LOS BLOQUEADOS, y quitarlos de la lista (`dotrino-vault block|unblock <ID>`).
|
|
4178
|
+
listBlocked: () => blocked.list(),
|
|
4179
|
+
setBlocked,
|
|
4061
4180
|
/**
|
|
4062
4181
|
* QUIÉN tiene envoltura de la generación vigente de un cajón (llaves de firma, más
|
|
4063
4182
|
* `#recovery`). Es diagnóstico: saber a cuántos se les envolvió no ayuda a abrir nada,
|