@dotrino/vaultd 0.38.0 → 0.49.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/src/daemon.js CHANGED
@@ -17,6 +17,7 @@
17
17
  import fs from 'node:fs'
18
18
  import path from 'node:path'
19
19
  import { startVaultManager } from './manager.js'
20
+ import { startSshAgent, defaultSocketPath } from './sshAgent.js'
20
21
  import { dataDir, writeJson, readJson } from './paths.js'
21
22
  import { VERSION } from './version.js'
22
23
 
@@ -63,6 +64,14 @@ export async function runDaemon () {
63
64
 
64
65
  const mgr = await startVaultManager({ root: dir, proxyUrl, onEnrollChallenge })
65
66
 
67
+ // --- agente SSH (la llave vive en el teléfono; ver sshAgent.js). `DOTRINO_VAULT_SSH_AGENT=0`
68
+ // lo apaga; cualquier otro valor es la ruta del socket.
69
+ let sshAgent = null
70
+ if (process.env.DOTRINO_VAULT_SSH_AGENT !== '0' && process.platform !== 'win32') {
71
+ const socketPath = process.env.DOTRINO_VAULT_SSH_AGENT || defaultSocketPath(dir)
72
+ try { sshAgent = startSshAgent({ socketPath, vault: () => mgr.current(), log: console.log }) } catch (e) { console.error('[vault] ssh-agent could not start: %s', e.message) }
73
+ }
74
+
66
75
  // --- state.json ---
67
76
  const stateFile = path.join(dir, 'state.json')
68
77
  const daemonVersion = VERSION
@@ -73,7 +82,7 @@ export async function runDaemon () {
73
82
  const cur = mgr.summary().find((p) => p.current) || {}
74
83
  writeJson(stateFile, {
75
84
  v: 2, version: daemonVersion, fingerprint: cur.fingerprint || null, iss: cur.iss || null,
76
- proxy: proxyUrl, pid: process.pid, startedAt: new Date().toISOString(),
85
+ proxy: proxyUrl, pid: process.pid, startedAt: new Date().toISOString(), sshAgent: sshAgent?.socketPath || null,
77
86
  current: mgr.currentId(), profiles: mgr.summary()
78
87
  })
79
88
  }
@@ -143,7 +152,20 @@ export async function runDaemon () {
143
152
  if (!vault) return
144
153
  const profileId = pairReq?.profile ? mgr.resolve(pairReq.profile) : mgr.currentId()
145
154
  const isService = typeof pairReq?.service === 'string' && pairReq.service
146
- const scope = isService ? ['vault:secrets:' + pairReq.service] : ['vault:sign', 'vault:read', 'vault:store']
155
+ // PERMISOS, no tipos (2026-08-22): el cert lleva lo que pidió `pair --scope`, y si
156
+ // no pidió nada, el juego de siempre. `--service <ns>` sigue siendo el atajo de
157
+ // `vault:secrets:<ns>`. Se valida aquí porque es la maestra la que firma: nada
158
+ // que no esté en esta lista entra en un cert, y `vault:admin` / `vault:approve` nunca
159
+ // por este camino (se conceden a mano con `caps`).
160
+ const ALLOWED = (x) => x === 'vault:sign' || x === 'vault:read' || x === 'vault:store' || /^vault:secrets:[a-z0-9-]{1,32}$/.test(x)
161
+ const asked = Array.isArray(pairReq?.scope) ? pairReq.scope.filter((x) => typeof x === 'string') : null
162
+ if (asked && asked.some((x) => !ALLOWED(x))) {
163
+ writeJson(pairFile, { v: 2, at: Date.now(), error: 'scope not allowed: ' + asked.filter((x) => !ALLOWED(x)).join(',') })
164
+ return console.error('[vault] pairing refused: scope not allowed (%s)', asked.join(','))
165
+ }
166
+ const scope = asked?.length
167
+ ? [...new Set(asked)]
168
+ : isService ? ['vault:secrets:' + pairReq.service] : ['vault:sign', 'vault:read', 'vault:store']
147
169
  const label = pairReq?.label || (isService ? 'service:' + pairReq.service : 'cli')
148
170
  // `profile`/`profileName`: la CUENTA del vault a la que entra el dispositivo.
149
171
  // Con varias bóvedas en el mismo daemon, el QR sale de UNA y quien empareja
@@ -181,6 +203,21 @@ export async function runDaemon () {
181
203
  const revokeReqFile = path.join(dir, 'revoke-request.json')
182
204
  const secretReqFile = path.join(dir, 'secret-request.json')
183
205
  const secretsListFile = path.join(dir, 'secrets-list.json')
206
+ /**
207
+ * Por qué falló la última orden de variables. La consola pide el cambio y el volcado
208
+ * por señales distintas, así que el motivo no cabe en la respuesta: se guarda aquí y
209
+ * viaja en el volcado siguiente. Sin esto, una contraseña equivocada se leía como
210
+ * «El daemon no aplicó el cambio», que no dice qué hacer.
211
+ */
212
+ let lastSecretError = null
213
+ /**
214
+ * El VALOR que se acaba de destapar, esperando al volcado siguiente (mismo camino que
215
+ * `lastSecretError`: la orden y el volcado son señales distintas). Vive en memoria un
216
+ * instante y se va con el volcado — quien lo lee borra el archivo enseguida.
217
+ */
218
+ let lastSecretValue = null
219
+ /** Y las versiones anteriores que se acaban de pedir. Mismo camino, mismo volcado. */
220
+ let lastSecretHistory = null
184
221
  const profileReqFile = path.join(dir, 'profile-request.json')
185
222
  const dumpReqFile = path.join(dir, 'dump-request.json')
186
223
  const meReqFile = path.join(dir, 'me-request.json')
@@ -200,6 +237,19 @@ export async function runDaemon () {
200
237
  * 0700 del vault y se BORRA al consumirla — mismo camino que ya usan los
201
238
  * secretos, y así nunca pasa por `ps` ni por el historial de la shell.
202
239
  */
240
+ /**
241
+ * Vuelve a cerrar la copia maestra de los secretos con otra llave. Se llama al poner
242
+ * o quitar la contraseña del perfil: los sobres de las variables NO se tocan (siguen
243
+ * sellados a la llave de cada aparato), solo cambia con qué se abre el llavero de
244
+ * administración. Sin esto, cambiar la contraseña dejaría los secretos ilegibles.
245
+ */
246
+ async function rekey (id, vieja, nueva) {
247
+ const v = mgr.get(id)
248
+ if (!v?.rekeySecrets) return
249
+ const r = await v.rekeySecrets(vieja, nueva)
250
+ if (r?.rekeyed) console.log('[vault] secrets master re-sealed (%d drawer(s))', r.drawers)
251
+ }
252
+
203
253
  async function handleProfileRequest (req) {
204
254
  const ref = () => mgr.resolve(req.profile || mgr.currentId())
205
255
  switch (req.op) {
@@ -210,10 +260,54 @@ export async function runDaemon () {
210
260
  case 'rm': { const r = await mgr.remove(req.profile); return { done: `perfil borrado: ${r.name || r.id}` } }
211
261
  case 'rename': { const p = mgr.profiles.rename(ref(), req.name); return { done: `perfil renombrado: ${p.name}` } }
212
262
  case 'use': { const p = mgr.profiles.setCurrent(ref()); return { done: `perfil activo: ${p.name || p.id}` } }
213
- case 'unlock': { await mgr.profiles.unlock(ref(), req.password); return { done: 'perfil desbloqueado' } }
263
+ // ABRIR LA BÓVEDA SALDA LO QUE SE DEBE. Un aparato que entró después de escrita
264
+ // una variable no puede abrirla —envolverle su llave exige abrir la CEK, y eso
265
+ // pide la frase—, así que se queda en deuda y se ve en la consola y en la TUI.
266
+ // Este es el único momento en que la frase está delante, así que es aquí donde se
267
+ // paga; el dueño no tiene que acordarse de un comando aparte.
268
+ case 'unlock': {
269
+ const id = ref()
270
+ await mgr.profiles.unlock(id, req.password)
271
+ let note = ''
272
+ try {
273
+ const ak = await mgr.profiles.adminKey(id, req.password)
274
+ // REHACER el llavero, no solo saldar: con la frase delante se puede dejar cada
275
+ // cajón envuelto para exactamente quien dice el acta — creando lo que falta,
276
+ // reemplazando lo que alguien metiera mal y quitando lo que sobre.
277
+ const r = await mgr.get(id)?.resealAll?.(ak)
278
+ if (r?.wrapped) console.log('[vault] keyring rebuilt on unlock: %d wrap(s) in %d drawer(s)%s',
279
+ r.wrapped, r.drawers, r.dropped ? `, ${r.dropped} stale one(s) dropped` : '')
280
+ if (r?.dropped) note = ` · llavero al día (${r.dropped} envoltura(s) de más retirada(s))`
281
+ else if (r?.wrapped) note = ' · llavero al día'
282
+ } catch (e) { console.error('[vault] could not rebuild the keyring on unlock:', e.message) }
283
+ return { done: 'perfil desbloqueado' + note }
284
+ }
214
285
  case 'lock': { mgr.profiles.lock(ref()); return { done: 'perfil bloqueado' } }
215
- case 'password-set': { await mgr.profiles.setPassword(ref(), req.password); return { done: 'contraseña guardada' } }
216
- case 'password-rm': { mgr.profiles.removePassword(ref()); return { done: 'contraseña quitada' } }
286
+ // PONER contraseña: los secretos pasan de abrirse con la llave de la máquina a
287
+ // abrirse con la frase. Hay que volver a cerrar la copia maestra con la nueva, o
288
+ // quedarían ilegibles. Si el perfil YA tenía contraseña, hace falta la vieja para
289
+ // poder abrirla: por eso el camino normal para cambiarla es quitarla y ponerla.
290
+ case 'password-set': {
291
+ const id = ref()
292
+ const tenia = !!mgr.profiles.get(id)?.protected
293
+ if (tenia && !req.current) throw new Error('this profile already has a password: remove it first (`profile password --rm`) and then set the new one')
294
+ const vieja = tenia ? await mgr.profiles.adminKey(id, req.current) : null
295
+ await mgr.profiles.setPassword(id, req.password)
296
+ const nueva = await mgr.profiles.adminKey(id, req.password)
297
+ await rekey(id, vieja, nueva)
298
+ return { done: 'contraseña guardada' }
299
+ }
300
+ // QUITARLA: al revés. Se abre la copia maestra con la frase y se vuelve a cerrar
301
+ // con la llave de la máquina, que es la protección de siempre — el disco sigue
302
+ // cifrado, pero su material vive en ese mismo disco.
303
+ case 'password-rm': {
304
+ const id = ref()
305
+ if (!req.password) throw new Error('removing the password needs the current one: the secrets must be re-sealed before it goes')
306
+ const vieja = await mgr.profiles.adminKey(id, req.password)
307
+ await rekey(id, vieja, null)
308
+ mgr.profiles.removePassword(id)
309
+ return { done: 'contraseña quitada · los secretos ahora se abren con la llave de esta máquina' }
310
+ }
217
311
  default: throw new Error('unknown profile operation: ' + req.op)
218
312
  }
219
313
  }
@@ -278,25 +372,76 @@ export async function runDaemon () {
278
372
  // 0700 del vault y se borra al consumir.
279
373
  const sec = readJsonSafe(secretReqFile)
280
374
  if (sec?.op) {
281
- rm(secretReqFile)
375
+ rm(secretReqFile) // puede llevar la contraseña: fuera del disco cuanto antes
376
+ lastSecretError = null
282
377
  try {
283
378
  const vault = targetOf(sec)
284
379
  // Carga en GRUPO (`secret set ns K=v K2=v2`, `secret import`): todas las
285
380
  // variables entran de una vez y sale UN solo aviso de cambio, para que el
286
381
  // servicio no se reinicie a media carga y arranque con la mitad puesta.
287
- if (sec.op === 'batch') {
382
+ // La CONTRASEÑA, si vino, se convierte en la llave que abre la copia de
383
+ // RECUPERACIÓN, y no se guarda en ningún sitio: se usa y se suelta. Desde v5
384
+ // solo la piden las operaciones que LEEN —ver un valor, cambiar su visibilidad,
385
+ // convertir el archivo, rotar re-cifrando—: escribir no (§8.1). Sin ella se cae
386
+ // a la llave de la máquina, que es la protección de antes de esto (y el vault lo
387
+ // avisa al arrancar).
388
+ const ak = sec.password ? await mgr.profiles.adminKey(sec.profile ? mgr.resolve(sec.profile) : mgr.currentId(), sec.password) : undefined
389
+ if (sec.op === 'migrate') {
390
+ // Sin lista a mano: la pone el vault, y es la MISMA que usa cualquier
391
+ // escritura (servicios del cajón + aparatos que administran). Con una lista
392
+ // propia aquí, lo convertido quedaba sellado solo a los servicios y el dueño
393
+ // no podía ver desde su consola nada de lo que ya tenía.
394
+ const r = await vault.migrateSecrets(null, ak)
395
+ if (!r.migrated) console.log('[vault] nothing to migrate: %s', r.reason)
396
+ else {
397
+ console.log('[vault] secrets SEALED (v%d -> v5). Backup left at secrets.json.v%d.bak', r.from, r.from)
398
+ for (const [owner, sin] of Object.entries(r.sinLlave || {})) {
399
+ console.log('[vault] WARNING %s: %d member(s) without an encryption key will NOT read their variables', owner, sin.length)
400
+ }
401
+ }
402
+ } else if (sec.op === 'batch') {
288
403
  const changed = sec.pub
289
- ? await vault.applyDeviceSecrets(sec.pub, sec.items)
290
- : vault.applySecrets(sec.ns, sec.items)
404
+ ? await vault.applyDeviceSecrets(sec.pub, sec.items, { by: null })
405
+ : await vault.applySecrets(sec.ns, sec.items, { by: null })
291
406
  console.log('[vault] %d secret(s) applied in one go: %s', changed.length, sec.pub ? 'device' : sec.ns)
292
- } else if (sec.op === 'set') { vault.setSecret(sec.ns, sec.key, sec.value, sec.public); console.log('[vault] secret saved: %s/%s', sec.ns, sec.key) }
293
- else if (sec.op === 'rm') { vault.deleteSecret(sec.ns, sec.key); console.log('[vault] secret deleted: %s/%s', sec.ns, sec.key) }
407
+ } else if (sec.op === 'set') { await vault.setSecret(sec.ns, sec.key, sec.value, sec.public); console.log('[vault] secret saved: %s/%s', sec.ns, sec.key) }
408
+ else if (sec.op === 'rm') { await vault.deleteSecret(sec.ns, sec.key); console.log('[vault] secret deleted: %s/%s', sec.ns, sec.key) }
409
+ else if (sec.op === 'policy') { const r = await vault.setSecretPolicy(sec.ns, { approval: !!sec.approval }); console.log('[vault] %s: approval %s', sec.ns, r.approval ? 'REQUIRED (15 min window)' : 'off') }
294
410
  else if (sec.op === 'dev-set') { await vault.setDeviceSecret(sec.pub, sec.key, sec.value, sec.public); console.log('[vault] device secret saved: %s', sec.key) }
295
411
  else if (sec.op === 'dev-rm') { await vault.deleteDeviceSecret(sec.pub, sec.key); console.log('[vault] device secret deleted: %s', sec.key) }
412
+ // Saldar lo que quedó a deber: heredarle a un aparato nuevo lo ya guardado y
413
+ // rotar de verdad el cajón del que salió alguien. Las dos cosas abren, así que
414
+ // van con la frase — y por eso se hacen aquí y no al escribir.
415
+ else if (sec.op === 'settle') {
416
+ const r = await vault.settleSecretDebts(ak)
417
+ const n = Object.keys(r).length
418
+ console.log(n ? `[vault] ${n} pending drawer(s) settled` : '[vault] nothing pending')
419
+ }
420
+ // Ver el valor de una privada: lo único que la frase guarda (§8.3).
421
+ else if (sec.op === 'reveal') {
422
+ const value = await vault.revealSecret(sec.owner, sec.key, ak)
423
+ lastSecretValue = { owner: sec.owner, key: sec.key, value }
424
+ }
425
+ // Qué versiones anteriores hay (sin valores: son sobres).
426
+ else if (sec.op === 'history') {
427
+ lastSecretHistory = { owner: sec.owner || null, key: sec.key || null, items: vault.secretHistory(sec.owner || null, sec.key || null) }
428
+ }
429
+ // REVERTIR: abrir la versión vieja (frase) y volver a guardarla (nada).
430
+ else if (sec.op === 'revert') {
431
+ const ok = await vault.revertSecret(sec.owner, sec.key, sec.ts, { adminKey: ak })
432
+ if (!ok) throw new Error('that version is not in the history any more')
433
+ console.log('[vault] secret reverted: %s/%s', sec.owner, sec.key)
434
+ }
296
435
  // Visibilidad: si el valor puede salir hacia la consola remota. No toca el valor.
297
- else if (sec.op === 'vis') { vault.setSecretVisibility(sec.ns, sec.key, sec.public); console.log('[vault] secret visibility: %s/%s → %s', sec.ns, sec.key, sec.public ? 'public' : 'private') }
298
- else if (sec.op === 'dev-vis') { await vault.setDeviceSecretVisibility(sec.pub, sec.key, sec.public); console.log('[vault] device secret visibility: %s → %s', sec.key, sec.public ? 'public' : 'private') }
299
- } catch (e) { console.error('[vault] secret failed:', e.message) }
436
+ else if (sec.op === 'vis') { await vault.setSecretVisibility(sec.ns, sec.key, sec.public, ak); console.log('[vault] secret visibility: %s/%s → %s', sec.ns, sec.key, sec.public ? 'public' : 'private') }
437
+ else if (sec.op === 'dev-vis') { await vault.setDeviceSecretVisibility(sec.pub, sec.key, sec.public, ak); console.log('[vault] device secret visibility: %s → %s', sec.key, sec.public ? 'public' : 'private') }
438
+ } catch (e) {
439
+ lastSecretError = {
440
+ error: e.message,
441
+ code: e.code || (/wrong password/i.test(e.message) ? 'WRONG_PASSWORD' : 'SECRET_FAILED')
442
+ }
443
+ console.error('[vault] secret failed:', e.message)
444
+ }
300
445
  }
301
446
  // Perfiles / candado.
302
447
  const preq = readJsonSafe(profileReqFile)
@@ -376,8 +521,21 @@ export async function runDaemon () {
376
521
  writeJson(secretsListFile, {
377
522
  v: 2, at: Date.now(), req: reqId, profile: t.id,
378
523
  ns: t.vault.listSecrets(),
379
- dev: await t.vault.listDeviceSecrets()
524
+ dev: await t.vault.listDeviceSecrets(),
525
+ // Lo que quedó a deber un sellado. Va en el volcado porque si no se ve, no se
526
+ // salda: son cajones cuyos miembros NO están leyendo sus variables.
527
+ pending: await t.vault.secretDebts(),
528
+ // Y quién NO puede abrir lo suyo. Es lo mismo visto desde el aparato, que es
529
+ // como lo mira quien administra: «este servicio está en el acta y aun así no
530
+ // arranca». Sin esto solo se veía en el log del propio servicio.
531
+ incomplete: await t.vault.incompleteMembers(),
532
+ ...(lastSecretError ? { secretError: lastSecretError } : {}),
533
+ ...(lastSecretValue ? { revealed: lastSecretValue } : {}),
534
+ ...(lastSecretHistory ? { history: lastSecretHistory } : {})
380
535
  })
536
+ lastSecretError = null
537
+ lastSecretValue = null
538
+ lastSecretHistory = null
381
539
  writeJson(devFile, { v: 1, at: Date.now(), req: reqId, profile: t.id, ...(await t.vault.listDevices()) })
382
540
  // Acta del perfil: quién es del perfil y qué puede hacer cada uno (`members`/`caps`).
383
541
  try { writeJson(path.join(dir, 'acta.json'), { v: 1, at: Date.now(), req: reqId, profile: t.id, ...(await t.vault.profileMembers()) }) } catch (_) {}
@@ -458,6 +616,7 @@ export async function runDaemon () {
458
616
  const shutdown = (sig) => {
459
617
  console.log(`\n[vault] ${sig} → deteniendo…`)
460
618
  rm(pairFile); rm(pendingEnrollFile)
619
+ try { sshAgent?.close() } catch (_) {}
461
620
  try { mgr.close() } catch (_) {}
462
621
  process.exit(0)
463
622
  }
package/src/manager.js CHANGED
@@ -56,6 +56,12 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
56
56
  proxyUrl,
57
57
  log: (...a) => log(`[${tag}]`, ...a),
58
58
  isLocked: () => profiles.isLocked(id),
59
+ // Para poder DECIR que este perfil no tiene contraseña, y por tanto que sus
60
+ // variables privadas se abren con material que vive en este mismo disco.
61
+ hasPassword: () => !!profiles.get(id)?.protected,
62
+ // Para la consola remota: la contraseña llega dentro del sobre firmado y hay que
63
+ // convertirla en la llave que abre la copia maestra de los secretos.
64
+ deriveAdminKey: (password) => profiles.adminKey(id, password),
59
65
  // Camino A: nació para adoptar la cuenta de un aparato (ver profiles.add).
60
66
  forAdoption: !!p?.adopt,
61
67
  // Ya adoptó: la marca se consume (no vuelve a estar «a la espera»).
package/src/profiles.js CHANGED
@@ -11,8 +11,8 @@
11
11
  * <root>/transport.json keypair del proxy-client (a nivel PROCESO, no por perfil)
12
12
  * <root>/p/<id>/… los datos de cada perfil (incluida su maestra)
13
13
  *
14
- * CONTRASEÑA (opcional, por perfil): es un VERIFICADOR PBKDF2 —mismo modelo que el
15
- * candado del navegador (`@dotrino/identity` vault/core.js)— NO cifra nada en reposo.
14
+ * CONTRASEÑA (opcional, por perfil): es un VERIFICADOR scrypt (v2; v1 era PBKDF2 y se
15
+ * asciende al desbloquear) que NO cifra nada en reposo.
16
16
  * Y solo bloquea EDITAR el perfil: el daemon sigue firmando y sirviendo a los
17
17
  * dispositivos ya enrolados aunque el perfil esté bloqueado, para que un reinicio
18
18
  * del PC no deje las apps muertas hasta que alguien teclee la contraseña.
@@ -21,12 +21,27 @@
21
21
  * cifrado en reposo, ver `paths.js`).
22
22
  */
23
23
  import fs from 'node:fs'
24
+ import crypto2 from 'node:crypto'
24
25
  import path from 'node:path'
25
26
  import { dataDir, ensureDir, readJson, writeJson } from './paths.js'
27
+ import { atRestFor, migrateFile, machineKey } from './atrest.js'
26
28
 
27
29
  const REGISTRY = 'profiles.json'
28
- const PWD_ITER = 300000 // mismo coste que el candado del navegador
30
+ const PWD_ITER = 300000 // PBKDF2 del verificador v1 (heredado); v2 usa scrypt
29
31
  const MAX_NAME = 40
32
+ /**
33
+ * Lo MÍNIMO que se acepta al poner una contraseña.
34
+ *
35
+ * Eran 4 caracteres, y eso se quedó corto el día que los secretos pasaron a sellarse:
36
+ * desde entonces la contraseña no bloquea una consola, **es la llave** que abre la
37
+ * copia maestra, y todo el cifrado vale lo que valga ella. Cuatro dígitos son 10.000
38
+ * combinaciones — se prueban enteras en un rato aunque la derivación sea cara.
39
+ *
40
+ * No se piden mayúsculas ni símbolos a propósito: hacen la frase difícil de recordar
41
+ * y fácil de adivinar. Lo que da fuerza es la LONGITUD y que no la elija un humano;
42
+ * por eso lo que se pide en pantalla son varias palabras al azar.
43
+ */
44
+ const PWD_MIN = 12
30
45
 
31
46
  /**
32
47
  * Cuánto tarda el freno en OLVIDAR los fallos. Sin esto la cuenta solo subía —solo la
@@ -48,7 +63,24 @@ const LEGACY_FILES = ['identity.json', 'peers.json', 'vault.json', 'threads.json
48
63
 
49
64
  const b64 = (buf) => Buffer.from(new Uint8Array(buf)).toString('base64')
50
65
 
51
- /** PBKDF2-SHA256 → verificador base64 (byte-idéntico al del navegador). */
66
+ /**
67
+ * El verificador del candado.
68
+ *
69
+ * v2 es **scrypt, con el mismo coste que `adminKey`**, y no por gusto: este valor vive
70
+ * EN CLARO en `profiles.json`, así que quien tenga el disco lo ataca fuera de línea. Si
71
+ * es más barato que la llave de verdad, se convierte en el camino corto para llegar a
72
+ * ella — que es exactamente lo que pasaba con PBKDF2 al lado de un scrypt.
73
+ *
74
+ * v1 (PBKDF2) se sigue aceptando porque hay perfiles con él en el disco, y se ASCIENDE
75
+ * a v2 en el primer desbloqueo correcto: es el único momento en que se tiene la
76
+ * contraseña en la mano.
77
+ */
78
+ function deriveScryptPwd (password, saltB64) {
79
+ const salt = Buffer.from(saltB64, 'base64')
80
+ return b64(crypto2.scryptSync(String(password || ''), salt, 32, { N: 16384, r: 8, p: 1 }))
81
+ }
82
+
83
+ /** PBKDF2-SHA256 → verificador base64 (v1, heredado). */
52
84
  async function derivePwd (password, saltB64, iter) {
53
85
  const salt = Buffer.from(saltB64, 'base64')
54
86
  const km = await crypto.subtle.importKey('raw', new TextEncoder().encode(String(password)), 'PBKDF2', false, ['deriveBits'])
@@ -61,9 +93,18 @@ const cleanName = (name) => String(name || '').slice(0, MAX_NAME)
61
93
 
62
94
  export function openProfiles (root = dataDir()) {
63
95
  const file = path.join(root, REGISTRY)
64
- let data = readJson(file, null)
96
+ // CIFRADO EN REPOSO, como todo lo demás. Era el único archivo del vault sin códec, y
97
+ // lleva dentro el verificador del candado. No protege de quien tenga el disco entero
98
+ // —el material de la llave vive en ese mismo disco, y eso está dicho en voz alta en
99
+ // `docs/secretos-sellados.md`— pero sí de que el registro viaje en claro en un
100
+ // respaldo o en una carpeta compartida por descuido, que es lo que el códec cubre
101
+ // para el resto. La migración verifica antes de reemplazar y es de una sola vez.
102
+ ensureDir(root)
103
+ try { migrateFile(file, machineKey(root)) } catch (_) {}
104
+ const atRest = atRestFor(root)
105
+ let data = readJson(file, null, atRest)
65
106
  if (!data || !Array.isArray(data.profiles)) data = { v: 1, current: null, profiles: [] }
66
- const save = () => writeJson(file, data)
107
+ const save = () => writeJson(file, data, atRest)
67
108
 
68
109
  // Perfiles DESBLOQUEADOS en esta ejecución del daemon (en memoria: un reinicio
69
110
  // vuelve a bloquear, igual que cerrar la pestaña en el navegador).
@@ -195,6 +236,26 @@ export function openProfiles (root = dataDir()) {
195
236
  if (api.isLocked(id)) throw new Error('profile locked: unlock it with your password (dotrino-vault unlock)')
196
237
  },
197
238
 
239
+ /**
240
+ * La llave con la que se abre la copia MAESTRA de los secretos, derivada de la
241
+ * contraseña del perfil. Va por operación: quien la pide la usa y la suelta.
242
+ *
243
+ * Es scrypt (mismo coste que `machineKey`) y NO reusa el verificador del candado:
244
+ * ese es PBKDF2 y vive en claro en este mismo archivo, así que sería el camino
245
+ * barato para atacarla. Aquí la prueba de que es correcta es el tag AES-GCM del
246
+ * propio sobre — si no cuadra, la contraseña no era.
247
+ */
248
+ async adminKey (id, password) {
249
+ const p = find(id)
250
+ if (!p) throw new Error('profile not found')
251
+ if (!p.kdf) {
252
+ p.kdf = { v: 1, salt: b64(crypto.getRandomValues(new Uint8Array(32))) }
253
+ save()
254
+ }
255
+ const salt = Buffer.from(p.kdf.salt, 'base64')
256
+ return new Uint8Array(crypto2.scryptSync(String(password || ''), salt, 32, { N: 16384, r: 8, p: 1 }))
257
+ },
258
+
198
259
  async unlock (id, password) {
199
260
  const p = assertExists(id)
200
261
  if (!p.pwd) { unlocked.add(id); return { ok: true, locked: false } }
@@ -218,12 +279,21 @@ export function openProfiles (root = dataDir()) {
218
279
  throw Object.assign(new Error(`too many tries: wait ${Math.ceil(left / 1000)} s`),
219
280
  { code: 'TOO_MANY_TRIES', waitSec: Math.ceil(left / 1000) })
220
281
  }
221
- const proof = await derivePwd(password, p.pwd.salt, p.pwd.iter)
282
+ const proof = p.pwd.v === 2
283
+ ? deriveScryptPwd(password, p.pwd.salt)
284
+ : await derivePwd(password, p.pwd.salt, p.pwd.iter)
222
285
  if (proof !== p.pwd.verifier) {
223
286
  p.tries = { n: tries.n + 1, at: Date.now() }
224
287
  save()
225
288
  throw Object.assign(new Error('wrong password'), { code: 'WRONG_PASSWORD', tries: p.tries.n })
226
289
  }
290
+ // ASCENSO v1 → v2. Aquí, y solo aquí, se tiene la contraseña correcta en la mano:
291
+ // es el momento de dejar de guardar el verificador barato. No cambia la
292
+ // contraseña ni toca los secretos — el `adminKey` sale de `p.kdf`, que es otro.
293
+ if (p.pwd.v !== 2) {
294
+ const salt = b64(crypto.getRandomValues(new Uint8Array(16)))
295
+ p.pwd = { v: 2, salt, verifier: deriveScryptPwd(password, salt) }
296
+ }
227
297
  delete p.tries
228
298
  save()
229
299
  unlocked.add(id)
@@ -236,9 +306,12 @@ export function openProfiles (root = dataDir()) {
236
306
  async setPassword (id, password) {
237
307
  const p = assertExists(id)
238
308
  api.assertUnlocked(id)
239
- if (!password || String(password).length < 4) throw new Error('password must be at least 4 characters')
309
+ if (!password || String(password).length < PWD_MIN) {
310
+ throw Object.assign(new Error(`password must be at least ${PWD_MIN} characters: use several random words`),
311
+ { code: 'PASSWORD_TOO_SHORT', min: PWD_MIN })
312
+ }
240
313
  const salt = b64(crypto.getRandomValues(new Uint8Array(16)))
241
- p.pwd = { v: 1, salt, iter: PWD_ITER, verifier: await derivePwd(password, salt, PWD_ITER) }
314
+ p.pwd = { v: 2, salt, verifier: deriveScryptPwd(password, salt) }
242
315
  delete p.tries
243
316
  save()
244
317
  unlocked.add(id)
package/src/sealKey.js ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * La LLAVE DE SELLADO de esta bóveda: con ella FIRMA los sobres de los secretos, para
3
+ * que se sepa que salieron de aquí. Diseño: `docs/secretos-sellados.md` §8.8 y §8.9.
4
+ *
5
+ * Tres cosas que la hacen distinta de todo lo demás que hay en este directorio:
6
+ *
7
+ * · **Firmar no es leer.** Esta llave no abre ningún valor. Por eso puede vivir
8
+ * cifrada en reposo con la llave de la máquina y usarse **sin la frase** — que es
9
+ * justo lo que hace posible administrar sin contraseña.
10
+ * · **Su autoridad la da el ACTA**, que la nombra (`sealPub`) y que sella únicamente
11
+ * la maestra. Ella no se autoriza a sí misma.
12
+ * · **Rota con el acta.** Cada acta nueva puede estrenar una, así que aquí se guarda
13
+ * un puñado: la vigente para firmar, y las anteriores porque un sobre se firmó con
14
+ * la que mandaba entonces y hay que poder seguir firmando… no, seguir
15
+ * **identificando** cuál era. Verificar se hace con la pública que dice el acta.
16
+ *
17
+ * No guarda ninguna correspondencia con `seq`: quién mandaba y cuándo lo dice el acta
18
+ * (`sealKeys` con su tramo). Aquí solo están las privadas, indexadas por su pública.
19
+ */
20
+ import path from 'node:path'
21
+ import { readJson, writeJson } from './paths.js'
22
+ import { atRestFor } from './atrest.js'
23
+ import { signWithDevice } from '@dotrino/identity/capabilities'
24
+
25
+ /**
26
+ * Cuántas llaves anteriores se conservan. Firmar solo usa la vigente; las viejas se
27
+ * guardan por si hay que re-firmar algo sellado con ellas (recoger el histórico), y
28
+ * porque tirarlas no ahorra nada: son cuatro líneas de JSON.
29
+ */
30
+ const MAX_KEYS = 8
31
+
32
+ /** Abre (o estrena) el llavero de sellado del perfil. */
33
+ export function openSealKeys (dir) {
34
+ const file = path.join(dir, 'sealkeys.json')
35
+ const atRest = atRestFor(dir)
36
+ const data = readJson(file, null, atRest) || { v: 1, keys: [] }
37
+ if (!Array.isArray(data.keys)) data.keys = []
38
+
39
+ const save = () => writeJson(file, data, atRest)
40
+ const find = (pub) => data.keys.find((k) => k.pub === pub) || null
41
+
42
+ return {
43
+ /** La pública de la llave vigente (la última estrenada), o `null` si no hay ninguna. */
44
+ current: () => data.keys[data.keys.length - 1]?.pub || null,
45
+
46
+ /** ¿Tenemos la privada de esta pública? Es lo que decide si podemos firmar por ella. */
47
+ has: (pub) => !!find(pub),
48
+
49
+ /**
50
+ * Estrena una llave y devuelve su PÚBLICA, para que el acta la nombre. La privada
51
+ * se queda aquí; no sale de esta máquina ni viaja a ninguna parte.
52
+ *
53
+ * Es lo que se le pasa a `identity.setSealKeyProvider`, así que se llama una vez
54
+ * por acta sellada.
55
+ */
56
+ async mint () {
57
+ const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'])
58
+ const pub = JSON.stringify(await crypto.subtle.exportKey('jwk', pair.publicKey))
59
+ const priv = await crypto.subtle.exportKey('jwk', pair.privateKey)
60
+ data.keys.push({ pub, priv, createdAt: Date.now() })
61
+ if (data.keys.length > MAX_KEYS) data.keys = data.keys.slice(-MAX_KEYS)
62
+ save()
63
+ return pub
64
+ },
65
+
66
+ /**
67
+ * Firma un cuerpo con la llave que el acta nombra. Devuelve `null` si esa llave no es
68
+ * nuestra —el disco se restauró, o el acta la puso otro master—: entonces el sobre
69
+ * sale SIN firma, que es peor que firmado pero mucho mejor que no poder guardar.
70
+ */
71
+ async sign (sealPub, body) {
72
+ const k = sealPub ? find(sealPub) : null
73
+ if (!k) return null
74
+ const { signature } = await signWithDevice({ privateJwk: k.priv, publickey: k.pub, data: body })
75
+ return signature
76
+ }
77
+ }
78
+ }
79
+
80
+ export default { openSealKeys }