@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 +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 +207 -13
- package/src/tui/i18n.js +86 -4
- package/src/vaultControl.js +10 -5
package/src/tui/app.js
CHANGED
|
@@ -36,6 +36,7 @@ import { createTerm, widthOf } from './term.js'
|
|
|
36
36
|
import { qrToString } from '../qr.js'
|
|
37
37
|
import { dict, otherLang, loadLang, saveLang } from './i18n.js'
|
|
38
38
|
import * as vc from '../vaultControl.js'
|
|
39
|
+
import * as hw from '../hwkeys.js'
|
|
39
40
|
import { VERSION } from '../version.js'
|
|
40
41
|
import { DEVICE_CAPS } from '@dotrino/identity/acta'
|
|
41
42
|
|
|
@@ -77,8 +78,17 @@ function humanErr (e, st) {
|
|
|
77
78
|
JOIN_BUSY: t.errJoinBusy,
|
|
78
79
|
APPROVE_FAILED: t.errWrongCode,
|
|
79
80
|
MASTER_WITH_MEMBERS: t.errMasterWithMembers,
|
|
80
|
-
PROFILE_LOCKED: t.errProfileLocked
|
|
81
|
+
PROFILE_LOCKED: t.errProfileLocked,
|
|
82
|
+
NEEDS_SECURITY_KEY: t.errNeedsKey,
|
|
83
|
+
NEEDS_PASSWORD: t.errNeedsPassword,
|
|
84
|
+
MASTER_SEALED_ELSEWHERE: t.errMasterSealedElsewhere,
|
|
85
|
+
HWKEY_NO_DEVICE: t.hwNoDevice,
|
|
86
|
+
HWKEY_NOT_TOUCHED: t.hwNotTouched,
|
|
87
|
+
HWKEY_WRONG_KEY: t.hwWrongKey,
|
|
88
|
+
HWKEY_WRONG_PIN: t.hwWrongPin,
|
|
89
|
+
HWKEY_PIN_BLOCKED: t.hwPinBlocked
|
|
81
90
|
}
|
|
91
|
+
if (e?.code === 'HWKEY_TOOLS_MISSING') return t.hwToolsMissing(e.tool, e.pkg)
|
|
82
92
|
return byCode[e?.code] || e?.message || String(e)
|
|
83
93
|
}
|
|
84
94
|
|
|
@@ -280,12 +290,49 @@ function profileRows (st, t) {
|
|
|
280
290
|
const list = st.profiles?.profiles || []
|
|
281
291
|
return list.map((p) => {
|
|
282
292
|
const mark = p.current ? t.accent('●') : ' '
|
|
283
|
-
const lk = !p.protected
|
|
293
|
+
const lk = !p.protected
|
|
294
|
+
? t.muted(i.noPassword)
|
|
295
|
+
: `${p.locked ? t.warn(i.locked) : t.ok(i.unlocked)} ${t.muted('· ' + doorsSummary(i, p))}`
|
|
284
296
|
const name = p.current ? t.bold(p.name || i.noName) : (p.name || i.noName)
|
|
285
297
|
return { text: ` ${mark} ${name} ${t.muted(p.id)} ${t.muted(p.fingerprint || '—')} ${lk}`, sel: true, meta: p }
|
|
286
298
|
})
|
|
287
299
|
}
|
|
288
300
|
|
|
301
|
+
/** Una puerta dicha corta: «llave (toque) + contraseña». */
|
|
302
|
+
function doorLabel (i, d) {
|
|
303
|
+
const base = d.kind === 'password' ? i.doorPassword : d.kind === 'fido2' ? i.doorTouch : d.kind === 'chalresp' ? i.doorNoTouch : d.kind
|
|
304
|
+
return d.withPassword ? base + i.doorPlusPassword : base
|
|
305
|
+
}
|
|
306
|
+
/** Con qué se abre un perfil: «contraseña o llave (toque)». */
|
|
307
|
+
const doorsSummary = (i, p) => (p.doors || []).map((d) => doorLabel(i, d)).join(i.doorsOr) || i.doorPassword
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* CON QUÉ SE ABRE LA BÓVEDA (`docs/llaves-de-hardware.md` §2bis). Las puertas que tiene y las
|
|
311
|
+
* que se le pueden añadir. La contraseña se sigue poniendo con `c` en Perfiles; aquí se ve y
|
|
312
|
+
* se puede quitar como cualquier otra puerta.
|
|
313
|
+
*/
|
|
314
|
+
function doorRows (st, t) {
|
|
315
|
+
const i = L(st)
|
|
316
|
+
const p = (st.profiles?.profiles || []).find((x) => x.id === st.doorsFor)
|
|
317
|
+
if (!p) return [{ text: t.muted(' —'), sel: false }]
|
|
318
|
+
const rows = [{ text: t.muted(' ' + i.doorsIntro(p.name || p.id)), sel: false }, { text: '', sel: false }]
|
|
319
|
+
if (!p.protected) rows.push({ text: ' ' + t.muted(i.doorsNone), sel: false })
|
|
320
|
+
for (const d of p.doors || []) {
|
|
321
|
+
const when = d.createdAt ? new Date(d.createdAt).toISOString().slice(0, 10) : ''
|
|
322
|
+
rows.push({ text: ` ${t.bold(doorLabel(i, d))} ${t.muted(d.label || '')} ${t.muted(when)}`, sel: true, meta: { door: d } })
|
|
323
|
+
}
|
|
324
|
+
rows.push({ text: '', sel: false })
|
|
325
|
+
for (const [add, label, hint] of [
|
|
326
|
+
['touch', i.addTouchKey, i.addTouchKeyHint],
|
|
327
|
+
['notouch', i.addNoTouchKey, i.addNoTouchKeyHint],
|
|
328
|
+
['touchpwd', i.addKeyWithPassword, i.addKeyWithPasswordHint]
|
|
329
|
+
]) {
|
|
330
|
+
rows.push({ text: ' ' + t.accent(label), sel: true, meta: { add } })
|
|
331
|
+
rows.push({ text: t.muted(' ' + hint), sel: false })
|
|
332
|
+
}
|
|
333
|
+
return rows
|
|
334
|
+
}
|
|
335
|
+
|
|
289
336
|
function deviceRows (st, t) {
|
|
290
337
|
const i = L(st)
|
|
291
338
|
const rows = []
|
|
@@ -957,7 +1004,7 @@ async function reunlockSilently (term, st, p, api = vc) {
|
|
|
957
1004
|
* que dura la sesión, no hasta que alguien reinicie el servicio. Lo que ya estaba abierto
|
|
958
1005
|
* antes de entrar no se toca — no lo abrió esta pantalla, no le toca cerrarlo.
|
|
959
1006
|
*/
|
|
960
|
-
async function ensureUnlocked (term, st, p, thenFn, reason = null, api = vc) {
|
|
1007
|
+
async function ensureUnlocked (term, st, p, thenFn, reason = null, api = vc, key = undefined) {
|
|
961
1008
|
if (!p.protected || !p.locked) return thenFn()
|
|
962
1009
|
// Cerrada, pero la contraseña ya se tecleó en esta sesión: se reabre sin molestar.
|
|
963
1010
|
if (await reunlockSilently(term, st, p, api)) {
|
|
@@ -965,8 +1012,34 @@ async function ensureUnlocked (term, st, p, thenFn, reason = null, api = vc) {
|
|
|
965
1012
|
return thenFn(fresh)
|
|
966
1013
|
}
|
|
967
1014
|
const i = L(st)
|
|
1015
|
+
const fresh = () => (st.profiles?.profiles || []).find((x) => x.id === p.id) || p
|
|
1016
|
+
const hasPassword = (p.doors || []).some((d) => d.kind === 'password')
|
|
1017
|
+
// CON LA LLAVE DE SEGURIDAD, si la bóveda tiene una: se le pide antes que la contraseña.
|
|
1018
|
+
// Una vez por intento (`key === undefined`); si no abre, se cae a la contraseña diciendo
|
|
1019
|
+
// por qué, o se para si la bóveda no tiene contraseña.
|
|
1020
|
+
const doors = hw.hardwareDoors(p)
|
|
1021
|
+
if (key === undefined && doors.length) {
|
|
1022
|
+
key = null
|
|
1023
|
+
st.busy = i.lookingForKey
|
|
1024
|
+
render(term, st)
|
|
1025
|
+
try {
|
|
1026
|
+
key = await hw.secretForDoors(doors, { onTouch: () => { st.busy = i.touchKey; render(term, st) } })
|
|
1027
|
+
} catch (e) { reason = i.keyFailed(humanErr(e, st)) } finally { st.busy = null }
|
|
1028
|
+
if (key && !key.withPassword) {
|
|
1029
|
+
const r = await guard(term, st, i.unlocking, () => api.unlockProfile(p.id, null, { door: key.door, secret: Buffer.from(key.secret).toString('base64') }))
|
|
1030
|
+
key.secret.fill(0)
|
|
1031
|
+
if (r.ok) {
|
|
1032
|
+
st.unlockedHere?.add(p.id)
|
|
1033
|
+
await refreshProfiles(term, st, api)
|
|
1034
|
+
return thenFn(fresh())
|
|
1035
|
+
}
|
|
1036
|
+
reason = humanErr(r.e, st)
|
|
1037
|
+
key = null
|
|
1038
|
+
}
|
|
1039
|
+
if (!key && !hasPassword) { flash(st, reason || i.plugKey, 'danger'); render(term, st); return }
|
|
1040
|
+
}
|
|
968
1041
|
setInput(st, {
|
|
969
|
-
label: i.passwordOf(p.name || p.id),
|
|
1042
|
+
label: key ? i.passwordWithKey(p.name || p.id) : i.passwordOf(p.name || p.id),
|
|
970
1043
|
mask: true,
|
|
971
1044
|
// Si la anterior fue rechazada, el motivo se queda AQUÍ, pegado al campo, en vez de
|
|
972
1045
|
// irse en un aviso de cuatro segundos que se lleva el siguiente redibujado. Eso era lo
|
|
@@ -974,15 +1047,18 @@ async function ensureUnlocked (term, st, p, thenFn, reason = null, api = vc) {
|
|
|
974
1047
|
hint: reason || i.passwordToEdit,
|
|
975
1048
|
onSubmit: async (pwd) => {
|
|
976
1049
|
st.input = null
|
|
977
|
-
const
|
|
1050
|
+
const extra = key ? { door: key.door, secret: Buffer.from(key.secret).toString('base64') } : null
|
|
1051
|
+
const r = await guard(term, st, i.unlocking, () => api.unlockProfile(p.id, pwd, extra))
|
|
978
1052
|
// Rechazada: se vuelve a pedir en el acto, diciendo por qué. Cerrar el campo obligaba
|
|
979
1053
|
// a adivinar qué había pasado y a empezar de nuevo.
|
|
980
|
-
if (!r.ok) return ensureUnlocked(term, st, p, thenFn, humanErr(r.e, st), api)
|
|
1054
|
+
if (!r.ok) return ensureUnlocked(term, st, p, thenFn, humanErr(r.e, st), api, key)
|
|
1055
|
+
if (key) key.secret.fill(0)
|
|
981
1056
|
st.unlockedHere?.add(p.id)
|
|
982
|
-
|
|
1057
|
+
// Solo la contraseña sola se recuerda para reabrir en silencio: la que va con llave
|
|
1058
|
+
// necesita la llave otra vez, que es justo su gracia.
|
|
1059
|
+
if (!key) st.sessionPwd?.set(p.id, pwd) // vale para toda la sesión (ver reunlockSilently)
|
|
983
1060
|
await refreshProfiles(term, st, api)
|
|
984
|
-
|
|
985
|
-
await thenFn(fresh)
|
|
1061
|
+
await thenFn(fresh())
|
|
986
1062
|
},
|
|
987
1063
|
onCancel: () => { st.input = null }
|
|
988
1064
|
})
|
|
@@ -1098,7 +1174,7 @@ async function onKeyProfiles (term, st, key) {
|
|
|
1098
1174
|
mask: true,
|
|
1099
1175
|
onSubmit: async (pwd) => {
|
|
1100
1176
|
st.input = null
|
|
1101
|
-
if (pwd.length <
|
|
1177
|
+
if (pwd.length < 12) { flash(st, i.passwordTooShort, 'danger'); return }
|
|
1102
1178
|
setInput(st, {
|
|
1103
1179
|
label: i.repeatPassword,
|
|
1104
1180
|
mask: true,
|
|
@@ -1115,8 +1191,15 @@ async function onKeyProfiles (term, st, key) {
|
|
|
1115
1191
|
},
|
|
1116
1192
|
onCancel: () => { st.input = null }
|
|
1117
1193
|
}))
|
|
1194
|
+
} else if (ch === 'y' && cur) { // las llaves: con qué se abre la bóveda
|
|
1195
|
+
await ensureUnlocked(term, st, cur, async (p = cur) => {
|
|
1196
|
+
st.doorsFor = p.id
|
|
1197
|
+
st.sel.doors = 0
|
|
1198
|
+
st.scroll.doors = { value: 0 }
|
|
1199
|
+
st.screen = 'doors'
|
|
1200
|
+
})
|
|
1118
1201
|
} else if (ch === 'x' && cur) { // quitar contraseña
|
|
1119
|
-
if (!cur.
|
|
1202
|
+
if (!(cur.doors || []).some((d) => d.kind === 'password')) { flash(st, i.noPasswordSet, 'warn'); return true }
|
|
1120
1203
|
await ensureUnlocked(term, st, cur, async (p = cur) => {
|
|
1121
1204
|
const r = await guard(term, st, i.removingPassword, () => vc.removeProfilePassword(p.id, st.sessionPwd?.get(p.id)))
|
|
1122
1205
|
if (r.ok) { st.sessionPwd?.delete(p.id); flash(st, i.passwordRemoved); await refreshProfiles(term, st) }
|
|
@@ -1461,6 +1544,113 @@ function choosePairCaps (st, { profile, service = null, label = null, after = nu
|
|
|
1461
1544
|
return true
|
|
1462
1545
|
}
|
|
1463
1546
|
|
|
1547
|
+
async function onKeyDoors (term, st, key) {
|
|
1548
|
+
const i = L(st)
|
|
1549
|
+
const rows = doorRows(st, term.t)
|
|
1550
|
+
const sels = rows.filter((r) => r.sel).map((r) => r.meta)
|
|
1551
|
+
moveSel(st, key, 'doors', sels.length)
|
|
1552
|
+
const cur = sels[Math.min(st.sel.doors, sels.length - 1)]
|
|
1553
|
+
const ch = key.name === 'char' ? key.ch.toLowerCase() : null
|
|
1554
|
+
const p = (st.profiles?.profiles || []).find((x) => x.id === st.doorsFor)
|
|
1555
|
+
if (key.name === 'escape' || ch === 'b' || !p) { st.screen = 'profiles'; st.doorsFor = null; return true }
|
|
1556
|
+
|
|
1557
|
+
if ((key.name === 'delete' || ch === 'd') && cur?.door) {
|
|
1558
|
+
const d = cur.door
|
|
1559
|
+
const ultima = (p.doors || []).length <= 1
|
|
1560
|
+
const what = d.label ? `${doorLabel(i, d)} · ${d.label}` : doorLabel(i, d)
|
|
1561
|
+
setConfirm(st, {
|
|
1562
|
+
text: ultima ? i.removeLastDoorConfirm(what) : i.removeDoorConfirm(what),
|
|
1563
|
+
onYes: () => ensureUnlocked(term, st, p, async (q = p) => {
|
|
1564
|
+
const r = await guard(term, st, i.removingDoor, () => vc.removeProfileDoor(q.id, d.kind === 'password' ? 'password' : d.id))
|
|
1565
|
+
if (r.ok) {
|
|
1566
|
+
if (d.kind === 'password') st.sessionPwd?.delete(q.id)
|
|
1567
|
+
flash(st, i.doorRemoved)
|
|
1568
|
+
st.sel.doors = 0
|
|
1569
|
+
await refreshProfiles(term, st)
|
|
1570
|
+
}
|
|
1571
|
+
}),
|
|
1572
|
+
onNo: () => { st.confirm = null }
|
|
1573
|
+
})
|
|
1574
|
+
return true
|
|
1575
|
+
}
|
|
1576
|
+
if (key.name === 'enter' && cur?.add) await ensureUnlocked(term, st, p, (q = p) => addKeyDoor(term, st, q, cur.add))
|
|
1577
|
+
return true
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
/**
|
|
1581
|
+
* Da de alta la llave como puerta, avisando en la barra de estado de cada toque ANTES de que
|
|
1582
|
+
* la llave parpadee: si no se ve a tiempo, la llave rechaza la operación al medio minuto.
|
|
1583
|
+
*/
|
|
1584
|
+
async function addKeyDoor (term, st, p, mode) {
|
|
1585
|
+
const i = L(st)
|
|
1586
|
+
const busy = (msg) => { st.busy = msg; render(term, st) }
|
|
1587
|
+
const fail = (e) => { st.busy = null; flash(st, i.keyFailed(humanErr(e, st)), 'danger') }
|
|
1588
|
+
const send = async (spec) => {
|
|
1589
|
+
const r = await guard(term, st, i.savingDoor, () => vc.addProfileDoor(p.id, { ...spec, secret: Buffer.from(spec.secret).toString('base64') }))
|
|
1590
|
+
spec.secret.fill(0)
|
|
1591
|
+
if (r.ok) { flash(st, spec.kind === 'chalresp' ? i.doorAddedNoTouch : i.doorAdded); await refreshProfiles(term, st) }
|
|
1592
|
+
}
|
|
1593
|
+
// Llave + contraseña: se pide dos veces, y con el mínimo dicho antes de mandar nada.
|
|
1594
|
+
const withPassword = (spec) => setInput(st, {
|
|
1595
|
+
label: i.keyPasswordLabel,
|
|
1596
|
+
hint: i.keyPasswordHint,
|
|
1597
|
+
mask: true,
|
|
1598
|
+
onSubmit: (pwd) => {
|
|
1599
|
+
st.input = null
|
|
1600
|
+
if (pwd.length < 12) { spec.secret.fill(0); flash(st, i.passwordTooShort, 'danger'); return }
|
|
1601
|
+
setInput(st, {
|
|
1602
|
+
label: i.repeatPassword,
|
|
1603
|
+
mask: true,
|
|
1604
|
+
onSubmit: async (again) => {
|
|
1605
|
+
st.input = null
|
|
1606
|
+
if (again !== pwd) { spec.secret.fill(0); flash(st, i.passwordMismatch, 'danger'); return }
|
|
1607
|
+
await send({ ...spec, password: pwd })
|
|
1608
|
+
},
|
|
1609
|
+
onCancel: () => { st.input = null; spec.secret.fill(0) }
|
|
1610
|
+
})
|
|
1611
|
+
},
|
|
1612
|
+
onCancel: () => { st.input = null; spec.secret.fill(0) }
|
|
1613
|
+
})
|
|
1614
|
+
|
|
1615
|
+
if (mode === 'notouch') {
|
|
1616
|
+
let serial
|
|
1617
|
+
try {
|
|
1618
|
+
busy(i.lookingForKey)
|
|
1619
|
+
serial = await hw.ykSerial()
|
|
1620
|
+
if (!serial) throw Object.assign(new Error('no key'), { code: 'HWKEY_NO_DEVICE' })
|
|
1621
|
+
const ocupada = await hw.ykSlot2Busy()
|
|
1622
|
+
st.busy = null
|
|
1623
|
+
const programar = async (overwrite) => {
|
|
1624
|
+
try {
|
|
1625
|
+
busy(i.programmingSlot)
|
|
1626
|
+
await hw.ykProgramSlot2({ overwrite })
|
|
1627
|
+
const challenge = hw.newChallenge()
|
|
1628
|
+
const secret = await hw.ykChallenge({ challenge })
|
|
1629
|
+
st.busy = null
|
|
1630
|
+
await send({ kind: 'chalresp', challenge, serial, secret, label: `YubiKey ${serial}` })
|
|
1631
|
+
} catch (e) { fail(e) }
|
|
1632
|
+
}
|
|
1633
|
+
// Sobrescribir la ranura borra lo que tenga: eso lo decide la persona, aquí y en voz alta.
|
|
1634
|
+
if (ocupada) { setConfirm(st, { text: i.slotBusyConfirm(serial), onYes: () => programar(true), onNo: () => { st.confirm = null } }); return }
|
|
1635
|
+
await programar(false)
|
|
1636
|
+
} catch (e) { fail(e) }
|
|
1637
|
+
return
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
let spec
|
|
1641
|
+
try {
|
|
1642
|
+
busy(i.touchKeyStep(1))
|
|
1643
|
+
const { credId, device } = await hw.fido2MakeCredential({ label: 'dotrino-vault' })
|
|
1644
|
+
const hsalt = hw.newHsalt()
|
|
1645
|
+
busy(i.touchKeyStep(2))
|
|
1646
|
+
const secret = await hw.fido2Secret({ credId, hsalt })
|
|
1647
|
+
st.busy = null
|
|
1648
|
+
spec = { kind: 'fido2', credId, hsalt, secret, label: device || 'YubiKey' }
|
|
1649
|
+
} catch (e) { return fail(e) }
|
|
1650
|
+
if (mode === 'touchpwd') return withPassword(spec)
|
|
1651
|
+
await send(spec)
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1464
1654
|
async function onKeyPairMode (term, st, key) {
|
|
1465
1655
|
const i = L(st)
|
|
1466
1656
|
const rows = pairModeRows(st, term.t)
|
|
@@ -2090,6 +2280,7 @@ const helpSegs = (i, screen, st = {}) => {
|
|
|
2090
2280
|
logins: i.helpLogins,
|
|
2091
2281
|
pairing: i.helpPairing,
|
|
2092
2282
|
pairmode: i.helpPairMode,
|
|
2283
|
+
doors: i.helpDoors,
|
|
2093
2284
|
paircaps: i.helpPairCaps,
|
|
2094
2285
|
join: i.helpJoin,
|
|
2095
2286
|
me: i.helpMe,
|
|
@@ -2121,6 +2312,7 @@ const title = (i, screen) => ({
|
|
|
2121
2312
|
profiles: i.titleProfiles,
|
|
2122
2313
|
pairing: i.titlePairing,
|
|
2123
2314
|
pairmode: i.titlePairMode,
|
|
2315
|
+
doors: i.titleDoors,
|
|
2124
2316
|
paircaps: i.titlePairCaps,
|
|
2125
2317
|
join: i.titleJoin,
|
|
2126
2318
|
caps: i.titleCaps,
|
|
@@ -2240,6 +2432,7 @@ function render (term, st) {
|
|
|
2240
2432
|
else if (st.screen === 'caps') body = renderList(capsRows(st, t), st.sel.caps || 0, contentH, cols, t, scrollRef)
|
|
2241
2433
|
else if (st.screen === 'devvars') body = renderList(devVarRows(st, t), st.sel.devvars || 0, contentH, cols, t, scrollRef)
|
|
2242
2434
|
else if (st.screen === 'pairmode') body = renderList(pairModeRows(st, t), st.sel.pairmode, contentH, cols, t, scrollRef)
|
|
2435
|
+
else if (st.screen === 'doors') body = renderList(doorRows(st, t), st.sel.doors || 0, contentH, cols, t, scrollRef)
|
|
2243
2436
|
else if (st.screen === 'paircaps') body = renderList(pairCapsRows(st, t), st.sel.paircaps || 0, contentH, cols, t, scrollRef)
|
|
2244
2437
|
else if (st.screen === 'pairing') {
|
|
2245
2438
|
const pb = pairingBody(st, t, cols, contentH)
|
|
@@ -2323,7 +2516,7 @@ export async function runTui () {
|
|
|
2323
2516
|
const st = {
|
|
2324
2517
|
screen: 'profiles', // se arranca en la lista de bóvedas: hay que ENTRAR a una
|
|
2325
2518
|
lang: loadLang(), // es/en — se conmuta con `l` y se recuerda en prefs.json
|
|
2326
|
-
sel: { profiles: 0, devices: 0, secrets: 0, pairmode: 0, paircaps: 0, devvars: 0, logins: 0 },
|
|
2519
|
+
sel: { profiles: 0, devices: 0, secrets: 0, pairmode: 0, paircaps: 0, devvars: 0, logins: 0, doors: 0 },
|
|
2327
2520
|
// Permisos tocados y sin guardar, por llave del aparato (ver `setCapsDraft`).
|
|
2328
2521
|
capsDrafts: {},
|
|
2329
2522
|
// Las bóvedas que ha abierto ESTA sesión, para volver a cerrarlas al salir.
|
|
@@ -2428,6 +2621,7 @@ export async function runTui () {
|
|
|
2428
2621
|
else if (st.screen === 'caps') running = await onKeyCaps(term, st, key)
|
|
2429
2622
|
else if (st.screen === 'devvars') running = await onKeyDevVars(term, st, key)
|
|
2430
2623
|
else if (st.screen === 'pairmode') running = await onKeyPairMode(term, st, key)
|
|
2624
|
+
else if (st.screen === 'doors') running = await onKeyDoors(term, st, key)
|
|
2431
2625
|
else if (st.screen === 'paircaps') running = await onKeyPairCaps(term, st, key)
|
|
2432
2626
|
else if (st.screen === 'pairing') running = await onKeyPairing(term, st, key)
|
|
2433
2627
|
else if (st.screen === 'join') running = await onKeyJoin(term, st, key)
|
|
@@ -2445,4 +2639,4 @@ export async function runTui () {
|
|
|
2445
2639
|
}
|
|
2446
2640
|
|
|
2447
2641
|
// Solo para pruebas headless (render sin terminal real). No usar en runtime.
|
|
2448
|
-
export const __test = { render, pairCapsRows, onKeyPairCaps, onKeyDevices, draftChanges, saveCapsDrafts, onKeySecrets, loginRows, onKeyLogins, refreshLogins, activeLocked, autoLockedIds, autoLockWakeIn, forgetAutoLocked, autoLockMin, refreshAll, ensureUnlocked, profileRows, deviceRows, secretRows, devVarRows, meRows, capsRows, onKeyCaps, pairModeRows, pairingBody, scrollBody, fitHelp, wrapHelp, wrapWords, joinBody, onKeyJoin, promptJoin, onKeyProfiles, onInputKey, toggleLang, mergeMembersAndCerts, seguirAqui, resetToque: () => { ultimoToque = 0 } }
|
|
2642
|
+
export const __test = { render, doorRows, onKeyDoors, pairCapsRows, onKeyPairCaps, onKeyDevices, draftChanges, saveCapsDrafts, onKeySecrets, loginRows, onKeyLogins, refreshLogins, activeLocked, autoLockedIds, autoLockWakeIn, forgetAutoLocked, autoLockMin, refreshAll, ensureUnlocked, profileRows, deviceRows, secretRows, devVarRows, meRows, capsRows, onKeyCaps, pairModeRows, pairingBody, scrollBody, fitHelp, wrapHelp, wrapWords, joinBody, onKeyJoin, promptJoin, onKeyProfiles, onInputKey, toggleLang, mergeMembersAndCerts, seguirAqui, resetToque: () => { ultimoToque = 0 } }
|
package/src/tui/i18n.js
CHANGED
|
@@ -167,15 +167,55 @@ const es = {
|
|
|
167
167
|
titleProfiles: 'Bóvedas',
|
|
168
168
|
titlePairing: 'Emparejar un dispositivo',
|
|
169
169
|
titlePairMode: 'Emparejar: ¿a qué cuenta entra?',
|
|
170
|
+
titleDoors: 'Con qué se abre la bóveda',
|
|
171
|
+
doorsIntro: (name) => `Con qué se abre «${name}». Abre cualquiera de estas.`,
|
|
172
|
+
doorsNone: 'Sin candado: se abre sola en esta máquina.',
|
|
173
|
+
doorPassword: 'contraseña',
|
|
174
|
+
doorTouch: 'llave (toque)',
|
|
175
|
+
doorNoTouch: 'llave (sin toque)',
|
|
176
|
+
doorPlusPassword: ' + contraseña',
|
|
177
|
+
doorsOr: ' o ',
|
|
178
|
+
addTouchKey: '+ Llave de seguridad con toque',
|
|
179
|
+
addTouchKeyHint: 'Para abrir hay que tocarla. Es la recomendada.',
|
|
180
|
+
addNoTouchKey: '+ Llave de seguridad sin toque',
|
|
181
|
+
addNoTouchKeyHint: 'Basta con que esté enchufada: cualquier programa de esta máquina puede usarla. Usa la ranura 2 de la YubiKey.',
|
|
182
|
+
addKeyWithPassword: '+ Llave con toque y contraseña',
|
|
183
|
+
addKeyWithPasswordHint: 'Hacen falta las dos para abrir.',
|
|
184
|
+
touchKeyStep: (n) => `Toca la llave de seguridad (${n} de 2)…`,
|
|
185
|
+
programmingSlot: 'Programando la ranura 2 de la llave…',
|
|
186
|
+
slotBusyConfirm: (serial) => `La ranura 2 de la YubiKey ${serial} ya está programada. ¿Sobrescribirla? Se BORRA lo que tenga.`,
|
|
187
|
+
keyPasswordLabel: 'Contraseña para esta llave',
|
|
188
|
+
keyPasswordHint: 'esta llave abrirá solo junto con esta contraseña (mínimo 12 caracteres)',
|
|
189
|
+
savingDoor: 'Guardando la llave…',
|
|
190
|
+
doorAdded: 'Llave añadida. Deja otra forma de abrir de repuesto: perder la única es perder la bóveda.',
|
|
191
|
+
doorAddedNoTouch: 'Llave sin toque añadida. Desenchúfala cuando no la uses. Deja otra forma de abrir de repuesto.',
|
|
192
|
+
removeDoorConfirm: (what) => `¿Quitar «${what}» como forma de abrir?`,
|
|
193
|
+
removeLastDoorConfirm: (what) => `¿Quitar «${what}»? Es la última: la bóveda quedará sin candado.`,
|
|
194
|
+
removingDoor: 'Quitando…',
|
|
195
|
+
doorRemoved: 'Quitada',
|
|
170
196
|
titleJoin: 'Entrar en la cuenta de otra bóveda',
|
|
171
197
|
|
|
172
198
|
// bóvedas (perfiles)
|
|
173
|
-
noPassword: 'sin
|
|
199
|
+
noPassword: 'sin candado',
|
|
174
200
|
noPasswordWarn: 'Este perfil no tiene contraseña: una copia de este disco abre las variables privadas.',
|
|
175
201
|
pendingSeal: (owner, kind, who) => kind === 'rotate' ? `${owner}: sin rotar (salió un aparato). Guarda una variable con la contraseña.` : `${owner}: sin llave todavía en ${who}. Se reparte sola con otro aparato del grupo encendido, o al abrir la bóveda.`,
|
|
176
202
|
locked: '🔒 bloqueada',
|
|
177
203
|
unlocked: '🔓 abierta',
|
|
178
204
|
passwordOf: (name) => `Contraseña de "${name}"`,
|
|
205
|
+
passwordWithKey: (name) => `Contraseña de "${name}" (su llave la pide también)`,
|
|
206
|
+
lookingForKey: 'Buscando la llave de seguridad…',
|
|
207
|
+
touchKey: 'Toca la llave de seguridad…',
|
|
208
|
+
keyFailed: (why) => `La llave no abrió: ${why}`,
|
|
209
|
+
plugKey: 'Esta bóveda se abre con su llave de seguridad: enchúfala y vuelve a intentarlo',
|
|
210
|
+
errNeedsKey: 'Esta bóveda no se abre solo con contraseña: usa su llave de seguridad',
|
|
211
|
+
errNeedsPassword: 'Esta llave pide también la contraseña',
|
|
212
|
+
errMasterSealedElsewhere: 'La llave maestra quedó cerrada con una contraseña anterior: pon esa misma para recuperarla',
|
|
213
|
+
hwToolsMissing: (tool, pkg) => `falta ${tool} (instala ${pkg})`,
|
|
214
|
+
hwNoDevice: 'no hay ninguna llave enchufada',
|
|
215
|
+
hwNotTouched: 'no se tocó la llave a tiempo',
|
|
216
|
+
hwWrongKey: 'esta llave no es la de esta bóveda',
|
|
217
|
+
hwWrongPin: 'el PIN de la llave no es ese',
|
|
218
|
+
hwPinBlocked: 'el PIN de la llave está bloqueado',
|
|
179
219
|
passwordToEdit: 'necesaria para abrir la bóveda: sin ella no se ve ni se toca',
|
|
180
220
|
unlocking: 'Desbloqueando…',
|
|
181
221
|
loading: 'Cargando…',
|
|
@@ -365,7 +405,7 @@ const es = {
|
|
|
365
405
|
// INGLÉS: new/rename/delete/password/unlock/locK/pair/approve/revoke/refresh/
|
|
366
406
|
// language/quit); lo único que se traduce es la palabra que las explica.
|
|
367
407
|
// Segmentos, no una línea: si no caben en una, el render los reparte en varias.
|
|
368
|
-
helpProfiles: ['↑↓', 'Enter entrar', 'p emparejar', 'j unirse', 'n nueva', 'r renombrar', 'd borrar', 'c clave', 'x quitar-clave', 'u desbloq', 'k bloquear', 'l English', 'q salir'],
|
|
408
|
+
helpProfiles: ['↑↓', 'Enter entrar', 'p emparejar', 'j unirse', 'n nueva', 'r renombrar', 'd borrar', 'c clave', 'x quitar-clave', 'y llaves', 'u desbloq', 'k bloquear', 'l English', 'q salir'],
|
|
369
409
|
// La barra dice lo que se PUEDE hacer AHORA, no todo lo que existe: aprobar/rechazar solo
|
|
370
410
|
// valen si hay alguien esperando, y renombrar/revocar solo si hay un aparato seleccionado.
|
|
371
411
|
// Anunciar teclas muertas confunde y además las quema para otros usos.
|
|
@@ -402,6 +442,7 @@ const es = {
|
|
|
402
442
|
revealNoPwd: 'Este perfil no tiene contraseña: se abre con la llave de esta máquina.',
|
|
403
443
|
helpPairing: ['a aprobar', 'x rechazar', 'r reiniciar', '↑↓ scroll', 'Esc atrás', 'l English'],
|
|
404
444
|
helpPairMode: ['↑↓', 'Enter elegir', 'Esc atrás', 'l English', 'q salir'],
|
|
445
|
+
helpDoors: ['↑↓', 'Enter añadir', 'd quitar', 'Esc atrás', 'l English', 'q salir'],
|
|
405
446
|
helpJoin: ['Esc bóvedas', 'l English', 'q salir'],
|
|
406
447
|
|
|
407
448
|
// pantalla "daemon caído"
|
|
@@ -570,14 +611,54 @@ const en = {
|
|
|
570
611
|
titleProfiles: 'Vaults',
|
|
571
612
|
titlePairing: 'Pair a device',
|
|
572
613
|
titlePairMode: 'Pairing: which account does it join?',
|
|
614
|
+
titleDoors: 'What opens this vault',
|
|
615
|
+
doorsIntro: (name) => `What opens “${name}”. Any of these opens it.`,
|
|
616
|
+
doorsNone: 'No lock: it opens by itself on this machine.',
|
|
617
|
+
doorPassword: 'password',
|
|
618
|
+
doorTouch: 'key (touch)',
|
|
619
|
+
doorNoTouch: 'key (no touch)',
|
|
620
|
+
doorPlusPassword: ' + password',
|
|
621
|
+
doorsOr: ' or ',
|
|
622
|
+
addTouchKey: '+ Security key with touch',
|
|
623
|
+
addTouchKeyHint: 'You have to touch it to open. The recommended one.',
|
|
624
|
+
addNoTouchKey: '+ Security key without touch',
|
|
625
|
+
addNoTouchKeyHint: 'Being plugged in is enough: any program on this machine can use it. Uses slot 2 of the YubiKey.',
|
|
626
|
+
addKeyWithPassword: '+ Key with touch and password',
|
|
627
|
+
addKeyWithPasswordHint: 'Both are needed to open.',
|
|
628
|
+
touchKeyStep: (n) => `Touch the security key (${n} of 2)…`,
|
|
629
|
+
programmingSlot: 'Programming slot 2 of the key…',
|
|
630
|
+
slotBusyConfirm: (serial) => `Slot 2 of YubiKey ${serial} is already programmed. Overwrite it? What it holds is ERASED.`,
|
|
631
|
+
keyPasswordLabel: 'Password for this key',
|
|
632
|
+
keyPasswordHint: 'this key will open only together with this password (at least 12 characters)',
|
|
633
|
+
savingDoor: 'Saving the key…',
|
|
634
|
+
doorAdded: 'Key added. Keep a spare way in: losing the only one means losing the vault.',
|
|
635
|
+
doorAddedNoTouch: 'No-touch key added. Unplug it when not in use. Keep a spare way in.',
|
|
636
|
+
removeDoorConfirm: (what) => `Remove “${what}” as a way in?`,
|
|
637
|
+
removeLastDoorConfirm: (what) => `Remove “${what}”? It is the last one: the vault will be left without a lock.`,
|
|
638
|
+
removingDoor: 'Removing…',
|
|
639
|
+
doorRemoved: 'Removed',
|
|
573
640
|
titleJoin: 'Join the account of another vault',
|
|
574
641
|
|
|
575
|
-
noPassword: 'no
|
|
642
|
+
noPassword: 'no lock',
|
|
576
643
|
noPasswordWarn: 'This profile has no password: a copy of this disk opens the private variables.',
|
|
577
644
|
pendingSeal: (owner, kind, who) => kind === 'rotate' ? `${owner}: not rotated (a device left). Save a variable with the password.` : `${owner}: no key yet on ${who}. It is handed out by another device of the group that is on, or when the vault is opened.`,
|
|
578
645
|
locked: '🔒 locked',
|
|
579
646
|
unlocked: '🔓 unlocked',
|
|
580
647
|
passwordOf: (name) => `Password for "${name}"`,
|
|
648
|
+
passwordWithKey: (name) => `Password for "${name}" (its key asks for it too)`,
|
|
649
|
+
lookingForKey: 'Looking for the security key…',
|
|
650
|
+
touchKey: 'Touch the security key…',
|
|
651
|
+
keyFailed: (why) => `The key did not open it: ${why}`,
|
|
652
|
+
plugKey: 'This vault opens with its security key: plug it in and try again',
|
|
653
|
+
errNeedsKey: 'This vault does not open with a password alone: use its security key',
|
|
654
|
+
errNeedsPassword: 'This key also needs the password',
|
|
655
|
+
errMasterSealedElsewhere: 'The master key was left sealed with an earlier password: set that same one to recover it',
|
|
656
|
+
hwToolsMissing: (tool, pkg) => `${tool} is missing (install ${pkg})`,
|
|
657
|
+
hwNoDevice: 'no key is plugged in',
|
|
658
|
+
hwNotTouched: 'the key was not touched in time',
|
|
659
|
+
hwWrongKey: 'this key is not the one for this vault',
|
|
660
|
+
hwWrongPin: 'wrong key PIN',
|
|
661
|
+
hwPinBlocked: 'the key PIN is blocked',
|
|
581
662
|
passwordToEdit: 'needed to open this vault: without it, nothing is shown or touched',
|
|
582
663
|
unlocking: 'Unlocking…',
|
|
583
664
|
loading: 'Loading…',
|
|
@@ -745,7 +826,7 @@ const en = {
|
|
|
745
826
|
helpInput: 'Enter confirm · Esc cancel · Ctrl-U clear',
|
|
746
827
|
helpConfirm: 'y confirm · n/Esc cancel',
|
|
747
828
|
|
|
748
|
-
helpProfiles: ['↑↓', 'Enter open', 'p pair', 'j join', 'n new', 'r rename', 'd delete', 'c password', 'x drop-password', 'u unlock', 'k lock', 'l Español', 'q quit'],
|
|
829
|
+
helpProfiles: ['↑↓', 'Enter open', 'p pair', 'j join', 'n new', 'r rename', 'd delete', 'c password', 'x drop-password', 'y keys', 'u unlock', 'k lock', 'l Español', 'q quit'],
|
|
749
830
|
helpDevices: ({ pending, hasDevices, isService, drafts } = {}) => [
|
|
750
831
|
'←→ tab', '↑↓', 'p pair',
|
|
751
832
|
...(drafts ? ['g save permissions'] : []),
|
|
@@ -776,6 +857,7 @@ const en = {
|
|
|
776
857
|
revealNoPwd: 'This profile has no password: it opens with this machine key.',
|
|
777
858
|
helpPairing: ['a approve', 'x reject', 'r restart', '↑↓ scroll', 'Esc back', 'l Español'],
|
|
778
859
|
helpPairMode: ['↑↓', 'Enter choose', 'Esc back', 'l Español', 'q quit'],
|
|
860
|
+
helpDoors: ['↑↓', 'Enter add', 'd remove', 'Esc back', 'l Español', 'q quit'],
|
|
779
861
|
helpJoin: ['Esc vaults', 'l Español', 'q quit'],
|
|
780
862
|
|
|
781
863
|
downTitle: 'The vault daemon is not running.',
|
package/src/vaultControl.js
CHANGED
|
@@ -207,12 +207,12 @@ async function waitFor (name, { tries = 60, interval = 100, req = null, since =
|
|
|
207
207
|
* OPERANDO (para use/rm/rename) como el DESTINO (para unlock/lock/password). La
|
|
208
208
|
* contraseña viaja en un archivo 0600 que el daemon borra al leerlo.
|
|
209
209
|
*/
|
|
210
|
-
async function profileOp (op, { profile,
|
|
210
|
+
async function profileOp (op, { profile, ...fields } = {}) {
|
|
211
211
|
requireAlive() // nunca escribir la contraseña si no hay quien la consuma
|
|
212
212
|
rm(F.profilesList)
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
213
|
+
// TODO lo que se pase viaja, no una lista fija: con la lista fija se perdían en silencio
|
|
214
|
+
// `current` (cambiar la contraseña) y `door`/`secret` (abrir con la llave de seguridad).
|
|
215
|
+
const extra = Object.fromEntries(Object.entries(fields).filter(([, v]) => v != null))
|
|
216
216
|
const since = Date.now()
|
|
217
217
|
const id = await writeReq(F.profileReq, { op, ...extra }, profile)
|
|
218
218
|
signalOrCleanup('SIGUSR2', [F.profileReq])
|
|
@@ -229,7 +229,8 @@ export const addProfile = (name) => profileOp('add', { name })
|
|
|
229
229
|
export const useProfile = (profile) => profileOp('use', { profile })
|
|
230
230
|
export const renameProfile = (profile, name) => profileOp('rename', { profile, name })
|
|
231
231
|
export const removeProfile = (profile) => profileOp('rm', { profile })
|
|
232
|
-
|
|
232
|
+
// `key`: lo que devolvió la llave de seguridad para una puerta, `{ door, secret }` (base64).
|
|
233
|
+
export const unlockProfile = (profile, password, key = null) => profileOp('unlock', { profile, password, ...(key ? { door: key.door, secret: key.secret } : {}) })
|
|
233
234
|
export const lockProfile = (profile) => profileOp('lock', { profile })
|
|
234
235
|
/**
|
|
235
236
|
* «Sigo aquí»: estira el plazo del bloqueo automático sin hacer nada más.
|
|
@@ -246,6 +247,10 @@ export const touchProfile = (profile) => profileOp('touch', { profile })
|
|
|
246
247
|
export const setProfilePassword = (profile, password, current) => profileOp('password-set', { profile, password, ...(current ? { current } : {}) })
|
|
247
248
|
// Quitarla también la pide: se re-sella a la llave de la máquina antes de que se vaya.
|
|
248
249
|
export const removeProfilePassword = (profile, password) => profileOp('password-rm', { profile, password })
|
|
250
|
+
// LLAVES DE SEGURIDAD: `spec` lleva lo que devolvió la llave (`secret` en base64), nunca nada
|
|
251
|
+
// que la abra sin ella. `door` es el id de la puerta, o 'password' para la contraseña.
|
|
252
|
+
export const addProfileDoor = (profile, spec) => profileOp('door-add', { profile, spec })
|
|
253
|
+
export const removeProfileDoor = (profile, door) => profileOp('door-rm', { profile, door })
|
|
249
254
|
|
|
250
255
|
// ---------------------------------------------------------------------------
|
|
251
256
|
// Inicios de sesión con contraseña (`docs/temporary-access.md`)
|