@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.139.1",
3
+ "version": "0.141.0",
4
4
  "type": "module",
5
5
  "description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
6
6
  "bin": {
package/src/ctl.js CHANGED
@@ -37,6 +37,7 @@ import { qrToString } from './qr.js'
37
37
  import { encodeInvite, inviteUrl, parseInvite } from '../lib/src/invite.js'
38
38
  import { atRestFor, readConfig as readKekConfig, probe as probeKek, rekeyDir, encryptedFilesIn, CONFIG_FILE as KEK_CONFIG_FILE } from '../lib/src/atrest.js'
39
39
  import { VERSION } from './version.js'
40
+ import * as hw from './hwkeys.js'
40
41
  import { isNewer } from './update.js'
41
42
 
42
43
  const dir = dataDir()
@@ -163,7 +164,7 @@ function cmdStatus () {
163
164
 
164
165
  /** Una línea por perfil: nombre, id, huella y estado del candado. */
165
166
  function describeProfile (p) {
166
- const lock = !p.protected ? 'sin contraseña' : (p.locked ? `${B}🔒 bloqueado${Z}` : '🔓 desbloqueado')
167
+ const lock = !p.protected ? 'sin candado' : `${p.locked ? `${B}🔒 bloqueado${Z}` : '🔓 desbloqueado'} · ${doorsSummary(p)}`
167
168
  // ALCANZABLE, Y SE DICE AQUÍ. Una bóveda puede estar corriendo y fuera de la red —si el
168
169
  // proxio le rechazó el `identify`, nadie la alcanza por su pubkey— y desde fuera eso se ve
169
170
  // igual que «no hay nada que aprobar»: el teléfono no timbra y no hay nada que mirar.
@@ -173,6 +174,16 @@ function describeProfile (p) {
173
174
  return `${B}${p.name || '(sin nombre)'}${Z} ${p.id} ${p.fingerprint || '—'} ${lock}${red}`
174
175
  }
175
176
 
177
+ /** Con qué se abre un perfil, dicho corto: «contraseña o YubiKey (toque)». */
178
+ function doorsSummary (p) {
179
+ const names = (p.doors || []).map(doorName)
180
+ return names.length ? names.join(' o ') : 'contraseña'
181
+ }
182
+ function doorName (d) {
183
+ const base = d.kind === 'password' ? 'contraseña' : d.kind === 'fido2' ? 'llave (toque)' : d.kind === 'chalresp' ? 'llave (sin toque)' : d.kind
184
+ return d.withPassword ? `${base} + contraseña` : base
185
+ }
186
+
176
187
  function showChallenge (pe) {
177
188
  console.log('\n%sUn dispositivo quiere conectarse a tu bóveda:%s', B, Z)
178
189
  console.log(' dispositivo : %s%s%s', B, pe.deviceId, Z)
@@ -1800,6 +1811,13 @@ function reportProfiles (d) {
1800
1811
  // resto se reenvía tal cual (son diagnósticos del servicio).
1801
1812
  if (d.code === 'WRONG_PASSWORD') console.error('Contraseña incorrecta%s.', d.tries ? ` — van ${d.tries} intentos fallidos` : '')
1802
1813
  else if (d.code === 'TOO_MANY_TRIES') console.error('Demasiados intentos: espera %s s antes de volver a probar.', d.waitSec || '?')
1814
+ else if (d.code === 'NEEDS_SECURITY_KEY') console.error('Este perfil no se abre solo con contraseña: enchufa su llave de seguridad.')
1815
+ else if (d.code === 'NEEDS_PASSWORD') console.error('Esa llave pide también la contraseña.')
1816
+ else if (d.code === 'PASSWORD_TOO_SHORT') console.error('La contraseña necesita al menos %s caracteres: usa varias palabras al azar.', d.min || 12)
1817
+ else if (d.code === 'MASTER_SEALED_ELSEWHERE') {
1818
+ console.error('La llave maestra de este perfil quedó cerrada con una contraseña ANTERIOR (al quitarla, una versión vieja no la volvió a cerrar).')
1819
+ console.error('Pon esa misma contraseña para recuperarla: dotrino-vault profile password')
1820
+ }
1803
1821
  else console.error('%s', d.error)
1804
1822
  process.exit(1)
1805
1823
  }
@@ -1815,7 +1833,9 @@ async function cmdProfile (rest) {
1815
1833
  const kmsAt = rawArgs.indexOf('--kms')
1816
1834
  const kmsFile = kmsAt !== -1 ? rawArgs[kmsAt + 1] : null
1817
1835
  const args = kmsAt !== -1 ? rawArgs.filter((_, i) => i !== kmsAt && i !== kmsAt + 1) : rawArgs
1818
- const name = args.join(' ').trim()
1836
+ // Las banderas de la llave tampoco son parte del nombre (`profile add trabajo --key`).
1837
+ const KEY_FLAGS = ['--key', '--no-touch', '--with-password']
1838
+ const name = args.filter((a) => !KEY_FLAGS.includes(a)).join(' ').trim()
1819
1839
  switch (sub || 'ls') {
1820
1840
  case 'ls': {
1821
1841
  const d = await profileRequest('list')
@@ -1826,7 +1846,7 @@ async function cmdProfile (rest) {
1826
1846
  return
1827
1847
  }
1828
1848
  case 'add': {
1829
- if (!name) { console.error('uso: dotrino-vault profile add <nombre> [--kms <config.json>]'); process.exit(2) }
1849
+ if (!name) { console.error('uso: dotrino-vault profile add <nombre> [--kms <config.json>] [--key [--no-touch] [--with-password]]'); process.exit(2) }
1830
1850
  // NACER con el KMS es la única forma de que la maestra no haya existido nunca bajo
1831
1851
  // la clave de esta máquina. Migrar después no da lo mismo y no se ofrece como si
1832
1852
  // lo diera (ver el freno de `atrest rekey`).
@@ -1843,7 +1863,14 @@ async function cmdProfile (rest) {
1843
1863
  console.error('No se creó ningún perfil.'); process.exit(1)
1844
1864
  }
1845
1865
  }
1846
- reportProfiles(await profileRequest('add', { name, ...(kek ? { kek } : {}) }))
1866
+ const creado = reportProfiles(await profileRequest('add', { name, ...(kek ? { kek } : {}) }))
1867
+ // CON LA LLAVE DESDE EL PRINCIPIO: el perfil nace sin candado y se le pone la puerta en
1868
+ // el acto, así que su maestra solo pasa un instante bajo la llave de la máquina.
1869
+ if (args.includes('--key')) {
1870
+ PROFILE = creado.id
1871
+ console.log('')
1872
+ await cmdProfileKey(['add', ...args.filter((a) => a === '--no-touch' || a === '--with-password')])
1873
+ }
1847
1874
  console.log('Conecta un dispositivo a este perfil: dotrino-vault pair --profile "%s"', name)
1848
1875
  return
1849
1876
  }
@@ -1906,6 +1933,7 @@ async function cmdProfile (rest) {
1906
1933
  reportProfiles(await profileRequest('password-set', { password: pwd, current: actual || undefined }))
1907
1934
  return
1908
1935
  }
1936
+ case 'key': return cmdProfileKey(args)
1909
1937
  // LA CONTRASEÑA DEL ADMIN. Otra, distinta, que solo sirve desde el admin remoto — aquí
1910
1938
  // no abre nada. Pantalla administrativa: dice qué hace y qué no, y ya (§5.1).
1911
1939
  case 'admin-password': {
@@ -1927,7 +1955,7 @@ async function cmdProfile (rest) {
1927
1955
  return
1928
1956
  }
1929
1957
  default:
1930
- console.error('uso: dotrino-vault profile {ls|add|rename|use|rm|password|admin-password}'); process.exit(2)
1958
+ console.error('uso: dotrino-vault profile {ls|add|rename|use|rm|password|key|admin-password}'); process.exit(2)
1931
1959
  }
1932
1960
  }
1933
1961
 
@@ -1940,17 +1968,127 @@ function askText (prompt) {
1940
1968
  })
1941
1969
  }
1942
1970
 
1943
- async function cmdUnlock () {
1944
- const pwd = await askPassword('Contraseña del perfil: ')
1971
+ /** La entrada del perfil al que apunta la orden: el de `--profile`, o el activo. */
1972
+ async function targetProfile () {
1973
+ const d = await profileRequest('list')
1974
+ const all = d.profiles || []
1975
+ if (!PROFILE) return all.find((p) => p.current) || null
1976
+ const n = String(PROFILE).toLowerCase()
1977
+ return all.find((p) => p.id === PROFILE) || all.find((p) => (p.name || '').toLowerCase() === n) ||
1978
+ all.find((p) => p.id.toLowerCase().startsWith(n)) || null
1979
+ }
1980
+
1981
+ const hwDoors = hw.hardwareDoors
1982
+ const b64s = (u8) => Buffer.from(u8).toString('base64')
1983
+
1984
+ /** Los fallos de la llave, en palabras de quien la tiene en la mano. */
1985
+ function hwReason (e) {
1986
+ switch (e.code) {
1987
+ case 'HWKEY_TOOLS_MISSING': return `falta ${e.tool} (instala el paquete ${e.pkg})`
1988
+ case 'HWKEY_NO_DEVICE': return 'no hay ninguna llave enchufada'
1989
+ case 'HWKEY_NOT_TOUCHED': return 'no se tocó la llave a tiempo'
1990
+ case 'HWKEY_WRONG_KEY': return 'esta llave no es la de este perfil'
1991
+ case 'HWKEY_WRONG_PIN': return 'el PIN de la llave no es ese'
1992
+ case 'HWKEY_PIN_BLOCKED': return 'el PIN de la llave está bloqueado'
1993
+ case 'HWKEY_SLOT_BUSY': return 'la ranura 2 de la llave ya está programada'
1994
+ default: return e.message
1995
+ }
1996
+ }
1997
+
1998
+ async function cmdUnlock (args = []) {
1999
+ const p = await targetProfile()
2000
+ const doors = hwDoors(p)
2001
+ const hasPassword = (p?.doors || []).some((d) => d.kind === 'password')
2002
+ let creds = null
2003
+ // CON LA LLAVE si el perfil tiene una y está enchufada; `--password` se la salta.
2004
+ if (doors.length && !args.includes('--password')) {
2005
+ try { creds = await hw.secretForDoors(doors, { onTouch: () => console.log('Toca la llave de seguridad…') }) } catch (e) { console.error('La llave de seguridad no abrió el perfil: %s.', hwReason(e)) }
2006
+ if (!creds && !hasPassword) {
2007
+ console.error('Este perfil se abre con su llave de seguridad. Enchúfala y vuelve a intentarlo.')
2008
+ process.exit(1)
2009
+ }
2010
+ }
2011
+ const password = !creds || creds.withPassword
2012
+ ? await askPassword(creds ? 'Contraseña (esta llave pide también la contraseña): ' : 'Contraseña del perfil: ')
2013
+ : null
1945
2014
  // El daemon devuelve cuánto aguanta abierto: se dice AQUÍ, al abrirlo, que es cuando
1946
2015
  // sirve de algo. Encontrárselo cerrado sin haberlo leído nunca parece una avería.
1947
- const out = await profileRequest('unlock', { password: pwd })
2016
+ const out = await profileRequest('unlock', { password, ...(creds ? { door: creds.door, secret: b64s(creds.secret) } : {}) })
2017
+ if (creds) creds.secret.fill(0)
1948
2018
  reportProfiles(out)
1949
2019
  const min = Math.max(1, Math.round((out?.autoLockMs || 5 * 60 * 1000) / 60000))
1950
2020
  console.log(`Ya puedes editar el perfil. Se vuelve a bloquear solo tras ${min} min sin usarse` +
1951
2021
  ' (o al reiniciar el servicio, o con: dotrino-vault lock).')
1952
2022
  }
1953
2023
 
2024
+ /**
2025
+ * Da de alta la llave como puerta: la credencial FIDO2 (dos toques) o la ranura 2 programada
2026
+ * para reto-respuesta sin toque. Devuelve lo que el daemon necesita para sellar la puerta.
2027
+ */
2028
+ async function enrollHwKey ({ noTouch = false, label = '', overwrite = false } = {}) {
2029
+ if (noTouch) {
2030
+ const serial = await hw.ykSerial()
2031
+ if (!serial) throw Object.assign(new Error('no key'), { code: 'HWKEY_NO_DEVICE' })
2032
+ if (!overwrite && await hw.ykSlot2Busy()) {
2033
+ console.error('La ranura 2 de la YubiKey %s ya está programada. Programarla otra vez BORRA lo que tenga.', serial)
2034
+ console.error('Si estás seguro: dotrino-vault profile key add --no-touch --overwrite-slot')
2035
+ process.exit(1)
2036
+ }
2037
+ console.log('Programando la ranura 2 de la YubiKey %s: reto-respuesta, sin toque…', serial)
2038
+ await hw.ykProgramSlot2({ overwrite })
2039
+ const challenge = hw.newChallenge()
2040
+ const secret = await hw.ykChallenge({ challenge })
2041
+ return { kind: 'chalresp', challenge, serial, secret, label: label || `YubiKey ${serial}` }
2042
+ }
2043
+ console.log('Toca la llave de seguridad (1 de 2)…')
2044
+ const { credId, device } = await hw.fido2MakeCredential({ label: label || 'dotrino-vault' })
2045
+ const hsalt = hw.newHsalt()
2046
+ console.log('Toca la llave otra vez (2 de 2)…')
2047
+ const secret = await hw.fido2Secret({ credId, hsalt })
2048
+ return { kind: 'fido2', credId, hsalt, secret, label: label || device || 'llave de seguridad' }
2049
+ }
2050
+
2051
+ /** `profile key add|ls|rm`: las llaves de seguridad con que se abre el perfil. */
2052
+ async function cmdProfileKey (args) {
2053
+ const [action = 'ls', ...rest] = args
2054
+ const flag = (f) => rest.includes(f)
2055
+ const at = rest.indexOf('--label')
2056
+ const label = at !== -1 ? (rest[at + 1] || '') : ''
2057
+ const p = await targetProfile()
2058
+ if (!p) { console.error('el perfil no existe: %s', PROFILE); process.exit(1) }
2059
+ if (action === 'ls') {
2060
+ console.log('%s se abre con: %s', p.name || p.id, p.protected ? doorsSummary(p) : 'nada (sin candado)')
2061
+ for (const d of hwDoors(p)) {
2062
+ console.log(' %s %s %s%s', d.id, doorName(d), d.label || '', d.createdAt ? ` (${new Date(d.createdAt).toISOString().slice(0, 10)})` : '')
2063
+ }
2064
+ return
2065
+ }
2066
+ if (action === 'rm') {
2067
+ if (!rest[0]) { console.error('uso: dotrino-vault profile key rm <id> (los ids salen en: profile key ls)'); process.exit(2) }
2068
+ reportProfiles(await profileRequest('door-rm', { door: rest[0] }))
2069
+ return
2070
+ }
2071
+ if (action !== 'add') { console.error('uso: dotrino-vault profile key {ls|add|rm}'); process.exit(2) }
2072
+ if (p.locked) { console.error('El perfil está bloqueado. Ábrelo primero: dotrino-vault unlock'); process.exit(1) }
2073
+ let spec
2074
+ try { spec = await enrollHwKey({ noTouch: flag('--no-touch'), label, overwrite: flag('--overwrite-slot') }) } catch (e) {
2075
+ console.error('No se pudo dar de alta la llave: %s.', hwReason(e)); process.exit(1)
2076
+ }
2077
+ if (flag('--with-password')) {
2078
+ console.log('\nEsta llave abrirá el perfil SOLO junto con una contraseña (mínimo 12 caracteres).')
2079
+ const pwd = await askPassword('Contraseña para esta llave: ')
2080
+ if (pwd !== await askPassword('Repítela: ')) { console.error('Las contraseñas no coinciden.'); process.exit(1) }
2081
+ spec.password = pwd
2082
+ }
2083
+ const out = await profileRequest('door-add', { spec: { ...spec, secret: b64s(spec.secret) } })
2084
+ spec.secret.fill(0)
2085
+ reportProfiles(out)
2086
+ const tiene = (out.profiles || []).find((x) => x.id === p.id)
2087
+ if (tiene) console.log('Ahora se abre con: %s', doorsSummary(tiene))
2088
+ if (spec.kind === 'chalresp') console.log('Sin toque: con la llave enchufada, cualquier programa de esta máquina puede abrirlo. Desenchúfala cuando no la uses.')
2089
+ console.log('Registra una segunda llave de repuesto, o deja también una contraseña: perder la única puerta es perder el perfil.')
2090
+ }
2091
+
1954
2092
  async function cmdLock () {
1955
2093
  reportProfiles(await profileRequest('lock'))
1956
2094
  }
@@ -2083,7 +2221,16 @@ Contraseña del perfil (opcional; solo se pide para EDITAR el perfil — tus
2083
2221
  dispositivos siguen firmando y leyendo aunque esté bloqueado):
2084
2222
  profile password [set] pone o cambia la contraseña
2085
2223
  profile password rm la quita
2086
- unlock desbloquea para poder editar
2224
+ unlock [--password] desbloquea para poder editar (con la llave de
2225
+ seguridad si el perfil tiene una y está enchufada)
2226
+
2227
+ Llaves de seguridad (YubiKey). Cada una es otra forma de abrir el perfil, como la
2228
+ contraseña; abre cualquiera. Necesitan fido2-tools y yubikey-personalization:
2229
+ profile key add con toque (FIDO2): pide tocar la llave dos veces
2230
+ profile key add --no-touch sin toque: programa la ranura 2 de la YubiKey
2231
+ profile key add --with-password esa llave abre SOLO junto con una contraseña
2232
+ profile key ls | rm <id> las llaves del perfil
2233
+ profile add <nombre> --key crea el perfil ya con su llave
2087
2234
  lock vuelve a bloquear (también solo, a los 5 min sin
2088
2235
  usarse, y al reiniciar el servicio)
2089
2236
 
@@ -2176,7 +2323,7 @@ export async function runCtl (argv) {
2176
2323
  switch (cmd) {
2177
2324
  case 'tui': return cmdTui()
2178
2325
  case 'profile': return cmdProfile(rest)
2179
- case 'unlock': return cmdUnlock()
2326
+ case 'unlock': return cmdUnlock(rest)
2180
2327
  case 'lock': return cmdLock()
2181
2328
  // `info` es el comando común de todo el ecosistema para «qué aparato soy» (regla del
2182
2329
  // dueño, 2026-09-29); en la bóveda eso es su estado, que ya enseña el id de cada perfil.
package/src/daemon.js CHANGED
@@ -389,19 +389,6 @@ export async function runDaemon () {
389
389
  * 0700 del vault y se BORRA al consumirla — mismo camino que ya usan los
390
390
  * secretos, y así nunca pasa por `ps` ni por el historial de la shell.
391
391
  */
392
- /**
393
- * Vuelve a cerrar la copia maestra de los secretos con otra llave. Se llama al poner
394
- * o quitar la contraseña del perfil: los sobres de las variables NO se tocan (siguen
395
- * sellados a la llave de cada aparato), solo cambia con qué se abre el llavero de
396
- * administración. Sin esto, cambiar la contraseña dejaría los secretos ilegibles.
397
- */
398
- async function rekey (id, vieja, nueva) {
399
- const v = mgr.get(id)
400
- if (!v?.rekeySecrets) return
401
- const r = await v.rekeySecrets(vieja, nueva)
402
- // Se re-sella UNA copia (la de recuperación, que abre el llavero), no cajón por cajón.
403
- if (r?.rekeyed) console.log('[vault] secrets recovery copy re-sealed under the new key')
404
- }
405
392
 
406
393
  /**
407
394
  * BORRA la llave derivada en cuanto se usó. Es lo único de la contraseña que se puede
@@ -484,11 +471,14 @@ export async function runDaemon () {
484
471
  // paga; el dueño no tiene que acordarse de un comando aparte.
485
472
  case 'unlock': {
486
473
  const id = ref()
487
- await mgr.unlock(id, req.password) // por el manager: mueve la maestra a memoria y la sella
474
+ // Con cualquier puerta: la contraseña, los bytes que dio la YubiKey (`door` + `secret`,
475
+ // en base64), o las dos cosas si la puerta pide ambas.
476
+ await mgr.unlock(id, { password: req.password || null, door: req.door || null, secret: req.secret || null })
488
477
  let note = ''
489
478
  let ak = null
490
479
  try {
491
- ak = await mgr.profiles.adminKey(id, req.password)
480
+ // Una COPIA: `wipe` pisa lo que se le pase, y la de memoria tiene que quedarse.
481
+ ak = new Uint8Array(mgr.profiles.openKey(id))
492
482
  // REHACER el llavero, no solo saldar: con la frase delante se puede dejar cada
493
483
  // cajón envuelto para exactamente quien dice el acta — creando lo que falta,
494
484
  // reemplazando lo que alguien metiera mal y quitando lo que sobre.
@@ -536,26 +526,47 @@ export async function runDaemon () {
536
526
  // abrirse con la frase. Hay que volver a cerrar la copia maestra con la nueva, o
537
527
  // quedarían ilegibles. Si el perfil YA tenía contraseña, hace falta la vieja para
538
528
  // poder abrirla: por eso el camino normal para cambiarla es quitarla y ponerla.
529
+ // LA CONTRASEÑA ES UNA PUERTA (`profiles.js`). Ponerla o cambiarla no vuelve a sellar nada:
530
+ // la llave del perfil es la misma, solo cambia el sobre. Si el perfil no tenía candado,
531
+ // `addDoor` estrena la llave y sella con ella lo que estaba bajo la de la máquina.
539
532
  case 'password-set': {
540
533
  const id = ref()
541
- const tenia = !!mgr.profiles.get(id)?.protected
542
- 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')
543
- const vieja = tenia ? await mgr.profiles.adminKey(id, req.current) : null
544
- await mgr.profiles.setPassword(id, req.password)
545
- const nueva = await mgr.profiles.adminKey(id, req.password)
546
- try { await rekey(id, vieja, nueva) } finally { wipe(vieja); wipe(nueva) }
534
+ if (mgr.profiles.isLocked(id)) {
535
+ if (!req.current) throw Object.assign(new Error('the profile is locked: unlock it first, or give the current password'), { code: 'PROFILE_LOCKED' })
536
+ await mgr.unlock(id, { password: req.current })
537
+ }
538
+ await mgr.addDoor(id, { kind: 'password', password: req.password })
547
539
  return { done: 'contraseña guardada' }
548
540
  }
549
- // QUITARLA: al revés. Se abre la copia maestra con la frase y se vuelve a cerrar
550
- // con la llave de la máquina, que es la protección de siempre — el disco sigue
551
- // cifrado, pero su material vive en ese mismo disco.
541
+ // QUITARLA. Si era la última puerta, el perfil se queda sin candado: su llave pasa a
542
+ // guardarse con la de esta máquina y se abre sola — lo sellado con ella sigue abriendo.
552
543
  case 'password-rm': {
553
544
  const id = ref()
554
- if (!req.password) throw new Error('removing the password needs the current one: the secrets must be re-sealed before it goes')
555
- const vieja = await mgr.profiles.adminKey(id, req.password)
556
- try { await rekey(id, vieja, null) } finally { wipe(vieja) }
557
- mgr.profiles.removePassword(id)
558
- return { done: 'contraseña quitada · los secretos ahora se abren con la llave de esta máquina' }
545
+ if (mgr.profiles.isLocked(id)) {
546
+ if (!req.password) throw Object.assign(new Error('the profile is locked: unlock it first, or give the current password'), { code: 'PROFILE_LOCKED' })
547
+ await mgr.unlock(id, { password: req.password })
548
+ }
549
+ const r = await mgr.removeDoor(id, 'password')
550
+ return { done: r.protected ? 'contraseña quitada · el perfil sigue cerrándose con su llave de seguridad' : 'contraseña quitada · el perfil ya no tiene candado: se abre con la llave de esta máquina' }
551
+ }
552
+ // LLAVES DE SEGURIDAD (YubiKey). Quien habla con la llave es la CLI; aquí llegan la
553
+ // credencial o el reto, y los bytes que devolvió — nunca un secreto que la abra sin ella.
554
+ case 'door-add': {
555
+ const id = ref()
556
+ const spec = req.spec || {}
557
+ if (spec.kind !== 'fido2' && spec.kind !== 'chalresp') throw Object.assign(new Error('door-add is for security keys; the password uses password-set'), { code: 'BAD_DOOR' })
558
+ const door = await mgr.addDoor(id, {
559
+ kind: spec.kind, label: spec.label || '', password: spec.password || null,
560
+ credId: spec.credId, hsalt: spec.hsalt, challenge: spec.challenge, serial: spec.serial,
561
+ secret: spec.secret ? Buffer.from(spec.secret, 'base64') : null
562
+ })
563
+ return { done: `llave de seguridad añadida (${door.id})`, door }
564
+ }
565
+ case 'door-rm': {
566
+ const id = ref()
567
+ if (!req.door) throw Object.assign(new Error('which door? (dotrino-vault profile key ls)'), { code: 'NO_SUCH_DOOR' })
568
+ const r = await mgr.removeDoor(id, req.door)
569
+ return { done: r.protected ? 'llave de seguridad quitada' : 'llave de seguridad quitada · el perfil ya no tiene candado: se abre con la llave de esta máquina' }
559
570
  }
560
571
  // ----- LA CONTRASEÑA DEL ADMIN (`docs/abrir-a-distancia.md`) -----
561
572
  //
package/src/hwkeys.js ADDED
@@ -0,0 +1,247 @@
1
+ /**
2
+ * LLAVES DE HARDWARE (YubiKey y compañía) como PUERTA del perfil — `docs/llaves-de-hardware.md`
3
+ * nivel 2. Esto vive del lado de la CLI y la TUI, **nunca del daemon**: la llave pide un toque
4
+ * (y a veces un PIN) a la persona que está delante, y esa persona está en la terminal.
5
+ *
6
+ * Lo único que sale de aquí hacia el daemon son los 32 (o 20) bytes que devuelve la llave, por
7
+ * el mismo camino que la contraseña. El daemon los convierte en la llave que abre la puerta.
8
+ *
9
+ * DOS SABORES, porque la llave tiene dos funciones que sirven y se comportan distinto:
10
+ *
11
+ * · `fido2` — extensión `hmac-secret`. **Siempre pide toque**: la llave contesta
12
+ * `FIDO_ERR_UP_REQUIRED` si no lo hay (comprobado con una YubiKey 5.7.4).
13
+ * · `chalresp` — reto-respuesta HMAC-SHA1 de la ranura OTP 2, programada SIN toque. Abre con
14
+ * la llave enchufada y nada más. Es la que pidió el dueño para no tener que
15
+ * tocar (2026-10-05). El precio se dice: enchufada, cualquier programa de esta
16
+ * máquina puede pedirle la respuesta.
17
+ *
18
+ * SIN MÓDULOS NATIVOS. Se llama a los comandos de libfido2 (`fido2-token`, `fido2-cred`,
19
+ * `fido2-assert`) y de yubikey-personalization (`ykinfo`, `ykchalresp`, `ykpersonalize`), igual
20
+ * que el proveedor de KEK `command` llama a un KMS: el binario único no puede llevar un `.node`.
21
+ * Dónde buscarlos: `DOTRINO_HWKEY_BIN` (carpeta) y si no, el PATH.
22
+ */
23
+ import fs from 'node:fs'
24
+ import os from 'node:os'
25
+ import path from 'node:path'
26
+ import crypto from 'node:crypto'
27
+ import { spawn } from 'node:child_process'
28
+
29
+ /** El «relying party» de las credenciales FIDO2 de la bóveda. No es un dominio: nadie lo resuelve. */
30
+ export const RP_ID = 'dotrino-vault'
31
+
32
+ const coded = (msg, code, extra = {}) => Object.assign(new Error(msg), { code, ...extra })
33
+
34
+ /** Dónde está un comando, o `null`. */
35
+ export function findTool (name) {
36
+ const dirs = [process.env.DOTRINO_HWKEY_BIN, ...String(process.env.PATH || '').split(path.delimiter)].filter(Boolean)
37
+ for (const d of dirs) {
38
+ for (const n of process.platform === 'win32' ? [name + '.exe', name] : [name]) {
39
+ const f = path.join(d, n)
40
+ try { fs.accessSync(f, fs.constants.X_OK); return f } catch (_) {}
41
+ }
42
+ }
43
+ return null
44
+ }
45
+
46
+ function tool (name, pkg) {
47
+ const f = findTool(name)
48
+ if (!f) throw coded(`${name} not found: install ${pkg} (or point DOTRINO_HWKEY_BIN at its folder)`, 'HWKEY_TOOLS_MISSING', { tool: name, pkg })
49
+ return f
50
+ }
51
+
52
+ /**
53
+ * Corre un comando y devuelve stdout. La entrada va por ARCHIVO (`-i`), no por stdin: así la
54
+ * terminal queda libre para que la herramienta pida el PIN si la llave tiene uno puesto.
55
+ */
56
+ function run (file, args, { timeoutMs = 60_000 } = {}) {
57
+ return new Promise((resolve, reject) => {
58
+ const p = spawn(file, args, { stdio: ['inherit', 'pipe', 'pipe'] })
59
+ let out = ''; let err = ''
60
+ p.stdout.on('data', (d) => { out += d })
61
+ p.stderr.on('data', (d) => { err += d })
62
+ const t = setTimeout(() => { try { p.kill() } catch (_) {} }, timeoutMs)
63
+ p.on('error', (e) => { clearTimeout(t); reject(e) })
64
+ p.on('close', (code) => {
65
+ clearTimeout(t)
66
+ if (code === 0) return resolve(out)
67
+ reject(classify(err.trim() || `${path.basename(file)} exited with ${code}`))
68
+ })
69
+ })
70
+ }
71
+
72
+ /** Los fallos de la llave con nombre: cada uno se arregla de una forma distinta. */
73
+ function classify (msg) {
74
+ if (/OPERATION_DENIED|ACTION_TIMEOUT|USER_ACTION_TIMEOUT/.test(msg)) return coded('the key was not touched in time', 'HWKEY_NOT_TOUCHED')
75
+ if (/NO_CREDENTIALS|INVALID_CREDENTIAL/.test(msg)) return coded('this key does not hold that credential', 'HWKEY_WRONG_KEY')
76
+ if (/PIN_INVALID|PIN_AUTH_INVALID/.test(msg)) return coded('wrong PIN', 'HWKEY_WRONG_PIN')
77
+ if (/PIN_BLOCKED/.test(msg)) return coded('the key PIN is blocked', 'HWKEY_PIN_BLOCKED')
78
+ if (/no yubikey present|No such device|Device not found/i.test(msg)) return coded('no key plugged in', 'HWKEY_NO_DEVICE')
79
+ return coded(msg, 'HWKEY_FAILED')
80
+ }
81
+
82
+ /** Escribe la entrada en un archivo 0600 de una carpeta propia, y la borra al terminar. */
83
+ async function withInput (lines, fn) {
84
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dotrino-hwkey-'))
85
+ const f = path.join(dir, 'in')
86
+ try {
87
+ fs.writeFileSync(f, lines.join('\n') + '\n', { mode: 0o600 })
88
+ return await fn(f)
89
+ } finally {
90
+ try { fs.rmSync(dir, { recursive: true, force: true }) } catch (_) {}
91
+ }
92
+ }
93
+
94
+ const b64 = (b) => Buffer.from(b).toString('base64')
95
+ const randomB64 = (n = 32) => b64(crypto.randomBytes(n))
96
+
97
+ // ----------------------------------------------------------------- FIDO2 (con toque)
98
+
99
+ /** Las llaves FIDO2 enchufadas: `[{ path, name }]`. */
100
+ export async function fido2Devices () {
101
+ const out = await run(tool('fido2-token', 'fido2-tools (libfido2)'), ['-L'])
102
+ return out.split('\n').map((l) => /^(\S+?):\s*(.*)$/.exec(l.trim())).filter(Boolean)
103
+ .map((m) => ({ path: m[1], name: (/\(([^)]*)\)\s*$/.exec(m[2]) || [])[1] || m[2] }))
104
+ }
105
+
106
+ async function oneFido2Device () {
107
+ const devs = await fido2Devices()
108
+ if (!devs.length) throw coded('no FIDO2 key plugged in', 'HWKEY_NO_DEVICE')
109
+ return devs
110
+ }
111
+
112
+ /**
113
+ * Crea la credencial de la puerta (un toque). No es residente: no ocupa sitio en la llave y
114
+ * su id lo guarda la bóveda. Devuelve `{ credId, device }`.
115
+ */
116
+ export async function fido2MakeCredential ({ label = 'dotrino-vault' } = {}) {
117
+ const [dev] = await oneFido2Device()
118
+ const out = await withInput([randomB64(), RP_ID, String(label).replace(/[\n\0]/g, ' ').slice(0, 60) || 'dotrino-vault', randomB64()],
119
+ (f) => run(tool('fido2-cred', 'fido2-tools (libfido2)'), ['-M', '-h', '-i', f, dev.path]))
120
+ const credId = out.split('\n')[4]?.trim()
121
+ if (!credId) throw coded('fido2-cred gave no credential id', 'HWKEY_FAILED')
122
+ return { credId, device: dev.name }
123
+ }
124
+
125
+ /** ¿Tiene esta llave la credencial? Sin toque (`up=false`): sirve para elegir puerta sin molestar. */
126
+ async function fido2Holds (devPath, credId) {
127
+ try {
128
+ await withInput([randomB64(), RP_ID, credId],
129
+ (f) => run(tool('fido2-assert', 'fido2-tools (libfido2)'), ['-G', '-t', 'up=false', '-i', f, devPath], { timeoutMs: 10_000 }))
130
+ return true
131
+ } catch (e) {
132
+ // Algunas llaves contestan «hace falta toque» en vez de «no la tengo» cuando SÍ la tienen.
133
+ return e.code !== 'HWKEY_WRONG_KEY'
134
+ }
135
+ }
136
+
137
+ /**
138
+ * El secreto de la puerta (un toque): `HMAC(secreto-de-la-credencial, hsalt)`, 32 bytes.
139
+ * Prueba las llaves enchufadas hasta encontrar la que guarda `credId`.
140
+ */
141
+ export async function fido2Secret ({ credId, hsalt }) {
142
+ const devs = await oneFido2Device()
143
+ for (const d of devs) {
144
+ if (devs.length > 1 && !(await fido2Holds(d.path, credId))) continue
145
+ const out = await withInput([randomB64(), RP_ID, credId, hsalt],
146
+ (f) => run(tool('fido2-assert', 'fido2-tools (libfido2)'), ['-G', '-h', '-i', f, d.path]))
147
+ const lines = out.split('\n').map((l) => l.trim()).filter(Boolean)
148
+ const secret = Buffer.from(lines[lines.length - 1] || '', 'base64')
149
+ if (secret.length !== 32) throw coded('the key returned no hmac-secret', 'HWKEY_FAILED')
150
+ return new Uint8Array(secret)
151
+ }
152
+ throw coded('none of the plugged keys holds this credential', 'HWKEY_WRONG_KEY')
153
+ }
154
+
155
+ /** De estas credenciales, ¿cuáles tiene alguna llave enchufada? Sin toque. */
156
+ export async function fido2Present (credIds) {
157
+ let devs = []
158
+ try { devs = await fido2Devices() } catch (e) { if (e.code === 'HWKEY_TOOLS_MISSING') throw e; return [] }
159
+ const hits = []
160
+ for (const id of credIds) {
161
+ for (const d of devs) { if (await fido2Holds(d.path, id)) { hits.push(id); break } }
162
+ }
163
+ return hits
164
+ }
165
+
166
+ // ------------------------------------------------- reto-respuesta, ranura OTP 2 (sin toque)
167
+
168
+ const SLOT = '2'
169
+
170
+ /** El número de serie de la YubiKey enchufada (por la interfaz OTP), o `null` si no hay. */
171
+ export async function ykSerial () {
172
+ try {
173
+ const out = await run(tool('ykinfo', 'yubikey-personalization'), ['-s', '-q'], { timeoutMs: 10_000 })
174
+ return out.trim() || null
175
+ } catch (e) {
176
+ if (e.code === 'HWKEY_TOOLS_MISSING') throw e
177
+ return null
178
+ }
179
+ }
180
+
181
+ /** ¿Está ocupada la ranura 2? Programarla encima borraría lo que tenga. */
182
+ export async function ykSlot2Busy () {
183
+ const out = await run(tool('ykinfo', 'yubikey-personalization'), ['-a'], { timeoutMs: 10_000 })
184
+ return /slot2_status:\s*1/.test(out)
185
+ }
186
+
187
+ /**
188
+ * Programa la ranura 2 con reto-respuesta HMAC-SHA1 **sin toque** y un secreto al azar que no se
189
+ * guarda en ningún sitio: queda solo dentro de la llave. Se niega si la ranura está ocupada,
190
+ * salvo `overwrite` — sobrescribirla es borrar lo que hubiera ahí, y eso lo decide la persona.
191
+ */
192
+ export async function ykProgramSlot2 ({ overwrite = false } = {}) {
193
+ if (!overwrite && await ykSlot2Busy()) {
194
+ throw coded('slot 2 of this key is already programmed: overwriting it erases what is there', 'HWKEY_SLOT_BUSY')
195
+ }
196
+ const secret = crypto.randomBytes(20)
197
+ try {
198
+ await run(tool('ykpersonalize', 'yubikey-personalization'),
199
+ ['-' + SLOT, '-y', '-ochal-resp', '-ochal-hmac', '-ohmac-lt64', '-oserial-api-visible', '-a', secret.toString('hex')])
200
+ } finally { secret.fill(0) }
201
+ return { serial: await ykSerial() }
202
+ }
203
+
204
+ /** La respuesta de la ranura 2 a `challenge` (hex), 20 bytes. Sin toque si se programó así. */
205
+ export async function ykChallenge ({ challenge }) {
206
+ const out = await run(tool('ykchalresp', 'yubikey-personalization'), ['-' + SLOT, '-x', challenge], { timeoutMs: 20_000 })
207
+ const resp = Buffer.from(out.trim(), 'hex')
208
+ if (resp.length !== 20) throw coded('the key gave no response (is slot 2 set to challenge-response?)', 'HWKEY_FAILED')
209
+ return new Uint8Array(resp)
210
+ }
211
+
212
+ /**
213
+ * Para ABRIR: le pide a la llave enchufada los bytes de una de las puertas del perfil. Primero
214
+ * la que NO pide toque (reto-respuesta), después la de toque (FIDO2), avisando con `onTouch`
215
+ * justo antes de que la llave parpadee. `null` si ninguna llave de este perfil está enchufada.
216
+ * Devuelve `{ door, secret, withPassword }`.
217
+ */
218
+ export async function secretForDoors (doors = [], { onTouch = () => {} } = {}) {
219
+ let last = null
220
+ const cr = doors.filter((d) => d.kind === 'chalresp')
221
+ if (cr.length) {
222
+ const serial = await ykSerial()
223
+ for (const d of cr.filter((x) => serial && (!x.serial || x.serial === serial))) {
224
+ try { return { door: d.id, secret: await ykChallenge({ challenge: d.challenge }), withPassword: !!d.withPassword } } catch (e) { last = e }
225
+ }
226
+ }
227
+ const f = doors.filter((d) => d.kind === 'fido2')
228
+ if (f.length) {
229
+ const present = await fido2Present(f.map((d) => d.credId))
230
+ const d = f.find((x) => present.includes(x.credId))
231
+ if (d) {
232
+ onTouch(d)
233
+ return { door: d.id, secret: await fido2Secret({ credId: d.credId, hsalt: d.hsalt }), withPassword: !!d.withPassword }
234
+ }
235
+ }
236
+ if (last) throw last
237
+ return null
238
+ }
239
+
240
+ /** Las puertas de hardware de un perfil (de su entrada en la lista). */
241
+ export const hardwareDoors = (p) => (p?.doors || []).filter((d) => d.kind === 'fido2' || d.kind === 'chalresp')
242
+
243
+ /** Un reto nuevo para una puerta: 32 bytes en hex (cabe en el modo `hmac-lt64`). */
244
+ export const newChallenge = () => crypto.randomBytes(32).toString('hex')
245
+ /** Un salt nuevo para `hmac-secret`: 32 bytes en base64. */
246
+ export const newHsalt = () => randomB64(32)
247
+