@dotrino/vaultd 0.7.4 → 0.7.6

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/README.md CHANGED
@@ -100,8 +100,10 @@ raw mode).
100
100
  los dispositivos/variables que estás viendo:
101
101
 
102
102
  1. **Bóvedas** es la pantalla de entrada: lista tus perfiles (`↑↓` mover, `Enter`
103
- **entrar** a uno — lo activa si no lo estaba). Ahí también creas una bóveda
104
- nueva, renombras, borras y pones/quitas/usas la contraseña (candado).
103
+ **entrar** a uno — lo activa si no lo estaba). Ahí también **conectas un
104
+ dispositivo** con `p` (sin entrar: activa la bóveda elegida y abre la pregunta
105
+ de a qué cuenta entra), creas una bóveda nueva, renombras, borras y
106
+ pones/quitas/usas la contraseña (candado).
105
107
  2. Al entrar caes en sus **pestañas horizontales**, que cambias con `←→`:
106
108
  - **Dispositivos (pares):** verlos, **emparejar** uno nuevo, **aprobar** con el
107
109
  código que muestra el dispositivo, **rechazar** y **revocar**. Al emparejar, la
@@ -132,7 +134,8 @@ en español (solo se traduce la palabra que las explica en la barra de ayuda).
132
134
  | `n` | new — bóveda nueva / variable nueva | Bóvedas · Scopes |
133
135
  | `r` | rename (Bóvedas) · refresh (Dispositivos/Scopes) · restart (Emparejar) | — |
134
136
  | `d` | delete — borrar la bóveda | Bóvedas |
135
- | `p` | password (Bóvedas) · pair emparejar (Dispositivos) | |
137
+ | `p` | **pair — conectar un dispositivo** (desde Bóvedas entra directo, sin `Enter`) | Bóvedas · Dispositivos |
138
+ | `c` | change password — poner/cambiar la contraseña | Bóvedas |
136
139
  | `x` | quitar: contraseña · dispositivo pendiente · variable/scope | todas |
137
140
  | `u` / `k` | unlock / locK — candado de la bóveda | Bóvedas |
138
141
  | `a` | approve — aprobar el dispositivo | Dispositivos · Emparejar |
package/lib/src/enroll.js CHANGED
@@ -108,13 +108,29 @@ export function createEnrollDesk ({
108
108
  const reply = (to, obj) => { try { send(to, obj) } catch (e) { log('[vault] no se pudo responder:', e.message) } }
109
109
  const isFresh = (d) => typeof d?.ts === 'number' && Math.abs(Date.now() - d.ts) <= FRESH_WINDOW_MS
110
110
 
111
- /** Inicia un emparejamiento: token + nonce de sesión. NO firma nada todavía. */
112
- function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '' } = {}) {
111
+ /**
112
+ * Inicia un emparejamiento: token + nonce de sesión. NO firma nada todavía.
113
+ *
114
+ * `mode` y `account` son LO QUE LA BÓVEDA DECLARA que va a pasar, y viajan en el QR
115
+ * para que el aparato pueda **decirlo antes de hacerlo** en vez de emparejar a
116
+ * ciegas (decisión V9 de `docs/vinculacion-de-cuentas.md`: pregunta el vault, el
117
+ * dispositivo muestra el proceso y sus consecuencias):
118
+ *
119
+ * · `mode: 'join'` → el dispositivo estrena una cuenta suya y entra a la de la
120
+ * bóveda. Es lo único que existe hoy.
121
+ * · `mode: 'adopt'` → la bóveda se quedaría con la cuenta que trae el aparato
122
+ * (camino A). Reservado: todavía no hay protocolo.
123
+ * · `account` → cómo se llama la cuenta de la bóveda, para nombrarla en el
124
+ * aviso. Es ORIENTATIVO (un nombre que puso su dueño); la
125
+ * identidad de verdad de la cuenta es `iss`.
126
+ */
127
+ function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '', mode = 'join', account = '' } = {}) {
113
128
  pending.clear() // uno a la vez: una sesión nueva supersede a la anterior
114
129
  const token = randToken()
115
130
  const sn = randToken()
116
- pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, state: 'AWAITING_ENROLL' })
117
- return { token, qr: { v: 2, iss, proxy, token, sn }, expiresInMs: PAIRING_TTL_MS }
131
+ const acct = String(account || '').slice(0, 40)
132
+ pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
133
+ return { token, qr: { v: 2, iss, proxy, token, sn, m: mode, ...(acct ? { acct } : {}) }, expiresInMs: PAIRING_TTL_MS }
118
134
  }
119
135
 
120
136
  function stopPairing (token) { pending.delete(token) }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
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/daemon.js CHANGED
@@ -117,11 +117,13 @@ export async function runDaemon () {
117
117
  const isService = typeof pairReq?.service === 'string' && pairReq.service
118
118
  const scope = isService ? ['vault:secrets:' + pairReq.service] : ['vault:sign', 'vault:read', 'vault:store']
119
119
  const label = pairReq?.label || (isService ? 'servicio:' + pairReq.service : 'cli')
120
- const { qr, expiresInMs } = vault.startPairing({ scope, label, ttlMs: DEVICE_TTL_MS })
121
120
  // `profile`/`profileName`: la CUENTA del vault a la que entra el dispositivo.
122
121
  // Con varias bóvedas en el mismo daemon, el QR sale de UNA y quien empareja
123
- // tiene que verlo (lo muestran la TUI y `dotrino-vault pair`).
122
+ // tiene que verlo (lo muestran la TUI y `dotrino-vault pair`). El nombre viaja
123
+ // TAMBIÉN dentro del QR (`acct`) para que el dispositivo pueda anunciar qué va
124
+ // a pasar antes de hacerlo (V9 de docs/vinculacion-de-cuentas.md).
124
125
  const profileName = mgr.profiles.get(profileId)?.name || ''
126
+ const { qr, expiresInMs } = vault.startPairing({ scope, label, ttlMs: DEVICE_TTL_MS, mode: 'join', account: profileName })
125
127
  writeJson(pairFile, { v: 2, qr, expiresAt: Date.now() + expiresInMs, profile: profileId, profileName })
126
128
  // El token es un secreto efímero: no debe quedar en disco más allá de su
127
129
  // vida. Se borra al VENCER (aquí) y al APROBARSE (abajo, consumido).
@@ -217,7 +219,9 @@ export async function runDaemon () {
217
219
  rm(profileReqFile) // lleva la contraseña: fuera del disco cuanto antes
218
220
  let extra = {}
219
221
  try { extra = await handleProfileRequest(preq) }
220
- catch (e) { extra = { error: e.message }; console.error('[vault] perfil: %s', e.message) }
222
+ // `code`: la TUI es bilingüe y traduce por código (un freno como el D12 tiene
223
+ // que leerse en el idioma de quien lo lee, no en el del daemon).
224
+ catch (e) { extra = { error: e.message, ...(e.code ? { code: e.code } : {}) }; console.error('[vault] perfil: %s', e.message) }
221
225
  dumpProfiles(extra)
222
226
  } else {
223
227
  dumpProfiles()
package/src/manager.js CHANGED
@@ -13,6 +13,28 @@ import { openProfiles } from './profiles.js'
13
13
  import { installNodeGlobals } from './node-globals.js'
14
14
  import { dataDir, ensureDir } from './paths.js'
15
15
 
16
+ /**
17
+ * D12 (`docs/acta-de-perfil.md`): la bóveda **no** borra una cuenta que ella manda si
18
+ * quedan otros miembros — antes tiene que pasarle el acta a un dispositivo conectado.
19
+ * Es D6 ("perder el master es perder la cuenta") leído al derecho: con más miembros,
20
+ * el que borra no la pierde solo para él.
21
+ *
22
+ * Pura a propósito (recibe el veredicto ya calculado) para poder probar la regla sin
23
+ * levantar un vault entero.
24
+ */
25
+ export function assertCanRemove ({ isMaster, memberCount, name = '' }) {
26
+ if (!isMaster || memberCount <= 1) return true
27
+ const otros = memberCount - 1
28
+ const e = new Error(
29
+ `la cuenta "${name}" la manda esta bóveda y tiene ${otros} dispositivo(s) más: ` +
30
+ 'pásale primero el mando a uno que esté conectado. Si la borras así, se quedan ' +
31
+ 'con su llave y sin nadie que pueda volver a firmar el acta.'
32
+ )
33
+ e.code = 'MASTER_WITH_MEMBERS'
34
+ e.members = memberCount
35
+ throw e
36
+ }
37
+
16
38
  export async function startVaultManager ({ root = dataDir(), proxyUrl, log = console.log, onEnrollChallenge } = {}) {
17
39
  ensureDir(root)
18
40
  // El keypair de transporte del proxy-client es del PROCESO, no de la identidad:
@@ -76,6 +98,21 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
76
98
  /** Borra el perfil: cierra su conexión y elimina su maestra y sus datos. */
77
99
  async remove (ref) {
78
100
  const id = profiles.resolve(ref)
101
+ // FRENO D12 (acta-de-perfil.md): si esta bóveda MANDA la cuenta y quedan otros
102
+ // miembros, borrarla los deja con su llave y sin nadie que pueda volver a sellar
103
+ // el acta: la cuenta muere para todos, en silencio. Primero se le pasa el mando
104
+ // a un dispositivo conectado. (En el dispositivo no hay tal freno: allí borrar
105
+ // se lleva su llave y su copia, y la cuenta sigue viva donde vive el master.)
106
+ const v = running.get(id)
107
+ if (v) {
108
+ const [soyMaster, acta] = await Promise.all([
109
+ v.isMaster().catch(() => false),
110
+ v.profileMembers().catch(() => ({ members: [] }))
111
+ ])
112
+ assertCanRemove({ isMaster: soyMaster, memberCount: (acta?.members || []).length, name: profiles.get(id)?.name || id })
113
+ } else {
114
+ log('[vault] perfil %s no está abierto: se borra sin poder comprobar su acta', id)
115
+ }
79
116
  const res = profiles.remove(id) // valida: no es el único, no está bloqueado
80
117
  try { running.get(id)?.close() } catch (_) {}
81
118
  running.delete(id)
package/src/tui/app.js CHANGED
@@ -18,8 +18,9 @@
18
18
  * LAS TECLAS NO CAMBIAN CON EL IDIOMA: son mnemónicos en INGLÉS y son las mismas
19
19
  * en español (lo que se traduce es la palabra que las explica en la barra de
20
20
  * ayuda). new · rename · delete · password · unlock · locK · pair · approve ·
21
- * reject · reVoke · refresh · back · language · quit. Por eso el candado dejó de
22
- * ser `l` (hoy idioma) y es `k`, la contraseña es `p` y emparejar es `p`.
21
+ * reject · reVoke · refresh · back · language · quit. Una tecla significa LO MISMO
22
+ * en todas las pantallas: `p` es SIEMPRE emparejar (también en Bóvedas, sin tener
23
+ * que entrar antes), el candado es `k` (la `l` es el idioma) y la contraseña `c`.
23
24
  */
24
25
  import { execFile } from 'node:child_process'
25
26
  import { createTerm, widthOf } from './term.js'
@@ -50,7 +51,8 @@ function humanErr (e, st) {
50
51
  NO_REPLY: t.errNoReply,
51
52
  NOT_APPLIED: t.errNotApplied,
52
53
  NOT_DELETED: t.errNotDeleted,
53
- PAIR_FAILED: t.errPairFailed
54
+ PAIR_FAILED: t.errPairFailed,
55
+ MASTER_WITH_MEMBERS: t.errMasterWithMembers
54
56
  }
55
57
  return byCode[e?.code] || e?.message || String(e)
56
58
  }
@@ -317,6 +319,19 @@ async function onKeyProfiles (term, st, key) {
317
319
  }
318
320
  st.screen = 'devices'
319
321
  await refreshDevices(term, st)
322
+ } else if (ch === 'p' && cur) {
323
+ // Emparejar SIN tener que entrar antes: `p` significa lo mismo aquí que en la
324
+ // pestaña Dispositivos. Se activa la bóveda elegida (el QR sale de UNA, y las
325
+ // acciones siguientes —aprobar, revocar— miran a la activa) y se abre la
326
+ // pregunta de a qué cuenta entra el dispositivo.
327
+ if (!cur.current) {
328
+ const r = await guard(term, st, i.switchingVault, () => vc.useProfile(cur.id))
329
+ if (!r.ok) return true
330
+ await refreshAll(term, st)
331
+ }
332
+ st.sel.pairmode = 0
333
+ st.scroll.pairmode = { value: 0 }
334
+ st.screen = 'pairmode'
320
335
  } else if (ch === 'n') {
321
336
  setInput(st, {
322
337
  label: i.newVaultLabel,
@@ -354,7 +369,7 @@ async function onKeyProfiles (term, st, key) {
354
369
  },
355
370
  onCancel: () => { st.input = null }
356
371
  }))
357
- } else if (ch === 'p' && cur) { // password
372
+ } else if (ch === 'c' && cur) { // change password (la `p` es emparejar, igual que en Dispositivos)
358
373
  await ensureUnlocked(term, st, cur, (p = cur) => setInput(st, {
359
374
  label: i.newPasswordLabel(p.name || p.id),
360
375
  mask: true,
package/src/tui/i18n.js CHANGED
@@ -151,7 +151,7 @@ const es = {
151
151
  // INGLÉS: new/rename/delete/password/unlock/locK/pair/approve/revoke/refresh/
152
152
  // language/quit); lo único que se traduce es la palabra que las explica.
153
153
  // Segmentos, no una línea: el render recorta del medio si no caben.
154
- helpProfiles: ['↑↓', 'Enter entrar', 'n nueva', 'r renombrar', 'd borrar', 'p clave', 'x quitar-clave', 'u desbloq', 'k bloquear', 'l English', 'q salir'],
154
+ helpProfiles: ['↑↓', 'Enter entrar', 'p emparejar', 'n nueva', 'r renombrar', 'd borrar', 'c clave', 'x quitar-clave', 'u desbloq', 'k bloquear', 'l English', 'q salir'],
155
155
  helpDevices: ['←→ pestaña', '↑↓', 'p emparejar', 'a aprobar', 'x rechazar', 'v revocar', 'r refrescar', 'Esc bóvedas', 'l English', 'q salir'],
156
156
  helpSecrets: ['←→ pestaña', '↑↓', 'n nueva variable', 'x quitar (variable/scope)', 'r refrescar', 'Esc bóvedas', 'l English', 'q salir'],
157
157
  helpPairing: ['a aprobar', 'x rechazar', 'r reiniciar', 'Esc atrás', 'l English'],
@@ -179,7 +179,8 @@ const es = {
179
179
  errNoReply: 'El daemon no respondió.',
180
180
  errNotApplied: 'El daemon no aplicó el cambio (revisa los logs del servicio).',
181
181
  errNotDeleted: 'El daemon no borró la variable (revisa los logs del servicio).',
182
- errPairFailed: 'El daemon no inició el emparejamiento.'
182
+ errPairFailed: 'El daemon no inició el emparejamiento.',
183
+ errMasterWithMembers: 'Esta cuenta la manda esta bóveda y tiene otros dispositivos: pásale primero el mando a uno que esté conectado. Si la borras así, se quedan con su llave y sin nadie que pueda volver a firmar el acta.'
183
184
  }
184
185
 
185
186
  // ---------------------------------- inglés ----------------------------------
@@ -307,7 +308,7 @@ const en = {
307
308
  helpInput: 'Enter confirm · Esc cancel · Ctrl-U clear',
308
309
  helpConfirm: 'y confirm · n/Esc cancel',
309
310
 
310
- helpProfiles: ['↑↓', 'Enter open', 'n new', 'r rename', 'd delete', 'p password', 'x drop-password', 'u unlock', 'k lock', 'l Español', 'q quit'],
311
+ helpProfiles: ['↑↓', 'Enter open', 'p pair', 'n new', 'r rename', 'd delete', 'c password', 'x drop-password', 'u unlock', 'k lock', 'l Español', 'q quit'],
311
312
  helpDevices: ['←→ tab', '↑↓', 'p pair', 'a approve', 'x reject', 'v revoke', 'r refresh', 'Esc vaults', 'l Español', 'q quit'],
312
313
  helpSecrets: ['←→ tab', '↑↓', 'n new variable', 'x remove (variable/scope)', 'r refresh', 'Esc vaults', 'l Español', 'q quit'],
313
314
  helpPairing: ['a approve', 'x reject', 'r restart', 'Esc back', 'l Español'],
@@ -333,7 +334,8 @@ const en = {
333
334
  errNoReply: 'The daemon did not answer.',
334
335
  errNotApplied: 'The daemon did not apply the change (check the service logs).',
335
336
  errNotDeleted: 'The daemon did not delete the variable (check the service logs).',
336
- errPairFailed: 'The daemon did not start the pairing.'
337
+ errPairFailed: 'The daemon did not start the pairing.',
338
+ errMasterWithMembers: 'This vault is in charge of this account and it has other devices: hand the lead over to one that is online first. If you delete it like this, they keep their key with nobody able to sign the record again.'
337
339
  }
338
340
 
339
341
  // --------------------------- selección y persistencia -----------------------
package/src/vault.js CHANGED
@@ -296,6 +296,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
296
296
  // Acta del perfil (quién es del perfil y qué puede cada uno): lo que muestran
297
297
  // `dotrino-vault members` y la consola de vault.dotrino.com.
298
298
  profileMembers: () => identity.profileMembers(),
299
+ // ¿Es ESTA bóveda la que sella el acta? Lo usa el freno de borrado (D12).
300
+ isMaster: () => identity.isMaster(),
299
301
  setCaps: (pub, caps) => identity.setCaps(pub, caps),
300
302
  revokeDevice: (nonce) => desk.revoke(nonce),
301
303
  close () { try { client.close() } catch (_) {} identity.destroy() }
@@ -149,7 +149,7 @@ async function profileOp (op, { profile, name, password } = {}) {
149
149
  signalOrCleanup('SIGUSR2', [F.profileReq])
150
150
  const d = await waitFor(F.profilesList)
151
151
  if (!d) throw coded('el daemon no respondió', 'NO_REPLY')
152
- if (d.error) throw new Error(d.error)
152
+ if (d.error) throw coded(d.error, d.code) // p.ej. MASTER_WITH_MEMBERS (freno D12)
153
153
  return d // { profiles:[{id,name,protected,locked,current,fingerprint,iss,createdAt}], current, done? }
154
154
  }
155
155