@dotrino/vaultd 0.109.0 → 0.111.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 +27 -2
- package/package.json +1 -1
- package/src/client.js +16 -3
- package/src/ctl.js +21 -1
- package/src/daemon.js +30 -0
- package/src/manager.js +16 -0
- package/src/profiles.js +136 -23
- package/src/vault.js +93 -3
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.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
|
|
6
6
|
"bin": {
|
package/src/client.js
CHANGED
|
@@ -194,16 +194,29 @@ export async function requestStore ({ masterPubkey, proxyUrl, device, cert, meth
|
|
|
194
194
|
* El `nonce` de un solo uso NO es decorativo: `approve` y `revoke` cambian estado, así
|
|
195
195
|
* que la ventana de frescura de ±5 min no basta para descartar un replay.
|
|
196
196
|
*/
|
|
197
|
-
export async function requestAdmin ({ masterPubkey, proxyUrl, device, cert, op, dir, ...rest } = {}) {
|
|
197
|
+
export async function requestAdmin ({ masterPubkey, proxyUrl, device, cert, op, dir, nonce: dado, ...rest } = {}) {
|
|
198
198
|
const client = await freshClient({ proxyUrl, dir })
|
|
199
199
|
try {
|
|
200
|
-
|
|
200
|
+
// EL NONCE SE PUEDE DAR. Abrir la bóveda a distancia lo mete TAMBIÉN dentro del sobre
|
|
201
|
+
// sellado —es lo que impide reenviar uno capturado con un nonce nuevo— y quien sella
|
|
202
|
+
// tiene que conocerlo antes de llamar.
|
|
203
|
+
const nonce = typeof dado === 'string' && dado.length >= 16
|
|
204
|
+
? dado
|
|
205
|
+
: [...crypto.getRandomValues(new Uint8Array(16))].map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
201
206
|
const data = { op, ...rest, publickey: device.publickey, ts: Date.now(), nonce }
|
|
202
207
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
203
208
|
const pending = waitFor(client, (p) => p.type === MSG.ADMIN_RESULT || p.type === MSG.ERROR)
|
|
204
209
|
client.sendByPubkey(masterPubkey, { type: MSG.ADMIN, data, signature, cert })
|
|
205
210
|
const res = await pending
|
|
206
|
-
if (res.type === MSG.ERROR)
|
|
211
|
+
if (res.type === MSG.ERROR) {
|
|
212
|
+
// CON LO QUE TRAE: el `code` y —al abrir— el freno. Emparejar por el TEXTO es lo que
|
|
213
|
+
// rompe en silencio en cuanto alguien lo traduce.
|
|
214
|
+
const e = new Error(res.error)
|
|
215
|
+
if (res.code) e.code = res.code
|
|
216
|
+
if (res.tries != null) e.tries = res.tries
|
|
217
|
+
if (res.waitSec != null) e.waitSec = res.waitSec
|
|
218
|
+
throw e
|
|
219
|
+
}
|
|
207
220
|
return res.result
|
|
208
221
|
} finally { client.close() }
|
|
209
222
|
}
|
package/src/ctl.js
CHANGED
|
@@ -1582,8 +1582,28 @@ async function cmdProfile (rest) {
|
|
|
1582
1582
|
reportProfiles(await profileRequest('password-set', { password: pwd, current: actual || undefined }))
|
|
1583
1583
|
return
|
|
1584
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
|
+
}
|
|
1585
1605
|
default:
|
|
1586
|
-
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)
|
|
1587
1607
|
}
|
|
1588
1608
|
}
|
|
1589
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/vault.js
CHANGED
|
@@ -28,6 +28,7 @@ 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
30
|
import { openSubacta } from './subacta.js'
|
|
31
|
+
import { makeEphemeralKey, openSealed } from '../lib/src/sealed.js'
|
|
31
32
|
import { VERSION } from './version.js'
|
|
32
33
|
import { declare as compatDeclare, check as compatCheck, annotate as compatAnnotate } from '@dotrino/compat'
|
|
33
34
|
import { loadManifest, brokenOf } from '@dotrino/roadmap'
|
|
@@ -63,7 +64,7 @@ const MAX_REPLY_BYTES = 768 * 1024
|
|
|
63
64
|
* Solo bloquea EDITAR el perfil (`profileSet`): firmar/leer y el resto del store
|
|
64
65
|
* siguen sirviendo a los dispositivos enrolados aunque esté bloqueado.
|
|
65
66
|
*/
|
|
66
|
-
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 } = {}) {
|
|
67
68
|
ensureDir(dir)
|
|
68
69
|
// CIFRADO EN REPOSO ligado a esta máquina: ningún archivo del dir queda en claro, así
|
|
69
70
|
// que copiarlos a otro equipo no sirve de nada. La identidad se migra AQUÍ (verificando
|
|
@@ -746,6 +747,74 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
746
747
|
* el motivo real por el que editar el perfil exigía abrirla, y es el síntoma que abrió
|
|
747
748
|
* todo esto: se cerraba sola a los cinco minutos y editar dejaba de funcionar.
|
|
748
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
|
+
|
|
749
818
|
const PROFILE_METHODS = new Set(['profilePut', 'profileBundle', 'profilePublic', 'profileRecipients'])
|
|
750
819
|
|
|
751
820
|
async function handleProfile (from, p, d) {
|
|
@@ -1897,7 +1966,14 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1897
1966
|
// llave de la máquina de la bóveda, cuyo material vive en ese mismo disco.
|
|
1898
1967
|
// El comentario de aquí decía «la consola lo dice en voz alta» y la consola
|
|
1899
1968
|
// no decía nada — el mismo error que el comentario mentiroso de `atrest.js`.
|
|
1900
|
-
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 } })()
|
|
1901
1977
|
}))
|
|
1902
1978
|
}
|
|
1903
1979
|
},
|
|
@@ -1998,6 +2074,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1998
2074
|
desk,
|
|
1999
2075
|
deviceIdOf,
|
|
2000
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,
|
|
2001
2080
|
// El MISMO candado que frena firmar y editar el perfil: revocar reescribe el acta y
|
|
2002
2081
|
// configurar toca los secretos, y ninguna de las dos se hace con la bóveda cerrada.
|
|
2003
2082
|
isLocked,
|
|
@@ -2104,7 +2183,18 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2104
2183
|
const r = await admin.handle(p.data, { signature: p.signature, cert: p.cert })
|
|
2105
2184
|
// El `code` viaja aparte del texto: el texto está en inglés y puede cambiar, el código
|
|
2106
2185
|
// es lo que empareja quien llama (`vault-locked` ≠ «no autorizado»).
|
|
2107
|
-
|
|
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
|
+
}
|
|
2108
2198
|
reply(from, { type: MSG.ADMIN_RESULT, op: p.data.op, result: r.result })
|
|
2109
2199
|
}
|
|
2110
2200
|
|