@dotrino/vaultd 0.139.1 → 0.141.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/manager.js CHANGED
@@ -13,7 +13,7 @@ import { openProfiles } from './profiles.js'
13
13
  import { installNodeGlobals } from './node-globals.js'
14
14
  import { dataDir, ensureDir } from './paths.js'
15
15
  import { Identity } from '@dotrino/identity/node'
16
- import { atRestFor } from './atrest.js'
16
+ import { atRestFor, kekFor } from './atrest.js'
17
17
 
18
18
  /**
19
19
  * D12 (`docs/acta-de-perfil.md`): la bóveda **no** borra una cuenta que ella manda si
@@ -169,8 +169,8 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
169
169
  * dice «abierto» sin estarlo es peor que uno cerrado, porque todo lo que venga detrás
170
170
  * falla en otro sitio y por otro motivo.
171
171
  */
172
- async function unlock (id, password) {
173
- const r = await profiles.unlock(id, password)
172
+ async function unlock (id, creds) {
173
+ const r = await profiles.unlock(id, creds)
174
174
  try {
175
175
  // El orden importa: `profiles.unlock` ya dejó la llave del perfil disponible
176
176
  // (`openKey`), así que ahora `startVault` sí puede abrir su maestra.
@@ -186,6 +186,52 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
186
186
  return r
187
187
  }
188
188
 
189
+ /**
190
+ * PONER UNA PUERTA (contraseña o llave de hardware), sellando en el acto.
191
+ *
192
+ * Si el perfil no tenía llave —nunca tuvo candado—, `ensureKey` estrena una, y lo que hasta
193
+ * ahora estaba bajo la llave de la máquina pasa a ella: la copia de recuperación de los
194
+ * secretos y la maestra. El orden importa: primero la puerta (guardada), después volver a
195
+ * sellar. Al revés, un fallo entre medias dejaría la maestra sellada con una llave sin
196
+ * ninguna puerta que la abra, que es justo el fallo que las puertas vienen a quitar.
197
+ */
198
+ async function addDoor (id, spec) {
199
+ const { K, fresh } = profiles.ensureKey(id, { password: spec.kind === 'password' ? spec.password : null })
200
+ // La puerta se fabrica (y se valida) ANTES de sellar nada con esta llave: si fallara
201
+ // después de sellar la maestra, quedaría sellada con una llave que se tira.
202
+ let prepared
203
+ try { prepared = profiles.prepareDoor(id, spec) } catch (e) { if (fresh) profiles.dropFreshKey(id); throw e }
204
+ let v = null
205
+ if (fresh) {
206
+ // ANTES DE GUARDAR NADA: la maestra tiene que abrir con esta llave. Si estaba sellada con
207
+ // otra (un perfil al que se le quitó la contraseña antes de las puertas), una puerta nueva
208
+ // daría un candado que no abre la maestra. Se para y se dice cómo recuperarla.
209
+ try {
210
+ v = running.get(id) || await open(id)
211
+ const r = await v.takeMasterKey()
212
+ if (r?.locked !== false) throw Object.assign(new Error('the master key is sealed with an earlier password: set that same password to recover it'), { code: 'MASTER_SEALED_ELSEWHERE' })
213
+ } catch (e) { profiles.dropFreshKey(id); throw e }
214
+ }
215
+ let door
216
+ try { door = profiles.addDoor(id, { prepared }) } catch (e) { if (fresh) profiles.dropFreshKey(id); throw e }
217
+ if (fresh) {
218
+ const maquina = new Uint8Array(kekFor(profiles.dirOf(id)))
219
+ try {
220
+ const r = await v.rekeySecrets?.(maquina, K)
221
+ if (r?.rekeyed) log('[vault] secrets recovery copy moved from the machine key to the profile key')
222
+ } catch (e) {
223
+ // No se arrastra: abrir el perfil vuelve a intentarlo (`migrarRecuperacionALaFrase`).
224
+ log('[vault] could not move the recovery copy to the profile key (it will be retried on unlock): %s', e.message)
225
+ } finally { maquina.fill(0) }
226
+ }
227
+ return door
228
+ }
229
+
230
+ /** Quitar una puerta (por id, o `'password'`). Sin la última, el perfil queda sin candado. */
231
+ async function removeDoor (id, which) {
232
+ return profiles.removeDoor(id, which)
233
+ }
234
+
189
235
  async function lock (id) {
190
236
  const r = profiles.lock(id)
191
237
  try { await running.get(id)?.dropMasterKey?.() } catch (_) {}
@@ -207,6 +253,8 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
207
253
  // dentro y fuera de la memoria. Llamar a `profiles.lock` a secas deja la llave puesta.
208
254
  unlock,
209
255
  lock,
256
+ addDoor,
257
+ removeDoor,
210
258
  running,
211
259
  get,
212
260
  list: () => profiles.list(),
package/src/profiles.js CHANGED
@@ -119,6 +119,99 @@ async function derivePwd (password, saltB64, iter) {
119
119
  return b64(bits)
120
120
  }
121
121
 
122
+ /**
123
+ * LAS PUERTAS: de qué se saca la llave que abre cada una.
124
+ *
125
+ * · `password` scrypt(contraseña, salt) — el mismo molino que antes daba `K` directamente.
126
+ * · `fido2` HKDF(hmac-secret de la llave), con toque.
127
+ * · `chalresp` HKDF(respuesta HMAC-SHA1 de la ranura 2), sin toque.
128
+ * · las dos de hardware con `withPassword`: HKDF(scrypt(contraseña) ‖ secreto de la llave).
129
+ * Hacen falta las DOS cosas.
130
+ * · `machine` la llave de esta máquina — la de un perfil sin candado.
131
+ *
132
+ * El `info` de HKDF lleva el tipo: los bytes de una llave no pueden abrir la puerta de otro tipo.
133
+ */
134
+ const HW_INFO = { fido2: 'dotrino-vault/door/fido2/v1', chalresp: 'dotrino-vault/door/chalresp/v1' }
135
+ const HW_KINDS = Object.keys(HW_INFO)
136
+
137
+ const scryptKey = (password, saltB64) => crypto2.scryptSync(String(password), Buffer.from(saltB64, 'base64'), SCRYPT.len, { N: SCRYPT.N, r: SCRYPT.r, p: SCRYPT.p })
138
+
139
+ /** La llave de una puerta con lo que se trae, o `null` si falta algo para esa puerta. */
140
+ function doorKey (door, { password = null, secret = null } = {}) {
141
+ if (door.kind === 'password') return password ? scryptKey(password, door.salt) : null
142
+ if (!HW_INFO[door.kind] || !secret?.length) return null
143
+ if (door.withPassword && !password) return null
144
+ const ikm = door.withPassword ? Buffer.concat([scryptKey(password, door.salt), Buffer.from(secret)]) : Buffer.from(secret)
145
+ const k = Buffer.from(crypto2.hkdfSync('sha256', ikm, Buffer.alloc(0), HW_INFO[door.kind] + (door.withPassword ? '+password' : ''), 32))
146
+ ikm.fill(0)
147
+ return k
148
+ }
149
+
150
+ const newDoorId = () => crypto2.randomBytes(4).toString('hex')
151
+ const realDoors = (p) => (p.doors || []).filter((d) => d.kind !== 'machine')
152
+ /** Tiene candado: alguna puerta de verdad, o la contraseña de antes de las puertas. */
153
+ const isProtected = (p) => !!p.pwd || realDoors(p).length > 0
154
+ /** Ya tiene llave de perfil (con o sin candado). */
155
+ const hasKey = (p) => !!p.pwd || (p.doors || []).length > 0
156
+
157
+ function checkPassword (password) {
158
+ if (!password || String(password).length < PWD_MIN) {
159
+ throw Object.assign(new Error(`password must be at least ${PWD_MIN} characters: use several random words`),
160
+ { code: 'PASSWORD_TOO_SHORT', min: PWD_MIN })
161
+ }
162
+ }
163
+
164
+ /** Fabrica una puerta: el sobre de `K` y lo que hace falta para volver a pedírselo a la llave. */
165
+ function makeDoor (K, spec = {}, { checkMin = true } = {}) {
166
+ const kind = spec.kind
167
+ if (kind !== 'password' && !HW_INFO[kind]) throw Object.assign(new Error('unknown door kind: ' + kind), { code: 'BAD_DOOR' })
168
+ const withPassword = kind !== 'password' && !!spec.password
169
+ if ((kind === 'password' || withPassword) && checkMin) checkPassword(spec.password)
170
+ if ((kind === 'password' || withPassword) && !spec.password) throw Object.assign(new Error('a password is required'), { code: 'BAD_DOOR' })
171
+ const door = { id: newDoorId(), kind, createdAt: Date.now(), ...(spec.label ? { label: String(spec.label).slice(0, MAX_NAME) } : {}) }
172
+ if (kind === 'password' || withPassword) door.salt = b64(crypto2.randomBytes(32))
173
+ if (withPassword) door.withPassword = true
174
+ if (kind === 'fido2') {
175
+ if (!spec.credId || !spec.hsalt) throw Object.assign(new Error('a fido2 door needs credId and hsalt'), { code: 'BAD_DOOR' })
176
+ Object.assign(door, { credId: spec.credId, hsalt: spec.hsalt })
177
+ }
178
+ if (kind === 'chalresp') {
179
+ if (!/^[0-9a-f]{2,126}$/.test(spec.challenge || '')) throw Object.assign(new Error('a chalresp door needs a hex challenge'), { code: 'BAD_DOOR' })
180
+ Object.assign(door, { challenge: spec.challenge, ...(spec.serial ? { serial: String(spec.serial) } : {}) })
181
+ }
182
+ const secret = spec.secret ? Buffer.from(spec.secret) : null
183
+ if (HW_INFO[kind] && !secret?.length) throw Object.assign(new Error('the key gave nothing to seal the door with'), { code: 'BAD_DOOR' })
184
+ const k = doorKey(door, { password: spec.password, secret })
185
+ door.wrapped = encryptText(b64(K), k)
186
+ // Se comprueba ANTES de guardar: una puerta que no abre es peor que no tenerla.
187
+ const back = Buffer.from(decryptText(door.wrapped, k), 'base64')
188
+ k.fill(0)
189
+ if (!back.equals(Buffer.from(K))) throw Object.assign(new Error('the new door does not open: not saved'), { code: 'BAD_DOOR' })
190
+ return door
191
+ }
192
+
193
+ /** Lo que se enseña de una puerta: nada secreto. `credId`/`hsalt`/`challenge` no abren nada sin la llave. */
194
+ const publicDoor = (d) => ({
195
+ id: d.id, kind: d.kind, createdAt: d.createdAt || null,
196
+ ...(d.label ? { label: d.label } : {}), ...(d.withPassword ? { withPassword: true } : {}),
197
+ ...(d.kind === 'fido2' ? { credId: d.credId, hsalt: d.hsalt } : {}),
198
+ ...(d.kind === 'chalresp' ? { challenge: d.challenge, ...(d.serial ? { serial: d.serial } : {}) } : {})
199
+ })
200
+ const publicDoors = (p) => [
201
+ ...(p.pwd ? [{ id: 'password', kind: 'password', legacy: true }] : []),
202
+ ...realDoors(p).map(publicDoor)
203
+ ]
204
+
205
+ /** Las credenciales en su forma: la contraseña sola (texto) se acepta por compatibilidad. */
206
+ function normCreds (c) {
207
+ if (typeof c === 'string' || c == null) return { password: c || null, door: null, secret: null }
208
+ return {
209
+ password: c.password || null,
210
+ door: c.door || null,
211
+ secret: c.secret ? (typeof c.secret === 'string' ? Buffer.from(c.secret, 'base64') : Buffer.from(c.secret)) : null
212
+ }
213
+ }
214
+
122
215
  /**
123
216
  * El nombre de la carpeta DE PASO, la que existe solo mientras se acuña la llave.
124
217
  *
@@ -191,12 +284,17 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
191
284
  * estando abierta: lo que acota la exposición es el auto-candado, no el olvido.
192
285
  */
193
286
  const llaves = new Map() // id -> Uint8Array (la llave derivada, solo si está abierto)
287
+ // La `K` de los perfiles SIN candado que la guardan bajo la llave de la máquina (`openKey`).
288
+ const porMaquina = new Map()
194
289
 
195
290
  /** Borra el material de verdad antes de soltar la referencia. */
196
291
  const olvidar = (id) => {
197
292
  const k = llaves.get(id)
198
293
  if (k) { try { k.fill(0) } catch (_) {} }
199
294
  llaves.delete(id)
295
+ const m = porMaquina.get(id)
296
+ if (m) { try { m.fill(0) } catch (_) {} }
297
+ porMaquina.delete(id)
200
298
  }
201
299
 
202
300
  /** ¿Sigue abierto? Vence al MIRARLO, así que no hace falta ningún temporizador. */
@@ -222,11 +320,13 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
222
320
  id: p.id,
223
321
  name: p.name || '',
224
322
  createdAt: p.createdAt || null,
225
- protected: !!p.pwd,
226
- locked: !!p.pwd && !isOpen(p.id),
323
+ protected: isProtected(p),
324
+ locked: isProtected(p) && !isOpen(p.id),
325
+ // Con qué se abre: contraseña, YubiKey… Es DATO, y la CLI lo necesita para saber qué pedir.
326
+ doors: publicDoors(p),
227
327
  // Hasta cuándo sigue abierto si nadie lo toca. Es DATO: la consola lo enseña para
228
328
  // que el cierre no llegue por sorpresa.
229
- ...(p.pwd && isOpen(p.id) && autoLockMs > 0 ? { until: unlocked.get(p.id) } : {}),
329
+ ...(isProtected(p) && isOpen(p.id) && autoLockMs > 0 ? { until: unlocked.get(p.id) } : {}),
230
330
  current: p.id === data.current,
231
331
  // Nació para adoptar la cuenta de un aparato (camino A) y todavía no lo ha hecho.
232
332
  ...(p.adopt ? { adopt: true } : {})
@@ -267,6 +367,65 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
267
367
  return tries
268
368
  }
269
369
 
370
+ /**
371
+ * La `K` de un perfil con candado, a partir de lo que se trae. Prueba solo las puertas que
372
+ * encajan con eso. Cuando nada encaja (una contraseña para un perfil que solo abre con llave)
373
+ * se dice con su código y NO cuenta como intento: no es adivinar, es equivocarse de puerta.
374
+ *
375
+ * La contraseña de ANTES de las puertas (`p.pwd` + `p.kdf`, `K = scrypt(contraseña)`) se sigue
376
+ * aceptando, y al acertarla se pasa a puerta: es el único momento en que se tiene en la mano.
377
+ */
378
+ async function resolveK (p, { password, door, secret }) {
379
+ const real = realDoors(p)
380
+ let cands
381
+ if (door) {
382
+ const d = real.find((x) => x.id === door)
383
+ if (!d) throw Object.assign(new Error('that key is not a door of this profile'), { code: 'NO_SUCH_DOOR' })
384
+ if (d.withPassword && !password) throw Object.assign(new Error('this key also needs the password'), { code: 'NEEDS_PASSWORD' })
385
+ cands = [d]
386
+ } else if (password) {
387
+ cands = real.filter((d) => d.kind === 'password')
388
+ if (!cands.length && !p.pwd) {
389
+ throw Object.assign(new Error('this profile does not open with a password alone: use its security key'), { code: 'NEEDS_SECURITY_KEY' })
390
+ }
391
+ } else {
392
+ throw Object.assign(new Error('nothing to open the profile with'), { code: 'NO_CREDENTIALS' })
393
+ }
394
+ const tries = frenar(p)
395
+ let K = null
396
+ for (const d of cands) {
397
+ const k = doorKey(d, { password, secret })
398
+ if (!k) continue
399
+ try { K = Buffer.from(decryptText(d.wrapped, k), 'base64') } catch (_) { K = null }
400
+ k.fill(0)
401
+ if (K?.length === 32) break
402
+ K = null
403
+ }
404
+ let legacy = false
405
+ if (!K && password && p.pwd && !door) {
406
+ const proof = p.pwd.v === 2 ? deriveScryptPwd(password, p.pwd.salt) : await derivePwd(password, p.pwd.salt, p.pwd.iter)
407
+ if (proof === p.pwd.verifier) {
408
+ if (!p.kdf) p.kdf = { v: 1, salt: b64(crypto.getRandomValues(new Uint8Array(32))) }
409
+ K = scryptKey(password, p.kdf.salt)
410
+ legacy = true
411
+ }
412
+ }
413
+ if (!K) {
414
+ p.tries = { n: tries.n + 1, at: Date.now() }
415
+ save()
416
+ throw Object.assign(new Error(door ? 'the key did not open this profile' : 'wrong password'), { code: 'WRONG_PASSWORD', tries: p.tries.n })
417
+ }
418
+ delete p.tries
419
+ if (legacy) {
420
+ // A PUERTA: la misma `K`, ahora en un sobre de la contraseña. El verificador y el salt de
421
+ // derivación directa se van — de aquí en adelante la contraseña es una puerta más.
422
+ p.doors = [...real, makeDoor(K, { kind: 'password', password }, { checkMin: false })]
423
+ delete p.pwd; delete p.kdf
424
+ }
425
+ save()
426
+ return K
427
+ }
428
+
270
429
  const api = {
271
430
  get root () { return root },
272
431
  dirOf,
@@ -500,8 +659,8 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
500
659
 
501
660
  // ----- candado -----
502
661
 
503
- isProtected: (id) => !!find(id)?.pwd,
504
- isLocked: (id) => { const p = find(id); return !!p?.pwd && !isOpen(id) },
662
+ isProtected: (id) => { const p = find(id); return !!p && isProtected(p) },
663
+ isLocked: (id) => { const p = find(id); return !!p && isProtected(p) && !isOpen(id) },
505
664
  /** Cuánto dura abierto el candado sin usarse (ms). 0 = no se cierra solo. */
506
665
  get autoLockMs () { return autoLockMs },
507
666
  /**
@@ -516,55 +675,151 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
516
675
  return true
517
676
  },
518
677
  assertUnlocked (id) {
519
- if (api.isLocked(id)) throw new Error('profile locked: unlock it with your password (dotrino-vault unlock)')
678
+ if (api.isLocked(id)) throw Object.assign(new Error('profile locked: unlock it first (dotrino-vault unlock)'), { code: 'PROFILE_LOCKED' })
520
679
  },
521
680
 
681
+ // ----- LAS PUERTAS (`docs/llaves-de-hardware.md` §2bis) -----
682
+ //
683
+ // La llave del perfil (`K`) es UNA y NO CAMBIA: con ella van selladas la maestra, la copia
684
+ // de recuperación de los secretos y las contraseñas. Lo que cambia son las PUERTAS, y cada
685
+ // una es un sobre de `K`: contraseña, YubiKey con toque, YubiKey sin toque, o YubiKey MÁS
686
+ // contraseña. Abre cualquiera. Poner o quitar una puerta no vuelve a sellar nada.
687
+ //
688
+ // Antes `K` SALÍA de la contraseña, y cambiarla —o quitarla— cambiaba `K` sin volver a
689
+ // sellar la maestra: después de reiniciar quedaba cerrada con una llave que ya no existía
690
+ // (reproducido el 2026-10-05). Con puertas eso no puede pasar.
691
+
692
+ /** Las puertas, sin nada secreto: tipo, nombre y lo que hace falta para pedirle a la llave. */
693
+ doors (id) { return publicDoors(assertExists(id)) },
694
+
522
695
  /**
523
- * La llave con la que se abre la copia MAESTRA de los secretos, derivada de la
524
- * contraseña del perfil. Va por operación: quien la pide la usa y la suelta.
525
- *
526
- * Es scrypt (mismo coste que `machineKey`) y NO reusa el verificador del candado:
527
- * ese es PBKDF2 y vive en claro en este mismo archivo, así que sería el camino
528
- * barato para atacarla. Aquí la prueba de que es correcta es el tag AES-GCM del
529
- * propio sobre — si no cuadra, la contraseña no era.
696
+ * La llave del perfil a partir de credenciales, SIN abrirlo. Abierto, una copia de la que
697
+ * está en memoria. Pasa por el mismo freno de intentos que abrir.
530
698
  */
531
- async adminKey (id, password) {
532
- const p = find(id)
533
- if (!p) throw new Error('profile not found')
534
- if (!p.kdf) {
535
- p.kdf = { v: 1, salt: b64(crypto.getRandomValues(new Uint8Array(32))) }
536
- save()
699
+ async keyWith (id, creds = {}) {
700
+ const p = assertExists(id)
701
+ if (isOpen(id) && llaves.get(id)) return new Uint8Array(llaves.get(id))
702
+ return new Uint8Array(await resolveK(p, normCreds(creds)))
703
+ },
704
+
705
+ /** Compatibilidad: la llave del perfil con la contraseña. Es `keyWith({ password })`. */
706
+ async adminKey (id, password) { return api.keyWith(id, { password }) },
707
+
708
+ /**
709
+ * ABRIR. `creds`: la contraseña (texto, por compatibilidad) o `{ password, door, secret }`,
710
+ * donde `secret` son los bytes que devolvió la llave de hardware para la puerta `door`.
711
+ */
712
+ async unlock (id, creds) {
713
+ const p = assertExists(id)
714
+ if (!isProtected(p)) { open(id); return { ok: true, locked: false } }
715
+ const K = await resolveK(p, normCreds(creds))
716
+ open(id)
717
+ // La llave se queda mientras el candado esté abierto: es lo que permite envolverle
718
+ // su cajón a un servicio que se enrola AHORA, sin volver a pedir la frase.
719
+ llaves.set(id, new Uint8Array(K))
720
+ K.fill(0)
721
+ return { ok: true, locked: false }
722
+ },
723
+
724
+ /**
725
+ * La llave del perfil para PONERLE UNA PUERTA. Si el perfil todavía no tiene (nunca tuvo
726
+ * candado), se estrena una al azar y `fresh` lo dice: quien llama tiene que volver a
727
+ * sellar con ella lo que estaba bajo la llave de la máquina (`manager.addDoor`).
728
+ */
729
+ ensureKey (id, { password = null } = {}) {
730
+ const p = assertExists(id)
731
+ if (hasKey(p)) {
732
+ api.assertUnlocked(id)
733
+ const K = api.openKey(id)
734
+ if (!K) throw Object.assign(new Error('the profile key is not in memory: unlock the profile first'), { code: 'PROFILE_LOCKED' })
735
+ return { K, fresh: false }
537
736
  }
538
- const salt = Buffer.from(p.kdf.salt, 'base64')
539
- return new Uint8Array(crypto2.scryptSync(String(password || ''), salt, SCRYPT.len, { N: SCRYPT.N, r: SCRYPT.r, p: SCRYPT.p }))
737
+ // LA DE ANTES, SI QUEDÓ SU SALT. Un perfil al que se le quitó la contraseña antes de las
738
+ // puertas tiene la maestra sellada con `scrypt(aquella, kdf.salt)`: poniendo la MISMA
739
+ // contraseña se recupera. Si no era esa, `manager.addDoor` lo ve (la maestra no abre) y
740
+ // no guarda nada.
741
+ const K = p.kdf?.salt && password
742
+ ? new Uint8Array(scryptKey(password, p.kdf.salt))
743
+ : new Uint8Array(crypto2.randomBytes(32))
744
+ llaves.set(id, K)
745
+ open(id)
746
+ return { K, fresh: true }
540
747
  },
541
748
 
542
- async unlock (id, password) {
749
+ /** Deshace un `ensureKey` fresco que no llegó a tener puerta. */
750
+ dropFreshKey (id) {
751
+ const p = find(id)
752
+ if (p && !hasKey(p)) { unlocked.delete(id); olvidar(id) }
753
+ },
754
+
755
+ /**
756
+ * Pone una puerta. `spec`:
757
+ * { kind: 'password', password } — sustituye a la contraseña que hubiera
758
+ * { kind: 'fido2', credId, hsalt, secret, label, password? }
759
+ * { kind: 'chalresp', challenge, serial, secret, label, password? }
760
+ * Con `password` en una de hardware, hacen falta las DOS cosas para abrir por ella.
761
+ */
762
+ /**
763
+ * Fabrica la puerta SIN guardarla: así quien llama comprueba que es válida (contraseña con
764
+ * el mínimo, llave que devolvió algo) antes de sellar nada con esa llave.
765
+ */
766
+ prepareDoor (id, spec) {
767
+ assertExists(id)
768
+ const K = api.openKey(id)
769
+ if (!K) throw Object.assign(new Error('unlock the profile first'), { code: 'PROFILE_LOCKED' })
770
+ return makeDoor(K, spec)
771
+ },
772
+
773
+ addDoor (id, spec) {
543
774
  const p = assertExists(id)
544
- if (!p.pwd) { open(id); return { ok: true, locked: false } }
545
- const tries = frenar(p)
546
- const proof = p.pwd.v === 2
547
- ? deriveScryptPwd(password, p.pwd.salt)
548
- : await derivePwd(password, p.pwd.salt, p.pwd.iter)
549
- if (proof !== p.pwd.verifier) {
550
- p.tries = { n: tries.n + 1, at: Date.now() }
551
- save()
552
- throw Object.assign(new Error('wrong password'), { code: 'WRONG_PASSWORD', tries: p.tries.n })
775
+ const K = api.openKey(id)
776
+ if (!K) throw Object.assign(new Error('unlock the profile first'), { code: 'PROFILE_LOCKED' })
777
+ const door = spec?.prepared || makeDoor(K, spec)
778
+ let doors = (p.doors || []).filter((d) => d.kind !== 'machine')
779
+ // UNA contraseña por perfil: poner otra la sustituye, también a la de antes de las puertas.
780
+ if (door.kind === 'password') { doors = doors.filter((d) => d.kind !== 'password'); delete p.pwd }
781
+ // El salt de derivación directa solo sirve para recuperar (ver `ensureKey`); con la llave
782
+ // ya en una puerta, sobra.
783
+ delete p.kdf
784
+ p.doors = [...doors, door]
785
+ delete p.tries
786
+ save()
787
+ // Si la llave venía de la de la máquina (perfil sin candado), ahora es la del perfil
788
+ // ABIERTO: tiene que estar donde la busca `openKey` con el candado puesto.
789
+ if (!llaves.get(id)) llaves.set(id, new Uint8Array(K))
790
+ open(id)
791
+ return publicDoor(door)
792
+ },
793
+
794
+ /**
795
+ * Quita una puerta (por id) o la contraseña (`'password'`). Si no queda ninguna, el perfil
796
+ * se queda SIN candado: `K` pasa a guardarse con la llave de esta máquina, igual que el
797
+ * resto del disco, y se abre sola. Lo sellado con `K` sigue abriendo — no se toca nada.
798
+ */
799
+ removeDoor (id, which) {
800
+ const p = assertExists(id)
801
+ api.assertUnlocked(id)
802
+ const K = api.openKey(id)
803
+ if (!K) throw Object.assign(new Error('unlock the profile first'), { code: 'PROFILE_LOCKED' })
804
+ const before = realDoors(p).length + (p.pwd ? 1 : 0)
805
+ if (which === 'password') {
806
+ p.doors = (p.doors || []).filter((d) => d.kind !== 'password')
807
+ delete p.pwd; delete p.kdf
808
+ } else {
809
+ if (!(p.doors || []).some((d) => d.id === which && d.kind !== 'machine')) {
810
+ throw Object.assign(new Error('that door does not exist: ' + which), { code: 'NO_SUCH_DOOR' })
811
+ }
812
+ p.doors = p.doors.filter((d) => d.id !== which)
553
813
  }
554
- // ASCENSO v1 → v2. Aquí, y solo aquí, se tiene la contraseña correcta en la mano:
555
- // es el momento de dejar de guardar el verificador barato. No cambia la
556
- // contraseña ni toca los secretos — el `adminKey` sale de `p.kdf`, que es otro.
557
- if (p.pwd.v !== 2) {
558
- const salt = b64(crypto.getRandomValues(new Uint8Array(16)))
559
- p.pwd = { v: 2, salt, verifier: deriveScryptPwd(password, salt) }
814
+ const removed = before - realDoors(p).length
815
+ if (!realDoors(p).length) {
816
+ p.doors = [{ id: newDoorId(), kind: 'machine', createdAt: Date.now(), wrapped: encryptText(b64(K), Buffer.from(kekFor(dirOf(id)))) }]
817
+ // Sin candado no hay nada que abrir a distancia.
818
+ delete p.kdf2
560
819
  }
561
- delete p.tries
562
820
  save()
563
821
  open(id)
564
- // La llave se queda mientras el candado esté abierto: es lo que permite envolverle
565
- // su cajón a un servicio que se enrola AHORA, sin volver a pedir la frase.
566
- llaves.set(id, await api.adminKey(id, password))
567
- return { ok: true, locked: false }
822
+ return { removed, protected: isProtected(p) }
568
823
  },
569
824
 
570
825
  // ----- LA SEGUNDA CONTRASEÑA: la del admin (`docs/abrir-a-distancia.md`) -----
@@ -614,7 +869,7 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
614
869
  */
615
870
  setSecondary (id, derivada) {
616
871
  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' })
872
+ if (!isProtected(p)) throw Object.assign(new Error('this profile has no lock: there is nothing to open remotely'), { code: 'NO_PASSWORD' })
618
873
  const llave = api.openKey(id)
619
874
  if (!llave) throw Object.assign(new Error('open the profile first (dotrino-vault unlock)'), { code: 'PROFILE_LOCKED' })
620
875
  if (!(derivada instanceof Uint8Array) || derivada.length !== 32) throw new Error('the admin key must be 32 bytes')
@@ -668,32 +923,37 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
668
923
  * volver a pedir la frase. Cerrado —o nunca abierto— devuelve `null` y quien llame
669
924
  * tendrá que pedirla, que es exactamente el comportamiento de antes.
670
925
  */
671
- openKey (id) { return isOpen(id) ? (llaves.get(id) || null) : null },
926
+ openKey (id) {
927
+ if (isOpen(id)) return llaves.get(id) || null
928
+ // SIN CANDADO PERO CON LLAVE: un perfil al que se le quitó la última puerta. Su `K` vive
929
+ // bajo la llave de la máquina y se abre sola, como el resto del disco.
930
+ const p = find(id)
931
+ const m = p && !isProtected(p) ? (p.doors || []).find((d) => d.kind === 'machine') : null
932
+ if (!m) return null
933
+ if (!porMaquina.has(id)) {
934
+ const K = Buffer.from(decryptText(m.wrapped, Buffer.from(kekFor(dirOf(id)))), 'base64')
935
+ if (K.length !== 32) throw Object.assign(new Error('the profile key under this machine key is malformed'), { code: 'BAD_PROFILE_KEY' })
936
+ porMaquina.set(id, new Uint8Array(K))
937
+ }
938
+ return porMaquina.get(id)
939
+ },
672
940
 
673
- /** Pone o cambia la contraseña. Cambiarla exige haber desbloqueado antes. */
941
+ /**
942
+ * Pone o cambia la contraseña (la puerta `password`). Atajo de `ensureKey` + `addDoor`: si
943
+ * el perfil no tenía llave se estrena una, y abrirlo después sella con ella la maestra y la
944
+ * copia de recuperación (`takeMasterKey`, `migrarRecuperacionALaFrase`). El camino completo,
945
+ * que vuelve a sellar en el acto, es `manager.addDoor`.
946
+ */
674
947
  async setPassword (id, password) {
675
- const p = assertExists(id)
676
- api.assertUnlocked(id)
677
- if (!password || String(password).length < PWD_MIN) {
678
- throw Object.assign(new Error(`password must be at least ${PWD_MIN} characters: use several random words`),
679
- { code: 'PASSWORD_TOO_SHORT', min: PWD_MIN })
680
- }
681
- const salt = b64(crypto.getRandomValues(new Uint8Array(16)))
682
- p.pwd = { v: 2, salt, verifier: deriveScryptPwd(password, salt) }
683
- delete p.tries
684
- save()
685
- open(id)
686
- return entry(p)
948
+ const { fresh } = api.ensureKey(id)
949
+ try { api.addDoor(id, { kind: 'password', password }) } catch (e) { if (fresh) api.dropFreshKey(id); throw e }
950
+ return entry(find(id))
687
951
  },
688
952
 
953
+ /** Quita la contraseña. Exige el perfil abierto. */
689
954
  removePassword (id) {
690
- const p = assertExists(id)
691
- api.assertUnlocked(id)
692
- delete p.pwd
693
- delete p.tries
694
- save()
695
- open(id)
696
- return entry(p)
955
+ api.removeDoor(id, 'password')
956
+ return entry(find(id))
697
957
  }
698
958
  }
699
959