@dotrino/vaultd 0.139.1 → 0.140.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 +1 -1
- package/src/ctl.js +157 -10
- package/src/daemon.js +40 -29
- package/src/hwkeys.js +247 -0
- package/src/manager.js +51 -3
- package/src/profiles.js +324 -64
- package/src/tui/app.js +47 -8
- package/src/tui/i18n.js +28 -0
- package/src/vaultControl.js +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.140.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
|
|
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
|
-
|
|
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
|
-
|
|
1944
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
|
550
|
-
// con la
|
|
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 (
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
+
|