@dotrino/vaultd 0.38.0 → 0.49.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.
@@ -0,0 +1,2 @@
1
+ export * from '../lib/src/sshAgent.js'
2
+ export { default } from '../lib/src/sshAgent.js'
package/src/sshKeys.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from '../lib/src/sshKeys.js'
2
+ export { default } from '../lib/src/sshKeys.js'
package/src/store.js CHANGED
@@ -64,6 +64,8 @@ export function openStore (dir) {
64
64
  return ok
65
65
  },
66
66
  getSetting (k) { return data.settings[k] },
67
- setSetting (k, v) { data.settings[k] = v; save() }
67
+ setSetting (k, v) { if (v === undefined) delete data.settings[k]; else data.settings[k] = v; save() },
68
+ /** Todos los ajustes (copia). Para quien necesita buscar por prefijo. */
69
+ listSettings () { return { ...data.settings } }
68
70
  }
69
71
  }
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 { VERSION } from '../version.js'
39
40
 
40
41
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
41
42
 
@@ -138,6 +139,13 @@ function activeProfile (st) {
138
139
  return list.find((p) => p.current) || list[0] || null
139
140
  }
140
141
  const activeId = (st) => activeProfile(st)?.id || undefined
142
+ /**
143
+ * La contraseña del perfil ACTIVO, que la TUI ya guarda para toda la sesión al
144
+ * desbloquear (ver `reunlockSilently`). Las operaciones que SELLAN una variable la
145
+ * necesitan: sin ella el daemon cae a la llave de la máquina, que no abre la copia
146
+ * maestra de un perfil con contraseña, y la escritura falla con «wrong password».
147
+ */
148
+ const activePwd = (st) => st.sessionPwd?.get(activeId(st)) || undefined
141
149
 
142
150
  function lockGlyph (p) {
143
151
  if (!p?.protected) return ''
@@ -251,8 +259,12 @@ function deviceRows (st, t) {
251
259
  for (const d of devices) {
252
260
  const label = d.label || t.muted(i.noLabel)
253
261
  const vars = devVarsOf(st, d.sub).length
262
+ const debt = debtOf(st, d.sub)
254
263
  const extra = (d.certCount > 1 ? t.muted(` certs:${d.certCount}`) : '') +
255
- (vars ? t.muted(` vars:${vars}`) : '')
264
+ (vars ? t.muted(` vars:${vars}`) : '') +
265
+ // EN DEUDA: en el acta y sin poder abrir lo suyo. Va en color de aviso al lado de
266
+ // sus variables, que es donde se mira cuando algo no arranca.
267
+ (debt ? t.warn(` ${i.deviceDebt(debt)}`) : '')
256
268
  // SIN ACCESO: está en el acta y no puede entrar. Es un aviso, no un adorno, así que va
257
269
  // en el color de aviso y en el sitio donde estaría su vencimiento.
258
270
  const status = d.noAccess
@@ -341,7 +353,19 @@ function secretRows (st, t) {
341
353
  // El puntero a la otra pantalla va SIEMPRE, con scopes y sin ellos: es la mitad de la
342
354
  // función y quien la busca no tiene por qué adivinar que vive en Dispositivos.
343
355
  const footer = [{ text: '', sel: false }, { text: t.muted(' ' + i.devVarsElsewhere), sel: false }]
344
- if (!names.length) return [{ text: t.muted(i.noScopes), sel: false }, ...footer]
356
+ // ARRIBA DEL TODO, antes que las variables: lo que está sin sellar significa que esos
357
+ // aparatos NO están leyendo su configuración ahora mismo, y que solo la contraseña lo
358
+ // arregla. Un aviso que hay que buscar no es un aviso.
359
+ const head = []
360
+ for (const [owner, info] of Object.entries(st.secrets?.pending || {})) {
361
+ const who = (info?.members || []).map((m) => deviceIdOf(st, m.pub) + ' (' + m.keys.join(', ') + ')').join(', ')
362
+ head.push({ text: t.warn(' ' + i.pendingSeal(owner, info?.kind, who)), sel: false })
363
+ }
364
+ const p = activeProfile(st)
365
+ if (p && !p.protected) head.push({ text: t.warn(' ' + i.noPasswordWarn), sel: false })
366
+ if (head.length) head.push({ text: '', sel: false })
367
+ if (!names.length) return [...head, { text: t.muted(i.noScopes), sel: false }, ...footer]
368
+ rows.push(...head)
345
369
  for (const n of names) {
346
370
  rows.push({ text: t.accent(` ▸ ${n}`) + t.muted(i.scopeOf(n)), sel: true, meta: { ns: n, key: null } })
347
371
  for (const k of sortByKey(ns[n])) {
@@ -354,6 +378,21 @@ function secretRows (st, t) {
354
378
  /** Las claves guardadas para UN aparato (`pub`), o `[]`. Cada una es `{key, public}`. */
355
379
  const devVarsOf = (st, pub) => (st.secrets?.dev || []).find((x) => x.pub === pub)?.keys || []
356
380
 
381
+ /**
382
+ * Lo que ese aparato NO puede abrir (§8.11). Un servicio que entra después de escrita
383
+ * una variable no tiene envoltura de ella, y hasta que alguien se la reparta está en el
384
+ * acta sin poder arrancar del todo. Aquí se cuenta cuántas, que es lo que cabe en una
385
+ * fila; el detalle está en la consola.
386
+ */
387
+ /** El ID corto de un aparato a partir de su llave, tal como lo enseña la lista de Aparatos. */
388
+ const deviceIdOf = (st, pub) =>
389
+ (st.devices?.issued || []).find((d) => d.sub === pub)?.deviceId || (st.members || []).find((m) => m.pub === pub)?.id || pub.slice(0, 8)
390
+
391
+ const debtOf = (st, pub) => {
392
+ const d = (st.secrets?.incomplete || []).find((x) => x.pub === pub)
393
+ return d ? [...new Set(Object.values(d.owners || {}).flat())].length : 0
394
+ }
395
+
357
396
  const sortByKey = (list) => (list || []).slice().sort((a, b) => a.key.localeCompare(b.key))
358
397
 
359
398
  /**
@@ -696,7 +735,7 @@ async function onKeyProfiles (term, st, key) {
696
735
  onSubmit: async (again) => {
697
736
  st.input = null
698
737
  if (again !== pwd) { flash(st, i.passwordMismatch, 'danger'); return }
699
- const r = await guard(term, st, i.savingPassword, () => vc.setProfilePassword(p.id, pwd))
738
+ const r = await guard(term, st, i.savingPassword, () => vc.setProfilePassword(p.id, pwd, st.sessionPwd?.get(p.id)))
700
739
  // La nueva es la que vale para el resto de la sesión: guardar la vieja dejaría
701
740
  // a la TUI reabriendo con una contraseña que ya no existe.
702
741
  if (r.ok) { st.sessionPwd?.set(p.id, pwd); flash(st, i.passwordSaved); await refreshProfiles(term, st) }
@@ -709,7 +748,7 @@ async function onKeyProfiles (term, st, key) {
709
748
  } else if (ch === 'x' && cur) { // quitar contraseña
710
749
  if (!cur.protected) { flash(st, i.noPasswordSet, 'warn'); return true }
711
750
  await ensureUnlocked(term, st, cur, async (p = cur) => {
712
- const r = await guard(term, st, i.removingPassword, () => vc.removeProfilePassword(p.id))
751
+ const r = await guard(term, st, i.removingPassword, () => vc.removeProfilePassword(p.id, st.sessionPwd?.get(p.id)))
713
752
  if (r.ok) { st.sessionPwd?.delete(p.id); flash(st, i.passwordRemoved); await refreshProfiles(term, st) }
714
753
  })
715
754
  } else if (ch === 'u' && cur) {
@@ -800,6 +839,19 @@ async function onKeyDevices (term, st, key) {
800
839
  return true
801
840
  }
802
841
 
842
+ /**
843
+ * Tira la cuenta que NACIÓ para un emparejamiento que no llegó a término y vuelve a la que
844
+ * estabas usando. Es una cuenta recién creada y vacía —el aparato nunca entró—, así que no
845
+ * hay nada dentro que perder; lo que sí molesta es que se queden acumulando.
846
+ */
847
+ async function descartarCuenta (term, st, id, volverA) {
848
+ const i = L(st)
849
+ const r = await guard(term, st, i.discardingAccount, () => vc.removeProfile(id))
850
+ if (volverA) await guard(term, st, i.switchingVault, () => vc.useProfile(volverA))
851
+ await refreshAll(term, st)
852
+ if (r.ok) flash(st, i.accountDiscarded)
853
+ }
854
+
803
855
  /**
804
856
  * Abre el emparejamiento contra `profile` y salta a la pantalla del QR. Con `service`,
805
857
  * el QR es el de un SERVICIO de ese namespace (cert limitado a sus variables).
@@ -895,6 +947,7 @@ async function onKeyPairMode (term, st, key) {
895
947
  st.input = null
896
948
  const name = raw.trim()
897
949
  if (!name) { flash(st, i.nameEmpty, 'danger'); return }
950
+ const previa = activeId(st)
898
951
  const r = await guard(term, st, i.creatingVault, () => vc.addProfile(name))
899
952
  if (!r.ok) return
900
953
  const created = r.v?.id || (r.v?.profiles || []).find((p) => p.name === name)?.id
@@ -903,7 +956,11 @@ async function onKeyPairMode (term, st, key) {
903
956
  if (!u.ok) return
904
957
  await refreshAll(term, st)
905
958
  flash(st, i.accountCreated(name))
906
- await beginPairing(term, st, created)
959
+ // Si el emparejamiento no llega a abrirse, la cuenta que se creó PARA él se va con
960
+ // él: si no, cada intento fallido dejaba una cuenta vacía —y encima activa— que
961
+ // luego había que ir a borrar a mano adivinando cuál era.
962
+ if (!await beginPairing(term, st, created)) await descartarCuenta(term, st, created, previa)
963
+ else st.pairing.born = { id: created, from: previa }
907
964
  },
908
965
  onCancel: () => { st.input = null }
909
966
  })
@@ -922,6 +979,9 @@ function promptApprove (term, st) {
922
979
  if (r.ok) {
923
980
  flash(st, i.deviceApproved)
924
981
  st.pending = null
982
+ // El aparato entró: la cuenta que se creó para esto ya es de alguien, así que deja
983
+ // de estar en la lista de las que se descartan al salir.
984
+ st.pairing = null
925
985
  st.screen = 'devices'
926
986
  // Sin lista (el volcado se perdió): se pide otra vez. El aparato ya está dentro;
927
987
  // lo único que falta es la foto, y esa se vuelve a pedir sin drama.
@@ -963,11 +1023,26 @@ async function onKeyPairing (term, st, key) {
963
1023
  return true
964
1024
  }
965
1025
  if (ch === 'r') { // restart: reiniciar el emparejamiento
1026
+ const born = st.pairing?.born
966
1027
  const r = await guard(term, st, i.restartingPairing, () => vc.startPairing({ profile: activeId(st) }))
967
- if (r.ok) { st.pairing = r.v; st.pending = null; st.scroll.pairing = { value: 0 } }
1028
+ if (r.ok) { st.pairing = { ...r.v, ...(born ? { born } : {}) }; st.pending = null; st.scroll.pairing = { value: 0 } }
968
1029
  return true
969
1030
  }
970
- if (key.name === 'escape' || ch === 'b') { st.screen = 'devices'; st.pairing = null; await refreshDevices(term, st) }
1031
+ if (key.name === 'escape' || ch === 'b') {
1032
+ // Te vas sin que nadie haya entrado, y la cuenta se creó PARA esto: se pregunta antes
1033
+ // de dejarla ahí. Es la otra mitad del mismo descuido — con «cuenta nueva» era fácil
1034
+ // acabar con tres cuentas vacías y ninguna forma de saber cuál era cuál.
1035
+ const born = st.pairing?.born
1036
+ if (born) {
1037
+ setConfirm(st, {
1038
+ text: i.confirmDiscardAccount,
1039
+ onYes: async () => { st.confirm = null; st.pairing = null; st.screen = 'devices'; await descartarCuenta(term, st, born.id, born.from) },
1040
+ onNo: async () => { st.confirm = null; st.pairing = null; st.screen = 'devices'; await refreshDevices(term, st) }
1041
+ })
1042
+ return true
1043
+ }
1044
+ st.screen = 'devices'; st.pairing = null; await refreshDevices(term, st)
1045
+ }
971
1046
  return true
972
1047
  }
973
1048
 
@@ -1015,8 +1090,10 @@ async function onKeySecrets (term, st, key) {
1015
1090
  onNo: () => { st.confirm = null }
1016
1091
  })
1017
1092
  }
1093
+ } else if (ch === 'v' && cur?.key) {
1094
+ await revealValue(term, st, `ns:${cur.ns}`, cur.key, cur.public)
1018
1095
  } else if (ch === 't' && cur?.key) {
1019
- await toggleVisibility(term, st, cur.public, () => vc.setSecretVisibility(cur.ns, cur.key, !cur.public, activeId(st)))
1096
+ await toggleVisibility(term, st, cur.public, () => vc.setSecretVisibility(cur.ns, cur.key, !cur.public, activeId(st), activePwd(st)))
1020
1097
  } else if (key.name === 'f5') {
1021
1098
  await refreshSecrets(term, st)
1022
1099
  }
@@ -1027,6 +1104,38 @@ async function onKeySecrets (term, st, key) {
1027
1104
  * Hacer pública una variable es dejar que su valor SALGA de esta máquina, así que se
1028
1105
  * pregunta; volverla privada no expone nada y se aplica directo.
1029
1106
  */
1107
+ /**
1108
+ * VER el valor de una variable. Es lo único que la contraseña guarda desde v5, y en la
1109
+ * máquina de la bóveda no hay otro camino: la llave de este aparato vive en este mismo
1110
+ * disco, así que si abriera sin frase una copia del disco abriría todo.
1111
+ *
1112
+ * Si el perfil no tiene contraseña se abre con la llave de la máquina y no se pregunta
1113
+ * nada — pero se dice, para que no parezca una protección que no está puesta.
1114
+ */
1115
+ async function revealValue (term, st, owner, key, isPublic) {
1116
+ const i = L(st)
1117
+ const p = activeProfile(st)
1118
+ const mostrar = async (pwd) => {
1119
+ const r = await guard(term, st, i.revealing, () => vc.revealSecret(owner, key, activeId(st), pwd))
1120
+ if (r.ok) flash(st, i.revealed(key, r.v), 'ok')
1121
+ }
1122
+ if (isPublic) return mostrar(undefined) // una pública no está cerrada
1123
+ if (!p?.protected) { flash(st, i.revealNoPwd, 'warn'); return mostrar(undefined) }
1124
+ const guardada = activePwd(st)
1125
+ if (guardada) return mostrar(guardada)
1126
+ setInput(st, {
1127
+ label: i.revealAsk,
1128
+ hint: i.revealHint,
1129
+ mask: true,
1130
+ onSubmit: async (pwd) => {
1131
+ st.input = null
1132
+ if (!pwd) return
1133
+ await mostrar(pwd)
1134
+ },
1135
+ onCancel: () => { st.input = null }
1136
+ })
1137
+ }
1138
+
1030
1139
  async function toggleVisibility (term, st, wasPublic, apply) {
1031
1140
  const i = L(st)
1032
1141
  const run = async () => {
@@ -1059,7 +1168,9 @@ async function onKeyDevVars (term, st, key) {
1059
1168
  if (ch === 'n') { promptNewDeviceVariable(term, st); return true }
1060
1169
  if (ch === 'i' && target) { promptLoadVars(term, st, { pub: target.pub, where: target.deviceId }); return true }
1061
1170
  if (ch === 't' && cur && target) {
1062
- await toggleVisibility(term, st, cur.public, () => vc.setDeviceSecretVisibility(target.pub, cur.key, !cur.public, activeId(st)))
1171
+ await toggleVisibility(term, st, cur.public, () => vc.setDeviceSecretVisibility(target.pub, cur.key, !cur.public, activeId(st), activePwd(st)))
1172
+ } else if (ch === 'v' && cur?.key) {
1173
+ await revealValue(term, st, `dev:${target.pub}`, cur.key, cur.public)
1063
1174
  return true
1064
1175
  }
1065
1176
  if ((ch === 'x' || key.name === 'delete') && cur && target) {
@@ -1355,7 +1466,10 @@ function render (term, st) {
1355
1466
  const ap = activeProfile(st)
1356
1467
  const apTxt = ap ? `${t.accent('●')} ${t.bold(ap.name || i.noName)} ${lockGlyph(ap)} ${t.muted('· ' + (ap.fingerprint || '—'))}` : t.muted('—')
1357
1468
  lines[1] = ' ' + i.activeVault + apTxt
1358
- lines[2] = ''
1469
+ // El .deb instala el binario pero NO reinicia el servicio (y si se reinicia ANTES de
1470
+ // instalar, peor: el daemon se queda con el binario viejo, ya borrado, y hay dos copias
1471
+ // en RAM). `status` ya lo avisa; aquí también, que es donde uno se queda mirando.
1472
+ lines[2] = (up && s?.version && VERSION !== 'dev' && s.version !== VERSION) ? ' ' + t.warn(i.daemonStale(s.version, VERSION)) : ''
1359
1473
  // Dispositivos/Scopes son pestañas de la bóveda activa (se entra desde Bóvedas);
1360
1474
  // el resto muestra su título simple.
1361
1475
  lines[3] = INNER_TABS.includes(st.screen) ? ' ' + renderTabs(st, t) : ' ' + t.title('» ' + title(i, st.screen))
package/src/tui/i18n.js CHANGED
@@ -26,6 +26,7 @@ const es = {
26
26
  // encabezado / estado
27
27
  daemonRunning: 'corriendo',
28
28
  daemonStopped: 'DETENIDO',
29
+ daemonStale: (running, installed) => `⚠ el servicio corre ${running} y el binario instalado es ${installed}: reinícialo (systemctl --user restart dotrino-vault)`,
29
30
  activeVault: 'Bóveda activa: ',
30
31
  noName: '(sin nombre)',
31
32
  tooSmall: 'Terminal muy pequeño',
@@ -83,6 +84,8 @@ const es = {
83
84
 
84
85
  // bóvedas (perfiles)
85
86
  noPassword: 'sin clave',
87
+ noPasswordWarn: 'Este perfil no tiene contraseña: una copia de este disco abre las variables privadas.',
88
+ 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.`,
86
89
  locked: '🔒 bloqueada',
87
90
  unlocked: '🔓 abierta',
88
91
  passwordOf: (name) => `Contraseña de "${name}"`,
@@ -109,7 +112,7 @@ const es = {
109
112
  deletingVault: 'Borrando bóveda…',
110
113
  vaultDeleted: 'Bóveda borrada',
111
114
  newPasswordLabel: (name) => `Contraseña nueva para "${name}" (mín. 4)`,
112
- passwordTooShort: 'La contraseña debe tener al menos 4 caracteres',
115
+ passwordTooShort: 'La contraseña debe tener al menos 12 caracteres: usa varias palabras al azar',
113
116
  repeatPassword: 'Repite la contraseña',
114
117
  passwordMismatch: 'Las contraseñas no coinciden',
115
118
  savingPassword: 'Guardando contraseña…',
@@ -129,6 +132,7 @@ const es = {
129
132
  noLabel: '(sin etiqueta)',
130
133
  // Está en el acta y no puede entrar: o le retiraron el certificado, o se le venció.
131
134
  deviceNoAccess: 'SIN ACCESO — está en el acta pero no puede entrar',
135
+ deviceDebt: (n) => `no abre ${n}`,
132
136
  thisVault: 'esta bóveda (manda ella)',
133
137
  cantRemoveMaster: 'Esta bóveda es la que manda: no se quita a sí misma.',
134
138
  revokedCount: (n) => ` Revocados: ${n}`,
@@ -218,6 +222,9 @@ const es = {
218
222
  newAccountLabel: 'Nombre de la cuenta nueva',
219
223
  newAccountHint: 'nace vacía; el dispositivo será su primer invitado',
220
224
  accountCreated: (name) => `Cuenta creada: ${name}`,
225
+ discardingAccount: 'Descartando la cuenta…',
226
+ accountDiscarded: 'Cuenta descartada: nadie llegó a entrar en ella',
227
+ confirmDiscardAccount: 'La cuenta se creó para este emparejamiento y quedó vacía. ¿La descarto?',
221
228
  // Cuenta + vigencia en UNA línea: cada línea de cabecera es una fila menos de QR
222
229
  // visible antes de tener que hacer scroll.
223
230
  pairAccount: (name, min) => `Cuenta que se comparte: ${name} · válido ~${min} min`,
@@ -257,14 +264,21 @@ const es = {
257
264
  deviceRenamed: (n) => `Ahora se llama «${n}»`,
258
265
  helpSecrets: ({ hasSecrets } = {}) => [
259
266
  '←→ pestaña', '↑↓', 'n nueva variable', 'i cargar varias',
260
- ...(hasSecrets ? ['t pública/privada', 'x quitar (variable/scope)'] : []),
267
+ ...(hasSecrets ? ['v ver valor', 't pública/privada', 'x quitar (variable/scope)'] : []),
261
268
  'F5 refrescar', 'Esc bóvedas', 'l English', 'q salir'
262
269
  ],
263
270
  helpDevVars: ({ hasVars } = {}) => [
264
271
  '↑↓', 'n nueva variable', 'i cargar varias',
265
- ...(hasVars ? ['t pública/privada', 'x quitar'] : []),
272
+ ...(hasVars ? ['v ver valor', 't pública/privada', 'x quitar'] : []),
266
273
  'F5 refrescar', 'Esc dispositivos', 'l English', 'q salir'
267
274
  ],
275
+ // Ver el valor de una privada: lo único que la contraseña guarda en esta máquina.
276
+ revealTitle: 'Ver el valor',
277
+ revealAsk: 'Contraseña del perfil (para ver el valor)',
278
+ revealHint: 'sin ella no se puede abrir: es lo único que la guarda (Esc cancela)',
279
+ revealing: 'abriendo…',
280
+ revealed: (k, v) => `${k} = ${v}`,
281
+ revealNoPwd: 'Este perfil no tiene contraseña: se abre con la llave de esta máquina.',
268
282
  helpPairing: ['a aprobar', 'x rechazar', 'r reiniciar', '↑↓ scroll', 'Esc atrás', 'l English'],
269
283
  helpPairMode: ['↑↓', 'Enter elegir', 'Esc atrás', 'l English', 'q salir'],
270
284
 
@@ -311,6 +325,7 @@ const en = {
311
325
 
312
326
  daemonRunning: 'running',
313
327
  daemonStopped: 'STOPPED',
328
+ daemonStale: (running, installed) => `⚠ the service runs ${running} but the installed binary is ${installed}: restart it (systemctl --user restart dotrino-vault)`,
314
329
  activeVault: 'Active vault: ',
315
330
  tooSmall: 'Terminal too small',
316
331
  tooSmallHint: (cols, rows) => `Resize to ≥ 24×9 (now ${cols}×${rows}).`,
@@ -363,6 +378,8 @@ const en = {
363
378
  titlePairMode: 'Pairing: which account does it join?',
364
379
 
365
380
  noPassword: 'no password',
381
+ noPasswordWarn: 'This profile has no password: a copy of this disk opens the private variables.',
382
+ 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.`,
366
383
  locked: '🔒 locked',
367
384
  unlocked: '🔓 unlocked',
368
385
  passwordOf: (name) => `Password for "${name}"`,
@@ -389,7 +406,7 @@ const en = {
389
406
  deletingVault: 'Deleting vault…',
390
407
  vaultDeleted: 'Vault deleted',
391
408
  newPasswordLabel: (name) => `New password for "${name}" (min. 4)`,
392
- passwordTooShort: 'The password must be at least 4 characters',
409
+ passwordTooShort: 'The password must be at least 12 characters: use several random words',
393
410
  repeatPassword: 'Repeat the password',
394
411
  passwordMismatch: 'The passwords do not match',
395
412
  savingPassword: 'Saving password…',
@@ -407,6 +424,7 @@ const en = {
407
424
  noDevices: ' (no devices enrolled — press P to pair one)',
408
425
  noLabel: '(no label)',
409
426
  deviceNoAccess: 'NO ACCESS — it is on the record but cannot get in',
427
+ deviceDebt: (n) => `cannot open ${n}`,
410
428
  thisVault: 'this vault (it is the Master)',
411
429
  cantRemoveMaster: 'This vault is the Master: it does not remove itself.',
412
430
  revokedCount: (n) => ` Revoked: ${n}`,
@@ -491,6 +509,9 @@ const en = {
491
509
  newAccountLabel: 'Name of the new account',
492
510
  newAccountHint: 'born empty; the device will be its first guest',
493
511
  accountCreated: (name) => `Account created: ${name}`,
512
+ discardingAccount: 'Discarding the account…',
513
+ accountDiscarded: 'Account discarded: nobody got to join it',
514
+ confirmDiscardAccount: 'The account was created for this pairing and is empty. Discard it?',
494
515
  pairAccount: (name, min) => `Account being shared: ${name} · valid ~${min} min`,
495
516
  pairScan: 'Scan it, or open this address on the device:',
496
517
  pairUrl: 'URL: ',
@@ -518,14 +539,20 @@ const en = {
518
539
  deviceRenamed: (n) => `Now called "${n}"`,
519
540
  helpSecrets: ({ hasSecrets } = {}) => [
520
541
  '←→ tab', '↑↓', 'n new variable', 'i load several',
521
- ...(hasSecrets ? ['t public/private', 'x remove (variable/scope)'] : []),
542
+ ...(hasSecrets ? ['v show value', 't public/private', 'x remove (variable/scope)'] : []),
522
543
  'F5 refresh', 'Esc vaults', 'l Español', 'q quit'
523
544
  ],
524
545
  helpDevVars: ({ hasVars } = {}) => [
525
546
  '↑↓', 'n new variable', 'i load several',
526
- ...(hasVars ? ['t public/private', 'x remove'] : []),
547
+ ...(hasVars ? ['v show value', 't public/private', 'x remove'] : []),
527
548
  'F5 refresh', 'Esc devices', 'l Español', 'q quit'
528
549
  ],
550
+ revealTitle: 'Show the value',
551
+ revealAsk: 'Profile password (to show the value)',
552
+ revealHint: 'without it there is no way to open it: it is the only thing guarding it (Esc cancels)',
553
+ revealing: 'opening…',
554
+ revealed: (k, v) => `${k} = ${v}`,
555
+ revealNoPwd: 'This profile has no password: it opens with this machine key.',
529
556
  helpPairing: ['a approve', 'x reject', 'r restart', '↑↓ scroll', 'Esc back', 'l Español'],
530
557
  helpPairMode: ['↑↓', 'Enter choose', 'Esc back', 'l Español', 'q quit'],
531
558