@dotrino/vaultd 0.108.0 → 0.111.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/lib/src/admin.js +27 -2
- package/package.json +2 -2
- package/src/ctl.js +26 -1
- package/src/daemon.js +30 -0
- package/src/manager.js +16 -0
- package/src/profiles.js +136 -23
- package/src/subacta.js +102 -0
- package/src/vault.js +184 -9
package/lib/src/admin.js
CHANGED
|
@@ -61,7 +61,11 @@ export const ADMIN_OPS = Object.freeze([
|
|
|
61
61
|
// documentada en `buildSealedVar` y la consola la llamaba, pero no existía — así que
|
|
62
62
|
// crear una variable desde la consola contestaba «admin: invalid operation». No expone
|
|
63
63
|
// nada nuevo: son llaves PÚBLICAS, las mismas que ya viajan en el acta.
|
|
64
|
-
'vars', 'var.set', 'var.setMany', 'var.recipients'
|
|
64
|
+
'vars', 'var.set', 'var.setMany', 'var.recipients',
|
|
65
|
+
// ABRIR LA BÓVEDA A DISTANCIA (`docs/abrir-a-distancia.md`). Dos pasos: `unlock.begin`
|
|
66
|
+
// pide una llave efímera y los parámetros del molino; `unlock` trae la derivada dentro
|
|
67
|
+
// de un sobre que solo esta bóveda puede abrir. No hay una tercera.
|
|
68
|
+
'unlock.begin', 'unlock'
|
|
65
69
|
])
|
|
66
70
|
|
|
67
71
|
/**
|
|
@@ -90,7 +94,13 @@ export const ADMIN_OPS = Object.freeze([
|
|
|
90
94
|
* Se dejan las de LEER a propósito: si se cortaran todas, la consola no podría ni
|
|
91
95
|
* enterarse de por qué no funciona y parecería rota. Así puede decir «está cerrada».
|
|
92
96
|
*/
|
|
93
|
-
export const ADMIN_OPS_WHILE_LOCKED = Object.freeze([
|
|
97
|
+
export const ADMIN_OPS_WHILE_LOCKED = Object.freeze([
|
|
98
|
+
'audit', 'vars', 'var.set', 'var.setMany', 'var.recipients',
|
|
99
|
+
// Las dos de abrir, obviamente: son las únicas que solo tienen sentido con el candado
|
|
100
|
+
// echado. Que estén aquí NO las deja pasar sin más — siguen exigiendo cert, acta, nonce
|
|
101
|
+
// y frescura como todas, y además la contraseña dentro del sobre.
|
|
102
|
+
'unlock.begin', 'unlock'
|
|
103
|
+
])
|
|
94
104
|
|
|
95
105
|
/** Cuánto se recuerda un nonce ya usado (el doble de la ventana de frescura). */
|
|
96
106
|
export const ADMIN_NONCE_TTL_MS = 10 * 60 * 1000
|
|
@@ -133,6 +143,10 @@ export function authorBody (owner, key, e, ts) {
|
|
|
133
143
|
export function createAdminDesk ({
|
|
134
144
|
desk, verify, readActivity = () => [], deviceIdOf, vars = null,
|
|
135
145
|
notify = async () => {}, audit = () => {}, isLocked = () => false,
|
|
146
|
+
// ABRIR A DISTANCIA. Va inyectado —como `vars`— porque abrir necesita cripto y disco, y
|
|
147
|
+
// este módulo es puro a propósito. `null` en una bóveda que no lo soporte: entonces la
|
|
148
|
+
// operación existe y contesta que no, en vez de romper con un TypeError.
|
|
149
|
+
unlockDesk = null,
|
|
136
150
|
now = () => Date.now()
|
|
137
151
|
}) {
|
|
138
152
|
const ops = new Set(ADMIN_OPS)
|
|
@@ -193,6 +207,17 @@ export function createAdminDesk ({
|
|
|
193
207
|
|
|
194
208
|
try {
|
|
195
209
|
|
|
210
|
+
// ABRIR A DISTANCIA. La bóveda hace el trabajo; aquí solo se enruta, porque este
|
|
211
|
+
// módulo es puro —sin cripto, sin disco— y abrir necesita las dos cosas.
|
|
212
|
+
if (data.op === 'unlock.begin') {
|
|
213
|
+
if (!unlockDesk) return { ok: false, error: 'admin: this vault cannot be opened remotely (update it)' }
|
|
214
|
+
return { ok: true, result: await unlockDesk.begin({ by, device: chk.device }) }
|
|
215
|
+
}
|
|
216
|
+
if (data.op === 'unlock') {
|
|
217
|
+
if (!unlockDesk) return { ok: false, error: 'admin: this vault cannot be opened remotely (update it)' }
|
|
218
|
+
return await unlockDesk.open({ nonce: data.nonce, enc: data.enc, by, device: chk.device })
|
|
219
|
+
}
|
|
220
|
+
|
|
196
221
|
if (data.op === 'audit') {
|
|
197
222
|
const limit = Math.min(Math.max(Number(data.limit) || 100, 1), AUDIT_MAX)
|
|
198
223
|
return { ok: true, result: { entries: readActivity(limit) } }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.111.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": {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@dotrino/compat": "0.4.0",
|
|
25
|
-
"@dotrino/identity": "0.
|
|
25
|
+
"@dotrino/identity": "0.81.0",
|
|
26
26
|
"@dotrino/passmanager": "^0.10.1",
|
|
27
27
|
"@dotrino/proxy-client": "0.15.0",
|
|
28
28
|
"@dotrino/roadmap": "0.2.0",
|
package/src/ctl.js
CHANGED
|
@@ -598,6 +598,11 @@ async function cmdMembers () {
|
|
|
598
598
|
// QUÉ CORRE, y si cuadra con esta bóveda (CONVENCIONES §14). Sin esto una
|
|
599
599
|
// incompatibilidad de versiones se ve como que ese aparato «no responde», que es el
|
|
600
600
|
// fallo más caro que ha tenido el ecosistema. No bloquea nada: se enseña.
|
|
601
|
+
// RENUNCIAS SIN SELLAR. Ya no los tiene —la bóveda no se los reconoce— pero el acta
|
|
602
|
+
// que se lee arriba todavía los nombra, así que hay que decirlo aquí o la lista miente.
|
|
603
|
+
if (m.renounced?.length) {
|
|
604
|
+
console.log(' %s↓ renunció a %s (pendiente de sellar: abre la bóveda)%s', R, m.renounced.join(', '), Z)
|
|
605
|
+
}
|
|
601
606
|
if (m.running) {
|
|
602
607
|
const v = `${m.running.product} ${m.running.version}`
|
|
603
608
|
if (m.running.compatible) console.log(' %scorre %s%s', D, v, Z)
|
|
@@ -1577,8 +1582,28 @@ async function cmdProfile (rest) {
|
|
|
1577
1582
|
reportProfiles(await profileRequest('password-set', { password: pwd, current: actual || undefined }))
|
|
1578
1583
|
return
|
|
1579
1584
|
}
|
|
1585
|
+
// LA CONTRASEÑA DEL ADMIN. Otra, distinta, que solo sirve desde el admin remoto — aquí
|
|
1586
|
+
// no abre nada. Pantalla administrativa: dice qué hace y qué no, y ya (§5.1).
|
|
1587
|
+
case 'admin-password': {
|
|
1588
|
+
const action = args[0]
|
|
1589
|
+
if (action === 'rm') {
|
|
1590
|
+
reportProfiles(await profileRequest('admin-password-rm', {}))
|
|
1591
|
+
return
|
|
1592
|
+
}
|
|
1593
|
+
if (action && action !== 'set') { console.error('uso: dotrino-vault profile admin-password [set|rm]'); process.exit(2) }
|
|
1594
|
+
console.log('Una contraseña APARTE, para abrir la bóveda desde el admin remoto.')
|
|
1595
|
+
console.log('Aquí no abre nada: tecleada en esta máquina no vale. Y para usarla hace')
|
|
1596
|
+
console.log('falta además un aparato tuyo que administre — la contraseña sola no basta.')
|
|
1597
|
+
console.log('\nEl perfil tiene que estar abierto ahora mismo para poder ponerla.')
|
|
1598
|
+
const pwd = await askPassword('\nContraseña del admin: ')
|
|
1599
|
+
if (!pwd) { console.error('Cancelado.'); process.exit(1) }
|
|
1600
|
+
const again = await askPassword('Repítela: ')
|
|
1601
|
+
if (pwd !== again) { console.error('Las contraseñas no coinciden.'); process.exit(1) }
|
|
1602
|
+
reportProfiles(await profileRequest('admin-password-set', { password: pwd }))
|
|
1603
|
+
return
|
|
1604
|
+
}
|
|
1580
1605
|
default:
|
|
1581
|
-
console.error('uso: dotrino-vault profile {ls|add|rename|use|rm|password}'); process.exit(2)
|
|
1606
|
+
console.error('uso: dotrino-vault profile {ls|add|rename|use|rm|password|admin-password}'); process.exit(2)
|
|
1582
1607
|
}
|
|
1583
1608
|
}
|
|
1584
1609
|
|
package/src/daemon.js
CHANGED
|
@@ -24,6 +24,7 @@ import { ipcRead, ipcWrite, migrateIpcDir } from './ipc.js'
|
|
|
24
24
|
import { parseInvite } from '../lib/src/invite.js'
|
|
25
25
|
import { watchBinary } from './selfupdate.js'
|
|
26
26
|
import { VERSION } from './version.js'
|
|
27
|
+
import crypto from 'node:crypto'
|
|
27
28
|
|
|
28
29
|
const readJsonSafe = (f) => ipcRead(f, null)
|
|
29
30
|
const rm = (f) => { try { fs.rmSync(f, { force: true }) } catch (_) {} }
|
|
@@ -400,6 +401,35 @@ export async function runDaemon () {
|
|
|
400
401
|
mgr.profiles.removePassword(id)
|
|
401
402
|
return { done: 'contraseña quitada · los secretos ahora se abren con la llave de esta máquina' }
|
|
402
403
|
}
|
|
404
|
+
// ----- LA CONTRASEÑA DEL ADMIN (`docs/abrir-a-distancia.md`) -----
|
|
405
|
+
//
|
|
406
|
+
// Otra contraseña, distinta, que **solo sirve por el camino del admin**. Lo que se
|
|
407
|
+
// guarda es una copia de la llave del perfil cifrada con lo que sale de ella; por eso
|
|
408
|
+
// ponerla exige el perfil abierto: esa llave solo está en memoria con el candado
|
|
409
|
+
// descorrido.
|
|
410
|
+
//
|
|
411
|
+
// El molino se hace AQUÍ porque quien la pone está delante de la máquina y ya tecleó
|
|
412
|
+
// la principal. Cuando se USA a distancia lo hace el navegador, y por eso la bóveda
|
|
413
|
+
// publica los parámetros (`secondaryParams`).
|
|
414
|
+
case 'admin-password-set': {
|
|
415
|
+
const id = ref()
|
|
416
|
+
if (!req.password) throw new Error('an admin password is required')
|
|
417
|
+
// El salt se acuña ANTES de derivar (`mint`), para derivar una sola vez con el
|
|
418
|
+
// definitivo. El molino se hace aquí porque quien la pone está delante de la
|
|
419
|
+
// máquina y acaba de teclear la principal; cuando se USA a distancia lo hace el
|
|
420
|
+
// navegador, y por eso la bóveda publica estos mismos parámetros.
|
|
421
|
+
const params = mgr.profiles.secondaryParams(id, { mint: true })
|
|
422
|
+
const derivada = new Uint8Array(crypto.scryptSync(
|
|
423
|
+
String(req.password), Buffer.from(params.salt, 'base64'),
|
|
424
|
+
params.len, { N: params.N, r: params.r, p: params.p }))
|
|
425
|
+
try { mgr.profiles.setSecondary(id, derivada) } finally { wipe(derivada) }
|
|
426
|
+
return { done: 'contraseña del admin guardada · solo sirve desde el admin, no aquí' }
|
|
427
|
+
}
|
|
428
|
+
case 'admin-password-rm': {
|
|
429
|
+
const id = ref()
|
|
430
|
+
const r = mgr.profiles.clearSecondary(id)
|
|
431
|
+
return { done: r.had ? 'contraseña del admin quitada' : 'no había ninguna' }
|
|
432
|
+
}
|
|
403
433
|
default: throw new Error('unknown profile operation: ' + req.op)
|
|
404
434
|
}
|
|
405
435
|
}
|
package/src/manager.js
CHANGED
|
@@ -98,6 +98,22 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
|
|
|
98
98
|
// Para la consola remota: la contraseña llega dentro del sobre firmado y hay que
|
|
99
99
|
// convertirla en la llave que abre la copia maestra de los secretos.
|
|
100
100
|
deriveAdminKey: (password) => profiles.adminKey(id, password),
|
|
101
|
+
// ABRIR A DISTANCIA (`docs/abrir-a-distancia.md`). La bóveda no toca el registro de
|
|
102
|
+
// perfiles: se le pasan las tres cosas que necesita y nada más — con qué números
|
|
103
|
+
// deriva el admin, cómo se abre con la derivada, y si hay contraseña de admin puesta.
|
|
104
|
+
secondaryParams: () => profiles.secondaryParams(id),
|
|
105
|
+
openWithSecondary: async (derivada) => {
|
|
106
|
+
const r = await profiles.openWithSecondary(id, derivada)
|
|
107
|
+
// Igual que `unlock`: abrir el perfil no basta, hay que traer la maestra a memoria.
|
|
108
|
+
// Sin esto el candado diría «abierto» y todo lo que la necesita fallaría en otro
|
|
109
|
+
// sitio y por otro motivo, que es el fallo que ya se pagó una vez.
|
|
110
|
+
try { await running.get(id)?.takeMasterKey?.() } catch (e) {
|
|
111
|
+
try { profiles.lock(id) } catch (_) {}
|
|
112
|
+
throw new Error(`opened the profile but could not open it: ${e.message}`)
|
|
113
|
+
}
|
|
114
|
+
return r
|
|
115
|
+
},
|
|
116
|
+
hasSecondary: () => profiles.hasSecondary(id),
|
|
101
117
|
// La llave del perfil MIENTRAS esté abierto, o null. Con ella, enrolar un servicio
|
|
102
118
|
// con la bóveda abierta le envuelve su cajón en el acto en vez de dejarlo entrar
|
|
103
119
|
// sin poder leer nada (ver `profiles.js`, `llaves`).
|
package/src/profiles.js
CHANGED
|
@@ -24,12 +24,22 @@ import fs from 'node:fs'
|
|
|
24
24
|
import crypto2 from 'node:crypto'
|
|
25
25
|
import path from 'node:path'
|
|
26
26
|
import { dataDir, ensureDir, readJson, writeJson } from './paths.js'
|
|
27
|
-
import { atRestFor, migrateFile, kekFor } from './atrest.js'
|
|
27
|
+
import { atRestFor, migrateFile, kekFor, encryptText, decryptText } from './atrest.js'
|
|
28
28
|
import { probe as probeKek, writeConfig as writeKekConfig, configFromEnv } from './atrest.js'
|
|
29
29
|
import { keyDirName, keyOwnerOf } from './keyowner.js'
|
|
30
30
|
|
|
31
31
|
const REGISTRY = 'profiles.json'
|
|
32
32
|
const PWD_ITER = 300000 // PBKDF2 del verificador v1 (heredado); v2 usa scrypt
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* EL MOLINO, en un solo sitio. Estaba escrito a mano en cada llamada, y con la contraseña
|
|
36
|
+
* del admin eso deja de ser cosmética: **el navegador tiene que derivar con exactamente
|
|
37
|
+
* estos números**. Si aquí se cambia uno y allá no, la contraseña «deja de funcionar» sin
|
|
38
|
+
* que nada diga por qué, que es la clase de fallo que este proyecto viene evitando.
|
|
39
|
+
*
|
|
40
|
+
* Por eso también se le mandan al admin (`secondaryParams`) en vez de que los copie.
|
|
41
|
+
*/
|
|
42
|
+
export const SCRYPT = Object.freeze({ N: 16384, r: 8, p: 1, len: 32 })
|
|
33
43
|
const MAX_NAME = 40
|
|
34
44
|
/**
|
|
35
45
|
* Lo MÍNIMO que se acepta al poner una contraseña.
|
|
@@ -98,7 +108,7 @@ const b64 = (buf) => Buffer.from(new Uint8Array(buf)).toString('base64')
|
|
|
98
108
|
*/
|
|
99
109
|
function deriveScryptPwd (password, saltB64) {
|
|
100
110
|
const salt = Buffer.from(saltB64, 'base64')
|
|
101
|
-
return b64(crypto2.scryptSync(String(password || ''), salt,
|
|
111
|
+
return b64(crypto2.scryptSync(String(password || ''), salt, SCRYPT.len, { N: SCRYPT.N, r: SCRYPT.r, p: SCRYPT.p }))
|
|
102
112
|
}
|
|
103
113
|
|
|
104
114
|
/** PBKDF2-SHA256 → verificador base64 (v1, heredado). */
|
|
@@ -228,6 +238,35 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
|
|
|
228
238
|
return p
|
|
229
239
|
}
|
|
230
240
|
|
|
241
|
+
/**
|
|
242
|
+
* EL FRENO DE FUERZA BRUTA, en un solo sitio porque ahora hay DOS puertas.
|
|
243
|
+
*
|
|
244
|
+
* Tras 5 fallos, espera exponencial (2^n s, tope 5 min) persistida en el registro. Los
|
|
245
|
+
* fallos VIEJOS se olvidan (`TRIES_FORGET_MS`): sigue frenando una ráfaga, pero no
|
|
246
|
+
* convierte un despiste de ayer en un vault que ya no se abre.
|
|
247
|
+
*
|
|
248
|
+
* **Las dos contraseñas cuentan en el MISMO contador**, y no es un detalle: si cada una
|
|
249
|
+
* llevara el suyo, probar por un camino no frenaría el otro y el freno valdría la mitad.
|
|
250
|
+
*
|
|
251
|
+
* Lanza con CÓDIGO: la TUI es bilingüe y lo traduce, y la CLI lo dice con sus palabras.
|
|
252
|
+
* Sin código, el rechazo llegaba como un texto suelto del daemon, indistinguible de
|
|
253
|
+
* «se volvió a pedir la contraseña porque sí».
|
|
254
|
+
*/
|
|
255
|
+
function frenar (p) {
|
|
256
|
+
let tries = p.tries || { n: 0, at: 0 }
|
|
257
|
+
if (tries.at && Date.now() - tries.at > TRIES_FORGET_MS) {
|
|
258
|
+
tries = { n: 0, at: 0 }
|
|
259
|
+
if (p.tries) { delete p.tries; save() }
|
|
260
|
+
}
|
|
261
|
+
const waitMs = tries.n >= 5 ? Math.min(2 ** (tries.n - 4) * 1000, 5 * 60 * 1000) : 0
|
|
262
|
+
const left = tries.at + waitMs - Date.now()
|
|
263
|
+
if (left > 0) {
|
|
264
|
+
throw Object.assign(new Error(`too many tries: wait ${Math.ceil(left / 1000)} s`),
|
|
265
|
+
{ code: 'TOO_MANY_TRIES', waitSec: Math.ceil(left / 1000) })
|
|
266
|
+
}
|
|
267
|
+
return tries
|
|
268
|
+
}
|
|
269
|
+
|
|
231
270
|
const api = {
|
|
232
271
|
get root () { return root },
|
|
233
272
|
dirOf,
|
|
@@ -497,32 +536,13 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
|
|
|
497
536
|
save()
|
|
498
537
|
}
|
|
499
538
|
const salt = Buffer.from(p.kdf.salt, 'base64')
|
|
500
|
-
return new Uint8Array(crypto2.scryptSync(String(password || ''), salt,
|
|
539
|
+
return new Uint8Array(crypto2.scryptSync(String(password || ''), salt, SCRYPT.len, { N: SCRYPT.N, r: SCRYPT.r, p: SCRYPT.p }))
|
|
501
540
|
},
|
|
502
541
|
|
|
503
542
|
async unlock (id, password) {
|
|
504
543
|
const p = assertExists(id)
|
|
505
544
|
if (!p.pwd) { open(id); return { ok: true, locked: false } }
|
|
506
|
-
|
|
507
|
-
// fallos, espera exponencial (2^n s, tope 5 min) persistida en el registro.
|
|
508
|
-
// Los fallos VIEJOS se olvidan (ver TRIES_FORGET_MS): si desde el último ha pasado
|
|
509
|
-
// el rato, se empieza de cero. Así el freno sigue frenando una ráfaga —los intentos
|
|
510
|
-
// seguidos se cuentan igual— pero no convierte un despiste de ayer en un vault que
|
|
511
|
-
// ya no se abre.
|
|
512
|
-
let tries = p.tries || { n: 0, at: 0 }
|
|
513
|
-
if (tries.at && Date.now() - tries.at > TRIES_FORGET_MS) {
|
|
514
|
-
tries = { n: 0, at: 0 }
|
|
515
|
-
if (p.tries) { delete p.tries; save() }
|
|
516
|
-
}
|
|
517
|
-
const waitMs = tries.n >= 5 ? Math.min(2 ** (tries.n - 4) * 1000, 5 * 60 * 1000) : 0
|
|
518
|
-
const left = tries.at + waitMs - Date.now()
|
|
519
|
-
// Con CÓDIGO: la TUI es bilingüe y lo traduce, y la CLI puede decirlo con sus
|
|
520
|
-
// palabras. Sin código, el rechazo llegaba como un texto suelto del daemon y era
|
|
521
|
-
// indistinguible de «se volvió a pedir la contraseña porque sí».
|
|
522
|
-
if (left > 0) {
|
|
523
|
-
throw Object.assign(new Error(`too many tries: wait ${Math.ceil(left / 1000)} s`),
|
|
524
|
-
{ code: 'TOO_MANY_TRIES', waitSec: Math.ceil(left / 1000) })
|
|
525
|
-
}
|
|
545
|
+
const tries = frenar(p)
|
|
526
546
|
const proof = p.pwd.v === 2
|
|
527
547
|
? deriveScryptPwd(password, p.pwd.salt)
|
|
528
548
|
: await derivePwd(password, p.pwd.salt, p.pwd.iter)
|
|
@@ -547,6 +567,99 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
|
|
|
547
567
|
return { ok: true, locked: false }
|
|
548
568
|
},
|
|
549
569
|
|
|
570
|
+
// ----- LA SEGUNDA CONTRASEÑA: la del admin (`docs/abrir-a-distancia.md`) -----
|
|
571
|
+
//
|
|
572
|
+
// DOS PUERTAS AL MISMO SITIO. La llave del perfil sale de `scrypt(principal, p.kdf.salt)`
|
|
573
|
+
// y es lo que destapa la maestra. Una segunda contraseña NO puede derivar una llave
|
|
574
|
+
// distinta —tiene que llegar a la misma—, así que lo que se guarda es **una copia de esa
|
|
575
|
+
// llave, cifrada con lo que sale de la secundaria**. Es el mismo patrón que los cajones:
|
|
576
|
+
// varios sobres, un destinatario cada uno.
|
|
577
|
+
//
|
|
578
|
+
// EL MOLINO LO HACE QUIEN LLAMA, no esto. Aquí entra `derivada` ya calculada, y hay dos
|
|
579
|
+
// motivos: la bóveda nunca llega a ver la contraseña del admin, y adivinar le cuesta el
|
|
580
|
+
// scrypt AL QUE PRUEBA en vez de costarle CPU a la bóveda (que además sería una forma de
|
|
581
|
+
// ahogarla). El freno sigue contando los fallos igual — ver `frenar`.
|
|
582
|
+
//
|
|
583
|
+
// Y por eso `derivada` VALE TANTO COMO LA CONTRASEÑA: quien la capture abre igual. Viaja
|
|
584
|
+
// cifrada a una llave efímera de la bóveda y la petición va firmada por un aparato del
|
|
585
|
+
// acta; esas dos cosas son las que la protegen, no el hecho de estar derivada.
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Los parámetros para que el admin derive EXACTAMENTE igual. Son públicos: un salt no es
|
|
589
|
+
* un secreto y ya vive en el disco junto a los datos. `null` si este perfil no tiene
|
|
590
|
+
* segunda contraseña — entonces abrir a distancia sencillamente no está disponible.
|
|
591
|
+
*/
|
|
592
|
+
secondaryParams (id, { mint = false } = {}) {
|
|
593
|
+
const p = find(id)
|
|
594
|
+
if (!p) return null
|
|
595
|
+
// `mint`: acuña el salt si todavía no hay ninguno. Lo pide QUIEN LA PONE, para poder
|
|
596
|
+
// derivar una sola vez con el salt definitivo — si no, habría que derivar, guardar, y
|
|
597
|
+
// volver a derivar con el salt que salió, que es como lo escribí primero y era torpe.
|
|
598
|
+
if (!p.kdf2) {
|
|
599
|
+
if (!mint) return null
|
|
600
|
+
p.kdf2 = { v: 1, salt: b64(crypto.getRandomValues(new Uint8Array(32))), wrapped: null }
|
|
601
|
+
save()
|
|
602
|
+
}
|
|
603
|
+
return { salt: p.kdf2.salt, ...SCRYPT }
|
|
604
|
+
},
|
|
605
|
+
|
|
606
|
+
/** ¿Tiene puesta la del admin? Es estado, y se enseña donde se administra. */
|
|
607
|
+
hasSecondary: (id) => !!find(id)?.kdf2?.wrapped,
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Pone o cambia la contraseña del admin. **Exige el perfil ABIERTO**, porque lo que se
|
|
611
|
+
* guarda es la llave del perfil y esa solo está en memoria con el candado descorrido.
|
|
612
|
+
* Es lo correcto además de lo posible: ponerla es una decisión del dueño, y el dueño
|
|
613
|
+
* acaba de teclear la principal.
|
|
614
|
+
*/
|
|
615
|
+
setSecondary (id, derivada) {
|
|
616
|
+
const p = assertExists(id)
|
|
617
|
+
if (!p.pwd) throw Object.assign(new Error('this profile has no password: there is nothing to open remotely'), { code: 'NO_PASSWORD' })
|
|
618
|
+
const llave = api.openKey(id)
|
|
619
|
+
if (!llave) throw Object.assign(new Error('open the profile first (dotrino-vault unlock)'), { code: 'PROFILE_LOCKED' })
|
|
620
|
+
if (!(derivada instanceof Uint8Array) || derivada.length !== 32) throw new Error('the admin key must be 32 bytes')
|
|
621
|
+
const salt = p.kdf2?.salt || b64(crypto.getRandomValues(new Uint8Array(32)))
|
|
622
|
+
p.kdf2 = { v: 1, salt, wrapped: encryptText(b64(llave), Buffer.from(derivada)) }
|
|
623
|
+
save()
|
|
624
|
+
return { ok: true }
|
|
625
|
+
},
|
|
626
|
+
|
|
627
|
+
/** La quita. Revocar es borrar el sobre: al instante y sin tocar la principal. */
|
|
628
|
+
clearSecondary (id) {
|
|
629
|
+
const p = assertExists(id)
|
|
630
|
+
if (!p.kdf2?.wrapped) { if (p.kdf2) { delete p.kdf2; save() } ; return { ok: true, had: false } }
|
|
631
|
+
delete p.kdf2
|
|
632
|
+
save()
|
|
633
|
+
return { ok: true, had: true }
|
|
634
|
+
},
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* ABRE CON LA DEL ADMIN. Recibe la derivada, descifra el sobre y saca la llave del
|
|
638
|
+
* perfil: de ahí para abajo el estado es idéntico a `unlock`, porque es la misma llave.
|
|
639
|
+
*
|
|
640
|
+
* No hay verificador aparte y no hace falta: AES-GCM autentica, así que una derivada
|
|
641
|
+
* equivocada **no descifra**, y eso ES la comprobación.
|
|
642
|
+
*
|
|
643
|
+
* Comparte el contador de `unlock` (`frenar`): probar por aquí frena allá y al revés.
|
|
644
|
+
*/
|
|
645
|
+
async openWithSecondary (id, derivada) {
|
|
646
|
+
const p = assertExists(id)
|
|
647
|
+
if (!p.kdf2?.wrapped) throw Object.assign(new Error('this profile has no admin password'), { code: 'NO_SECONDARY' })
|
|
648
|
+
const tries = frenar(p)
|
|
649
|
+
let llave = null
|
|
650
|
+
try { llave = Buffer.from(decryptText(p.kdf2.wrapped, Buffer.from(derivada)), 'base64') } catch (_) { llave = null }
|
|
651
|
+
if (!llave || llave.length !== 32) {
|
|
652
|
+
p.tries = { n: tries.n + 1, at: Date.now() }
|
|
653
|
+
save()
|
|
654
|
+
throw Object.assign(new Error('wrong admin password'), { code: 'WRONG_PASSWORD', tries: p.tries.n })
|
|
655
|
+
}
|
|
656
|
+
delete p.tries
|
|
657
|
+
save()
|
|
658
|
+
open(id)
|
|
659
|
+
llaves.set(id, new Uint8Array(llave))
|
|
660
|
+
return { ok: true, locked: false }
|
|
661
|
+
},
|
|
662
|
+
|
|
550
663
|
lock (id) { assertExists(id); unlocked.delete(id); olvidar(id); return { ok: true, locked: api.isLocked(id) } },
|
|
551
664
|
|
|
552
665
|
/**
|
package/src/subacta.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subacta.js — LO DECIDIDO QUE TODAVÍA NO SELLÓ EL ACTA.
|
|
3
|
+
*
|
|
4
|
+
* Idea del dueño (2026-09-04): *«una subacta efímera que se comparte entre todos los
|
|
5
|
+
* dispositivos y puede tener decisiones a futuro que se aceptan cuando el acta se abre»*.
|
|
6
|
+
*
|
|
7
|
+
* EL AGUJERO QUE TAPA. Sellar el acta es de la maestra, y con el perfil cerrado la maestra
|
|
8
|
+
* no está. Así que una renuncia —«me quito estos permisos»— no surtía NINGÚN efecto hasta
|
|
9
|
+
* que alguien iba a la máquina y tecleaba la contraseña. Justo el caso que la renuncia
|
|
10
|
+
* existe para cubrir: te roban el teléfono, renuncias desde él, y el ladrón sigue siendo
|
|
11
|
+
* admin hasta que llegues. El pilar ya tenía la pieza (`extraRenounces`) y no la pasaba
|
|
12
|
+
* nadie.
|
|
13
|
+
*
|
|
14
|
+
* LA REGLA DURA, y es la que sostiene todo lo demás:
|
|
15
|
+
*
|
|
16
|
+
* LA SUBACTA SOLO PUEDE QUITAR, NUNCA DAR.
|
|
17
|
+
*
|
|
18
|
+
* De ahí salen las tres propiedades que la hacen segura sin maestra:
|
|
19
|
+
*
|
|
20
|
+
* · **Se autentica sola.** Cada entrada la firma EL MIEMBRO AL QUE AFECTA. No hace falta
|
|
21
|
+
* sellador, así que no hay una segunda autoridad al lado del acta.
|
|
22
|
+
* · **Una entrada falsa o repetida cuesta disponibilidad, no autoridad.** Lo peor que
|
|
23
|
+
* consigue quien cuele una es quitarle permisos a alguien: molesto y reversible.
|
|
24
|
+
* · **Nadie puede quitarle nada a otro.** Si valiera la firma de cualquiera, un servicio
|
|
25
|
+
* con `secrets` en un VPS alquilado podría dejar sin `admin` a tu teléfono.
|
|
26
|
+
*
|
|
27
|
+
* Y por eso el catálogo de operaciones es una LISTA BLANCA de una sola: `renounce`. La
|
|
28
|
+
* lista es la regla — si algún día entra otra, tiene que quitar y venir firmada por su
|
|
29
|
+
* sujeto, y se escribe aquí a propósito, no se cuela.
|
|
30
|
+
*
|
|
31
|
+
* JUNTAR DOS ES UNIR. Dos entradas nunca se contradicen porque las dos quitan, así que no
|
|
32
|
+
* hay conflictos ni hace falta un `seq`. Y **solo salen al absorberse** en el acta: nunca
|
|
33
|
+
* por reloj, o un aparato apagado perdería justo la que lo protege.
|
|
34
|
+
*/
|
|
35
|
+
import path from 'node:path'
|
|
36
|
+
import { verifyRenounce } from '@dotrino/identity/acta'
|
|
37
|
+
import { readJson, writeJson } from './paths.js'
|
|
38
|
+
|
|
39
|
+
/** Las operaciones que caben. La lista ES la regla: todas quitan, todas las firma su sujeto. */
|
|
40
|
+
export const OPS = Object.freeze(['renounce'])
|
|
41
|
+
|
|
42
|
+
/** Identidad de una entrada, para no guardar dos veces la misma. */
|
|
43
|
+
const idOf = (e) => `${e.op}|${e.member}|${[...(e.caps || [])].sort().join(',')}|${e.ts}`
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* ¿Es una entrada que se puede aceptar? Comprueba la FORMA y la FIRMA; que quite es
|
|
47
|
+
* estructural (el catálogo) y no una comprobación aparte.
|
|
48
|
+
*/
|
|
49
|
+
export async function isAcceptable (e) {
|
|
50
|
+
if (!e || !OPS.includes(e.op)) return false
|
|
51
|
+
if (e.op === 'renounce') return verifyRenounce(e).catch(() => false)
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* La subacta de un perfil. Vive en su directorio y va cifrada en reposo como todo lo
|
|
57
|
+
* demás: sus entradas dicen qué aparatos se quedaron sin qué, y eso es material de la
|
|
58
|
+
* cuenta aunque no sea un secreto.
|
|
59
|
+
*/
|
|
60
|
+
export function openSubacta (dir, atRest) {
|
|
61
|
+
const file = path.join(dir, 'subacta.json')
|
|
62
|
+
let data = readJson(file, null, atRest) || { v: 1, entries: [] }
|
|
63
|
+
const guardar = () => writeJson(file, data, atRest)
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
/** Las entradas vigentes, tal cual se le pasan a `memberCan` y compañía. */
|
|
67
|
+
entries () { return [...data.entries] },
|
|
68
|
+
get count () { return data.entries.length },
|
|
69
|
+
|
|
70
|
+
/** Las de UN miembro. Es lo que se mira al decidir sobre él. */
|
|
71
|
+
forMember (pub) { return data.entries.filter((e) => e.member === pub) },
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Acepta una entrada. Devuelve `{ok, reason}` — y un `false` aquí no es un fallo del
|
|
75
|
+
* que la manda: puede ser que ya la tuviéramos, que es el caso normal cuando dos
|
|
76
|
+
* aparatos se cuentan lo mismo.
|
|
77
|
+
*/
|
|
78
|
+
async add (e) {
|
|
79
|
+
if (!(await isAcceptable(e))) return { ok: false, reason: 'no-aceptable' }
|
|
80
|
+
const id = idOf(e)
|
|
81
|
+
if (data.entries.some((x) => idOf(x) === id)) return { ok: false, reason: 'ya-estaba' }
|
|
82
|
+
data.entries = [...data.entries, e]
|
|
83
|
+
guardar()
|
|
84
|
+
return { ok: true, reason: 'nueva' }
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* SACA TODO para absorberlo en el acta, y vacía. Se llama con la bóveda ABIERTA, que es
|
|
89
|
+
* la única que puede sellar. Vaciar aquí y fallar el sellado perdería la entrada, así
|
|
90
|
+
* que quien llama tiene que absorber primero y vaciar después (`clear`).
|
|
91
|
+
*/
|
|
92
|
+
drain () { return [...data.entries] },
|
|
93
|
+
|
|
94
|
+
/** Vacía lo ya absorbido. Se le pasa lo que se absorbió, no «todo»: mientras se sella
|
|
95
|
+
* puede haber entrado una nueva, y tirarla sería perder una renuncia sin aplicar. */
|
|
96
|
+
clear (absorbidas) {
|
|
97
|
+
const ids = new Set(absorbidas.map(idOf))
|
|
98
|
+
data.entries = data.entries.filter((e) => !ids.has(idOf(e)))
|
|
99
|
+
guardar()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
package/src/vault.js
CHANGED
|
@@ -15,7 +15,7 @@ import nodeCrypto from 'node:crypto'
|
|
|
15
15
|
import path from 'node:path'
|
|
16
16
|
import { Identity } from '@dotrino/identity/node'
|
|
17
17
|
import { verifyChain as verifyChainRaw, pubkeyId, verifyDeviceSig } from '@dotrino/identity/capabilities'
|
|
18
|
-
import * as
|
|
18
|
+
import * as ActaPilar from '@dotrino/identity/acta'
|
|
19
19
|
import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS } from '../lib/src/enroll.js'
|
|
20
20
|
import { createAdminDesk, authorBody } from '../lib/src/admin.js'
|
|
21
21
|
import { shouldNotifyRevoked } from '../lib/src/revocation.js'
|
|
@@ -27,6 +27,8 @@ 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
29
|
import { openSecretsStore, assertVar, RECOVERY as RECOVERY_WRAP, PROFILE_OWNER } from './secretsStore.js'
|
|
30
|
+
import { openSubacta } from './subacta.js'
|
|
31
|
+
import { makeEphemeralKey, openSealed } from '../lib/src/sealed.js'
|
|
30
32
|
import { VERSION } from './version.js'
|
|
31
33
|
import { declare as compatDeclare, check as compatCheck, annotate as compatAnnotate } from '@dotrino/compat'
|
|
32
34
|
import { loadManifest, brokenOf } from '@dotrino/roadmap'
|
|
@@ -62,7 +64,7 @@ const MAX_REPLY_BYTES = 768 * 1024
|
|
|
62
64
|
* Solo bloquea EDITAR el perfil (`profileSet`): firmar/leer y el resto del store
|
|
63
65
|
* siguen sirviendo a los dispositivos enrolados aunque esté bloqueado.
|
|
64
66
|
*/
|
|
65
|
-
export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log, onEnrollChallenge, isLocked = () => false, hasPassword = () => true, deriveAdminKey = null, openKey = null, forAdoption = false, onAdopted } = {}) {
|
|
67
|
+
export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log, onEnrollChallenge, isLocked = () => false, hasPassword = () => true, deriveAdminKey = null, openKey = null, secondaryParams = null, openWithSecondary = null, hasSecondary = () => false, forAdoption = false, onAdopted } = {}) {
|
|
66
68
|
ensureDir(dir)
|
|
67
69
|
// CIFRADO EN REPOSO ligado a esta máquina: ningún archivo del dir queda en claro, así
|
|
68
70
|
// que copiarlos a otro equipo no sirve de nada. La identidad se migra AQUÍ (verificando
|
|
@@ -372,6 +374,55 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
372
374
|
/** El dictamen de un aparato, para enseñarlo o para pegárselo a un error. */
|
|
373
375
|
const versionDe = (pub) => versiones.get(pub) || null
|
|
374
376
|
|
|
377
|
+
// ----- LA SUBACTA: lo decidido que todavía no selló el acta (`src/subacta.js`) -----
|
|
378
|
+
//
|
|
379
|
+
// Sellar es de la maestra, así que con el perfil CERRADO una renuncia no surtía ningún
|
|
380
|
+
// efecto: el aparato conservaba todos sus permisos hasta que alguien fuera a la máquina
|
|
381
|
+
// a teclear la contraseña. Justo el caso que la renuncia existe para cubrir.
|
|
382
|
+
//
|
|
383
|
+
// Se honra ya, sin sellar, y es seguro porque una entrada de la subacta SOLO PUEDE
|
|
384
|
+
// QUITAR: no concede nada a nadie, y va firmada por el miembro al que afecta.
|
|
385
|
+
const subacta = openSubacta(dir, atRestFor(dir))
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* EL ACTA, LEÍDA CON LA SUBACTA EN LA MANO.
|
|
389
|
+
*
|
|
390
|
+
* Envuelve al pilar en UN sitio en vez de pasar la lista en los diecisiete mostradores:
|
|
391
|
+
* una comprobación repetida diecisiete veces son dieciséis sitios donde olvidarla, y
|
|
392
|
+
* olvidarla aquí significa atender a un aparato que ya renunció.
|
|
393
|
+
*
|
|
394
|
+
* Solo se tocan las cinco que DECIDEN. El resto del pilar pasa tal cual.
|
|
395
|
+
*/
|
|
396
|
+
const Acta = {
|
|
397
|
+
...ActaPilar,
|
|
398
|
+
memberCan: (acta, pub, cap) => ActaPilar.memberCan(acta, pub, cap, subacta.forMember(pub)),
|
|
399
|
+
memberCanScope: (acta, pub, scope) => ActaPilar.memberCanScope(acta, pub, scope, subacta.forMember(pub)),
|
|
400
|
+
memberCanReadSecrets: (acta, pub, ns) => ActaPilar.memberCanReadSecrets(acta, pub, ns, subacta.forMember(pub)),
|
|
401
|
+
memberCanSign: (acta, pub, ns = null) => ActaPilar.memberCanSign(acta, pub, ns, subacta.forMember(pub)),
|
|
402
|
+
memberScopes: (acta, pub) => ActaPilar.memberScopes(acta, pub, subacta.forMember(pub)),
|
|
403
|
+
effectiveCaps: (acta, pub) => ActaPilar.effectiveCaps(acta, pub, subacta.forMember(pub))
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* ABSORBER lo pendiente en el acta. Solo con la bóveda abierta, que es la única que
|
|
408
|
+
* sella. Se vacía DESPUÉS y solo lo absorbido: mientras se sella puede entrar una nueva,
|
|
409
|
+
* y tirarla sería perder una renuncia sin aplicar.
|
|
410
|
+
*/
|
|
411
|
+
async function absorberSubacta (motivo = 'unlock') {
|
|
412
|
+
const pendientes = subacta.drain()
|
|
413
|
+
if (!pendientes.length) return 0
|
|
414
|
+
const hechas = []
|
|
415
|
+
for (const e of pendientes) {
|
|
416
|
+
try { await identity.absorbRenounce(e); hechas.push(e) }
|
|
417
|
+
catch (err) { log(`[vault] ${motivo}: could not absorb a pending renounce: ${err.message}`) }
|
|
418
|
+
}
|
|
419
|
+
if (hechas.length) {
|
|
420
|
+
subacta.clear(hechas)
|
|
421
|
+
log(`[vault] ${motivo}: ${hechas.length} pending renounce(s) absorbed into the record`)
|
|
422
|
+
}
|
|
423
|
+
return hechas.length
|
|
424
|
+
}
|
|
425
|
+
|
|
375
426
|
/**
|
|
376
427
|
* LA VERSIÓN SE APUNTA DONDE YA SE COMPRUEBA LA FIRMA, y por eso esto envuelve a
|
|
377
428
|
* `verifyChain` en vez de mirar el envoltorio del mensaje: `data` va firmado, así que
|
|
@@ -696,6 +747,74 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
696
747
|
* el motivo real por el que editar el perfil exigía abrirla, y es el síntoma que abrió
|
|
697
748
|
* todo esto: se cerraba sola a los cinco minutos y editar dejaba de funcionar.
|
|
698
749
|
*/
|
|
750
|
+
/**
|
|
751
|
+
* ABRIR LA BÓVEDA DESDE EL ADMIN (`docs/abrir-a-distancia.md`).
|
|
752
|
+
*
|
|
753
|
+
* Dos pasos, y el segundo no se puede reproducir por DOS candados distintos:
|
|
754
|
+
*
|
|
755
|
+
* · el **nonce** hace que se NIEGUE a atenderlo dos veces (memoria, 10 min);
|
|
756
|
+
* · la **efímera** hace que no se PUEDA abrir dos veces — se tira al usarla, así que
|
|
757
|
+
* ese sobre queda inabrible hasta para nosotros, incluso con el disco en la mano.
|
|
758
|
+
*
|
|
759
|
+
* Por qué una efímera y no la llave de cifrado fija de la bóveda: esa cede a la llave de
|
|
760
|
+
* la máquina (modelo de amenazas §4.2), o sea que cae con el disco. La contraseña es
|
|
761
|
+
* justo lo único que un ladrón de disco NO tiene — cifrarla con algo que el disco
|
|
762
|
+
* entrega sería regalársela a quien grabe el tráfico hoy y robe el disco mañana.
|
|
763
|
+
*
|
|
764
|
+
* Y el molino lo hace el ADMIN: aquí llega la derivada, nunca la contraseña.
|
|
765
|
+
*/
|
|
766
|
+
const EPHEMERAL_TTL_MS = 60 * 1000
|
|
767
|
+
const aperturas = new Map() // nonce -> { privateKey, exp }
|
|
768
|
+
|
|
769
|
+
const unlockDesk = (typeof openWithSecondary === 'function' && typeof secondaryParams === 'function')
|
|
770
|
+
? {
|
|
771
|
+
async begin ({ by }) {
|
|
772
|
+
const params = secondaryParams()
|
|
773
|
+
if (!params) {
|
|
774
|
+
throw new Error('this profile has no admin password: set one with `dotrino-vault profile admin-password`')
|
|
775
|
+
}
|
|
776
|
+
// Se barre lo vencido aquí y no con un temporizador: es barato, y un temporizador
|
|
777
|
+
// más es una cosa más que puede quedarse corriendo cuando el perfil ya no existe.
|
|
778
|
+
const t = Date.now()
|
|
779
|
+
for (const [n, v] of aperturas) if (v.exp <= t) aperturas.delete(n)
|
|
780
|
+
const eph = await makeEphemeralKey()
|
|
781
|
+
aperturas.set('pendiente', { privateKey: eph.privateKey, exp: t + EPHEMERAL_TTL_MS })
|
|
782
|
+
audit('unlock.begin', { by })
|
|
783
|
+
return { ...params, ek: eph.ek }
|
|
784
|
+
},
|
|
785
|
+
|
|
786
|
+
async open ({ nonce, enc, by }) {
|
|
787
|
+
const guardada = aperturas.get('pendiente')
|
|
788
|
+
aperturas.delete('pendiente')
|
|
789
|
+
if (!guardada || guardada.exp <= Date.now()) {
|
|
790
|
+
return { ok: false, error: 'unlock: ask for a key first, and use it within a minute' }
|
|
791
|
+
}
|
|
792
|
+
let payload = null
|
|
793
|
+
try { payload = await openSealed({ privateKey: guardada.privateKey, enc }) } catch (_) { payload = null }
|
|
794
|
+
// EL NONCE VA DENTRO DEL SOBRE. Si fuera solo por fuera, un sobre capturado se
|
|
795
|
+
// reenvía con un nonce nuevo y el candado del nonce no sirve de nada.
|
|
796
|
+
if (!payload || payload.nonce !== nonce || typeof payload.key !== 'string') {
|
|
797
|
+
audit('rejected', { what: 'unlock', by, reason: 'sobre' })
|
|
798
|
+
return { ok: false, error: 'unlock: the envelope does not check out' }
|
|
799
|
+
}
|
|
800
|
+
const derivada = new Uint8Array(Buffer.from(payload.key, 'base64'))
|
|
801
|
+
try {
|
|
802
|
+
const r = await openWithSecondary(derivada)
|
|
803
|
+
audit('unlock', { by })
|
|
804
|
+
log(`[vault] opened from the admin by ${by || '????-????'}`)
|
|
805
|
+
return { ok: true, result: { ok: true, locked: false } }
|
|
806
|
+
} catch (e) {
|
|
807
|
+
audit('rejected', { what: 'unlock', by, reason: e.code || 'error' })
|
|
808
|
+
// El freno viaja con el rechazo: sin él, el admin reintenta contra una puerta
|
|
809
|
+
// que ya no responde y no sabe por qué.
|
|
810
|
+
return { ok: false, code: e.code || null, error: e.message, tries: e.tries ?? null, waitSec: e.waitSec ?? null }
|
|
811
|
+
} finally {
|
|
812
|
+
derivada.fill(0)
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
: null
|
|
817
|
+
|
|
699
818
|
const PROFILE_METHODS = new Set(['profilePut', 'profileBundle', 'profilePublic', 'profileRecipients'])
|
|
700
819
|
|
|
701
820
|
async function handleProfile (from, p, d) {
|
|
@@ -958,7 +1077,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
958
1077
|
deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null, label: x.label || '', scope: x.scope, exp: x.exp, nonce: x.nonce,
|
|
959
1078
|
running: x.sub ? versionDe(x.sub) : null
|
|
960
1079
|
})))
|
|
961
|
-
|
|
1080
|
+
// LA SUBACTA VIAJA CON EL ACTA. Es la mitad de «se comparte entre todos los
|
|
1081
|
+
// dispositivos»: sin esto, una renuncia sin sellar solo la respeta esta máquina.
|
|
1082
|
+
// Cada entrada va firmada por su sujeto, así que quien la recibe la verifica sola.
|
|
1083
|
+
reply(from, fitChain({ type: MSG.DEVICES_RESULT, devices, revoked, acta: record, chain, vault: MI_VERSION, subacta: subacta.entries() }))
|
|
962
1084
|
}
|
|
963
1085
|
|
|
964
1086
|
/**
|
|
@@ -1844,7 +1966,14 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1844
1966
|
// llave de la máquina de la bóveda, cuyo material vive en ese mismo disco.
|
|
1845
1967
|
// El comentario de aquí decía «la consola lo dice en voz alta» y la consola
|
|
1846
1968
|
// no decía nada — el mismo error que el comentario mentiroso de `atrest.js`.
|
|
1847
|
-
hasPassword: (() => { try { return !!hasPassword() } catch (_) { return true } })()
|
|
1969
|
+
hasPassword: (() => { try { return !!hasPassword() } catch (_) { return true } })(),
|
|
1970
|
+
// SI ESTÁ CERRADA, Y SI SE PUEDE ABRIR DESDE AQUÍ. La consola no tenía forma de
|
|
1971
|
+
// saberlo: se enteraba solo al chocar con un `vault-locked` intentando algo. Y
|
|
1972
|
+
// `remoteUnlock` distingue las dos razones por las que el formulario no aparece —
|
|
1973
|
+
// «no está cerrada» y «no hay contraseña de admin puesta»— que son cosas
|
|
1974
|
+
// distintas y llevan a acciones distintas.
|
|
1975
|
+
locked: (() => { try { return !!isLocked() } catch (_) { return true } })(),
|
|
1976
|
+
remoteUnlock: (() => { try { return !!hasSecondary() } catch (_) { return false } })()
|
|
1848
1977
|
}))
|
|
1849
1978
|
}
|
|
1850
1979
|
},
|
|
@@ -1945,6 +2074,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1945
2074
|
desk,
|
|
1946
2075
|
deviceIdOf,
|
|
1947
2076
|
audit,
|
|
2077
|
+
// Abrir a distancia. Va inyectado porque `admin.js` es puro y esto necesita cripto y
|
|
2078
|
+
// disco; `null` si este perfil no lo soporta, y entonces la operación contesta que no.
|
|
2079
|
+
unlockDesk,
|
|
1948
2080
|
// El MISMO candado que frena firmar y editar el perfil: revocar reescribe el acta y
|
|
1949
2081
|
// configurar toca los secretos, y ninguna de las dos se hace con la bóveda cerrada.
|
|
1950
2082
|
isLocked,
|
|
@@ -2013,12 +2145,32 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2013
2145
|
return reply(from, { type: MSG.ERROR, error: 'renounce: that member no longer holds any of those capabilities' })
|
|
2014
2146
|
}
|
|
2015
2147
|
try {
|
|
2016
|
-
|
|
2148
|
+
// A LA SUBACTA PRIMERO, y aquí está el arreglo: desde este momento surte efecto,
|
|
2149
|
+
// esté la bóveda abierta o cerrada. Antes se intentaba sellar directamente, sellar es
|
|
2150
|
+
// de la maestra, y con el perfil cerrado la renuncia se perdía con un error — en el
|
|
2151
|
+
// único momento en que de verdad hace falta.
|
|
2152
|
+
const guardada = await subacta.add(record)
|
|
2153
|
+
if (!guardada.ok && guardada.reason === 'no-aceptable') {
|
|
2154
|
+
audit('rejected', { what: 'renounce', reason: 'no-aceptable' })
|
|
2155
|
+
return reply(from, { type: MSG.ERROR, error: 'renounce: the entry is not one this vault accepts' })
|
|
2156
|
+
}
|
|
2017
2157
|
const device = await deviceIdOf(record.member).catch(() => null)
|
|
2018
2158
|
audit('renounce', { device, caps: record.caps })
|
|
2019
|
-
log(`[vault] ${device} renounced: ${(record.caps || []).join(', ')}`)
|
|
2159
|
+
log(`[vault] ${device} renounced: ${(record.caps || []).join(', ')} — in effect now`)
|
|
2160
|
+
|
|
2161
|
+
// Y SE SELLA SI SE PUEDE. Con la bóveda abierta entra en el acta y lo respeta toda la
|
|
2162
|
+
// cuenta; cerrada se queda pendiente y lo absorbe el `unlock`. Las dos ramas son
|
|
2163
|
+
// correctas, así que el fallo al sellar NO es un error para quien renunció.
|
|
2164
|
+
let seq = null
|
|
2165
|
+
try {
|
|
2166
|
+
const r = await identity.absorbRenounce(record)
|
|
2167
|
+
subacta.clear([record])
|
|
2168
|
+
seq = r?.seq ?? null
|
|
2169
|
+
} catch (e) {
|
|
2170
|
+
log(`[vault] the renounce stays pending until the vault is opened (${e.message})`)
|
|
2171
|
+
}
|
|
2020
2172
|
await notifyMembers('renounce', { deviceId: device, caps: record.caps })
|
|
2021
|
-
reply(from, { type: MSG.RENOUNCE_RESULT, ok: true, seq:
|
|
2173
|
+
reply(from, { type: MSG.RENOUNCE_RESULT, ok: true, seq, pending: seq === null })
|
|
2022
2174
|
} catch (e) {
|
|
2023
2175
|
reply(from, { type: MSG.ERROR, error: 'renounce: ' + e.message })
|
|
2024
2176
|
}
|
|
@@ -2031,7 +2183,18 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2031
2183
|
const r = await admin.handle(p.data, { signature: p.signature, cert: p.cert })
|
|
2032
2184
|
// El `code` viaja aparte del texto: el texto está en inglés y puede cambiar, el código
|
|
2033
2185
|
// es lo que empareja quien llama (`vault-locked` ≠ «no autorizado»).
|
|
2034
|
-
|
|
2186
|
+
// EL FRENO VIAJA CON EL RECHAZO. `tries` y `waitSec` solo los trae abrir a distancia, y
|
|
2187
|
+
// sin ellos el admin reintenta contra una puerta que ya no responde y no sabe por qué —
|
|
2188
|
+
// el mismo silencio que se arregló en el resto del ecosistema.
|
|
2189
|
+
if (!r.ok) {
|
|
2190
|
+
return reply(from, {
|
|
2191
|
+
type: MSG.ERROR,
|
|
2192
|
+
error: r.error,
|
|
2193
|
+
...(r.code ? { code: r.code } : {}),
|
|
2194
|
+
...(r.tries != null ? { tries: r.tries } : {}),
|
|
2195
|
+
...(r.waitSec != null ? { waitSec: r.waitSec } : {})
|
|
2196
|
+
})
|
|
2197
|
+
}
|
|
2035
2198
|
reply(from, { type: MSG.ADMIN_RESULT, op: p.data.op, result: r.result })
|
|
2036
2199
|
}
|
|
2037
2200
|
|
|
@@ -3019,6 +3182,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
3019
3182
|
// aquí donde puede estrenar su llave de comunicación (ver `ensureCommKeyInActa`).
|
|
3020
3183
|
// Si falla, no se arrastra: abrir el perfil no puede depender de esto.
|
|
3021
3184
|
if (r?.locked === false) { try { await ensureCommKeyInActa() } catch (_) {} }
|
|
3185
|
+
// Y ABSORBER LO PENDIENTE. La subacta ya lo estaba honrando con el perfil cerrado —de
|
|
3186
|
+
// eso vive—, pero mientras siga ahí solo lo sabe esta máquina: al sellarlo, entra en
|
|
3187
|
+
// el acta y lo respeta toda la cuenta. Si falla no se arrastra: abrir el perfil no
|
|
3188
|
+
// puede depender de esto, y lo pendiente sigue surtiendo efecto igual.
|
|
3189
|
+
if (r?.locked === false) { try { await absorberSubacta('unlock') } catch (_) {} }
|
|
3022
3190
|
return { locked: r?.locked !== false }
|
|
3023
3191
|
},
|
|
3024
3192
|
// Forzar un empujón: lo usa `replica push` de la CLI y el smoke, para no depender
|
|
@@ -3117,7 +3285,14 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
3117
3285
|
return {
|
|
3118
3286
|
...r,
|
|
3119
3287
|
vault: MI_VERSION,
|
|
3120
|
-
|
|
3288
|
+
// Y LO QUE RENUNCIÓ Y NO SE HA SELLADO. Ya surte efecto —de eso vive la subacta—
|
|
3289
|
+
// pero si no se ve, el dueño mira la lista, lee los permisos del acta y cree que
|
|
3290
|
+
// ese aparato los tiene. Un freno que no se explica es otro silencio.
|
|
3291
|
+
members: (r?.members || []).map((m) => ({
|
|
3292
|
+
...m,
|
|
3293
|
+
running: versionDe(m.pub),
|
|
3294
|
+
renounced: subacta.forMember(m.pub).flatMap((e) => e.caps || [])
|
|
3295
|
+
}))
|
|
3121
3296
|
}
|
|
3122
3297
|
},
|
|
3123
3298
|
// Los namespaces que quedaron a deber una rotación (se fue un miembro y no se pudo
|