@dotrino/vaultd 0.119.0 → 0.122.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/src/ctl.js CHANGED
@@ -13,6 +13,7 @@
13
13
  * reject <id> rechaza un dispositivo pendiente
14
14
  * devices lista dispositivos enrolados / revocados
15
15
  * revoke <nonce> revoca un dispositivo (y le ordena autoborrarse)
16
+ * logins … aparatos que se abren con usuario y contraseña (equipo prestado)
16
17
  * profile … perfiles (varias identidades en el mismo PC) y su contraseña
17
18
  * unlock / lock candado del perfil (la contraseña solo hace falta para EDITAR)
18
19
  * logs últimos logs del servicio
@@ -1479,6 +1480,199 @@ async function profileRequest (op, extra = {}) {
1479
1480
  console.error('El daemon no respondió.'); process.exit(1)
1480
1481
  }
1481
1482
 
1483
+ // ---------------------------------------------------------------------------
1484
+ // ENTRAR CON USUARIO Y CONTRASEÑA (`docs/temporary-access.md`)
1485
+ //
1486
+ // LA CONTRASEÑA NO SALE DE ESTA TERMINAL. Aquí se hace la mitad del cliente de OPAQUE, se
1487
+ // generan las llaves del aparato y se cierran con la llave que sale de la contraseña; al
1488
+ // daemon solo le llegan mensajes del protocolo y un paquete que no puede abrir. Por eso el
1489
+ // alta va en dos viajes y cambiar la contraseña en cuatro, y no en uno: si el daemon
1490
+ // hiciera la parte del cliente, la contraseña tendría que cruzar hasta él.
1491
+ // ---------------------------------------------------------------------------
1492
+
1493
+ const loginsFile = path.join(dir, 'logins-list.json')
1494
+ let loginsSeq = 0
1495
+
1496
+ /**
1497
+ * Un viaje al daemon. La respuesta lleva el `id` de la petición, para no quedarse con la
1498
+ * del intento anterior, y se BORRA al leerla: puede traer el paquete de llaves del aparato.
1499
+ */
1500
+ async function loginsRequest (op, extra = {}) {
1501
+ const s = requireDaemon()
1502
+ try { fs.rmSync(loginsFile, { force: true }) } catch (_) {}
1503
+ const id = `${process.pid}-${++loginsSeq}`
1504
+ writeReq('logins-request.json', { op, id, ...extra })
1505
+ sendSignal(s.pid, 'SIGUSR2')
1506
+ for (let i = 0; i < 100; i++) {
1507
+ await sleep(100)
1508
+ const d = ipcRead(loginsFile, null)
1509
+ if (!d?.at || (d.req && d.req !== id)) continue
1510
+ try { fs.rmSync(loginsFile, { force: true }) } catch (_) {}
1511
+ if (d.ok === false) {
1512
+ if (d.code === 'PROFILE_LOCKED') { console.error('Perfil bloqueado. Ábrelo con: dotrino-vault unlock'); process.exit(1) }
1513
+ // `too-many-tries` trae cuánto falta: es el único dato que cambia lo que se hace.
1514
+ if (d.code === 'too-many-tries') console.error('Demasiados intentos. Espera %d s, o quita la espera con: dotrino-vault logins unblock <usuario>', Math.ceil((d.waitMs || 0) / 1000))
1515
+ else console.error('%s', d.error)
1516
+ process.exit(1)
1517
+ }
1518
+ return d
1519
+ }
1520
+ console.error('El daemon no respondió.'); process.exit(1)
1521
+ }
1522
+
1523
+ /**
1524
+ * Pide la contraseña dos veces y comprueba que coinciden.
1525
+ *
1526
+ * El mínimo no es una manía: quien tenga una COPIA DEL DISCO de la bóveda puede probar
1527
+ * contraseñas contra el registro de OPAQUE sin límite y sin que nadie se entere
1528
+ * (`temporary-access.md` §4). Contra eso lo único que aguanta es la contraseña.
1529
+ */
1530
+ const MIN_PASSWORD = 12
1531
+ async function askNewPassword (user) {
1532
+ const pw = await askPassword(`Contraseña para ${user} (mínimo ${MIN_PASSWORD} caracteres): `)
1533
+ if (pw.length < MIN_PASSWORD) {
1534
+ console.error('Demasiado corta. Con una copia del disco de esta bóveda se pueden probar contraseñas sin límite, así que lo único que aguanta es su largo.')
1535
+ process.exit(2)
1536
+ }
1537
+ if (await askPassword('Repítela: ') !== pw) { console.error('No coinciden.'); process.exit(2) }
1538
+ return pw
1539
+ }
1540
+
1541
+ /**
1542
+ * Entrar de verdad, desde aquí: es la única forma de conseguir la llave que abre el paquete.
1543
+ *
1544
+ * Con la contraseña equivocada quien se entera es ESTA punta, al recibir la respuesta —así
1545
+ * funciona OPAQUE, y por eso los intentos se cuentan al empezar—. Se dice y se para: sin la
1546
+ * contraseña vieja no hay nada que volver a cerrar.
1547
+ */
1548
+ async function loginHere (opaqueClient, user, password) {
1549
+ const start = opaqueClient.loginStart({ password })
1550
+ const begun = await loginsRequest('login-begin', { user, request: start.request })
1551
+ let fin
1552
+ try { fin = opaqueClient.loginFinish({ state: start.state, response: begun.response, password }) }
1553
+ catch (e) { console.error('Contraseña incorrecta (o ese usuario no existe).'); process.exit(1) }
1554
+ const done = await loginsRequest('login-end', { lid: begun.lid, finalization: fin.finalization, label: 'consola' })
1555
+ return { ...done, exportKey: fin.exportKey }
1556
+ }
1557
+
1558
+ const LOGINS_USAGE = `uso: dotrino-vault logins <orden>
1559
+
1560
+ logins [ls] los aparatos que se abren con usuario y contraseña
1561
+ logins add <usuario> [nombre del equipo]
1562
+ logins passwd <usuario> cambia la contraseña (cierra lo que estuviera abierto)
1563
+ logins close <usuario> [sid] cierra una sesión abierta (sin sid, todas)
1564
+ logins unblock <usuario> quita la espera de los intentos fallidos
1565
+ logins rm <usuario> lo quita y saca su llave del acta`
1566
+
1567
+ async function cmdLogins (rest) {
1568
+ const [sub, ...args] = rest
1569
+ const user = args[0]
1570
+ const needUser = () => { if (!user) { console.error(LOGINS_USAGE); process.exit(2) } return user }
1571
+
1572
+ switch (sub || 'ls') {
1573
+ case 'ls': {
1574
+ const { logins, fingerprint } = await loginsRequest('list')
1575
+ if (!logins.length) {
1576
+ console.log('No hay ningún inicio de sesión con contraseña.')
1577
+ console.log('Crea uno con: dotrino-vault logins add <usuario>')
1578
+ return
1579
+ }
1580
+ const { loginAddress } = await import('../lib/src/passwordLogins.js')
1581
+ console.log('Inicios de sesión con contraseña: %d', logins.length)
1582
+ for (const l of logins) {
1583
+ const espera = l.blockedUntil > Date.now() ? ` ${R}espera ${Math.ceil((l.blockedUntil - Date.now()) / 1000)} s${Z}` : ''
1584
+ console.log(' · %s%s%s %s%s creado %s%s', B, loginAddress(l.user, fingerprint), Z, l.deviceId || '????-????',
1585
+ l.label ? ' «' + l.label + '»' : '', fechaCorta(l.createdAt), espera)
1586
+ for (const s of l.sessions || []) {
1587
+ console.log(' %sabierto%s %s %s usado %s', D, Z, s.sid.slice(0, 8), s.label || '(sin nombre)', fechaCorta(s.lastUsedAt))
1588
+ }
1589
+ }
1590
+ console.log('\nCerrar una sesión: dotrino-vault logins close <usuario> [sid]')
1591
+ return
1592
+ }
1593
+ case 'add': {
1594
+ needUser()
1595
+ const label = args.slice(1).join(' ').trim() || 'equipo prestado'
1596
+ const { client: opaqueClient } = await import('@dotrino/opaque')
1597
+ const { makeDeviceKey, makeDeviceEncKey } = await import('@dotrino/identity/capabilities')
1598
+ const { sealDeviceKeys } = await import('../lib/src/passwordLogins.js')
1599
+ const password = await askNewPassword(user)
1600
+
1601
+ const start = opaqueClient.registrationStart({ password })
1602
+ const begun = await loginsRequest('register-begin', { user, request: start.request })
1603
+ // La bóveda contesta con su mitad; el registro se termina AQUÍ, con la contraseña.
1604
+ const fin = opaqueClient.registrationFinish({ state: start.state, response: begun.response, password })
1605
+ // Las llaves del aparato NACEN en esta terminal y salen de aquí ya cerradas.
1606
+ const device = await makeDeviceKey({ label })
1607
+ const enc = await makeDeviceEncKey()
1608
+ const blob = await sealDeviceKeys(fin.exportKey, { sign: device.privateJwk, enc: enc.privateJwk })
1609
+ const r = await loginsRequest('register-finish', {
1610
+ user, upload: fin.upload, pub: device.publickey, encPub: enc.publickey, label, blob
1611
+ })
1612
+ const { loginAddress } = await import('../lib/src/passwordLogins.js')
1613
+ console.log('\nListo. Se entra con esta dirección y esa contraseña:')
1614
+ console.log(' %s%s%s', B, loginAddress(user, r.fingerprint), Z)
1615
+ console.log(' aparato : %s «%s»', r.deviceId, label)
1616
+ console.log(' permisos: %s', (r.caps || []).join(', ') || '(ninguno)')
1617
+ console.log('\nLa dirección no es secreta: dice DÓNDE está tu cuenta, no quién eres.')
1618
+ return
1619
+ }
1620
+ // CAMBIAR LA CONTRASEÑA ES ABRIR Y VOLVER A CERRAR. El aparato, su llave y su
1621
+ // certificado siguen siendo los mismos: lo único que cambia es con qué se abre el
1622
+ // paquete. Por eso hace falta la vieja — sin ella no hay nada que volver a cerrar.
1623
+ case 'passwd': {
1624
+ needUser()
1625
+ const { client: opaqueClient } = await import('@dotrino/opaque')
1626
+ const { sealDeviceKeys, openDeviceKeys } = await import('../lib/src/passwordLogins.js')
1627
+ const old = await askPassword(`Contraseña ACTUAL de ${user}: `)
1628
+ const entered = await loginHere(opaqueClient, user, old)
1629
+ const keys = await openDeviceKeys(entered.exportKey, entered.blob)
1630
+
1631
+ const password = await askNewPassword(user)
1632
+ const start = opaqueClient.registrationStart({ password })
1633
+ const begun = await loginsRequest('register-begin', { user, request: start.request, replace: true })
1634
+ const fin = opaqueClient.registrationFinish({ state: start.state, response: begun.response, password })
1635
+ await loginsRequest('register-finish', {
1636
+ user, upload: fin.upload, blob: await sealDeviceKeys(fin.exportKey, keys), replace: true
1637
+ })
1638
+ console.log('Listo: %s entra con la contraseña nueva, y lo que estuviera abierto se cerró.', user)
1639
+ return
1640
+ }
1641
+ case 'close': {
1642
+ needUser()
1643
+ const sid = args[1]
1644
+ if (sid) {
1645
+ const r = await loginsRequest('close', { user, sid })
1646
+ console.log(r.ok ? `Sesión cerrada: ${sid}` : 'Esa sesión ya no estaba abierta.')
1647
+ return
1648
+ }
1649
+ const { logins } = await loginsRequest('list')
1650
+ const fila = logins.find((x) => x.user === user)
1651
+ if (!fila) { console.error('No hay ningún inicio de sesión con ese usuario. Míralos con: dotrino-vault logins'); process.exit(1) }
1652
+ const abiertas = fila.sessions || []
1653
+ if (!abiertas.length) { console.log('No tiene ninguna sesión abierta.'); return }
1654
+ for (const s of abiertas) await loginsRequest('close', { user, sid: s.sid })
1655
+ console.log('Cerradas %d sesión(es) de %s. Para entrar otra vez hace falta la contraseña.', abiertas.length, user)
1656
+ return
1657
+ }
1658
+ case 'unblock': {
1659
+ needUser()
1660
+ await loginsRequest('unblock', { user })
1661
+ console.log('Listo: %s puede volver a intentarlo. La contraseña no cambió.', user)
1662
+ return
1663
+ }
1664
+ case 'rm': {
1665
+ needUser()
1666
+ const r = await loginsRequest('rm', { user })
1667
+ if (!r.ok) { console.error('No hay ningún inicio de sesión con ese usuario. Míralos con: dotrino-vault logins'); process.exit(1) }
1668
+ console.log('Quitado %s (aparato %s). Su llave sale del acta y se autoborrará al reconectar.', user, r.deviceId || '????-????')
1669
+ return
1670
+ }
1671
+ default:
1672
+ console.error(LOGINS_USAGE); process.exit(2)
1673
+ }
1674
+ }
1675
+
1482
1676
  function reportProfiles (d) {
1483
1677
  if (d.error) {
1484
1678
  // Los dos rechazos del candado se dicen con palabras y con el dato que hace falta; el
@@ -1728,6 +1922,15 @@ function help () {
1728
1922
  +sella = OTRA BÓVEDA que puede sellar el acta de esta cuenta, para
1729
1923
  que perder una máquina no se la lleve. No es un traspaso
1730
1924
  revoke <ID|nonce> quita un dispositivo (con el ID, todos sus certificados)
1925
+ logins los aparatos que se abren con usuario y contraseña (para un equipo
1926
+ prestado, donde no puedes emparejar nada)
1927
+ logins add <usuario> [nombre del equipo]
1928
+ crea uno. La contraseña se teclea aquí y no sale de esta terminal:
1929
+ la bóveda guarda un paquete de llaves que solo ella abre
1930
+ logins passwd <usuario> cambia la contraseña (cierra lo que estuviera abierto)
1931
+ logins close <usuario> [sid] cierra una sesión abierta (sin sid, todas)
1932
+ logins unblock <usuario> quita la espera de los intentos fallidos
1933
+ logins rm <usuario> lo quita y saca su llave del acta
1731
1934
  atrest status de dónde sale la clave que cifra el disco (esta máquina, o un KMS)
1732
1935
  atrest test comprueba que el KMS envuelve y desenvuelve, SIN tocar los datos
1733
1936
  atrest rekey <f> cambia de proveedor: descifra con la vieja y recifra con la nueva
@@ -1863,6 +2066,7 @@ export async function runCtl (argv) {
1863
2066
  case 'label': return cmdLabel(rest)
1864
2067
  case 'caps': return cmdCaps(rest)
1865
2068
  case 'revoke': return cmdRevoke(rest[0])
2069
+ case 'logins': return cmdLogins(rest)
1866
2070
  case 'secret': return cmdSecret(rest)
1867
2071
  // `approval` se quitó el 2026-09-01 (ahora es el permiso `unattended`), pero la ruta se
1868
2072
  // quedó apuntando a una función que ya no existía: reventaba con un ReferenceError. Se
package/src/daemon.js CHANGED
@@ -324,6 +324,12 @@ export async function runDaemon () {
324
324
  let lastSecretValue = null
325
325
  /** Y las versiones anteriores que se acaban de pedir. Mismo camino, mismo volcado. */
326
326
  let lastSecretHistory = null
327
+ // ENTRAR CON USUARIO Y CONTRASEÑA, desde esta máquina (`dotrino-vault logins`).
328
+ //
329
+ // La respuesta NO se llama `logins.json`: ese nombre ya es el almacén del escritorio en
330
+ // el dir del perfil, y pisarlo sería borrar los inicios de sesión al listarlos.
331
+ const loginsReqFile = path.join(dir, 'logins-request.json')
332
+ const loginsFile = path.join(dir, 'logins-list.json')
327
333
  const profileReqFile = path.join(dir, 'profile-request.json')
328
334
  const dumpReqFile = path.join(dir, 'dump-request.json')
329
335
  const meReqFile = path.join(dir, 'me-request.json')
@@ -366,6 +372,54 @@ export async function runDaemon () {
366
372
  */
367
373
  const wipe = (k) => { try { if (k instanceof Uint8Array) k.fill(0) } catch (_) {} }
368
374
 
375
+ /**
376
+ * Los inicios de sesión con contraseña de ESTA máquina (`dotrino-vault logins`).
377
+ *
378
+ * LA CONTRASEÑA NO PASA POR AQUÍ, y esa es la razón de que el alta vaya en dos viajes en
379
+ * vez de uno: quien la teclea es la CLI, que hace su mitad de OPAQUE, genera las llaves
380
+ * del aparato y las cierra con la llave que sale de la contraseña. Aquí solo entran
381
+ * mensajes del protocolo y un paquete que esta máquina no puede abrir.
382
+ */
383
+ async function handleLoginsRequest (req) {
384
+ const vault = targetOf(req)
385
+ if (!vault) throw Object.assign(new Error('profile locked'), { code: 'PROFILE_LOCKED' })
386
+ // La huella de la CUENTA viaja en cada respuesta: es la mitad de después de la `@` de
387
+ // la dirección, y quien pregunta no puede sacarla por su cuenta sin adivinar a qué
388
+ // perfil apuntaba la petición.
389
+ return { fingerprint: vault.fingerprint || null, ...(await loginsOp(vault, req)) }
390
+ }
391
+
392
+ async function loginsOp (vault, req) {
393
+ switch (req.op) {
394
+ case 'list': return { logins: vault.listLogins() }
395
+ case 'register-begin': return vault.loginRegisterBegin({ user: req.user, request: req.request, replace: !!req.replace })
396
+ case 'register-finish': return vault.loginRegisterFinish({
397
+ user: req.user, upload: req.upload, pub: req.pub, encPub: req.encPub || null,
398
+ label: req.label || '', blob: req.blob, scope: req.scope, unattended: !!req.unattended, replace: !!req.replace
399
+ })
400
+ // Entrar desde la propia máquina de la bóveda. Lo usa `logins passwd`: para volver a
401
+ // cerrar el paquete de llaves hay que ABRIRLO antes, y eso solo puede hacerlo quien
402
+ // sabe la contraseña vieja. Pasa por el mismo freno que cualquier otro intento.
403
+ case 'login-begin': return vault.loginBegin({ user: req.user, request: req.request })
404
+ case 'login-end': return vault.loginEnd({ lid: req.lid, finalization: req.finalization, label: req.label || '' })
405
+ case 'close': return vault.closeLogin({ user: req.user, sid: req.sid })
406
+ case 'unblock': return vault.clearLoginBlock({ user: req.user })
407
+ // QUITARLO ES QUITARLO: el usuario se va y su llave sale del acta. Borrar solo el
408
+ // inicio de sesión dejaba un miembro que ya no puede entrar pero sigue siendo de la
409
+ // cuenta — media baja, que es peor que ninguna.
410
+ case 'rm': {
411
+ const r = await vault.removeLogin({ user: req.user })
412
+ if (r.ok && r.pub) {
413
+ try { await vault.revokeDevice({ sub: r.pub }) } catch (e) {
414
+ throw Object.assign(new Error(`the login is gone but its key is still in the record: ${e.message}`), { code: 'REVOKE_FAILED' })
415
+ }
416
+ }
417
+ return r
418
+ }
419
+ default: throw Object.assign(new Error(`unknown logins op: ${req.op}`), { code: 'BAD_OP' })
420
+ }
421
+ }
422
+
369
423
  async function handleProfileRequest (req) {
370
424
  // Resolver el destino de una orden de perfil CUENTA COMO USO: estira el plazo del
371
425
  // bloqueo automático igual que cualquier otra cosa que se haga desde la consola.
@@ -662,6 +716,19 @@ export async function runDaemon () {
662
716
  console.error('[vault] secret failed:', e.message)
663
717
  } finally { wipe(ak) }
664
718
  }
719
+ // Inicios de sesión con contraseña (`dotrino-vault logins`). La respuesta lleva el
720
+ // `id` de la petición, para que quien espera no lea la de la vuelta anterior.
721
+ const lreq = readJsonSafe(loginsReqFile)
722
+ if (lreq?.op) {
723
+ rm(loginsReqFile)
724
+ const answer = (extra) => ipcWrite(loginsFile, { at: Date.now(), req: lreq.id || null, ...extra })
725
+ try { answer({ ok: true, ...(await handleLoginsRequest(lreq)) }) } catch (e) {
726
+ console.error('[vault] logins: %s', e.message)
727
+ // `waitMs` viaja con el error: cuánto falta es el único dato que cambia lo que
728
+ // hace quien lo lee, y sin él «demasiados intentos» no dice nada accionable.
729
+ answer({ ok: false, error: e.message, code: e.code || 'LOGINS_FAILED', ...(e.waitMs ? { waitMs: e.waitMs } : {}) })
730
+ }
731
+ }
665
732
  // Perfiles / candado.
666
733
  const preq = readJsonSafe(profileReqFile)
667
734
  if (preq?.op) {
package/src/transport.js CHANGED
@@ -36,7 +36,7 @@ export async function masterPubkeyOf (identity) {
36
36
  * @param {string} [opts.url] URL del proxy (default wss://proxy.dotrino.com).
37
37
  * @returns {Promise<{ client, token:string, identify():Promise<void> }>}
38
38
  */
39
- export async function createTransport ({ identity, dir, url = DEFAULT_PROXY, commKey = null, log = () => {}, makeClient = null }) {
39
+ export async function createTransport ({ identity, dir, url = DEFAULT_PROXY, commKey = null, log = () => {}, makeClient = null, announce = null }) {
40
40
  installNodeGlobals(dir)
41
41
  // Import dinámico DESPUÉS de instalar los globals que el paquete usa.
42
42
  // `WebSocketProxyClient` (la clase) y NO el helper `getWebSocketProxyClient`:
@@ -160,12 +160,34 @@ export async function createTransport ({ identity, dir, url = DEFAULT_PROXY, com
160
160
  * intento se dice en voz alta: una bóveda inalcanzable en silencio es el fallo que ya
161
161
  * costó un día entero (`CLAUDE.md`, el apagón del 1-2 de septiembre).
162
162
  */
163
+ /**
164
+ * EL ANUNCIO DE LA CUENTA (`temporary-access.md` §3.2).
165
+ *
166
+ * Quien entra con usuario y contraseña no tiene ninguna llave todavía: no sabe la pubkey
167
+ * de esta bóveda y no puede escribirle. Lo único que trae es el código de la dirección
168
+ * (`nombre@AB12-CD34-EF56`), y con él lista este canal para encontrar a quien atiende esa
169
+ * cuenta — esta bóveda, o cualquiera de sus réplicas.
170
+ *
171
+ * Va PEGADO a `identify` y no en el arranque, porque la pertenencia a un canal es del
172
+ * token, y el token cambia en cada reconexión: anunciarse una sola vez deja a la bóveda
173
+ * fuera del canal en cuanto se cae el socket, y eso se ve desde fuera como «esa cuenta no
174
+ * existe». Si el anuncio falla, la bóveda sigue sirviendo a todo lo demás y se dice en voz
175
+ * alta: lo que se pierde es que la encuentren por su dirección, no que deje de funcionar.
176
+ */
177
+ const announceAccount = async () => {
178
+ if (!announce || !identificado) return
179
+ try { await client.publish(announce) } catch (e) {
180
+ log(`[vault] could not announce this account on the proxy (${e.message}) — a borrowed machine will not find this vault by its address`)
181
+ }
182
+ }
183
+
163
184
  let reintento = null
164
185
  let fallos = 0
165
186
  const identifyWithRetry = async () => {
166
187
  if (reintento) { clearTimeout(reintento); reintento = null }
167
188
  try {
168
189
  await identify()
190
+ await announceAccount()
169
191
  if (fallos) log(`[vault] identified on the proxy after ${fallos + 1} attempt(s)`)
170
192
  fallos = 0
171
193
  } catch (e) {
@@ -180,5 +202,50 @@ export async function createTransport ({ identity, dir, url = DEFAULT_PROXY, com
180
202
  // Re-identificar al reconectar (el token cambia).
181
203
  client.on('token', () => { fallos = 0; identifyWithRetry() })
182
204
 
205
+ /**
206
+ * QUE NO SE PUEDA VOLVER A QUEDAR MUDA.
207
+ *
208
+ * El 16 de septiembre esta bóveda se cayó del proxio y no volvió en 36 HORAS, con el
209
+ * proceso vivo y sin una sola línea en el log: el cliente del transporte se rendía tras
210
+ * un reintento fallido (arreglado en `@dotrino/proxy-client` 0.23.0). Desde fuera eso se
211
+ * ve igual que «no pasa nada»: el teléfono no timbra y nadie sabe por qué.
212
+ *
213
+ * El arreglo del pilar quita ESA causa. Esto quita el SILENCIO, que es lo que costó las
214
+ * 36 horas — con cualquier causa, futura incluida:
215
+ *
216
+ * · se dice cuándo se cae y cuándo vuelve, una vez cada cosa (no una línea cada 4 s);
217
+ * · y si lleva 5 minutos fuera, se vuelve a marcar en vez de esperar sentada.
218
+ *
219
+ * `connect()` es seguro de repetir: si ya está conectado devuelve el token y no abre
220
+ * nada.
221
+ */
222
+ const RECHECK_MS = 60_000
223
+ const PATIENCE_MS = 5 * 60_000
224
+ let downSince = 0
225
+ let saidDown = false
226
+ client.on('disconnect', ({ code, reason } = {}) => {
227
+ if (!downSince) downSince = Date.now()
228
+ if (saidDown) return
229
+ saidDown = true
230
+ log(`[vault] off the proxy (${code || '?'}${reason ? ': ' + reason : ''}) — nobody can reach this vault while this lasts`)
231
+ })
232
+ client.on('reconnect_failed', (attempts) => {
233
+ log(`[vault] the transport gave up reconnecting after ${attempts} attempt(s) — this vault is unreachable until it is restarted`)
234
+ })
235
+ const watchdog = setInterval(async () => {
236
+ if (identificado && client.token) {
237
+ if (downSince) log(`[vault] back on the proxy after ${Math.round((Date.now() - downSince) / 1000)}s`)
238
+ downSince = 0; saidDown = false
239
+ return
240
+ }
241
+ if (!downSince) downSince = Date.now()
242
+ const downMs = Date.now() - downSince
243
+ if (downMs < PATIENCE_MS) return
244
+ log(`[vault] ${Math.round(downMs / 60000)} min off the proxy — dialling again`)
245
+ try { await client.connect() } catch (e) { log(`[vault] could not reconnect: ${e.message}`) }
246
+ await identifyWithRetry()
247
+ }, RECHECK_MS)
248
+ watchdog.unref?.()
249
+
183
250
  return { client, token: client.token, identify: identifyWithRetry, isIdentified: () => identificado }
184
251
  }
package/src/vault.js CHANGED
@@ -15,8 +15,9 @@ import nodeCrypto from 'node:crypto'
15
15
  import path from 'node:path'
16
16
  import { Identity } from '@dotrino/identity/node'
17
17
  import { verifyChain as verifyChainRaw, pubkeyId, verifyDeviceSig } from '@dotrino/identity/capabilities'
18
+ import { createLoginDesk, registerLogin, vaultChannel, accountFingerprint } from '../lib/src/passwordLogins.js'
18
19
  import * as ActaPilar from '@dotrino/identity/acta'
19
- import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS } from '../lib/src/enroll.js'
20
+ import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS, scopeToCaps, scopeToCn } from '../lib/src/enroll.js'
20
21
  import { createAdminDesk, authorBody } from '../lib/src/admin.js'
21
22
  import { shouldNotifyRevoked } from '../lib/src/revocation.js'
22
23
  // El sobre del gestor, el mismo que usan la bóveda-en-pestaña y la extensión.
@@ -218,6 +219,18 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
218
219
  },
219
220
  }
220
221
 
222
+ // ENTRAR CON USUARIO Y CONTRASEÑA (`passwordLogins.js`). Va cifrado en reposo con la llave
223
+ // de la MÁQUINA y no con la de la contraseña del perfil, por lo mismo que la llave de
224
+ // comunicación: entrar tiene que funcionar con el perfil cerrado. Lo que hay dentro no
225
+ // abre nada por sí solo — el registro de OPAQUE no deja probar contraseñas sin el
226
+ // protocolo, y el paquete de llaves está cerrado con la llave que sale de la contraseña.
227
+ const loginsFile = path.join(dir, 'logins.json')
228
+ const loginsAtRest = atRestFor(dir)
229
+ const logins = createLoginDesk({
230
+ load: () => { try { return JSON.parse(loginsAtRest.decrypt(fs.readFileSync(loginsFile, 'utf8'))) } catch (_) { return null } },
231
+ save: (state) => fs.writeFileSync(loginsFile, loginsAtRest.encrypt(JSON.stringify(state)), { mode: 0o600 })
232
+ })
233
+
221
234
  /** Los aparatos que el acta autoriza a pedir credenciales (`caps <ID> +contraseñas`). */
222
235
  const passwordDevices = () => (actaCache?.members || []).filter((m) => Acta.memberCan(actaCache, m.pub, 'passwords'))
223
236
 
@@ -287,7 +300,17 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
287
300
  } catch (_) {}
288
301
 
289
302
  const master = await masterPubkeyOf(identity)
290
- const fp = (await pubkeyId(master)).slice(0, 16)
303
+ /**
304
+ * LA HUELLA DE LA CUENTA, no la de esta bóveda. Sale del `profileId` del acta (la pieza
305
+ * compartida la calcula), y con ella se arman la dirección `nombre@AB12-CD34-EF56` y el
306
+ * canal donde se anuncia esta cuenta.
307
+ *
308
+ * Antes salía de la llave de ESTA bóveda. Coinciden mientras la cuenta haya nacido aquí,
309
+ * y dejan de coincidir en cuanto hay una réplica o la bóveda adoptó la cuenta de otro
310
+ * aparato: cada bóveda daba entonces una dirección distinta para la misma cuenta, y el
311
+ * canal las separaba justo cuando lo que hace falta es que se junten.
312
+ */
313
+ const fp = await accountFingerprint(identity)
291
314
 
292
315
  /**
293
316
  * El acta tiene que nombrar una llave de sellado QUE SEA NUESTRA. Si no nombra ninguna
@@ -362,7 +385,12 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
362
385
  }
363
386
  await ensureCommKeyInActa()
364
387
 
365
- const { client, identify: reidentificar, isIdentified } = await createTransport({ identity, dir, url: proxyUrl, commKey, log })
388
+ // `announce`: el canal de ESTA cuenta, para que una máquina prestada —que no tiene
389
+ // ninguna llave y solo sabe la dirección `nombre@AB12-CD34-EF56`— pueda encontrar a quien
390
+ // la atiende. Lo construye la pieza compartida, porque quien lo busca es la extensión.
391
+ const { client, identify: reidentificar, isIdentified } = await createTransport({
392
+ identity, dir, url: proxyUrl, commKey, log, announce: vaultChannel(fp)
393
+ })
366
394
 
367
395
  // El registro público de cadenas de selladores: deposita, si hay a dónde, los eslabones
368
396
  // que le dicen a un tercero si esta cuenta sigue sellada por quien él cree. Ver
@@ -1071,6 +1099,66 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1071
1099
  * nombre de SU propia llave, que no le sirve contra nadie más (`verifyRevoke` exige que
1072
1100
  * el aviso nombre al aparato que lo recibe).
1073
1101
  */
1102
+ // --- ENTRAR CON USUARIO Y CONTRASEÑA ---------------------------------------------------
1103
+ //
1104
+ // Los tres únicos mensajes que se atienden SIN certificado, porque quien pregunta todavía
1105
+ // no tiene llaves: están dentro del paquete que solo abre su contraseña. La bóveda no ve
1106
+ // la contraseña en ningún momento (OPAQUE), y responde igual exista o no el usuario.
1107
+ //
1108
+ // Funcionan con el perfil CERRADO: el paquete se devuelve tal cual, sin abrir nada. Lo que
1109
+ // sí exige la bóveda abierta es CREAR el aparato, porque eso sella el acta.
1110
+
1111
+ function loginError (from, e) {
1112
+ if (e?.code === 'too-many-tries') {
1113
+ return reply(from, { type: MSG.ERROR, error: e.message, code: e.code, waitMs: e.waitMs || 0 })
1114
+ }
1115
+ if (e?.code === 'login-failed' || e?.code === 'no-exchange' || e?.code === 'bad-input' || e?.code === 'bad-user') {
1116
+ return reply(from, { type: MSG.ERROR, error: e.message, code: e.code })
1117
+ }
1118
+ log('[vault] login: %s', e?.message || e)
1119
+ reply(from, { type: MSG.ERROR, error: 'login failed', code: 'login-error' })
1120
+ }
1121
+
1122
+ async function handleLoginStart (from, p) {
1123
+ try {
1124
+ const { lid, response } = logins.loginBegin({ user: p?.user, request: p?.request })
1125
+ audit('login.start', { user: String(p?.user || '').slice(0, 32) })
1126
+ reply(from, { type: MSG.LOGIN_RESPONSE, lid, response })
1127
+ } catch (e) { loginError(from, e) }
1128
+ }
1129
+
1130
+ async function handleLoginFinish (from, p) {
1131
+ try {
1132
+ const r = logins.loginEnd({ lid: p?.lid, finalization: p?.finalization, label: p?.label })
1133
+ const record = (await identity.profileActa?.().catch(() => null))?.acta || null
1134
+ audit('login.ok', { user: r.user, sid: r.sid.slice(0, 8) })
1135
+ log('[vault] login: %s entered (session %s)', r.user, r.sid.slice(0, 8))
1136
+ reply(from, { type: MSG.LOGIN_OK, sid: r.sid, blob: r.blob, cert: r.cert, iss: r.iss || identity.me?.publickey || null, acta: record })
1137
+ } catch (e) { loginError(from, e) }
1138
+ }
1139
+
1140
+ /**
1141
+ * SALIR. Va firmado con la llave del propio aparato —la que acaba de abrir— porque cerrar
1142
+ * la sesión de otro sería echarlo de su cuenta.
1143
+ */
1144
+ async function handleLoginClose (from, p) {
1145
+ if (!isFresh(p?.data)) return staleReply(from)
1146
+ const d = p.data
1147
+ if (d?.op !== 'login.close' || typeof d.publickey !== 'string' || typeof d.user !== 'string' || typeof d.sid !== 'string') {
1148
+ return reply(from, { type: MSG.ERROR, error: 'unauthorized: shape', code: 'bad-input' })
1149
+ }
1150
+ if (!(await verifyDeviceSig({ publickey: d.publickey, data: d, signature: p.signature }))) {
1151
+ return reply(from, { type: MSG.ERROR, error: 'unauthorized: bad-signature', code: 'bad-signature' })
1152
+ }
1153
+ const mine = logins.list().find((x) => x.user === d.user)
1154
+ if (!mine || mine.pub !== d.publickey) {
1155
+ return reply(from, { type: MSG.ERROR, error: 'unauthorized: that key does not own this login', code: 'not-yours' })
1156
+ }
1157
+ const r = logins.closeSession({ user: d.user, sid: d.sid })
1158
+ audit('login.close', { user: d.user, sid: d.sid.slice(0, 8), by: 'device' })
1159
+ reply(from, { type: MSG.LOGIN_CLOSED, ok: r.ok })
1160
+ }
1161
+
1074
1162
  async function handleCheck (from, p) {
1075
1163
  if (!isFresh(p?.data)) return staleReply(from)
1076
1164
  const pub = p.data.publickey
@@ -1632,6 +1720,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1632
1720
  if (payload.type === MSG.RENOUNCE) return await handleRenounce(from, payload)
1633
1721
  if (payload.type === MSG.ADMIN_EVENT) return await handleAdminEvent(payload)
1634
1722
  if (payload.type === MSG.REPLICA_ACK) return await handleReplicaAck(from, payload)
1723
+ if (payload.type === MSG.LOGIN_START) return await handleLoginStart(from, payload)
1724
+ if (payload.type === MSG.LOGIN_FINISH) return await handleLoginFinish(from, payload)
1725
+ if (payload.type === MSG.LOGIN_CLOSE) return await handleLoginClose(from, payload)
1635
1726
  } catch (e) {
1636
1727
  reply(from, { type: MSG.ERROR, error: e.message })
1637
1728
  }
@@ -3375,6 +3466,42 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
3375
3466
  // llave. Lo que no puede es listarla un aparato.
3376
3467
  passwordDevices,
3377
3468
  passwordsVault: () => passwords?.vault || null,
3469
+ // --- ENTRAR CON USUARIO Y CONTRASEÑA -------------------------------------------------
3470
+ //
3471
+ // El alta va en DOS pasos porque la contraseña no pasa por aquí: quien crea el aparato
3472
+ // (la CLI, en esta máquina) hace su mitad de OPAQUE, genera las llaves del aparato y las
3473
+ // cierra con la llave que sale de la contraseña. La bóveda solo guarda lo que no puede
3474
+ // abrir, sella el acta y firma el certificado — y eso último sí pide el perfil abierto.
3475
+ listLogins: () => logins.list(),
3476
+ loginRegisterBegin: ({ user, request, replace = false }) => logins.registerBegin({ user, request, replace }),
3477
+ // El alta es la MISMA pieza que usa la bóveda-pestaña (`registerLogin`): aquí solo se
3478
+ // añade lo que es del binario — la bitácora y el aviso a los demás aparatos.
3479
+ loginRegisterFinish: async (opts) => {
3480
+ const r = await registerLogin({ ...opts, identity, logins })
3481
+ if (r.replaced) { audit('login.passwd', { user: r.user }); return r }
3482
+ audit('login.create', { user: r.user, device: r.deviceId, caps: r.caps })
3483
+ await notifyMembers('enrolled', { deviceId: r.deviceId, by: 'login' })
3484
+ return r
3485
+ },
3486
+ // ENTRAR DESDE ESTA MISMA MÁQUINA. Es el mismo intercambio que hace un navegador por el
3487
+ // proxio, y pasa por el mismo freno: lo usa `logins passwd`, que necesita abrir el
3488
+ // paquete de llaves con la contraseña vieja antes de volver a cerrarlo con la nueva.
3489
+ loginBegin: ({ user, request }) => logins.loginBegin({ user, request }),
3490
+ loginEnd: ({ lid, finalization, label }) => logins.loginEnd({ lid, finalization, label }),
3491
+ closeLogin: ({ user, sid }) => {
3492
+ const r = logins.closeSession({ user, sid })
3493
+ if (r.ok) audit('login.close', { user, sid: String(sid).slice(0, 8), by: 'console' })
3494
+ return r
3495
+ },
3496
+ clearLoginBlock: ({ user }) => logins.clearBlock({ user }),
3497
+ removeLogin: async ({ user }) => {
3498
+ const found = logins.list().find((x) => x.user === user)
3499
+ if (!found) return { ok: false }
3500
+ logins.remove({ user })
3501
+ audit('login.remove', { user, device: found.deviceId })
3502
+ // Quitarlo de aquí no lo saca del acta: lo suyo es revocar el aparato, y eso ya existe.
3503
+ return { ok: true, pub: found.pub, deviceId: found.deviceId }
3504
+ },
3378
3505
  listApprovals: () => approvals.list(),
3379
3506
  // LAS CONCESIONES VIVAS, para verlas y para cortarlas. Se enseñan en la máquina del
3380
3507
  // dueño (TUI/CLI), donde el contexto ya es suyo y no hay nada que sellar.