@dotrino/vaultd 0.151.0 → 0.152.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.
package/lib/src/enroll.js CHANGED
@@ -30,7 +30,7 @@
30
30
  * `docs/pairing-protocol.md`).
31
31
  */
32
32
  import { verifyDeviceSig, pubkeyId, commitCode } from '@dotrino/identity/capabilities'
33
- import { verifyContinuity, canSeal, DEVICE_CAPS } from '@dotrino/identity/acta'
33
+ import { verifyContinuity, canSeal, DEVICE_CAPS, VAULT_CN } from '@dotrino/identity/acta'
34
34
 
35
35
  /** Un token de emparejamiento vale 5 min. */
36
36
  export const PAIRING_TTL_MS = 5 * 60 * 1000
@@ -169,12 +169,16 @@ export function createEnrollDesk ({
169
169
  * el código, igual que hacía `caps` después.
170
170
  */
171
171
  async function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '', mode = 'join', account = '', caps = null } = {}) {
172
+ // El nombre de la propia bóveda no es un servicio: su llave firma con el perfil cerrado
173
+ // y no puede abrir ningún cajón. Un servicio que entrara con ese nombre tampoco podría.
174
+ if (scopeToCn(scope) === VAULT_CN) throw Object.assign(new Error(`"${VAULT_CN}" is the vault's own name: a service cannot use it`), { code: 'reserved-cn' })
172
175
  if (caps != null) {
173
176
  if (!Array.isArray(caps)) throw Object.assign(new Error('caps must be a list'), { code: 'bad-caps' })
174
177
  const cn = scopeToCn(scope)
175
- const bad = caps.filter((c) => !(DEVICE_CAPS.includes(c) || (c === 'secrets' && cn)))
178
+ const bad = caps.filter((c) => !DEVICE_CAPS.includes(c))
176
179
  if (bad.length) throw Object.assign(new Error('unknown permission(s): ' + bad.join(', ')), { code: 'bad-caps' })
177
- if (!caps.length) throw Object.assign(new Error('the pairing grants no permission'), { code: 'empty-scope' })
180
+ // Un servicio puede entrar sin ningún permiso: su cajón lo abre el CN.
181
+ if (!caps.length && !cn) throw Object.assign(new Error('the pairing grants no permission'), { code: 'empty-scope' })
178
182
  caps = [...new Set(caps)]
179
183
  }
180
184
  pending.clear() // uno a la vez: una sesión nueva supersede a la anterior
@@ -378,13 +382,14 @@ export function createEnrollDesk ({
378
382
  throw Object.assign(new Error('this vault cannot add devices to the account record: no certificate was issued'), { code: 'admit-unavailable' })
379
383
  }
380
384
  // PERMISOS, no tipos (2026-08-22): las capacidades son las del scope ENTERO. Un
381
- // cajón (`secrets:<ns>`) suma `secrets` y fija el CN; no borra lo demás — un bot
382
- // con `sign,secrets:eco` firma como aparato del acta Y lee solo su cajón.
385
+ // cajón (`secrets:<ns>`) fija el CN, que es lo que lo abre —no hay permiso aparte
386
+ // (dueño, 2026-10-13)—, y no borra lo demás: un bot con `sign,secrets:eco` firma como
387
+ // aparato del acta Y lee solo su cajón.
383
388
  // Si quien emparejó ELIGIÓ los permisos (`startPairing({ caps })`), entran esos y en
384
389
  // esta misma acta; si no, los que da el scope.
385
390
  const cn = scopeToCn(pend.scope)
386
- const caps = [...new Set([...(pend.caps || scopeToCaps(pend.scope)), ...(cn ? ['secrets'] : [])])]
387
- if (!caps.length) {
391
+ const caps = [...new Set(pend.caps || scopeToCaps(pend.scope))]
392
+ if (!caps.length && !cn) {
388
393
  throw Object.assign(new Error('the pairing scope grants no permission: no certificate was issued'), { code: 'empty-scope' })
389
394
  }
390
395
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.151.0",
3
+ "version": "0.152.0",
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": {
@@ -22,7 +22,7 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@dotrino/compat": "0.4.0",
25
- "@dotrino/identity": "^0.107.1",
25
+ "@dotrino/identity": "^0.111.0",
26
26
  "@dotrino/opaque": "0.1.0",
27
27
  "@dotrino/passmanager": "0.17.0",
28
28
  "@dotrino/proxy-client": "0.30.1",
package/src/ctl.js CHANGED
@@ -39,6 +39,7 @@ import { atRestFor, readConfig as readKekConfig, probe as probeKek, rekeyDir, en
39
39
  import { VERSION } from './version.js'
40
40
  import * as hw from './hwkeys.js'
41
41
  import { isNewer } from './update.js'
42
+ import { COMM_CN } from './commKey.js'
42
43
 
43
44
  const dir = dataDir()
44
45
  const stateFile = path.join(dir, 'state.json')
@@ -614,7 +615,7 @@ async function cmdMembers () {
614
615
  // español. Se resaltan como `admin` y `approve`: los cuatro cambian lo que ese aparato
615
616
  // puede hacerle a la cuenta, y eso se lee de un vistazo o no se lee.
616
617
  const CAP = {
617
- sign: 'firma', store: 'guarda', read: 'lee', secrets: 'lee sus claves',
618
+ sign: 'firma', store: 'guarda', read: 'lee',
618
619
  admin: `${B}administra el perfil${Z}`, approve: `${B}aprueba pedidos${Z}`,
619
620
  sealer: `${B}sella el acta${Z}`, passwords: `${B}pide contraseñas${Z}`,
620
621
  // Resaltado como los otros cuatro: dice si esa máquina se lleva tus claves privadas
@@ -644,7 +645,8 @@ async function cmdMembers () {
644
645
  m.isMe ? 'este dispositivo' : null,
645
646
  m.cn ? `servicio «${m.cn}»` : null
646
647
  ].filter(Boolean)
647
- const caps = m.caps.length ? m.caps.map((c) => CAP[c] || c).join(', ') : '(sin permisos)'
648
+ // Un servicio sin permisos no está «sin nada»: su cajón lo abre el nombre, no un permiso.
649
+ const caps = m.caps.length ? m.caps.map((c) => CAP[c] || c).join(', ') : (m.cn && m.cn !== COMM_CN ? '(solo su cajón)' : '(sin permisos)')
648
650
  // CUÁNDO ENTRÓ. El nombre lo pone el propio aparato al emparejarse y muchas veces no
649
651
  // distingue nada —dos teléfonos con el mismo apodo, o «cli» a secas—, así que la fecha
650
652
  // es lo que deja reconocer cuál es cuál y ver si hay uno que no recuerdas haber
@@ -656,13 +658,13 @@ async function cmdMembers () {
656
658
  // selladas a esa llave. Se dice aquí, junto a él, porque es el único sitio donde
657
659
  // se mira quién es quién — y en la lista de variables ya seria tarde.
658
660
  //
659
- // Solo si LEE variables (`secrets`). La llave de comunicación de la bóveda entra con
660
- // `cn: 'vault'` y solo `firma`, y no tiene llave de cifrado a propósito: salía marcada
661
- // en rojo «NO puede leer sus variables» cuando no tiene ninguna que leer.
661
+ // Menos la llave de comunicación de la bóveda: entra con `cn: 'vault'` y solo `firma`,
662
+ // y no tiene llave de cifrado a propósito: salía marcada en rojo «NO puede leer sus
663
+ // variables» cuando no tiene ninguna que leer.
662
664
  // SI LE LLEGAN LOS AVISOS, en quien aprueba. No cambia a quién se le pide —se le pide a
663
665
  // todos—: es para ver a cuál no le suena el pedido.
664
666
  if (m.caps.includes('approve') && 'notifiedAt' in m) console.log(' %s%s%s', D, avisosTxt(m.notifiedAt), Z)
665
- if (m.cn && !m.canSeal && m.caps.includes('secrets')) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
667
+ if (m.cn && m.cn !== COMM_CN && !m.canSeal) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
666
668
  // QUÉ CORRE, y si cuadra con esta bóveda (CONVENCIONES §14). Sin esto una
667
669
  // incompatibilidad de versiones se ve como que ese aparato «no responde», que es el
668
670
  // fallo más caro que ha tenido el ecosistema. No bloquea nada: se enseña.
package/src/daemon.js CHANGED
@@ -245,7 +245,7 @@ export async function runDaemon () {
245
245
  if (caps) {
246
246
  caps = [...new Set(caps)]
247
247
  const cn = scopeToCn(base)
248
- const fromCaps = caps.map((c) => capScope(c, cn)).filter((x) => x && ALLOWED(x))
248
+ const fromCaps = caps.map((c) => capScope(c)).filter((x) => x && ALLOWED(x))
249
249
  // Sin `--scope` el papel sale de los permisos; con él, se suman.
250
250
  scope = [...new Set([...(asked?.length || isService ? base : []), ...fromCaps])]
251
251
  if (!scope.length) {
package/src/tui/app.js CHANGED
@@ -562,17 +562,12 @@ const CAPS_ORDER = [...ORDEN.filter((c) => DEVICE_CAPS.includes(c)),
562
562
  ...DEVICE_CAPS.filter((c) => !ORDEN.includes(c))]
563
563
 
564
564
  /**
565
- * Los permisos que se le pueden dar A ESTE miembro, en orden.
566
- *
567
- * `secrets` solo existe para un SERVICIO (los que tienen `cn`), y por eso faltaba: la
568
- * lista era fija y no cabía algo que depende de quién sea. Pero para un servicio es EL
569
- * permiso —el que decide si abre su cajón— y no verlo aquí hacía creer que no existe,
570
- * igual que pasaba con `unattended`. Se enseña primero, que es su importancia real.
571
- *
572
- * A un aparato SIN `cn` no se le ofrece: no hay cajón que abrir, el acta lo filtra
573
- * (`allowedCaps`) y enseñar una casilla que no hace nada es peor que no enseñarla.
565
+ * EL CAJÓN NO ES UNA CASILLA (dueño, 2026-10-13: «no entiendo por qué es un permiso el
566
+ * acceso a un cajón, el cn ya garantiza eso»). Antes un servicio traía aquí «secretos
567
+ * «passmanager»» al lado de «contrasenas», y parecían dos permisos sobre lo mismo. Un
568
+ * servicio abre el cajón de su nombre por ser ese servicio: se DICE arriba de la lista
569
+ * (`capsDrawer`), y no se marca ni se desmarca.
574
570
  */
575
- const capsForMember = (member) => (member?.cn ? ['secrets', ...CAPS_ORDER] : CAPS_ORDER)
576
571
 
577
572
  /** Con lo que entra un aparato si no eliges otra cosa: lo mismo que `pair` sin `--scope`. */
578
573
  const PAIR_DEFAULT_CAPS = ['sign', 'read', 'store']
@@ -586,11 +581,10 @@ const PAIR_DEFAULT_CAPS = ['sign', 'read', 'store']
586
581
  function capChecklist (i, t, { cn = null, has, real = has }) {
587
582
  const rows = []
588
583
  let tocados = 0
589
- for (const cap of capsForMember({ cn })) {
584
+ if (cn) rows.push({ text: t.muted(' ' + i.capsDrawer(cn)), sel: false }, { text: '', sel: false })
585
+ for (const cap of CAPS_ORDER) {
590
586
  const mark = has.has(cap) ? '[x]' : '[ ]'
591
- // El nombre de `secrets` lleva el cajón dentro: «las claves de proxy» dice qué abre,
592
- // «lee sus claves» te deja preguntándote cuáles.
593
- const name = typeof i.capName[cap] === 'function' ? i.capName[cap](cn) : i.capName[cap]
587
+ const name = i.capName[cap]
594
588
  // LO QUE CAMBIA SE SEÑALA. Sin esto el borrador es indistinguible del acta y no sabes
595
589
  // qué vas a firmar — que es justo lo que hace peligroso acumular cambios.
596
590
  const cambia = has.has(cap) !== real.has(cap)
@@ -599,8 +593,7 @@ function capChecklist (i, t, { cn = null, has, real = has }) {
599
593
  // así que no hay que repetirlo al lado. Lo que hace va debajo.
600
594
  const line = ` ${mark}${cambia ? t.bold('*') : ' '} ${cap === 'admin' ? t.bold(name) : name}`
601
595
  rows.push({ text: line, sel: true, meta: { cap } })
602
- const hint = typeof i.capHint[cap] === 'function' ? i.capHint[cap](cn) : i.capHint[cap]
603
- rows.push({ text: t.muted(' ' + hint), sel: false })
596
+ rows.push({ text: t.muted(' ' + i.capHint[cap]), sel: false })
604
597
  }
605
598
  return { rows, tocados }
606
599
  }
@@ -1344,7 +1337,8 @@ async function onKeyDevices (term, st, key) {
1344
1337
  value: cur.label || '',
1345
1338
  onSubmit: async (raw) => {
1346
1339
  const name = String(raw || '').trim()
1347
- if (!name) return
1340
+ st.input = null
1341
+ if (!name) { flash(st, i.nameEmpty, 'danger'); return }
1348
1342
  const r = await guard(term, st, i.renaming, () => vc.setDeviceLabel(cur.sub, name, activeId(st)))
1349
1343
  if (r.ok) { applyDump(st, r.v); flash(st, i.deviceRenamed(name)) }
1350
1344
  }
@@ -1415,6 +1409,7 @@ async function onKeyLogins (term, st, key, api = vc) {
1415
1409
  mask: true,
1416
1410
  onSubmit: async (pw2) => {
1417
1411
  if (pw1 !== pw2) { flash(st, i.loginPassMismatch, 'warn'); return }
1412
+ st.input = null
1418
1413
  const r = await guard(term, st, i.loginCreating,
1419
1414
  () => api.addLogin({ user, password: pw1, label }))
1420
1415
  if (r.ok) {
@@ -1442,6 +1437,7 @@ async function onKeyLogins (term, st, key, api = vc) {
1442
1437
  mask: true,
1443
1438
  onSubmit: async (nueva) => {
1444
1439
  if (String(nueva || '').length < MIN_LOGIN_PASSWORD) { flash(st, i.loginPassShort, 'warn'); return }
1440
+ st.input = null
1445
1441
  const r = await guard(term, st, i.loginChanging,
1446
1442
  () => api.passwdLogin({ user: cur.user, oldPassword: vieja, newPassword: nueva }))
1447
1443
  if (r.ok) { flash(st, i.loginChanged); await refreshLogins(term, st, api) }
@@ -1593,7 +1589,8 @@ async function onKeyPairCaps (term, st, key) {
1593
1589
  return true
1594
1590
  }
1595
1591
  if (ch === 'g') {
1596
- if (!pc.caps.length) { flash(st, i.pairCapsNone, 'warn'); return true }
1592
+ // Un servicio puede entrar sin ningún permiso: su cajón lo abre el nombre.
1593
+ if (!pc.caps.length && !pc.service) { flash(st, i.pairCapsNone, 'warn'); return true }
1597
1594
  const go = async () => {
1598
1595
  st.pairCaps = null
1599
1596
  const ok = await beginPairing(term, st, pc.profile, pc.service, pc.label, pc.caps)
@@ -1614,7 +1611,7 @@ async function onKeyPairCaps (term, st, key) {
1614
1611
 
1615
1612
  /** Abre la pantalla de permisos del emparejamiento; el QR sale al pulsar G. */
1616
1613
  function choosePairCaps (st, { profile, service = null, label = null, after = null }) {
1617
- st.pairCaps = { profile, service, label, after, caps: service ? ['secrets'] : [...PAIR_DEFAULT_CAPS] }
1614
+ st.pairCaps = { profile, service, label, after, caps: service ? [] : [...PAIR_DEFAULT_CAPS] }
1618
1615
  st.sel.paircaps = 0
1619
1616
  st.scroll.paircaps = { value: 0 }
1620
1617
  st.screen = 'paircaps'
package/src/tui/i18n.js CHANGED
@@ -79,6 +79,7 @@ const es = {
79
79
  // PERMISOS de un dispositivo (§9.1: se dice el beneficio, no el scope del cert).
80
80
  titleCaps: 'Permisos del dispositivo',
81
81
  capsFor: (id, name) => `Permisos de ${id}${name ? ' · ' + name : ''}`,
82
+ capsDrawer: (cn) => `Es el servicio «${cn}»: abre el cajón «${cn}» y ningún otro.`,
82
83
  capsNoMember: ' (este dispositivo ya no está en el perfil)',
83
84
  capsApplyHint: 'Enter marca o desmarca · G guarda · Esc vuelve a la lista sin perder lo marcado',
84
85
  capsPending: (n) => `${n} cambio(s) sin guardar en este aparato (*) · G guarda todo en una sola acta`,
@@ -104,7 +105,6 @@ const es = {
104
105
  // resultado fue una lista donde no se sabía qué permiso era cuál ni cómo nombrarlo para
105
106
  // el CLI. Lo que hace cada uno va DEBAJO, que es su sitio.
106
107
  capName: {
107
- secrets: (cn) => `secretos «${cn}»`,
108
108
  sign: 'firma',
109
109
  store: 'guarda',
110
110
  read: 'lee',
@@ -120,7 +120,6 @@ const es = {
120
120
  // continuaba a «Conectar y quitar dispositivos»—; ahora el título es la palabra que se
121
121
  // teclea, así que la pista tiene que decir la frase entera.
122
122
  capHint: {
123
- secrets: (cn) => `abrir el cajón de «${cn}» y ningún otro`,
124
123
  sign: 'usar tu identidad en las apps del ecosistema',
125
124
  store: 'escribir en tu bóveda: perfil, contenido, datos',
126
125
  read: 'ver lo que guardaste',
@@ -564,6 +563,7 @@ const en = {
564
563
  tabMe: 'Profile',
565
564
  titleCaps: 'Device permissions',
566
565
  capsFor: (id, name) => `Permissions for ${id}${name ? ' · ' + name : ''}`,
566
+ capsDrawer: (cn) => `It is the “${cn}” service: it opens the “${cn}” drawer and no other.`,
567
567
  capsNoMember: ' (this device is no longer in the record)',
568
568
  capsApplyHint: 'Enter ticks or unticks · G saves · Esc goes back to the list keeping your ticks',
569
569
  capsPending: (n) => `${n} unsaved change(s) on this device (*) · G saves everything in ONE record`,
@@ -583,7 +583,6 @@ const en = {
583
583
  // El título es el NOMBRE del permiso, el mismo que se teclea. Ver la nota en el bloque
584
584
  // español: describirlo dejaba una lista donde no se sabía qué permiso era cuál.
585
585
  capName: {
586
- secrets: (cn) => `secrets «${cn}»`,
587
586
  sign: 'sign',
588
587
  store: 'store',
589
588
  read: 'read',
@@ -597,7 +596,6 @@ const en = {
597
596
  },
598
597
  // Cada pista se sostiene sola: el título es la palabra, no una frase que continuar.
599
598
  capHint: {
600
- secrets: (cn) => `open the «${cn}» drawer and no other`,
601
599
  sign: 'use your identity across the ecosystem apps',
602
600
  store: 'write to your vault: profile, content, data',
603
601
  read: 'see what you saved',