@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 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(['audit', 'vars', 'var.set', 'var.setMany', 'var.recipients'])
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.109.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
- const nonce = [...crypto.getRandomValues(new Uint8Array(16))].map((b) => b.toString(16).padStart(2, '0')).join('')
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) throw new Error(res.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, 32, { N: 16384, r: 8, p: 1 }))
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, 32, { N: 16384, r: 8, p: 1 }))
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
- // Freno de fuerza bruta (una contraseña corta se adivina probando): tras 5
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
- if (!r.ok) return reply(from, { type: MSG.ERROR, error: r.error, ...(r.code ? { code: r.code } : {}) })
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