@dotrino/vault 0.66.0 → 0.68.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.66.0",
3
+ "version": "0.68.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",
@@ -34,6 +34,9 @@
34
34
  "./password-logins": {
35
35
  "import": "./src/passwordLogins.js"
36
36
  },
37
+ "./login-client": {
38
+ "import": "./src/loginClient.js"
39
+ },
37
40
  "./admin": {
38
41
  "import": "./src/admin.js"
39
42
  }
@@ -53,7 +56,7 @@
53
56
  "pairing"
54
57
  ],
55
58
  "peerDependencies": {
56
- "@dotrino/identity": ">=0.92.0",
59
+ "@dotrino/identity": ">=0.99.0",
57
60
  "@dotrino/proxy-client": ">=0.23.1"
58
61
  },
59
62
  "license": "MIT",
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)
@@ -100,10 +104,35 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
100
104
  })()
101
105
 
102
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
+
103
131
  const identify = async () => {
104
132
  if (!client.token) return
105
133
  // El sobre lo arma el pilar (`identifyAs`), que le pone el destinatario.
106
134
  await client.identifyAs({ publickey: iss, sign: (d) => identity.signData(d), cert: selfCert })
135
+ await announce()
107
136
  }
108
137
  await identify()
109
138
  client.on('token', () => identify().catch(() => {}))
@@ -365,7 +394,7 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
365
394
  async function handleLoginStart (from, p) {
366
395
  if (!logins) return noLogins(from)
367
396
  try {
368
- const { lid, response } = logins.loginBegin({ user: p?.user, request: p?.request })
397
+ const { lid, response } = await logins.loginBegin({ user: p?.user, request: p?.request })
369
398
  send(from, { type: MSG.LOGIN_RESPONSE, lid, response })
370
399
  } catch (e) { loginError(from, e) }
371
400
  }
@@ -373,7 +402,7 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
373
402
  async function handleLoginFinish (from, p) {
374
403
  if (!logins) return noLogins(from)
375
404
  try {
376
- const r = logins.loginEnd({ lid: p?.lid, finalization: p?.finalization, label: p?.label })
405
+ const r = await logins.loginEnd({ lid: p?.lid, finalization: p?.finalization, label: p?.label })
377
406
  const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
378
407
  send(from, { type: MSG.LOGIN_OK, sid: r.sid, blob: r.blob, cert: r.cert, iss: r.iss || iss, acta })
379
408
  } catch (e) { loginError(from, e) }
@@ -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 }
@@ -24,7 +24,8 @@
24
24
  * binario, `lib/src/index.js` en la pestaña—. Lo único que no es puro es `registerLogin`, al
25
25
  * final, porque hace falta la identidad que firma.
26
26
  */
27
- import { server as opaque, suiteId } from '@dotrino/opaque'
27
+ import { server as opaquePorDefecto, suiteId as suitePorDefecto } from '@dotrino/opaque'
28
+ import { pubkeyId } from '@dotrino/identity/capabilities'
28
29
  import { deviceIdOf, scopeToCaps, scopeToCn } from './enroll.js'
29
30
  import { SCOPE } from './protocol.js'
30
31
  import { bytesToB64url, b64urlToBytes } from './b64.js'
@@ -53,6 +54,43 @@ export function accountCode (fingerprint) {
53
54
  }
54
55
  export const loginAddress = (user, fingerprint) => `${user}@${accountCode(fingerprint)}`
55
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
+
56
94
  /**
57
95
  * EL CANAL DONDE SE ANUNCIA CADA BÓVEDA DE ESA CUENTA, réplicas incluidas.
58
96
  *
@@ -143,10 +181,25 @@ export async function openDeviceKeys (exportKey, blob) {
143
181
  * `load()` devuelve el estado guardado (o `null` la primera vez)
144
182
  * `save(s)` lo guarda
145
183
  * `now()` el reloj, para poder probar el freno sin esperar una hora
184
+ * `opaque` la mitad de SERVIDOR de OPAQUE. Por defecto la del paquete, que es WASM y
185
+ * corre en el mismo hilo. Se puede inyectar OTRA porque en una extensión MV3 el WASM
186
+ * vive al otro lado de un `postMessage` —en una página sandbox, para no tener que
187
+ * abrirle la CSP a toda la extensión—, y desde allí solo puede contestar una promesa.
188
+ *
189
+ * **Por eso los métodos que tocan OPAQUE son ASÍNCRONOS**, también con la implementación de
190
+ * casa: una firma que cambia según quién la use son dos mostradores, y de eso va la regla
191
+ * de las tres versiones.
146
192
  */
147
- export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
193
+ export function createLoginDesk ({ load, save, now = () => Date.now(), opaque = opaquePorDefecto } = {}) {
148
194
  if (typeof load !== 'function' || typeof save !== 'function') throw new Error('createLoginDesk: load and save are required')
149
195
 
196
+ /**
197
+ * QUÉ SUITE se está usando, dicha por quien hace el cálculo. Con el OPAQUE de casa es la
198
+ * del paquete; con uno inyectado, la suya — preguntárselo a él es lo único que no miente,
199
+ * porque el registro que queda guardado lo hizo él.
200
+ */
201
+ const suite = () => (typeof opaque.suiteId === 'function' ? opaque.suiteId() : suitePorDefecto())
202
+
150
203
  /** Intercambios a medias: viven en memoria, y por eso un reinicio los tira. */
151
204
  const exchanges = new Map()
152
205
 
@@ -157,9 +210,9 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
157
210
  }
158
211
 
159
212
  /** La preparación del servidor nace con el primer usuario y no se toca nunca más. */
160
- function setupOf (state) {
213
+ async function setupOf (state) {
161
214
  if (state.setup) return state.setup
162
- state.setup = opaque.createSetup()
215
+ state.setup = await opaque.createSetup()
163
216
  return state.setup
164
217
  }
165
218
 
@@ -196,30 +249,30 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
196
249
  * ALTA, primer paso. La contraseña no llega hasta aquí: lo que llega es el mensaje de
197
250
  * registro de OPAQUE, que no la lleva ni permite adivinarla.
198
251
  */
199
- registerBegin ({ user, request, replace = false } = {}) {
252
+ async registerBegin ({ user, request, replace = false } = {}) {
200
253
  const state = read()
201
254
  const existing = userOf(state, user)
202
255
  if (existing && !replace) throw err('user-exists', `there is already a login called "${user}"`)
203
256
  if (!existing && replace) throw err('no-user', `there is no login called "${user}"`)
204
257
  if (typeof request !== 'string' || !request) throw err('bad-input', 'request required')
205
- const setup = setupOf(state)
206
- const response = opaque.registrationResponse({ setup, request, credentialId: user })
258
+ const setup = await setupOf(state)
259
+ const response = await opaque.registrationResponse({ setup, request, credentialId: user })
207
260
  save(state)
208
- return { response, suite: suiteId() }
261
+ return { response, suite: await suite() }
209
262
  },
210
263
 
211
264
  /**
212
265
  * ALTA, segundo paso. `blob` son las llaves privadas del aparato, ya cerradas por quien
213
266
  * creó el aparato con la llave que sale de la contraseña: aquí no se abre nunca.
214
267
  */
215
- registerFinish ({ user, upload, pub, encPub = null, deviceId = null, label = '', blob, replace = false } = {}) {
268
+ async registerFinish ({ user, upload, pub, encPub = null, deviceId = null, label = '', blob, replace = false } = {}) {
216
269
  const state = read()
217
270
  const existing = userOf(state, user)
218
271
  if (existing && !replace) throw err('user-exists', `there is already a login called "${user}"`)
219
272
  if (!existing && replace) throw err('no-user', `there is no login called "${user}"`)
220
273
  if (typeof blob !== 'string' || !blob) throw err('bad-input', 'blob required (the device keys, sealed with the password)')
221
274
  if (!replace && (typeof pub !== 'string' || !pub)) throw err('bad-input', 'pub required')
222
- const record = opaque.registrationFinish({ upload })
275
+ const record = await opaque.registrationFinish({ upload })
223
276
  const t = now()
224
277
  state.users[user] = {
225
278
  ...(existing || {}),
@@ -229,7 +282,7 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
229
282
  label: label || existing?.label || '',
230
283
  record,
231
284
  blob,
232
- suite: suiteId(),
285
+ suite: await suite(),
233
286
  createdAt: existing?.createdAt || t,
234
287
  passwordChangedAt: t,
235
288
  // Cambiar la contraseña cierra lo abierto: si alguien entró con la vieja, deja de
@@ -239,7 +292,7 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
239
292
  nextTryAt: 0
240
293
  }
241
294
  save(state)
242
- return { user, suite: suiteId() }
295
+ return { user, suite: await suite() }
243
296
  },
244
297
 
245
298
  /** El certificado del aparato, que firma la bóveda al admitirlo en el acta. Es público. */
@@ -260,7 +313,7 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
260
313
  * registro inventado: desde fuera no se puede averiguar qué usuarios hay. Y cuenta
261
314
  * contra el mismo freno, para que tampoco se pueda medir por el tiempo.
262
315
  */
263
- loginBegin ({ user, request } = {}) {
316
+ async loginBegin ({ user, request } = {}) {
264
317
  sweepExchanges()
265
318
  const state = read()
266
319
  if (typeof request !== 'string' || !request) throw err('bad-input', 'request required')
@@ -276,8 +329,8 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
276
329
  u.fails = (u.fails || 0) + 1
277
330
  u.nextTryAt = t + backoffFor(u.fails)
278
331
  }
279
- const setup = setupOf(state)
280
- const { state: serverState, response } = opaque.loginStart({
332
+ const setup = await setupOf(state)
333
+ const { state: serverState, response } = await opaque.loginStart({
281
334
  setup,
282
335
  record: u?.record || null,
283
336
  request,
@@ -294,7 +347,7 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
294
347
  * INICIO DE SESIÓN, segundo paso. Si la contraseña es la buena, devuelve el paquete de
295
348
  * llaves —que solo abre esa contraseña—, el certificado y el acta.
296
349
  */
297
- loginEnd ({ lid, finalization, label = '' } = {}) {
350
+ async loginEnd ({ lid, finalization, label = '' } = {}) {
298
351
  sweepExchanges()
299
352
  const x = exchanges.get(lid)
300
353
  if (!x) throw err('no-exchange', 'that login is not in flight any more: start again')
@@ -303,7 +356,7 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
303
356
  const u = x.known ? state.users[x.user] : null
304
357
 
305
358
  try {
306
- opaque.loginFinish({ state: x.state, finalization })
359
+ await opaque.loginFinish({ state: x.state, finalization })
307
360
  } catch (e) {
308
361
  // Contraseña equivocada, usuario inexistente o mensaje alterado: el MISMO error.
309
362
  // Distinguirlos diría qué usuarios existen. El intento ya está contado desde
@@ -400,7 +453,7 @@ export async function registerLogin ({
400
453
  scope, unattended = false, replace = false
401
454
  } = {}) {
402
455
  if (replace) {
403
- logins.registerFinish({ user, upload, blob, replace: true })
456
+ await logins.registerFinish({ user, upload, blob, replace: true })
404
457
  return { ok: true, user, replaced: true }
405
458
  }
406
459
  // PERMISOS, no tipos: los del scope, más `unattended` si quien lo crea lo eligió
@@ -414,7 +467,7 @@ export async function registerLogin ({
414
467
  throw Object.assign(new Error('this vault cannot add devices to the account record: no login was created'), { code: 'admit-unavailable' })
415
468
  }
416
469
  const deviceId = await deviceIdOf(pub)
417
- logins.registerFinish({ user, upload, pub, encPub, deviceId, label, blob })
470
+ await logins.registerFinish({ user, upload, pub, encPub, deviceId, label, blob })
418
471
  try {
419
472
  const { cert } = await identity.signDelegation(pub, scopes, { label: label || `login:${user}` })
420
473
  const cn = scopeToCn(scopes)