@dotrino/vaultd 0.140.0 → 0.141.1

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.140.0",
3
+ "version": "0.141.1",
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
@@ -140,7 +140,7 @@ function cmdStatus () {
140
140
  // corriendo es más viejo que el CLI, avisar (nos mordió 3 veces).
141
141
  if (s.version && s.version !== VERSION) {
142
142
  console.log(' ⚠ el servicio corre la versión %s (binario instalado: %s).', s.version, VERSION)
143
- console.log(' Reinicia para actualizarlo: %s', RESTART_HINT)
143
+ console.log(' Si acabas de actualizar, se pone al día solo en uno o dos minutos. Si no: %s', RESTART_HINT)
144
144
  }
145
145
  console.log(' fingerprint : %s', s.fingerprint)
146
146
  console.log(' proxy : %s', s.proxy)
@@ -1732,6 +1732,30 @@ async function cmdLogins (rest) {
1732
1732
  * Instalar no reinicia nada a mano: el daemon vigila su propio binario y, al cambiar, se
1733
1733
  * va para que systemd lo levante con el nuevo (`src/selfupdate.js`).
1734
1734
  */
1735
+ /**
1736
+ * ESPERA A QUE EL SERVICIO CORRA LA VERSIÓN INSTALADA, y lo dice. El servicio se reinicia
1737
+ * solo cuando ve el binario nuevo (`selfupdate.js`), pero espera a verlo quieto dos minutos
1738
+ * seguidos: decir «se reinicia solo, compruébalo con status» y que `status` contestara un
1739
+ * minuto después «reinicia para actualizarlo» era mandar a hacer a mano lo que ya iba a pasar.
1740
+ */
1741
+ async function waitForService (version, { timeoutMs = 4 * 60_000 } = {}) {
1742
+ const s0 = ipcRead(stateFile, null)
1743
+ if (!s0 || !alive(s0.pid)) {
1744
+ console.log('El servicio no está corriendo: arráncalo y ya será la %s. %s', version, START_HINT)
1745
+ return false
1746
+ }
1747
+ process.stdout.write('El servicio la arranca solo en uno o dos minutos. Esperando… ')
1748
+ const t0 = Date.now()
1749
+ while (Date.now() - t0 < timeoutMs) {
1750
+ const s = ipcRead(stateFile, null)
1751
+ if (s?.version === version && alive(s.pid)) { console.log('✓ ya corre la %s', version); return true }
1752
+ await sleep(3000)
1753
+ }
1754
+ console.log('')
1755
+ console.error('El servicio sigue sin correr la %s después de %d min. Reinícialo: %s', version, Math.round(timeoutMs / 60000), RESTART_HINT)
1756
+ return false
1757
+ }
1758
+
1735
1759
  async function cmdUpdate (args = []) {
1736
1760
  const { latestRelease, assetFor, download, verifyArtifact, isNewer, isUserInstall, installUserRelease } = await import('./update.js')
1737
1761
  const soloMirar = args.includes('--check')
@@ -1763,7 +1787,8 @@ async function cmdUpdate (args = []) {
1763
1787
  process.exit(1)
1764
1788
  }
1765
1789
  console.log('firmada por el release de %s ✓', 'imdotrino/dotrino-vault')
1766
- console.log('Instalada la %s. El servicio se reinicia solo; compruébalo con: dotrino-vault status', res.version)
1790
+ console.log('Instalada la %s.', res.version)
1791
+ if (!args.includes('--no-wait')) await waitForService(res.version)
1767
1792
  return
1768
1793
  }
1769
1794
 
@@ -2110,6 +2135,8 @@ function help () {
2110
2135
 
2111
2136
  tui interfaz de terminal a pantalla completa (bóvedas, pares, secretos)
2112
2137
  status | info estado del servicio + fingerprint + el id de cada perfil
2138
+ update [--check] trae la versión nueva (verificando la firma) y espera a que el
2139
+ servicio la arranque · --check solo mira · --no-wait no espera
2113
2140
  pair [--save <f>] inicia un emparejamiento (QR + espera); --save escribe la invitación (.dpair)
2114
2141
  pair --kms <config.json>
2115
2142
  el sitio que se cree (--adopt o --new-account) NACE con su clave
@@ -2320,6 +2347,10 @@ async function cmdReplica (args) {
2320
2347
 
2321
2348
  export async function runCtl (argv) {
2322
2349
  const [cmd, ...rest] = takeProfileFlag(argv)
2350
+ // `--help` EN CUALQUIER COMANDO ENSEÑA LA AYUDA Y NO HACE NADA. Ningún subcomando lo miraba,
2351
+ // así que se ejecutaban igual: `pair --help` lanzaba una invitación real y `update --help`
2352
+ // instalaba la versión nueva (2026-10-05). Mirar cómo se usa algo no puede cambiar nada.
2353
+ if (rest.includes('--help') || rest.includes('-h')) return help()
2323
2354
  switch (cmd) {
2324
2355
  case 'tui': return cmdTui()
2325
2356
  case 'profile': return cmdProfile(rest)
package/src/tui/app.js CHANGED
@@ -290,12 +290,49 @@ function profileRows (st, t) {
290
290
  const list = st.profiles?.profiles || []
291
291
  return list.map((p) => {
292
292
  const mark = p.current ? t.accent('●') : ' '
293
- const lk = !p.protected ? t.muted(i.noPassword) : (p.locked ? t.warn(i.locked) : t.ok(i.unlocked))
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))}`
294
296
  const name = p.current ? t.bold(p.name || i.noName) : (p.name || i.noName)
295
297
  return { text: ` ${mark} ${name} ${t.muted(p.id)} ${t.muted(p.fingerprint || '—')} ${lk}`, sel: true, meta: p }
296
298
  })
297
299
  }
298
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
+
299
336
  function deviceRows (st, t) {
300
337
  const i = L(st)
301
338
  const rows = []
@@ -1137,7 +1174,7 @@ async function onKeyProfiles (term, st, key) {
1137
1174
  mask: true,
1138
1175
  onSubmit: async (pwd) => {
1139
1176
  st.input = null
1140
- if (pwd.length < 4) { flash(st, i.passwordTooShort, 'danger'); return }
1177
+ if (pwd.length < 12) { flash(st, i.passwordTooShort, 'danger'); return }
1141
1178
  setInput(st, {
1142
1179
  label: i.repeatPassword,
1143
1180
  mask: true,
@@ -1154,8 +1191,15 @@ async function onKeyProfiles (term, st, key) {
1154
1191
  },
1155
1192
  onCancel: () => { st.input = null }
1156
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
+ })
1157
1201
  } else if (ch === 'x' && cur) { // quitar contraseña
1158
- if (!cur.protected) { flash(st, i.noPasswordSet, 'warn'); return true }
1202
+ if (!(cur.doors || []).some((d) => d.kind === 'password')) { flash(st, i.noPasswordSet, 'warn'); return true }
1159
1203
  await ensureUnlocked(term, st, cur, async (p = cur) => {
1160
1204
  const r = await guard(term, st, i.removingPassword, () => vc.removeProfilePassword(p.id, st.sessionPwd?.get(p.id)))
1161
1205
  if (r.ok) { st.sessionPwd?.delete(p.id); flash(st, i.passwordRemoved); await refreshProfiles(term, st) }
@@ -1500,6 +1544,113 @@ function choosePairCaps (st, { profile, service = null, label = null, after = nu
1500
1544
  return true
1501
1545
  }
1502
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
+
1503
1654
  async function onKeyPairMode (term, st, key) {
1504
1655
  const i = L(st)
1505
1656
  const rows = pairModeRows(st, term.t)
@@ -2129,6 +2280,7 @@ const helpSegs = (i, screen, st = {}) => {
2129
2280
  logins: i.helpLogins,
2130
2281
  pairing: i.helpPairing,
2131
2282
  pairmode: i.helpPairMode,
2283
+ doors: i.helpDoors,
2132
2284
  paircaps: i.helpPairCaps,
2133
2285
  join: i.helpJoin,
2134
2286
  me: i.helpMe,
@@ -2160,6 +2312,7 @@ const title = (i, screen) => ({
2160
2312
  profiles: i.titleProfiles,
2161
2313
  pairing: i.titlePairing,
2162
2314
  pairmode: i.titlePairMode,
2315
+ doors: i.titleDoors,
2163
2316
  paircaps: i.titlePairCaps,
2164
2317
  join: i.titleJoin,
2165
2318
  caps: i.titleCaps,
@@ -2279,6 +2432,7 @@ function render (term, st) {
2279
2432
  else if (st.screen === 'caps') body = renderList(capsRows(st, t), st.sel.caps || 0, contentH, cols, t, scrollRef)
2280
2433
  else if (st.screen === 'devvars') body = renderList(devVarRows(st, t), st.sel.devvars || 0, contentH, cols, t, scrollRef)
2281
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)
2282
2436
  else if (st.screen === 'paircaps') body = renderList(pairCapsRows(st, t), st.sel.paircaps || 0, contentH, cols, t, scrollRef)
2283
2437
  else if (st.screen === 'pairing') {
2284
2438
  const pb = pairingBody(st, t, cols, contentH)
@@ -2362,7 +2516,7 @@ export async function runTui () {
2362
2516
  const st = {
2363
2517
  screen: 'profiles', // se arranca en la lista de bóvedas: hay que ENTRAR a una
2364
2518
  lang: loadLang(), // es/en — se conmuta con `l` y se recuerda en prefs.json
2365
- 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 },
2366
2520
  // Permisos tocados y sin guardar, por llave del aparato (ver `setCapsDraft`).
2367
2521
  capsDrafts: {},
2368
2522
  // Las bóvedas que ha abierto ESTA sesión, para volver a cerrarlas al salir.
@@ -2467,6 +2621,7 @@ export async function runTui () {
2467
2621
  else if (st.screen === 'caps') running = await onKeyCaps(term, st, key)
2468
2622
  else if (st.screen === 'devvars') running = await onKeyDevVars(term, st, key)
2469
2623
  else if (st.screen === 'pairmode') running = await onKeyPairMode(term, st, key)
2624
+ else if (st.screen === 'doors') running = await onKeyDoors(term, st, key)
2470
2625
  else if (st.screen === 'paircaps') running = await onKeyPairCaps(term, st, key)
2471
2626
  else if (st.screen === 'pairing') running = await onKeyPairing(term, st, key)
2472
2627
  else if (st.screen === 'join') running = await onKeyJoin(term, st, key)
@@ -2484,4 +2639,4 @@ export async function runTui () {
2484
2639
  }
2485
2640
 
2486
2641
  // Solo para pruebas headless (render sin terminal real). No usar en runtime.
2487
- 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,10 +167,36 @@ 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 clave',
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',
@@ -379,7 +405,7 @@ const es = {
379
405
  // INGLÉS: new/rename/delete/password/unlock/locK/pair/approve/revoke/refresh/
380
406
  // language/quit); lo único que se traduce es la palabra que las explica.
381
407
  // Segmentos, no una línea: si no caben en una, el render los reparte en varias.
382
- 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'],
383
409
  // La barra dice lo que se PUEDE hacer AHORA, no todo lo que existe: aprobar/rechazar solo
384
410
  // valen si hay alguien esperando, y renombrar/revocar solo si hay un aparato seleccionado.
385
411
  // Anunciar teclas muertas confunde y además las quema para otros usos.
@@ -416,6 +442,7 @@ const es = {
416
442
  revealNoPwd: 'Este perfil no tiene contraseña: se abre con la llave de esta máquina.',
417
443
  helpPairing: ['a aprobar', 'x rechazar', 'r reiniciar', '↑↓ scroll', 'Esc atrás', 'l English'],
418
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'],
419
446
  helpJoin: ['Esc bóvedas', 'l English', 'q salir'],
420
447
 
421
448
  // pantalla "daemon caído"
@@ -584,9 +611,35 @@ const en = {
584
611
  titleProfiles: 'Vaults',
585
612
  titlePairing: 'Pair a device',
586
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',
587
640
  titleJoin: 'Join the account of another vault',
588
641
 
589
- noPassword: 'no password',
642
+ noPassword: 'no lock',
590
643
  noPasswordWarn: 'This profile has no password: a copy of this disk opens the private variables.',
591
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.`,
592
645
  locked: '🔒 locked',
@@ -773,7 +826,7 @@ const en = {
773
826
  helpInput: 'Enter confirm · Esc cancel · Ctrl-U clear',
774
827
  helpConfirm: 'y confirm · n/Esc cancel',
775
828
 
776
- 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'],
777
830
  helpDevices: ({ pending, hasDevices, isService, drafts } = {}) => [
778
831
  '←→ tab', '↑↓', 'p pair',
779
832
  ...(drafts ? ['g save permissions'] : []),
@@ -804,6 +857,7 @@ const en = {
804
857
  revealNoPwd: 'This profile has no password: it opens with this machine key.',
805
858
  helpPairing: ['a approve', 'x reject', 'r restart', '↑↓ scroll', 'Esc back', 'l Español'],
806
859
  helpPairMode: ['↑↓', 'Enter choose', 'Esc back', 'l Español', 'q quit'],
860
+ helpDoors: ['↑↓', 'Enter add', 'd remove', 'Esc back', 'l Español', 'q quit'],
807
861
  helpJoin: ['Esc vaults', 'l Español', 'q quit'],
808
862
 
809
863
  downTitle: 'The vault daemon is not running.',
@@ -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, name, password } = {}) {
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
- const extra = {}
214
- if (name != null) extra.name = name
215
- if (password != null) extra.password = password
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])
@@ -247,6 +247,10 @@ export const touchProfile = (profile) => profileOp('touch', { profile })
247
247
  export const setProfilePassword = (profile, password, current) => profileOp('password-set', { profile, password, ...(current ? { current } : {}) })
248
248
  // Quitarla también la pide: se re-sella a la llave de la máquina antes de que se vaya.
249
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 })
250
254
 
251
255
  // ---------------------------------------------------------------------------
252
256
  // Inicios de sesión con contraseña (`docs/temporary-access.md`)