@dotrino/vaultd 0.38.0 → 0.46.2

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