@dotrino/vaultd 0.7.4 → 0.7.5

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/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.5",
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
@@ -50,7 +50,8 @@ function humanErr (e, st) {
50
50
  NO_REPLY: t.errNoReply,
51
51
  NOT_APPLIED: t.errNotApplied,
52
52
  NOT_DELETED: t.errNotDeleted,
53
- PAIR_FAILED: t.errPairFailed
53
+ PAIR_FAILED: t.errPairFailed,
54
+ MASTER_WITH_MEMBERS: t.errMasterWithMembers
54
55
  }
55
56
  return byCode[e?.code] || e?.message || String(e)
56
57
  }
package/src/tui/i18n.js CHANGED
@@ -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 ----------------------------------
@@ -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