@dotrino/vault 0.65.0 → 0.67.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vault",
3
- "version": "0.65.0",
3
+ "version": "0.67.0",
4
4
  "description": "Usa ESTE dispositivo (navegador) como bóveda/CA del ecosistema Dotrino: atiende enrolamientos por el proxy y firma certificados de delegación a tus máquinas. Incluye el cliente de SERVICIO (Node): un proyecto se enrola una vez y jala sus credenciales del vault en vez del .env (`import '@dotrino/vault/config'`).",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -31,6 +31,12 @@
31
31
  "./protocol": {
32
32
  "import": "./src/protocol.js"
33
33
  },
34
+ "./password-logins": {
35
+ "import": "./src/passwordLogins.js"
36
+ },
37
+ "./login-client": {
38
+ "import": "./src/loginClient.js"
39
+ },
34
40
  "./admin": {
35
41
  "import": "./src/admin.js"
36
42
  }
@@ -51,7 +57,7 @@
51
57
  ],
52
58
  "peerDependencies": {
53
59
  "@dotrino/identity": ">=0.92.0",
54
- "@dotrino/proxy-client": ">=0.18.0"
60
+ "@dotrino/proxy-client": ">=0.23.1"
55
61
  },
56
62
  "license": "MIT",
57
63
  "repository": {
@@ -65,5 +71,8 @@
65
71
  },
66
72
  "scripts": {
67
73
  "type-check": "tsc --noEmit"
74
+ },
75
+ "dependencies": {
76
+ "@dotrino/opaque": "0.1.0"
68
77
  }
69
78
  }
package/src/b64.js ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * b64.js — base64url a mano, sin `Buffer` ni `btoa`.
3
+ *
4
+ * Vive aparte porque lo usan piezas que corren en los tres sitios: el binario (Node), la
5
+ * pestaña y la extensión. `Buffer` no existe en el navegador y `btoa` no existe en algunos
6
+ * workers, así que la única forma de tener UNA implementación es esta.
7
+ */
8
+
9
+ const B64_STD = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
10
+
11
+ export function bytesToB64url (bytes) {
12
+ let out = ''
13
+ for (let i = 0; i < bytes.length; i += 3) {
14
+ const a = bytes[i]; const b = bytes[i + 1]; const c = bytes[i + 2]
15
+ out += B64_STD[a >> 2]
16
+ out += B64_STD[((a & 3) << 4) | ((b ?? 0) >> 4)]
17
+ if (b === undefined) break
18
+ out += B64_STD[((b & 15) << 2) | ((c ?? 0) >> 6)]
19
+ if (c === undefined) break
20
+ out += B64_STD[c & 63]
21
+ }
22
+ return out.replace(/\+/g, '-').replace(/\//g, '_')
23
+ }
24
+
25
+ export function b64urlToBytes (s) {
26
+ const clean = String(s).replace(/-/g, '+').replace(/_/g, '/').replace(/[^A-Za-z0-9+/]/g, '')
27
+ const out = []
28
+ let acc = 0; let bits = 0
29
+ for (const ch of clean) {
30
+ const v = B64_STD.indexOf(ch)
31
+ if (v < 0) return null
32
+ acc = (acc << 6) | v; bits += 6
33
+ if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 0xff) }
34
+ }
35
+ return Uint8Array.from(out)
36
+ }
package/src/index.js CHANGED
@@ -31,6 +31,10 @@ import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS, FRESH_WINDOW_MS } from './
31
31
  // Las constantes del protocolo salen del MISMO módulo que usa el daemon: si la lista
32
32
  // local se queda corta, el dispositivo deja de handle mensajes sin que nadie lo note.
33
33
  import { MSG, SCOPE } from './protocol.js'
34
+ // El canal donde se anuncia esta cuenta y la huella que lo nombra: la MISMA pieza que usa
35
+ // el daemon y que lee quien entra con usuario y contraseña. Un canal que se escriba distinto
36
+ // en cada bóveda no es el mismo canal.
37
+ import { vaultChannel, accountFingerprint } from './passwordLogins.js'
34
38
 
35
39
  const SIGN_SCOPE = SCOPE.SIGN
36
40
  const RENEW_TTL_MS = DEVICE_TTL_MS // la renovación extiende la misma ventana (30 días)
@@ -46,11 +50,16 @@ export { deviceIdOf }
46
50
  * `me.publickey`, `signData`, `signDelegation`, `listDelegations`, `revokeDelegation`.
47
51
  * @param {object} [opts]
48
52
  * @param {string} [opts.proxyUrl='wss://proxy.dotrino.com']
53
+ * @param {object} [opts.logins] escritorio de `createLoginDesk` (`./password-logins`) para
54
+ * ENTRAR CON USUARIO Y CONTRASEÑA. Lo monta quien levanta esta bóveda, porque en el
55
+ * navegador no hay archivos y el estado tiene que guardarlo él. Sin él, esta bóveda
56
+ * contesta `logins-unavailable` — no se inventa un almacén.
49
57
  * @returns {Promise<object>} handle: { iss, proxy, client, startPairing, stopPairing,
50
- * approve, reject, listPending, listMachines, revoke, getSelfCert, onPendingChange,
51
- * onAdopted, close }
58
+ * approve, reject, listPending, listMachines, revoke, getSelfCert, listLogins,
59
+ * loginRegisterBegin, loginRegisterFinish, loginBegin, loginEnd, closeLogin,
60
+ * clearLoginBlock, removeLogin, onPendingChange, onAdopted, close }
52
61
  */
53
- export async function startDeviceVault (identity, { proxyUrl, client: injectedClient } = {}) {
62
+ export async function startDeviceVault (identity, { proxyUrl, client: injectedClient, logins = null } = {}) {
54
63
  const iss = identity.me?.publickey
55
64
  if (!iss) throw new Error('no identity: create/unlock your identity before using this device as a vault')
56
65
  const proxy = proxyUrl || 'wss://proxy.dotrino.com'
@@ -95,10 +104,35 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
95
104
  })()
96
105
 
97
106
  const selfCert = await getSelfCert()
107
+
108
+ /**
109
+ * ANUNCIARSE EN EL CANAL DE LA CUENTA, igual que el daemon del PC (`src/transport.js`).
110
+ *
111
+ * Sin esto, una bóveda-pestaña no existe para quien entra con usuario y contraseña: ese
112
+ * equipo no tiene ninguna llave, solo el código de la dirección, y el canal es lo único
113
+ * que puede convertirlo en «con quién hablo». Faltaba, así que quien tenía su bóveda solo
114
+ * en una pestaña no era encontrable por dirección — y la pestaña sí sabe atender el
115
+ * inicio de sesión desde que existe `logins`.
116
+ *
117
+ * Va DESPUÉS de identificarse y en cada token nuevo, porque el canal guarda el token y el
118
+ * token cambia con cada reconexión. Y no bloquea el arranque: una bóveda que no consigue
119
+ * anunciarse sigue sirviendo a sus aparatos, que es casi todo lo que hace.
120
+ */
121
+ const announce = async () => {
122
+ if (!logins || !client.token || typeof client.publish !== 'function') return
123
+ try {
124
+ const canal = vaultChannel(await accountFingerprint(identity))
125
+ await client.publish(canal)
126
+ } catch (e) {
127
+ console.warn('[device-vault] could not announce this account on the proxy:', e?.message || e)
128
+ }
129
+ }
130
+
98
131
  const identify = async () => {
99
132
  if (!client.token) return
100
133
  // El sobre lo arma el pilar (`identifyAs`), que le pone el destinatario.
101
134
  await client.identifyAs({ publickey: iss, sign: (d) => identity.signData(d), cert: selfCert })
135
+ await announce()
102
136
  }
103
137
  await identify()
104
138
  client.on('token', () => identify().catch(() => {}))
@@ -328,8 +362,72 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
328
362
  else if (p.type === MSG.GET) handle('get', handleGet(_from, p), _from)
329
363
  else if (p.type === MSG.STORE) handle('store', handleStore(_from, p), _from)
330
364
  else if (p.type === MSG.CHECK) handle('check', handleCheck(_from, p), _from)
365
+ // ENTRAR CON USUARIO Y CONTRASEÑA. La misma pieza que usa el binario
366
+ // (`passwordLogins.js`), con el almacén que le ponga quien monta esta bóveda: en el
367
+ // navegador no hay archivos, así que lo pone el iframe de identidad.
368
+ else if (p.type === MSG.LOGIN_START) handle('login', handleLoginStart(_from, p), _from)
369
+ else if (p.type === MSG.LOGIN_FINISH) handle('login', handleLoginFinish(_from, p), _from)
370
+ else if (p.type === MSG.LOGIN_CLOSE) handle('login', handleLoginClose(_from, p), _from)
331
371
  })
332
372
 
373
+ // --- ENTRAR CON USUARIO Y CONTRASEÑA ---------------------------------------------------
374
+ //
375
+ // Sin almacén no se inventa nada: se contesta que aquí no hay inicios de sesión. Es una
376
+ // bóveda que no atiende esto, no una que dice que la contraseña está mal.
377
+ const noLogins = (from) => send(from, { type: MSG.ERROR, error: 'this vault does not keep password logins', code: 'logins-unavailable' })
378
+
379
+ /** Lo mismo desde la consola: sin almacén no se administra nada, y se dice. */
380
+ function needLogins () {
381
+ if (!logins) throw Object.assign(new Error('this vault does not keep password logins: no store was given to startDeviceVault'), { code: 'logins-unavailable' })
382
+ return logins
383
+ }
384
+
385
+ function loginError (from, e) {
386
+ const code = e?.code
387
+ if (code === 'too-many-tries') return send(from, { type: MSG.ERROR, error: e.message, code, waitMs: e.waitMs || 0 })
388
+ if (code === 'login-failed' || code === 'no-exchange' || code === 'bad-input' || code === 'bad-user') {
389
+ return send(from, { type: MSG.ERROR, error: e.message, code })
390
+ }
391
+ send(from, { type: MSG.ERROR, error: 'login failed', code: 'login-error' })
392
+ }
393
+
394
+ async function handleLoginStart (from, p) {
395
+ if (!logins) return noLogins(from)
396
+ try {
397
+ const { lid, response } = logins.loginBegin({ user: p?.user, request: p?.request })
398
+ send(from, { type: MSG.LOGIN_RESPONSE, lid, response })
399
+ } catch (e) { loginError(from, e) }
400
+ }
401
+
402
+ async function handleLoginFinish (from, p) {
403
+ if (!logins) return noLogins(from)
404
+ try {
405
+ const r = logins.loginEnd({ lid: p?.lid, finalization: p?.finalization, label: p?.label })
406
+ const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
407
+ send(from, { type: MSG.LOGIN_OK, sid: r.sid, blob: r.blob, cert: r.cert, iss: r.iss || iss, acta })
408
+ } catch (e) { loginError(from, e) }
409
+ }
410
+
411
+ /** Salir va firmado con la llave que acaba de abrir: cerrar la de otro sería echarlo. */
412
+ async function handleLoginClose (from, p) {
413
+ if (!logins) return noLogins(from)
414
+ const d = p?.data
415
+ if (typeof d?.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) {
416
+ return send(from, { type: MSG.ERROR, error: 'stale request', code: 'stale' })
417
+ }
418
+ if (d?.op !== 'login.close' || typeof d.publickey !== 'string' || typeof d.user !== 'string' || typeof d.sid !== 'string') {
419
+ return send(from, { type: MSG.ERROR, error: 'unauthorized: shape', code: 'bad-input' })
420
+ }
421
+ if (!(await verifyDeviceSig({ publickey: d.publickey, data: d, signature: p.signature }))) {
422
+ return send(from, { type: MSG.ERROR, error: 'unauthorized: bad-signature', code: 'bad-signature' })
423
+ }
424
+ const mine = logins.list().find((x) => x.user === d.user)
425
+ if (!mine || mine.pub !== d.publickey) {
426
+ return send(from, { type: MSG.ERROR, error: 'unauthorized: that key does not own this login', code: 'not-yours' })
427
+ }
428
+ send(from, { type: MSG.LOGIN_CLOSED, ok: logins.closeSession({ user: d.user, sid: d.sid }).ok })
429
+ }
430
+
333
431
  /**
334
432
  * Máquinas enroladas bajo esta identidad (P), vigentes, con scope de firma y label
335
433
  * propio (excluye navegadores enrolados con label 'cli', que no atienden peticiones).
@@ -368,6 +466,31 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
368
466
  // exactamente esto.
369
467
  revoke: (nonce) => desk.revoke(nonce),
370
468
  getSelfCert,
469
+ // ENTRAR CON USUARIO Y CONTRASEÑA, desde la consola de esta bóveda. Es la misma pieza
470
+ // que el binario (`registerLogin`), para que el aparato que sale de aquí sea idéntico.
471
+ // Sin almacén no hay nada que administrar, y se dice en vez de contestar una lista vacía.
472
+ listLogins: () => needLogins().list(),
473
+ loginRegisterBegin: (opts) => needLogins().registerBegin(opts),
474
+ // El alta se trae cuando se usa: arrastra el OPAQUE en WASM (unos 260 KB) y la mayoría
475
+ // de las apps que usan este módulo no crean ningún inicio de sesión.
476
+ loginRegisterFinish: async (opts) => {
477
+ const { registerLogin } = await import('./passwordLogins.js')
478
+ return registerLogin({ ...opts, identity, logins: needLogins() })
479
+ },
480
+ // Entrar desde la propia consola, sin pasar por el proxio. Hace falta para cambiar la
481
+ // contraseña: el paquete de llaves se abre con la vieja y se vuelve a cerrar con la nueva.
482
+ loginBegin: ({ user, request }) => needLogins().loginBegin({ user, request }),
483
+ loginEnd: ({ lid, finalization, label }) => needLogins().loginEnd({ lid, finalization, label }),
484
+ closeLogin: ({ user, sid }) => needLogins().closeSession({ user, sid }),
485
+ clearLoginBlock: ({ user }) => needLogins().clearBlock({ user }),
486
+ removeLogin: ({ user }) => {
487
+ const store = needLogins()
488
+ const found = store.list().find((x) => x.user === user)
489
+ if (!found) return { ok: false }
490
+ store.remove({ user })
491
+ // Quitarlo de aquí no lo saca del acta: lo suyo es revocar el aparato, y eso ya existe.
492
+ return { ok: true, pub: found.pub, deviceId: found.deviceId }
493
+ },
371
494
  onPendingChange (fn) { _onPendingChange = fn || (() => {}) },
372
495
  /** Camino A: la cuenta del aparato quedó adoptada por esta bóveda. */
373
496
  onAdopted (fn) { _onAdopted = fn || (() => {}) },
package/src/invite.js CHANGED
@@ -80,37 +80,11 @@ export const PAIR_URL = 'https://vault.dotrino.com/d#v='
80
80
 
81
81
  // ---------------------------------------------------------------------------
82
82
  // Conversiones (sin dependencias: esto corre en el navegador, en Node y dentro
83
- // del binario SEA)
83
+ // del binario SEA). El base64url vive en `b64.js`, porque el paquete de llaves de
84
+ // los inicios de sesión usa el mismo.
84
85
  // ---------------------------------------------------------------------------
85
86
 
86
- const B64_STD = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
87
-
88
- function bytesToB64url (bytes) {
89
- let out = ''
90
- for (let i = 0; i < bytes.length; i += 3) {
91
- const a = bytes[i]; const b = bytes[i + 1]; const c = bytes[i + 2]
92
- out += B64_STD[a >> 2]
93
- out += B64_STD[((a & 3) << 4) | ((b ?? 0) >> 4)]
94
- if (b === undefined) break
95
- out += B64_STD[((b & 15) << 2) | ((c ?? 0) >> 6)]
96
- if (c === undefined) break
97
- out += B64_STD[c & 63]
98
- }
99
- return out.replace(/\+/g, '-').replace(/\//g, '_')
100
- }
101
-
102
- function b64urlToBytes (s) {
103
- const clean = String(s).replace(/-/g, '+').replace(/_/g, '/').replace(/[^A-Za-z0-9+/]/g, '')
104
- const out = []
105
- let acc = 0; let bits = 0
106
- for (const ch of clean) {
107
- const v = B64_STD.indexOf(ch)
108
- if (v < 0) return null
109
- acc = (acc << 6) | v; bits += 6
110
- if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 0xff) }
111
- }
112
- return Uint8Array.from(out)
113
- }
87
+ import { bytesToB64url, b64urlToBytes } from './b64.js'
114
88
 
115
89
  const utf8 = (s) => new TextEncoder().encode(s)
116
90
  const fromUtf8 = (b) => new TextDecoder().decode(b)
@@ -0,0 +1,216 @@
1
+ /**
2
+ * ENTRAR CON USUARIO Y CONTRASEÑA — EL LADO DEL QUE ENTRA.
3
+ *
4
+ * La otra mitad de `passwordLogins.js`: allí está la bóveda que atiende, aquí el equipo
5
+ * prestado que llega sin ninguna llave y solo con una dirección escrita a mano. Diseño en
6
+ * `dotrino-passmanager/docs/temporary-access.md` §3.2.
7
+ *
8
+ * nombre@AB12-CD34-EF56 + contraseña
9
+ * lista el canal del código ──► las bóvedas de esa cuenta que estén encendidas
10
+ * OPAQUE (inicio) ◄──► comprueba sin ver la contraseña
11
+ * abre el paquete de llaves ◄── { sid, blob, cert, iss, acta }
12
+ * desde aquí es un aparato del acta
13
+ *
14
+ * **Vive en el pilar y no en la página** porque lo van a hacer tres sitios distintos —la
15
+ * pantalla de `profile.dotrino.com`, la extensión del gestor y cualquier app que ofrezca
16
+ * entrar— y una dirección que se lea distinto, o una comprobación que uno se salte, no es
17
+ * la misma puerta. Aquí no se abre ninguna conexión: el transporte se INYECTA, como en
18
+ * `@dotrino/identity/session-flow`.
19
+ *
20
+ * QUÉ SE COMPRUEBA, Y EN QUÉ ORDEN (importa):
21
+ *
22
+ * 1. **OPAQUE autentica a las DOS partes.** Una bóveda falsa no tiene tu registro, así
23
+ * que no puede armar una respuesta que cuadre: `loginFinish` revienta en ESTE lado y
24
+ * la contraseña no se le ha dicho a nadie. Por eso mandarle el primer mensaje a un
25
+ * desconocido del canal no cuenta nada — es el único orden posible, porque el canal
26
+ * solo da tokens y un token no dice de quién es.
27
+ * 2. **El acta tiene que ser de la cuenta que escribiste**: `pubkeyId(acta.profileId)`
28
+ * empieza por el código de la dirección. Esto es lo que ata la respuesta a TU cuenta
29
+ * y no a otra bóveda que también sepa contestar.
30
+ * 3. **El papel y el acta se sostienen entre sí** (`checkVaultReply`).
31
+ * 4. **La llave que acaba de salir del paquete es la del papel**: se firma un reto y se
32
+ * verifica contra `cert.sub`. Sin esto, una bóveda podría devolver el paquete de otro.
33
+ *
34
+ * Si algo de eso falla, se PARA con un `code` propio. No hay repliegue: entrar «a medias»
35
+ * en una cuenta es peor que no entrar.
36
+ */
37
+ import { client as opaque } from '@dotrino/opaque'
38
+ import { pubkeyId, verifyDeviceSig, signWithDevice } from '@dotrino/identity/capabilities'
39
+ import { checkVaultReply } from '@dotrino/identity/acta'
40
+ import { MSG } from './protocol.js'
41
+ import { accountCode, parseLoginAddress, vaultChannel, openDeviceKeys } from './passwordLogins.js'
42
+
43
+ const err = (code, message, extra = {}) => Object.assign(new Error(message), { code, ...extra })
44
+
45
+ /** Cuánto se espera a que una bóveda conteste antes de probar con la siguiente. */
46
+ export const REPLY_TIMEOUT_MS = 15_000
47
+
48
+ /**
49
+ * Escucha UNA respuesta de un token concreto. El `off` se suelta siempre —también al
50
+ * agotarse la espera—: un oyente que se queda pegado hace que el segundo intento vea la
51
+ * respuesta del primero.
52
+ */
53
+ function waitFor (transport, from, match, timeoutMs) {
54
+ return new Promise((resolve, reject) => {
55
+ let listo = false
56
+ const fin = (fn, arg) => { if (!listo) { listo = true; clearTimeout(reloj); quitar(); fn(arg) } }
57
+ const reloj = setTimeout(() => fin(reject, err('no-answer', 'the vault did not answer in time')), timeoutMs)
58
+ const oyente = (quien, payload) => {
59
+ if (from && quien !== from) return
60
+ const p = typeof payload === 'string' ? parseJson(payload) : payload
61
+ if (p && match(p)) fin(resolve, p)
62
+ }
63
+ const off = transport.on('message', oyente)
64
+ const quitar = () => {
65
+ if (typeof off === 'function') return off()
66
+ transport.off?.('message', oyente)
67
+ }
68
+ })
69
+ }
70
+
71
+ const parseJson = (s) => { try { return JSON.parse(s) } catch (_) { return null } }
72
+
73
+ /**
74
+ * ENTRAR. Devuelve el aparato entero —sus llaves privadas, su papel y el acta— para que
75
+ * quien llama decida dónde vive eso: en el navegador lo adopta `@dotrino/identity` como una
76
+ * cuenta más; en la extensión, su propio almacén.
77
+ *
78
+ * @param {object} opts
79
+ * @param {object} opts.transport cliente de `@dotrino/proxy-client` ya conectado. NO hace
80
+ * falta identificarse: quien entra todavía no tiene con qué.
81
+ * @param {string} opts.address `nombre@AB12-CD34-EF56`, tal como lo teclea una persona.
82
+ * @param {string} opts.password
83
+ * @param {string} [opts.label] de dónde se entra («el cyber de la esquina»): es lo que
84
+ * el dueño va a leer en su consola para decidir si cerrarlo.
85
+ * @returns {Promise<{user:string,address:string,code:string,sid:string,cert:object,
86
+ * iss:string,acta:object,vaultToken:string,publickey:string,encPublickey:(string|null),
87
+ * keys:{sign:object,enc:(object|null)}}>}
88
+ */
89
+ export async function loginWithPassword ({ transport, address, password, label = '', timeoutMs = REPLY_TIMEOUT_MS } = {}) {
90
+ if (!transport || typeof transport.on !== 'function' || typeof transport.send !== 'function') {
91
+ throw err('no-transport', 'loginWithPassword: a connected transport is required')
92
+ }
93
+ if (typeof password !== 'string' || !password) throw err('no-password', 'loginWithPassword: password required')
94
+ const { user, code, address: dir } = parseLoginAddress(address)
95
+
96
+ const tokens = await transport.list(vaultChannel(code))
97
+ if (!tokens.length) {
98
+ throw err('no-vault', 'no vault is answering for that address right now: turn yours on, or check the address')
99
+ }
100
+
101
+ // De una en una, y parando en cuanto la contraseña resulte estar mal: cada intento GASTA
102
+ // uno del freno en la bóveda que lo atiende (`passwordLogins.js`), así que repartir el
103
+ // mismo error entre las réplicas solo sirve para bloquearse en todas a la vez.
104
+ let ultimo = null
105
+ for (const token of tokens) {
106
+ try {
107
+ return await unIntento({ transport, token, user, code, dir, password, label, timeoutMs })
108
+ } catch (e) {
109
+ // Se para: son cosas del que entra, y probar con otra bóveda no las arregla.
110
+ if (e?.code === 'login-failed' || e?.code === 'too-many-tries' || e?.code === 'wrong-account' ||
111
+ e?.code === 'bad-blob' || e?.code === 'bad-keys' || e?.code === 'bad-reply') throw e
112
+ ultimo = e // «esa no atiende inicios de sesión», «no contestó»: quizá la siguiente sí
113
+ }
114
+ }
115
+ throw ultimo || err('no-vault', 'none of the vaults on that address could let you in')
116
+ }
117
+
118
+ async function unIntento ({ transport, token, user, code, dir, password, label, timeoutMs }) {
119
+ const start = opaque.loginStart({ password })
120
+ transport.send(token, { type: MSG.LOGIN_START, user, request: start.request })
121
+ const r1 = await waitFor(transport, token, (p) => p.type === MSG.LOGIN_RESPONSE || p.type === MSG.ERROR, timeoutMs)
122
+ if (r1.type === MSG.ERROR) throw deVuelta(r1)
123
+
124
+ let fin
125
+ try {
126
+ fin = opaque.loginFinish({ state: start.state, response: r1.response, password })
127
+ } catch (_) {
128
+ // OPAQUE comprueba a las dos partes: aquí se sabe que el otro lado NO conoce el
129
+ // registro de este usuario. Contraseña equivocada, usuario que no existe o una bóveda
130
+ // que no es la tuya — y son el mismo error a propósito, porque distinguirlos diría
131
+ // desde fuera qué usuarios hay.
132
+ throw err('login-failed', 'wrong address or password')
133
+ }
134
+
135
+ transport.send(token, { type: MSG.LOGIN_FINISH, lid: r1.lid, finalization: fin.finalization, label })
136
+ const ok = await waitFor(transport, token, (p) => p.type === MSG.LOGIN_OK || p.type === MSG.ERROR, timeoutMs)
137
+ if (ok.type === MSG.ERROR) throw deVuelta(ok)
138
+
139
+ const acta = ok.acta
140
+ const cert = ok.cert
141
+ if (!acta || !cert?.sub) throw err('bad-reply', 'the vault let you in without saying which account this is')
142
+
143
+ // 2. ¿ES LA CUENTA QUE ESCRIBISTE? Es la única comprobación que ata todo esto a lo que la
144
+ // persona tecleó: el canal no prueba nada y el acta viene de quien contesta.
145
+ if (accountCode((await pubkeyId(acta.profileId)).slice(0, 16)) !== code) {
146
+ throw err('wrong-account', 'that vault serves a different account than the address says')
147
+ }
148
+ // 3. El papel lo firmó esa bóveda, esa bóveda puede sellar esta acta, y el papel vale
149
+ // según ella. Una sola llamada, la misma que usa el enrolamiento.
150
+ const v = await checkVaultReply({ acta, cert, vault: ok.iss, sub: cert.sub })
151
+ if (!v.ok) throw err('bad-reply', 'the vault reply does not hold together: ' + v.reason)
152
+
153
+ const keys = await openDeviceKeys(fin.exportKey, ok.blob) // `bad-blob` si no es el paquete
154
+ if (!keys?.sign) throw err('bad-blob', 'the key package does not carry a signing key')
155
+
156
+ // 4. La llave que salió del paquete tiene que SER la del papel. Se prueba firmando.
157
+ const reto = { op: 'login.proof', sub: cert.sub, sid: ok.sid, ts: Date.now() }
158
+ const { signature } = await signWithDevice({ privateJwk: keys.sign, data: reto })
159
+ if (!(await verifyDeviceSig({ publickey: cert.sub, data: reto, signature }))) {
160
+ throw err('bad-keys', 'the keys in the package are not the ones this certificate is for')
161
+ }
162
+
163
+ const member = (acta.members || []).find((m) => m?.pub === cert.sub)
164
+ if (!member) throw err('bad-reply', 'that certificate names a key the account record does not list')
165
+
166
+ return {
167
+ user,
168
+ address: dir,
169
+ code,
170
+ sid: ok.sid,
171
+ cert,
172
+ iss: ok.iss,
173
+ acta,
174
+ vaultToken: token,
175
+ publickey: cert.sub,
176
+ // La pública de CIFRADO la dice el acta, no el paquete: es lo que los demás miran para
177
+ // envolverle un secreto a este aparato, y tiene que ser la misma que ellos ven.
178
+ encPublickey: member.encPub || null,
179
+ caps: [...(member.caps || [])],
180
+ keys: { sign: keys.sign, enc: keys.enc || null }
181
+ }
182
+ }
183
+
184
+ /** Un error de la bóveda con su `code` intacto: `too-many-tries` se arregla esperando. */
185
+ function deVuelta (p) {
186
+ const code = p.code || 'login-error'
187
+ const e = err(code, p.error || 'the vault refused the login')
188
+ if (typeof p.waitMs === 'number') e.waitMs = p.waitMs
189
+ return e
190
+ }
191
+
192
+ /**
193
+ * SALIR. Va firmado con la llave que acabas de abrir —cerrar el inicio de sesión de otro
194
+ * sería echarlo de su cuenta— y es lo que suelta la plaza en la bóveda; lo que se borre en
195
+ * este navegador es cosa de quien llama.
196
+ *
197
+ * Es «mejor esfuerzo» a propósito: si la bóveda está apagada, salir de este equipo no puede
198
+ * quedarse bloqueado esperándola. La sesión sigue abierta allí hasta que se cierre desde la
199
+ * consola, y eso ya está dicho en el diseño (§3.2).
200
+ */
201
+ export async function closeLogin ({ transport, token, user, sid, publickey, privateJwk, privateKey, timeoutMs = REPLY_TIMEOUT_MS } = {}) {
202
+ if (!transport || typeof transport.send !== 'function') throw err('no-transport', 'closeLogin: transport required')
203
+ if (!token || !user || !sid || !publickey) throw err('bad-input', 'closeLogin: token, user, sid and publickey are required')
204
+ const data = { op: 'login.close', publickey, user, sid, ts: Date.now() }
205
+ const { signature } = await signWithDevice({ privateJwk, privateKey, publickey, data })
206
+ transport.send(token, { type: MSG.LOGIN_CLOSE, data, signature })
207
+ try {
208
+ const r = await waitFor(transport, token, (p) => p.type === MSG.LOGIN_CLOSED || p.type === MSG.ERROR, timeoutMs)
209
+ if (r.type === MSG.ERROR) return { ok: false, reason: r.code || 'login-error' }
210
+ return { ok: !!r.ok }
211
+ } catch (e) {
212
+ return { ok: false, reason: e?.code || 'no-answer' }
213
+ }
214
+ }
215
+
216
+ export default { loginWithPassword, closeLogin, REPLY_TIMEOUT_MS }
@@ -0,0 +1,469 @@
1
+ /**
2
+ * EL APARATO QUE SE ABRE CON USUARIO Y CONTRASEÑA.
3
+ *
4
+ * Es un miembro del acta como cualquier otro —su llave de firma, su llave de cifrado, sus
5
+ * permisos—, y lo único distinto es dónde vive su llave privada: aquí, **cifrada con algo
6
+ * que solo sale de la contraseña**. Diseño en
7
+ * `dotrino-passmanager/docs/temporary-access.md`.
8
+ *
9
+ * Lo que esta pieza guarda y lo que NO:
10
+ *
11
+ * · el registro OPAQUE del usuario y la preparación del servidor — con ellos NO se puede
12
+ * comprobar una contraseña sin el protocolo, ni sacarla de ahí;
13
+ * · el paquete con las llaves privadas del aparato, **cerrado por quien lo creó** con la
14
+ * llave que sale de la contraseña (`exportKey`). La bóveda no lo abre nunca: lo guarda
15
+ * y lo devuelve cuando alguien demuestra saber la contraseña;
16
+ * · el certificado del aparato y su acta, que son públicos.
17
+ *
18
+ * **La bóveda nunca ve la contraseña.** OPAQUE (`@dotrino/opaque`, RFC 9807) la comprueba
19
+ * sin recibirla y sin entregar nada con qué adivinarla desde fuera: solo se puede probar en
20
+ * línea, contra el freno de abajo.
21
+ *
22
+ * El escritorio (`createLoginDesk`) es puro: recibe el estado, lo devuelve cambiado y no toca
23
+ * el disco ni la red. Quien lo guarda y quien lo sirve es cada bóveda —`src/vault.js` en el
24
+ * binario, `lib/src/index.js` en la pestaña—. Lo único que no es puro es `registerLogin`, al
25
+ * final, porque hace falta la identidad que firma.
26
+ */
27
+ import { server as opaque, suiteId } from '@dotrino/opaque'
28
+ import { pubkeyId } from '@dotrino/identity/capabilities'
29
+ import { deviceIdOf, scopeToCaps, scopeToCn } from './enroll.js'
30
+ import { SCOPE } from './protocol.js'
31
+ import { bytesToB64url, b64urlToBytes } from './b64.js'
32
+
33
+ /** Un usuario es la parte de antes de la `@` en `nombre@AB12-CD34-EF56`. */
34
+ export const isValidUser = (u) => typeof u === 'string' && /^[a-z0-9][a-z0-9._-]{0,31}$/.test(u)
35
+
36
+ /**
37
+ * LA DIRECCIÓN: `nombre@AB12-CD34-EF56` (`temporary-access.md` §3.2). Lo de después de la
38
+ * `@` son los primeros **48 bits de la huella de la cuenta** — el mismo `pubkeyId` que ya
39
+ * identifica cualquier llave, con un grupo más que `keyLabel`.
40
+ *
41
+ * Se ata a la CUENTA y no a una máquina porque la cuenta no cambia nunca y la bóveda puede
42
+ * mudarse o tener réplicas. Y son 48 bits y no 32 porque este código es lo único que ata la
43
+ * dirección a la cuenta de verdad: fabricar otra cuenta con la misma huella corta cuesta
44
+ * siglos con 48 bits y horas con los 32 de `keyLabel`.
45
+ *
46
+ * Vive aquí, y no en cada bóveda, porque una dirección que se escriba distinta en el binario
47
+ * y en la extensión no es la misma dirección.
48
+ */
49
+ export const ACCOUNT_CODE_HEX = 12
50
+ export function accountCode (fingerprint) {
51
+ const hex = String(fingerprint || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase()
52
+ if (hex.length < ACCOUNT_CODE_HEX) throw err('bad-account', 'the account fingerprint is too short to build an address')
53
+ return hex.slice(0, ACCOUNT_CODE_HEX).match(/.{4}/g).join('-')
54
+ }
55
+ export const loginAddress = (user, fingerprint) => `${user}@${accountCode(fingerprint)}`
56
+
57
+ /**
58
+ * LA HUELLA DE LA CUENTA, que es de la CUENTA y no de la bóveda que la atiende.
59
+ *
60
+ * Sale del `profileId` del acta —la llave del génesis, la que no cambia nunca—, y por eso
61
+ * la dirección sobrevive a que la bóveda se mude, se replique o adopte la cuenta de otro
62
+ * aparato. Derivarla de la llave de la bóveda daba una dirección distinta en cada réplica
63
+ * y ninguna en la segunda bóveda de un multivault.
64
+ *
65
+ * Es la MISMA cuenta para todas las bóvedas de esa cuenta, que es justo lo que hace que el
66
+ * canal las junte a todas.
67
+ */
68
+ export async function accountFingerprint (identity) {
69
+ const pid = (await identity?.profileActa?.().catch(() => null))?.acta?.profileId
70
+ if (!pid) throw err('no-acta', 'this vault has no account record yet: there is no address to build')
71
+ return (await pubkeyId(pid)).slice(0, 16)
72
+ }
73
+
74
+ /**
75
+ * Lee `nombre@AB12-CD34-EF56`. Es lo que TECLEA una persona en un equipo prestado, así que
76
+ * se le perdona el formato —mayúsculas, espacios, guiones de más o de menos— pero no el
77
+ * contenido: el código son 12 dígitos hexadecimales, ni uno más ni uno menos.
78
+ *
79
+ * Lo que NO se perdona tiene su razón: un código corto de menos no es «casi» la dirección,
80
+ * es otra cuenta con la que se colisiona antes, y aceptarlo sería quitarle los bits que lo
81
+ * atan a tu cuenta (§3.2 de `temporary-access.md`).
82
+ */
83
+ export function parseLoginAddress (address) {
84
+ const raw = String(address || '').trim()
85
+ const at = raw.lastIndexOf('@')
86
+ if (at <= 0) throw err('bad-address', 'an address looks like name@AB12-CD34-EF56')
87
+ const user = raw.slice(0, at).trim().toLowerCase()
88
+ const hex = raw.slice(at + 1).replace(/[^0-9a-fA-F]/g, '').toUpperCase()
89
+ if (!isValidUser(user)) throw err('bad-user', 'a user name is lowercase letters, digits, dot, dash or underscore (1-32)')
90
+ if (hex.length !== ACCOUNT_CODE_HEX) throw err('bad-address', `the account code is ${ACCOUNT_CODE_HEX} hex digits: AB12-CD34-EF56`)
91
+ return { user, code: accountCode(hex), address: `${user}@${accountCode(hex)}` }
92
+ }
93
+
94
+ /**
95
+ * EL CANAL DONDE SE ANUNCIA CADA BÓVEDA DE ESA CUENTA, réplicas incluidas.
96
+ *
97
+ * Quien entra con usuario y contraseña no tiene ninguna llave todavía, así que no puede
98
+ * escribirle a la bóveda por su pubkey: no la sabe. Lo único que tiene es el código de la
99
+ * dirección, y con él lista este canal y encuentra a quien atiende.
100
+ *
101
+ * **El canal no prueba nada**: cualquiera puede publicarse en uno. Lo que ata la bóveda a
102
+ * la cuenta es el acta que enseña después — su huella tiene que empezar por ese mismo
103
+ * código. Por eso son 48 bits, y por eso mirar este canal solo deja ver que detrás de un
104
+ * código hay una bóveda encendida: ni de quién es, ni qué guarda.
105
+ */
106
+ export const vaultChannel = (fingerprint) => `dotrino-vault-${accountCode(fingerprint)}`
107
+
108
+ /**
109
+ * EL FRENO. Cinco intentos y, a partir de ahí, una espera que se DUPLICA con cada fallo
110
+ * (decidido por el dueño el 2026-09-17). Se eligió frente a bloquear la cuenta porque un
111
+ * bloqueo deja que cualquiera que sepa tu usuario te impida entrar a propósito.
112
+ *
113
+ * **Se cuenta al EMPEZAR el intento, no al terminarlo**, y no es un detalle: en OPAQUE quien
114
+ * prueba una contraseña se entera ÉL SOLO al recibir la respuesta, sin mandar el último
115
+ * mensaje. Contando al final, quien prueba contraseñas no gastaría ni un intento y el freno
116
+ * no frenaría nada. Un inicio de sesión que termina bien reinicia la cuenta.
117
+ *
118
+ * El precio, dicho claro: quien sepa tu usuario puede gastarte los intentos y hacerte
119
+ * esperar. Por eso hay tope de una hora y `clearBlock`, que lo quita desde la máquina de la
120
+ * bóveda. No hay forma de evitarlo del todo: para saber si una contraseña es la buena hay
121
+ * que dejar intentar.
122
+ */
123
+ export const FREE_TRIES = 5
124
+ export const BACKOFF_BASE_MS = 60_000
125
+ export const BACKOFF_CAP_MS = 60 * 60_000
126
+ export const backoffFor = (fails) =>
127
+ fails < FREE_TRIES ? 0 : Math.min(BACKOFF_BASE_MS * 2 ** (fails - FREE_TRIES), BACKOFF_CAP_MS)
128
+
129
+ /** Un intercambio de inicio de sesión a medias no puede quedarse abierto para siempre. */
130
+ export const EXCHANGE_TTL_MS = 2 * 60_000
131
+
132
+ const err = (code, message) => Object.assign(new Error(message), { code })
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // EL PAQUETE DE LLAVES
136
+ //
137
+ // Lo cierra y lo abre QUIEN SABE LA CONTRASEÑA: la CLI al crear el aparato, y el
138
+ // navegador al entrar. La bóveda solo lo guarda y lo devuelve — nunca tiene con qué
139
+ // abrirlo, y por eso esto no está en ninguna de las tres bóvedas sino aquí, donde las
140
+ // tres lo importan y cierran el paquete EXACTAMENTE igual.
141
+ //
142
+ // AES-GCM con los primeros 32 bytes de la llave que sale de la contraseña (`exportKey`
143
+ // de OPAQUE). El formato lleva marca de versión porque este blob se guarda durante años
144
+ // y lo leen tres programas distintos: sin ella, cambiarlo sería adivinar.
145
+ // ---------------------------------------------------------------------------
146
+
147
+ const BLOB_V = 'k1'
148
+
149
+ /** La llave AES que sale del `exportKey` del intercambio. No se guarda en ningún sitio. */
150
+ async function keyFrom (exportKey, use) {
151
+ const raw = b64urlToBytes(exportKey)
152
+ if (!raw || raw.length < 32) throw err('bad-key', 'the key that opens the package is not valid')
153
+ return crypto.subtle.importKey('raw', raw.subarray(0, 32), 'AES-GCM', false, [use])
154
+ }
155
+
156
+ /** Cierra las llaves privadas del aparato. `keys` es JSON: `{ sign, enc }` en JWK. */
157
+ export async function sealDeviceKeys (exportKey, keys) {
158
+ const key = await keyFrom(exportKey, 'encrypt')
159
+ const iv = crypto.getRandomValues(new Uint8Array(12))
160
+ const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(JSON.stringify(keys)))
161
+ return [BLOB_V, bytesToB64url(iv), bytesToB64url(new Uint8Array(ct))].join('.')
162
+ }
163
+
164
+ /**
165
+ * Abre el paquete. Una contraseña equivocada NO llega hasta aquí —OPAQUE la para antes—,
166
+ * así que si esto falla es que el paquete está roto o es de otra versión, y se dice.
167
+ */
168
+ export async function openDeviceKeys (exportKey, blob) {
169
+ const [v, iv, ct] = String(blob || '').split('.')
170
+ if (v !== BLOB_V || !iv || !ct) throw err('bad-blob', 'the key package is not in a format this version understands')
171
+ const key = await keyFrom(exportKey, 'decrypt')
172
+ let plain
173
+ try {
174
+ plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: b64urlToBytes(iv) }, key, b64urlToBytes(ct))
175
+ } catch (_) { throw err('bad-blob', 'the key package did not open with that key') }
176
+ return JSON.parse(new TextDecoder().decode(plain))
177
+ }
178
+
179
+ /**
180
+ * @param {object} opts
181
+ * `load()` devuelve el estado guardado (o `null` la primera vez)
182
+ * `save(s)` lo guarda
183
+ * `now()` el reloj, para poder probar el freno sin esperar una hora
184
+ */
185
+ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
186
+ if (typeof load !== 'function' || typeof save !== 'function') throw new Error('createLoginDesk: load and save are required')
187
+
188
+ /** Intercambios a medias: viven en memoria, y por eso un reinicio los tira. */
189
+ const exchanges = new Map()
190
+
191
+ const read = () => {
192
+ const s = load()
193
+ if (s && typeof s === 'object') return s
194
+ return { v: 1, setup: null, users: {} }
195
+ }
196
+
197
+ /** La preparación del servidor nace con el primer usuario y no se toca nunca más. */
198
+ function setupOf (state) {
199
+ if (state.setup) return state.setup
200
+ state.setup = opaque.createSetup()
201
+ return state.setup
202
+ }
203
+
204
+ const userOf = (state, user) => {
205
+ if (!isValidUser(user)) throw err('bad-user', 'a user name is lowercase letters, digits, dot, dash or underscore (1-32)')
206
+ return state.users[user] || null
207
+ }
208
+
209
+ const sweepExchanges = () => {
210
+ const t = now()
211
+ for (const [lid, x] of exchanges) if (t - x.at > EXCHANGE_TTL_MS) exchanges.delete(lid)
212
+ }
213
+
214
+ return {
215
+ /** Lo que se puede enseñar: ni el registro, ni el paquete de llaves, ni la preparación. */
216
+ list () {
217
+ const state = read()
218
+ return Object.entries(state.users).map(([user, u]) => ({
219
+ user,
220
+ deviceId: u.deviceId || null,
221
+ label: u.label || '',
222
+ pub: u.pub,
223
+ createdAt: u.createdAt || 0,
224
+ passwordChangedAt: u.passwordChangedAt || u.createdAt || 0,
225
+ fails: u.fails || 0,
226
+ blockedUntil: u.nextTryAt || 0,
227
+ sessions: Object.entries(u.sessions || {}).map(([sid, s]) => ({
228
+ sid, openedAt: s.openedAt, lastUsedAt: s.lastUsedAt || s.openedAt, label: s.label || ''
229
+ })).sort((a, b) => b.lastUsedAt - a.lastUsedAt)
230
+ })).sort((a, b) => a.user.localeCompare(b.user))
231
+ },
232
+
233
+ /**
234
+ * ALTA, primer paso. La contraseña no llega hasta aquí: lo que llega es el mensaje de
235
+ * registro de OPAQUE, que no la lleva ni permite adivinarla.
236
+ */
237
+ registerBegin ({ user, request, replace = false } = {}) {
238
+ const state = read()
239
+ const existing = userOf(state, user)
240
+ if (existing && !replace) throw err('user-exists', `there is already a login called "${user}"`)
241
+ if (!existing && replace) throw err('no-user', `there is no login called "${user}"`)
242
+ if (typeof request !== 'string' || !request) throw err('bad-input', 'request required')
243
+ const setup = setupOf(state)
244
+ const response = opaque.registrationResponse({ setup, request, credentialId: user })
245
+ save(state)
246
+ return { response, suite: suiteId() }
247
+ },
248
+
249
+ /**
250
+ * ALTA, segundo paso. `blob` son las llaves privadas del aparato, ya cerradas por quien
251
+ * creó el aparato con la llave que sale de la contraseña: aquí no se abre nunca.
252
+ */
253
+ registerFinish ({ user, upload, pub, encPub = null, deviceId = null, label = '', blob, replace = false } = {}) {
254
+ const state = read()
255
+ const existing = userOf(state, user)
256
+ if (existing && !replace) throw err('user-exists', `there is already a login called "${user}"`)
257
+ if (!existing && replace) throw err('no-user', `there is no login called "${user}"`)
258
+ if (typeof blob !== 'string' || !blob) throw err('bad-input', 'blob required (the device keys, sealed with the password)')
259
+ if (!replace && (typeof pub !== 'string' || !pub)) throw err('bad-input', 'pub required')
260
+ const record = opaque.registrationFinish({ upload })
261
+ const t = now()
262
+ state.users[user] = {
263
+ ...(existing || {}),
264
+ pub: replace ? existing.pub : pub,
265
+ encPub: replace ? existing.encPub : encPub,
266
+ deviceId: replace ? existing.deviceId : deviceId,
267
+ label: label || existing?.label || '',
268
+ record,
269
+ blob,
270
+ suite: suiteId(),
271
+ createdAt: existing?.createdAt || t,
272
+ passwordChangedAt: t,
273
+ // Cambiar la contraseña cierra lo abierto: si alguien entró con la vieja, deja de
274
+ // valer. Y el freno se reinicia, que si no la cuenta vieja castigaría a la nueva.
275
+ sessions: {},
276
+ fails: 0,
277
+ nextTryAt: 0
278
+ }
279
+ save(state)
280
+ return { user, suite: suiteId() }
281
+ },
282
+
283
+ /** El certificado del aparato, que firma la bóveda al admitirlo en el acta. Es público. */
284
+ setCert ({ user, cert, iss = null } = {}) {
285
+ const state = read()
286
+ const u = userOf(state, user)
287
+ if (!u) throw err('no-user', `there is no login called "${user}"`)
288
+ u.cert = cert
289
+ if (iss) u.iss = iss
290
+ save(state)
291
+ return { ok: true }
292
+ },
293
+
294
+ /**
295
+ * INICIO DE SESIÓN, primer paso.
296
+ *
297
+ * Un usuario que no existe recibe una respuesta **igual por fuera**, hecha con un
298
+ * registro inventado: desde fuera no se puede averiguar qué usuarios hay. Y cuenta
299
+ * contra el mismo freno, para que tampoco se pueda medir por el tiempo.
300
+ */
301
+ loginBegin ({ user, request } = {}) {
302
+ sweepExchanges()
303
+ const state = read()
304
+ if (typeof request !== 'string' || !request) throw err('bad-input', 'request required')
305
+ const u = isValidUser(user) ? state.users[user] || null : null
306
+ const t = now()
307
+ if (u?.nextTryAt && u.nextTryAt > t) {
308
+ throw Object.assign(err('too-many-tries', 'too many tries: wait before trying again'), { waitMs: u.nextTryAt - t })
309
+ }
310
+ // EL INTENTO SE CUENTA AQUÍ. Ver el comentario del freno: quien prueba una contraseña
311
+ // puede no mandar nunca el último mensaje, así que si se contara al final no se
312
+ // contaría nada. `loginEnd` lo reinicia cuando el intento sale bien.
313
+ if (u) {
314
+ u.fails = (u.fails || 0) + 1
315
+ u.nextTryAt = t + backoffFor(u.fails)
316
+ }
317
+ const setup = setupOf(state)
318
+ const { state: serverState, response } = opaque.loginStart({
319
+ setup,
320
+ record: u?.record || null,
321
+ request,
322
+ credentialId: String(user || ''),
323
+ identifiers: {}
324
+ })
325
+ const lid = crypto.randomUUID()
326
+ exchanges.set(lid, { user: String(user || ''), known: !!u, state: serverState, at: t })
327
+ save(state)
328
+ return { lid, response }
329
+ },
330
+
331
+ /**
332
+ * INICIO DE SESIÓN, segundo paso. Si la contraseña es la buena, devuelve el paquete de
333
+ * llaves —que solo abre esa contraseña—, el certificado y el acta.
334
+ */
335
+ loginEnd ({ lid, finalization, label = '' } = {}) {
336
+ sweepExchanges()
337
+ const x = exchanges.get(lid)
338
+ if (!x) throw err('no-exchange', 'that login is not in flight any more: start again')
339
+ exchanges.delete(lid)
340
+ const state = read()
341
+ const u = x.known ? state.users[x.user] : null
342
+
343
+ try {
344
+ opaque.loginFinish({ state: x.state, finalization })
345
+ } catch (e) {
346
+ // Contraseña equivocada, usuario inexistente o mensaje alterado: el MISMO error.
347
+ // Distinguirlos diría qué usuarios existen. El intento ya está contado desde
348
+ // `loginBegin`, así que aquí no se vuelve a contar.
349
+ throw err('login-failed', 'wrong user or password')
350
+ }
351
+
352
+ const t = now()
353
+ const sid = crypto.randomUUID()
354
+ u.fails = 0
355
+ u.nextTryAt = 0
356
+ u.sessions = { ...(u.sessions || {}), [sid]: { openedAt: t, lastUsedAt: t, label: String(label || '').slice(0, 60) } }
357
+ save(state)
358
+ return { sid, user: x.user, blob: u.blob, cert: u.cert || null, iss: u.iss || null, pub: u.pub }
359
+ },
360
+
361
+ /**
362
+ * QUITAR LA ESPERA, desde la máquina de la bóveda. Existe porque el freno se cuenta al
363
+ * empezar el intento (ver arriba): quien sepa tu usuario puede gastártelos, y el dueño
364
+ * tiene que poder devolverte la entrada sin cambiar la contraseña.
365
+ */
366
+ clearBlock ({ user } = {}) {
367
+ const state = read()
368
+ const u = userOf(state, user)
369
+ if (!u) throw err('no-user', `there is no login called "${user}"`)
370
+ u.fails = 0
371
+ u.nextTryAt = 0
372
+ save(state)
373
+ return { ok: true }
374
+ },
375
+
376
+ /**
377
+ * Un inicio de sesión NO vence solo: lo decide el cliente (dueño, 2026-09-17). Se cierra
378
+ * al salir, al cambiar la contraseña, al quitar el aparato, o desde la consola — que es
379
+ * la única forma de cortar el que quedó abierto en un equipo prestado.
380
+ */
381
+ closeSession ({ user, sid } = {}) {
382
+ const state = read()
383
+ const u = userOf(state, user)
384
+ if (!u || !u.sessions?.[sid]) return { ok: false }
385
+ delete u.sessions[sid]
386
+ save(state)
387
+ return { ok: true }
388
+ },
389
+
390
+ /** «Se usó»: es lo que deja ver en la consola cuál sigue vivo y cuál se olvidó abierto. */
391
+ touch ({ user, sid } = {}) {
392
+ const state = read()
393
+ const u = userOf(state, user)
394
+ if (!u || !u.sessions?.[sid]) return { ok: false }
395
+ u.sessions[sid].lastUsedAt = now()
396
+ save(state)
397
+ return { ok: true }
398
+ },
399
+
400
+ /** ¿Tiene esta llave algún inicio de sesión abierto? Lo pregunta quien sirve al aparato. */
401
+ hasOpenSession ({ pub } = {}) {
402
+ const state = read()
403
+ for (const u of Object.values(state.users)) {
404
+ if (u.pub === pub && Object.keys(u.sessions || {}).length) return true
405
+ }
406
+ return false
407
+ },
408
+
409
+ /** Quitar el aparato de aquí. Sacarlo del acta es aparte, y también hay que hacerlo. */
410
+ remove ({ user } = {}) {
411
+ const state = read()
412
+ if (!userOf(state, user)) return { ok: false }
413
+ delete state.users[user]
414
+ save(state)
415
+ return { ok: true }
416
+ }
417
+ }
418
+ }
419
+
420
+ /**
421
+ * EL ALTA ENTERA: guardar el registro, firmar el certificado y meter al aparato en el acta.
422
+ *
423
+ * Vive aquí —y no en cada bóveda— porque las tres versiones tienen que crear exactamente el
424
+ * mismo aparato: el binario, la pestaña y la extensión. Lo que cambia entre ellas es lo que
425
+ * hacen DESPUÉS (la bitácora, avisar a los demás aparatos), y eso se queda fuera.
426
+ *
427
+ * `replace: true` es cambiar la contraseña: el aparato, su llave y su certificado siguen
428
+ * siendo los mismos; lo que cambia es con qué se abre el paquete.
429
+ *
430
+ * @param {object} opts
431
+ * `identity` la identidad que firma (`signDelegation`, `admitMember`)
432
+ * `logins` el escritorio de `createLoginDesk`
433
+ * `scope` permisos del aparato; por defecto firmar, leer y guardar
434
+ * `unattended` si además puede trabajar sin que nadie apruebe
435
+ */
436
+ export async function registerLogin ({
437
+ identity, logins, user, upload, pub, encPub = null, label = '', blob,
438
+ scope, unattended = false, replace = false
439
+ } = {}) {
440
+ if (replace) {
441
+ logins.registerFinish({ user, upload, blob, replace: true })
442
+ return { ok: true, user, replaced: true }
443
+ }
444
+ // PERMISOS, no tipos: los del scope, más `unattended` si quien lo crea lo eligió
445
+ // (temporary-access.md §3.1). `passwords` no entra todavía — hasta que existan las
446
+ // contraseñas selladas, este aparato se llevaría TODAS (sealed-passwords.md).
447
+ const scopes = Array.isArray(scope) && scope.length ? scope : [SCOPE.SIGN, SCOPE.READ, SCOPE.STORE]
448
+ if (scopes.includes(SCOPE.PASSWORDS)) {
449
+ throw Object.assign(new Error('a password login cannot take `contrasenas` yet: the sealed passwords come first'), { code: 'passwords-not-yet' })
450
+ }
451
+ if (typeof identity?.admitMember !== 'function') {
452
+ throw Object.assign(new Error('this vault cannot add devices to the account record: no login was created'), { code: 'admit-unavailable' })
453
+ }
454
+ const deviceId = await deviceIdOf(pub)
455
+ logins.registerFinish({ user, upload, pub, encPub, deviceId, label, blob })
456
+ try {
457
+ const { cert } = await identity.signDelegation(pub, scopes, { label: label || `login:${user}` })
458
+ const cn = scopeToCn(scopes)
459
+ const caps = [...new Set([...scopeToCaps(scopes), ...(unattended ? ['unattended'] : [])])]
460
+ await identity.admitMember({ pub, encPub, label: label || `login:${user}`, cn, caps, cert })
461
+ logins.setCert({ user, cert, iss: identity.me?.publickey || null })
462
+ return { ok: true, user, deviceId, cert, caps }
463
+ } catch (e) {
464
+ // NADA DE MEDIAS ALTAS: si no entra en el acta, no queda un usuario que pueda entrar a
465
+ // una cuenta que no lo reconoce. Se deshace y se dice.
466
+ logins.remove({ user })
467
+ throw e
468
+ }
469
+ }
package/src/protocol.js CHANGED
@@ -90,6 +90,19 @@ export const MSG = Object.freeze({
90
90
  // firmados de antes y no se pueden falsificar) y la réplica acusa hasta qué `seq` tiene.
91
91
  REPLICA_PUSH: 'vault.replica.push', // master → réplica: { body:{seq,acta,secrets,ts}, signature }
92
92
  REPLICA_ACK: 'vault.replica.ack', // réplica → master: { body:{seq,ts}, signature }
93
+ // ENTRAR CON USUARIO Y CONTRASEÑA (`src/passwordLogins.js`,
94
+ // `dotrino-passmanager/docs/temporary-access.md`). Es lo ÚNICO que se atiende sin
95
+ // certificado y sin firma: quien pregunta todavía no tiene llaves — vienen dentro del
96
+ // paquete que solo abre su contraseña. La contraseña NO viaja: viajan los mensajes de
97
+ // OPAQUE, que no la llevan ni dejan adivinarla.
98
+ LOGIN_START: 'vault.login.start', // aparato → vault: { user, request }
99
+ LOGIN_RESPONSE: 'vault.login.response', // vault → aparato: { lid, response }
100
+ LOGIN_FINISH: 'vault.login.finish', // aparato → vault: { lid, finalization, label }
101
+ LOGIN_OK: 'vault.login.ok', // vault → aparato: { sid, blob, cert, iss, acta }
102
+ // Salir SÍ va firmado, con la llave que acaba de abrir: cerrar la sesión de otro sería
103
+ // una forma de echar a alguien de su cuenta.
104
+ LOGIN_CLOSE: 'vault.login.close', // aparato → vault: { data:{op,user,sid,publickey,ts}, signature }
105
+ LOGIN_CLOSED: 'vault.login.closed', // vault → aparato: { ok }
93
106
  ERROR: 'vault.error' // vault → dispositivo: { error }
94
107
  })
95
108