@dotrino/identity 0.95.0 → 0.96.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/identity",
3
- "version": "0.95.0",
3
+ "version": "0.96.0",
4
4
  "description": "Identidad y rating de usuarios compartidos entre apps de Dotrino (vault iframe + postMessage)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -591,6 +591,28 @@ export class Identity {
591
591
  /** Borra un perfil y sus datos (no el único). */
592
592
  async deleteProfile (id) { return this._call('deleteProfile', { id }) }
593
593
 
594
+ // ----- entrar con usuario y contraseña (`temporary-access.md`) -----
595
+
596
+ /**
597
+ * ENTRAR en un aparato de tu cuenta desde un navegador que no te conoce, con la dirección
598
+ * `nombre@AB12-CD34-EF56` y su contraseña. Devuelve `{ id, name, address, user, sid,
599
+ * volatile }` y la app debe **recargar**: este navegador pasa a ser ese aparato.
600
+ *
601
+ * `remember: false` (lo normal en un equipo prestado) deja la cuenta en MEMORIA —se va al
602
+ * cerrar o recargar la pestaña y no toca el disco de esa máquina—; `true` la guarda como
603
+ * cualquier otra de este navegador, con la llave no extraíble, hasta que salgas.
604
+ *
605
+ * La contraseña no sale de aquí: se comprueba con OPAQUE, que no la manda ni deja
606
+ * adivinarla. Los errores llegan con su `code`: `login-failed` (dirección o contraseña),
607
+ * `too-many-tries` (con `waitMs`), `no-vault` (ninguna bóveda encendida en esa dirección).
608
+ */
609
+ async loginWithPassword ({ address, password, remember = false, label = '', proxyUrl = null } = {}) {
610
+ return this._call('loginWithPassword', { address, password, remember, label, proxyUrl }, 60000)
611
+ }
612
+
613
+ /** SALIR del inicio de sesión con contraseña (el activo, o el que digas). Recargar después. */
614
+ async logoutLogin (id = null) { return this._call('logoutLogin', { id }, 30000) }
615
+
594
616
  /**
595
617
  * Merge endorsements (signed ratings from third parties) about a subject
596
618
  * into the local peer book. Returns { merged, total }.
package/vault/core.js CHANGED
@@ -38,12 +38,15 @@ export const ACTA_HISTORY_STORAGE = 'dotrino.identity.acta.history' // últimas
38
38
  export const PENDING_JOIN_STORAGE = 'dotrino.identity.pendingJoin' // «nací para adoptar la cuenta de otro»
39
39
  export const RENOUNCE_STORAGE = 'dotrino.identity.renounced' // renuncias propias aún no absorbidas por el master
40
40
  export const GRANTS_STORAGE = 'dotrino.identity.grants' // qué le concediste a cada origen (permiso por origen)
41
+ export const LOGIN_STORAGE = 'dotrino.identity.login' // se entró con usuario y contraseña: { user, address, sid, … }
41
42
  // Multi-perfil por dispositivo: lista de perfiles + el activo. Cada perfil tiene su propio
42
43
  // namespace `dotrino.identity.p.<id>.<suffix>` para TODAS las claves de arriba (keypair, me, etc.).
43
44
  export const PROFILES_STORAGE = 'dotrino.identity.profiles' // [{ id, name, pubkey }]
44
45
  export const CURRENT_STORAGE = 'dotrino.identity.current' // id del perfil activo
45
46
 
46
47
  const NONCE_TTL_MS = 5 * 60 * 1000
48
+ /** El proxio del ecosistema. No hay otro: quien quiera el suyo lo dice al llamar. */
49
+ const DEFAULT_PROXY = 'wss://proxy.dotrino.com'
47
50
 
48
51
  // ----- crypto helpers (puros) -----
49
52
 
@@ -192,11 +195,54 @@ function sanitizeProfilePatch (patch = {}) {
192
195
  *
193
196
  * Si no se inyecta, no se concede nada nuevo: sin forma de preguntar, la respuesta es no.
194
197
  */
195
- export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, keyStore = null, sessionKv = null, removeAccountOnExpulsion = true, keyLock = null, askConsent = null }) {
198
+ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null, keyStore: hostKeyStore = null, sessionKv = null, removeAccountOnExpulsion = true, keyLock = null, askConsent = null }) {
196
199
  const {
197
200
  initPeerStorage, loadPeers, savePeers, setPeersDirect, upsertPeer, onDirty
198
201
  } = peers
199
202
 
203
+ // ----- CUENTAS DE PASO: entrar en un equipo que NO es tuyo -----
204
+ //
205
+ // Una cuenta de paso es la que se abre con usuario y contraseña SIN marcar «Recordar»
206
+ // (`temporary-access.md` §3.4). No es una cuenta de este navegador: es de ESTA PESTAÑA, y
207
+ // desaparece —entera, con su llave— en cuanto la pestaña se va.
208
+ //
209
+ // Lo primero que se intentó fue tenerla solo en memoria, que suena mejor y no sirve: el
210
+ // iframe de identidad muere con cada navegación, así que entrar y pulsar el primer enlace
211
+ // te dejaba fuera otra vez. Así que se guarda como cualquier otra —la llave, `CryptoKey`
212
+ // NO EXTRAÍBLE en IndexedDB, nunca en claro— y lo que cambia es QUIÉN la ve y CUÁNTO dura:
213
+ //
214
+ // · **no entra en la lista de perfiles del disco**, así que ninguna otra pestaña la ve
215
+ // ni puede cambiarse a ella; la lista de esta pestaña la lleva en memoria;
216
+ // · **no toca el puntero del perfil activo**: al cerrar, este navegador vuelve a la
217
+ // cuenta que tenía, sin haberse enterado;
218
+ // · **la reclama la pestaña** (`sessionStorage`, que es por pestaña y sobrevive a
219
+ // navegar) y la mantiene viva un latido cada 20 s;
220
+ // · **el primer arranque que vea una sin latido la borra**, con sus llaves.
221
+ //
222
+ // Lo que eso NO tapa, dicho claro: si el navegador se cierra de golpe y nadie vuelve a
223
+ // abrir Dotrino en esa máquina, la llave se queda ahí —cifrada y no extraíble— hasta que
224
+ // alguien lo haga, y ese alguien la borra antes de poder usarla. Salir la borra en el acto.
225
+ const VOLATILE_STORAGE = 'dotrino.identity.volatile' // { <pid>: último latido }
226
+ const VOLATILE_CLAIM = 'dotrino.identity.volatile.claim' // en sessionKv: la de ESTA pestaña
227
+ const VOLATILE_STALE_MS = 90_000
228
+ const VOLATILE_BEAT_MS = 20_000
229
+ const volatilePids = new Set()
230
+ let volatileProfiles = []
231
+ let volatileBeat = null
232
+ const rawKv = hostKv
233
+ const keyStore = hostKeyStore
234
+
235
+ const loadVolatile = () => { try { return JSON.parse(hostKv.getItem(VOLATILE_STORAGE) || '{}') || {} } catch (_) { return {} } }
236
+ const saveVolatile = (m) => hostKv.setItem(VOLATILE_STORAGE, JSON.stringify(m))
237
+ const beatVolatile = (pid) => { const m = loadVolatile(); m[pid] = Date.now(); saveVolatile(m) }
238
+ const keepBeating = (pid) => {
239
+ if (volatileBeat) clearInterval(volatileBeat)
240
+ beatVolatile(pid)
241
+ volatileBeat = setInterval(() => beatVolatile(pid), VOLATILE_BEAT_MS)
242
+ // En el navegador, que el latido no impida cerrar nada.
243
+ try { volatileBeat.unref?.() } catch (_) {}
244
+ }
245
+
200
246
  // ----- multi-perfil: kv SCOPEADO por el perfil activo -----
201
247
  // Todas las claves `dotrino.identity.*` (keypair, me, nonces, delegations, vault.*) se
202
248
  // namespacean transparentemente bajo `dotrino.identity.p.<currentPid>.*`. Las dos claves
@@ -210,8 +256,16 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
210
256
  setItem: (k, v) => rawKv.setItem(_scoped(k), v),
211
257
  removeItem: (k) => rawKv.removeItem(_scoped(k))
212
258
  }
213
- const loadProfiles = () => { try { return JSON.parse(rawKv.getItem(PROFILES_STORAGE) || '[]') || [] } catch { return [] } }
214
- const saveProfiles = (list) => rawKv.setItem(PROFILES_STORAGE, JSON.stringify(list))
259
+ const loadProfiles = () => {
260
+ let list = []
261
+ try { list = JSON.parse(hostKv.getItem(PROFILES_STORAGE) || '[]') || [] } catch { list = [] }
262
+ return volatileProfiles.length ? [...list, ...volatileProfiles] : list
263
+ }
264
+ /** Lo volátil se queda en memoria; al disco va solo el resto. */
265
+ const saveProfiles = (list) => {
266
+ if (volatilePids.size) volatileProfiles = list.filter((p) => volatilePids.has(p.id))
267
+ hostKv.setItem(PROFILES_STORAGE, JSON.stringify(list.filter((p) => !volatilePids.has(p.id))))
268
+ }
215
269
 
216
270
  // ----- la MARCA de «este perfil nació para adoptar la cuenta de una bóveda» -----
217
271
  // Unirse a otra cuenta borra la que este perfil tenía, así que no puede pasar por
@@ -489,8 +543,11 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
489
543
  */
490
544
  async function openProfileInMemory (pid) {
491
545
  currentPid = pid
492
- rawKv.setItem(CURRENT_STORAGE, pid)
493
- await peers.setProfile?.(pid)
546
+ // Una cuenta volátil no deja rastro: si escribiera aquí, al cerrar la pestaña el
547
+ // puntero señalaría a una cuenta que ya no existe y las demás pestañas verían cambiar
548
+ // la suya sin haber tocado nada.
549
+ if (!volatilePids.has(pid)) rawKv.setItem(CURRENT_STORAGE, pid)
550
+ await peers.setProfile?.(pid, { volatile: volatilePids.has(pid) })
494
551
  await initPeerStorage()
495
552
  keypair = await loadOrCreateKeypair(); publickeyJwkStr = JSON.stringify(keypair.publicJwk)
496
553
  encKeypair = await loadOrCreateEncKeypair(); encPublickeyJwkStr = JSON.stringify(encKeypair.publicJwk)
@@ -636,7 +693,11 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
636
693
  async function purgeProfile (id) {
637
694
  const list = loadProfiles().filter((p) => p.id !== id)
638
695
  saveProfiles(list)
639
- for (const s of ['keypair', 'enc-keypair', 'me', 'nonces', 'delegations', 'revocations', 'vault.device', 'vault.cert', 'acta', 'renounced']) {
696
+ // TODO lo que escribe una cuenta. Faltaban tres —el historial de actas, lo que le
697
+ // concediste a cada aplicación y la marca de haber entrado con contraseña—, y en una
698
+ // cuenta de paso eso no es basura: es rastro en un equipo que no es tuyo.
699
+ for (const s of ['keypair', 'enc-keypair', 'me', 'nonces', 'delegations', 'revocations',
700
+ 'vault.device', 'vault.cert', 'acta', 'acta.history', 'renounced', 'grants', 'login', 'pendingJoin']) {
640
701
  rawKv.removeItem(`dotrino.identity.p.${id}.${s}`)
641
702
  }
642
703
  // …y sus CryptoKeys no extractables del keyStore (IndexedDB).
@@ -935,6 +996,152 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
935
996
  return { gen, sinLlave }
936
997
  }
937
998
 
999
+ // ----- ENTRAR CON USUARIO Y CONTRASEÑA (`temporary-access.md` §3.4) -----
1000
+ //
1001
+ // Lo que llega de `@dotrino/vault/login-client` es un APARATO entero: sus dos llaves
1002
+ // privadas, el papel que firmó la bóveda y el acta. Aquí se le da sitio en este navegador
1003
+ // como una cuenta más, y desde ese momento este navegador ES ese aparato: firma con su
1004
+ // llave y lo que puede lo dice el acta, igual que una máquina enrolada.
1005
+ //
1006
+ // No se parece a `createProfile` en lo importante: ahí nace una llave nueva, aquí se
1007
+ // ADOPTA una que ya existe y ya está en el acta. Por eso las llaves se escriben ANTES de
1008
+ // abrir la cuenta — si se abriera primero, `loadOrCreateKeypair` estrenaría una llave que
1009
+ // esa cuenta no reconoce y el aparato quedaría fuera de su propio perfil.
1010
+
1011
+ /** Lo que quedó anotado de un inicio de sesión con contraseña, para cerrarlo o enseñarlo. */
1012
+ const loginMetaOf = (pid) => {
1013
+ try {
1014
+ const raw = pid === currentPid
1015
+ ? kv.getItem(LOGIN_STORAGE)
1016
+ : rawKv.getItem(`dotrino.identity.p.${pid}.login`)
1017
+ return raw ? JSON.parse(raw) : null
1018
+ } catch (_) { return null }
1019
+ }
1020
+
1021
+ const deviceIdDe = async (pub) => (await pubkeyIdOf(pub)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
1022
+
1023
+ /**
1024
+ * @param {object} entrada lo que devuelve `loginWithPassword` del pilar
1025
+ * @param {boolean} remember `false` = cuenta VOLÁTIL (se va con la pestaña)
1026
+ */
1027
+ async function adoptLogin (entrada, { remember = false, proxy = null } = {}) {
1028
+ // LO MÍNIMO QUE ESTA CASA TIENE QUE COMPROBAR ANTES DE ADOPTAR NADA. Quien entró ya
1029
+ // verificó la conversación entera (OPAQUE, la dirección, el papel); esto es otra cosa y
1030
+ // es suya: que la llave que se va a instalar SEA de esta cuenta. Sin ello, el navegador
1031
+ // se quedaría con una identidad que ningún acta reconoce — y sin forma de notarlo.
1032
+ const v = await Acta.verifyActa({ acta: entrada?.acta })
1033
+ if (!v.ok) throw Object.assign(new Error('the account record does not verify: ' + v.reason), { code: 'bad-acta' })
1034
+ if (!(entrada.acta.members || []).some((m) => m?.pub === entrada.publickey)) {
1035
+ throw Object.assign(new Error('that key is not a member of the account record'), { code: 'not-a-member' })
1036
+ }
1037
+
1038
+ const pid = 'p' + crypto.randomUUID().slice(0, 8)
1039
+ const veniaDe = currentPid
1040
+ if (!remember) {
1041
+ volatilePids.add(pid)
1042
+ try { sessionKv?.setItem(VOLATILE_CLAIM, pid) } catch (_) {}
1043
+ keepBeating(pid) // mientras esta pestaña viva, nadie la barre
1044
+ }
1045
+ currentPid = pid // desde aquí, `kv` escribe en el namespace de la cuenta nueva
1046
+
1047
+ try {
1048
+ // Las públicas se toman TAL CUAL las escribe el acta: es la cadena exacta con la que
1049
+ // esa llave es miembro, y una que se re-serialice distinta deja de coincidir.
1050
+ //
1051
+ // `key_ops` se quita: dice para qué la generó QUIEN la creó (la llave de cifrado nace
1052
+ // con `deriveBits` a secas), y aquí hace falta además `deriveKey`. Es lo mismo que ya
1053
+ // se hace al generar una propia — quién puede hacer qué con esta llave lo decide esta
1054
+ // casa, no una etiqueta que viajó dentro del paquete.
1055
+ const sinTopes = (jwk) => { const { key_ops: _o, ...resto } = jwk || {}; return resto }
1056
+ await adoptJwkPair('sign', KEY_STORAGE, sinTopes(entrada.keys.sign), JSON.parse(entrada.publickey))
1057
+ if (entrada.keys.enc && entrada.encPublickey) {
1058
+ await adoptJwkPair('enc', ENC_KEY_STORAGE, sinTopes(entrada.keys.enc), JSON.parse(entrada.encPublickey))
1059
+ }
1060
+ saveActa(entrada.acta)
1061
+ kv.setItem(ACTA_HISTORY_STORAGE, '[]')
1062
+ kv.setItem(VAULT_DEVICE_STORAGE, JSON.stringify({ useIdentityKey: true, publickey: entrada.publickey }))
1063
+ kv.setItem(VAULT_CERT_STORAGE, JSON.stringify({
1064
+ cert: entrada.cert, master: entrada.iss, proxy: proxy || DEFAULT_PROXY,
1065
+ deviceId: await deviceIdDe(entrada.publickey), pairedAt: Date.now()
1066
+ }))
1067
+ kv.setItem(LOGIN_STORAGE, JSON.stringify({
1068
+ user: entrada.user, address: entrada.address, code: entrada.code, sid: entrada.sid,
1069
+ vault: entrada.iss, proxy: proxy || DEFAULT_PROXY, volatile: !remember, at: Date.now()
1070
+ }))
1071
+
1072
+ await openProfileInMemory(pid) // carga las llaves recién adoptadas; no estrena ninguna
1073
+ if (publickeyJwkStr !== entrada.publickey) {
1074
+ throw Object.assign(new Error('the adopted key is not the one the record names'), { code: 'bad-keys' })
1075
+ }
1076
+ me = { publickey: publickeyJwkStr, encryptionPubkey: encPublickeyJwkStr, nickname: entrada.user }
1077
+ saveMe(me)
1078
+ const list = loadProfiles()
1079
+ list.push({ id: pid, name: entrada.user, pubkey: publickeyJwkStr })
1080
+ saveProfiles(list)
1081
+ emitVault({ phase: 'login', address: entrada.address, volatile: !remember })
1082
+ return { id: pid, name: entrada.user, address: entrada.address, user: entrada.user, sid: entrada.sid, volatile: !remember }
1083
+ } catch (e) {
1084
+ // NADA DE MEDIAS CUENTAS: si algo falla, no se queda una identidad a medio adoptar en
1085
+ // este navegador. Se deshace y se vuelve a donde estabas.
1086
+ try { await purgeProfile(pid) } catch (_) {}
1087
+ forgetVolatile(pid)
1088
+ if (veniaDe && loadProfiles().some((x) => x.id === veniaDe)) await openProfileInMemory(veniaDe)
1089
+ throw e
1090
+ }
1091
+ }
1092
+
1093
+ /** Deja de tener por nuestra una cuenta de paso: ni en la lista, ni reclamada, ni latiendo. */
1094
+ function forgetVolatile (pid) {
1095
+ volatilePids.delete(pid)
1096
+ volatileProfiles = volatileProfiles.filter((x) => x.id !== pid)
1097
+ const m = loadVolatile()
1098
+ if (m[pid]) { delete m[pid]; saveVolatile(m) }
1099
+ try { if (sessionKv?.getItem(VOLATILE_CLAIM) === pid) sessionKv.removeItem(VOLATILE_CLAIM) } catch (_) {}
1100
+ if (volatileBeat) { clearInterval(volatileBeat); volatileBeat = null }
1101
+ }
1102
+
1103
+ /** Borra una cuenta de paso —con sus llaves— y vuelve a la que este navegador tenía. */
1104
+ async function dropVolatile (pid) {
1105
+ forgetVolatile(pid)
1106
+ await purgeProfile(pid)
1107
+ const list = loadProfiles()
1108
+ const back = hostKv.getItem(CURRENT_STORAGE)
1109
+ const destino = list.find((x) => x.id === back) || list[0]
1110
+ if (destino) await openProfileInMemory(destino.id)
1111
+ return { ok: true, current: destino?.id || null }
1112
+ }
1113
+
1114
+ /**
1115
+ * SALIR: se le dice a la bóveda que suelte la plaza y se borra la cuenta de aquí.
1116
+ *
1117
+ * Avisar es «mejor esfuerzo» —con la bóveda apagada, salir de este equipo no puede quedarse
1118
+ * colgado esperándola—, pero borrar la cuenta de este navegador no lo es: eso pasa siempre.
1119
+ * La plaza que quede abierta allí se cierra desde la consola, y así está dicho en el diseño.
1120
+ */
1121
+ async function closeLoginOn (meta) {
1122
+ if (!meta?.user || !meta?.sid) return { ok: false, reason: 'sin-inicio-de-sesion' }
1123
+ let client = null
1124
+ try {
1125
+ const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
1126
+ const { closeLogin } = await import('@dotrino/vault/login-client')
1127
+ const { vaultChannel } = await import('@dotrino/vault/password-logins')
1128
+ client = new WebSocketProxyClient({ url: meta.proxy || DEFAULT_PROXY, enableWebRTC: false, autoReconnect: false })
1129
+ await client.connect()
1130
+ // El token de la bóveda cambia con cada reconexión suya, así que se vuelve a mirar el
1131
+ // canal en vez de guardarlo: el de hace una hora ya no es de nadie.
1132
+ for (const token of await client.list(vaultChannel(meta.code))) {
1133
+ const r = await closeLogin({
1134
+ transport: client, token, user: meta.user, sid: meta.sid,
1135
+ publickey: publickeyJwkStr, privateKey: keypair?.privateKey
1136
+ })
1137
+ if (r.ok) return r
1138
+ }
1139
+ return { ok: false, reason: 'no-vault' }
1140
+ } catch (e) {
1141
+ return { ok: false, reason: e?.code || 'no-answer' }
1142
+ } finally { try { client?.close() } catch (_) {} }
1143
+ }
1144
+
938
1145
  /**
939
1146
  * UNIRSE a la cuenta de la bóveda con la que te acabas de emparejar (camino B de
940
1147
  * `dotrino-vault/docs/vinculacion-de-cuentas.md`). NO es adoptar una versión nueva de TU
@@ -1538,7 +1745,10 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1538
1745
  // nada que lea datos o firme).
1539
1746
  const LOCK_EXEMPT = new Set([
1540
1747
  'profileLockStatus', 'unlockProfile', 'listProfiles', 'currentProfile',
1541
- 'switchProfile', 'createProfile',
1748
+ // Entrar con usuario y contraseña abre OTRA cuenta: que la de aquí esté bajo llave no
1749
+ // tiene nada que ver, y exigir abrirla primero dejaría fuera a quien viene a entrar en
1750
+ // la suya desde un equipo prestado.
1751
+ 'switchProfile', 'createProfile', 'loginWithPassword',
1542
1752
  'profileActa', 'profileMembers', 'myMembership', 'isMaster', 'sealerChain'
1543
1753
  ])
1544
1754
 
@@ -2029,7 +2239,14 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
2029
2239
  vault = !!v?.cert
2030
2240
  approve = vault && (v.cert.scope || []).includes('vault:approve')
2031
2241
  } catch (_) {}
2032
- return { id: p.id, name: p.name || '', pubkey: p.pubkey || null, avatar, current: p.id === currentPid, pendingJoin: !!p.pendingJoin, vault, approve }
2242
+ // Un inicio de sesión con contraseña se enseña como lo que es: el menú del botón de
2243
+ // perfil pone su «Salir» al lado, que es lo único que lo cierra desde aquí.
2244
+ const login = loginMetaOf(p.id)
2245
+ return {
2246
+ id: p.id, name: p.name || '', pubkey: p.pubkey || null, avatar, current: p.id === currentPid,
2247
+ pendingJoin: !!p.pendingJoin, vault, approve,
2248
+ ...(login ? { login: { user: login.user, address: login.address, volatile: !!login.volatile } } : {})
2249
+ }
2033
2250
  })
2034
2251
  },
2035
2252
  async currentProfile () {
@@ -2060,6 +2277,14 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
2060
2277
  },
2061
2278
  async switchProfile ({ id } = {}) {
2062
2279
  if (!loadProfiles().find((p) => p.id === id)) throw new Error('profile does not exist')
2280
+ // Una cuenta volátil no puede ser «la activa» de este navegador: vive en la memoria de
2281
+ // esta pestaña y no sobreviviría a la recarga que viene justo después.
2282
+ if (volatilePids.has(id)) {
2283
+ throw Object.assign(new Error('that account only lives in this tab'), { code: 'volatile-profile' })
2284
+ }
2285
+ // Y cambiarse DESDE una volátil es dejarla: se suelta la plaza en la bóveda en vez de
2286
+ // dejar un inicio de sesión abierto sin nadie dentro.
2287
+ if (volatilePids.has(currentPid)) { try { await handlers.logoutLogin({}) } catch (_) {} }
2063
2288
  rawKv.setItem(CURRENT_STORAGE, id) // la app recarga la página → re-init con el nuevo perfil
2064
2289
  return { id }
2065
2290
  },
@@ -2080,6 +2305,53 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
2080
2305
  return purgeProfile(id)
2081
2306
  },
2082
2307
 
2308
+ // ----- ENTRAR CON USUARIO Y CONTRASEÑA -----
2309
+
2310
+ /**
2311
+ * ENTRAR en un aparato de tu cuenta desde un navegador que no te conoce, con
2312
+ * `nombre@AB12-CD34-EF56` y la contraseña. Al volver, ESTE navegador es ese aparato y la
2313
+ * app tiene que RECARGAR, como con cualquier cambio de cuenta.
2314
+ *
2315
+ * `remember: false` (lo normal en un equipo prestado) deja la cuenta en MEMORIA: se va
2316
+ * al cerrar o recargar esta pestaña, y el disco de esa máquina queda como estaba. Con
2317
+ * `true` se guarda como cualquier otra cuenta de este navegador —la llave como
2318
+ * `CryptoKey` no extraíble, nunca en claro— hasta que salgas.
2319
+ *
2320
+ * La contraseña NO viaja: lo que viaja son los mensajes de OPAQUE, que no la llevan ni
2321
+ * dejan adivinarla (`@dotrino/vault/login-client`).
2322
+ */
2323
+ async loginWithPassword ({ address, password, remember = false, label = '', proxyUrl = null } = {}) {
2324
+ const url = proxyUrl || DEFAULT_PROXY
2325
+ const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
2326
+ const { loginWithPassword: entrar } = await import('@dotrino/vault/login-client')
2327
+ const client = new WebSocketProxyClient({ url, enableWebRTC: false, autoReconnect: false })
2328
+ await client.connect()
2329
+ let entrada
2330
+ try {
2331
+ entrada = await entrar({ transport: client, address, password, label })
2332
+ } finally { try { client.close() } catch (_) {} }
2333
+ return adoptLogin(entrada, { remember, proxy: url })
2334
+ },
2335
+
2336
+ /**
2337
+ * SALIR del inicio de sesión con contraseña: se le dice a la bóveda que suelte la plaza
2338
+ * y la cuenta desaparece de este navegador. Sin `id`, el activo.
2339
+ */
2340
+ async logoutLogin ({ id = null } = {}) {
2341
+ const pid = id || currentPid
2342
+ const meta = loginMetaOf(pid)
2343
+ if (!meta) throw Object.assign(new Error('that account was not entered with a password'), { code: 'not-a-login' })
2344
+ if (pid !== currentPid) throw Object.assign(new Error('switch to that account before leaving it'), { code: 'not-current' })
2345
+ const avisado = await closeLoginOn(meta)
2346
+ if (volatilePids.has(pid)) {
2347
+ const r = await dropVolatile(pid) // borra la cuenta entera, llaves incluidas
2348
+ return { ok: true, told: avisado.ok, current: r.current }
2349
+ }
2350
+ const r = await purgeProfile(pid)
2351
+ if (r.current && r.current !== pid) await openProfileInMemory(r.current)
2352
+ return { ok: true, told: avisado.ok, current: r.current }
2353
+ },
2354
+
2083
2355
  // ----- ACTA DE PERFIL -----
2084
2356
  // Quién es de este perfil y qué puede hacer cada uno. Solo el master sella; los demás
2085
2357
  // adoptan. Ver `dotrino-vault/docs/acta-de-perfil.md`.
@@ -3009,7 +3281,38 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
3009
3281
  rawKv.setItem(CURRENT_STORAGE, currentPid)
3010
3282
  }
3011
3283
  }
3012
- await peers.setProfile?.(currentPid)
3284
+ // ----- CUENTAS DE PASO: barrer las abandonadas y recuperar la de ESTA pestaña -----
3285
+ //
3286
+ // Va después de elegir el perfil activo y antes de abrir ninguna llave: si esta pestaña
3287
+ // reclama una, es ESA la que se abre, y las que nadie mantiene vivas se van antes de que
3288
+ // nada las pueda usar.
3289
+ {
3290
+ const vivas = loadVolatile()
3291
+ const reclamada = (() => { try { return sessionKv?.getItem(VOLATILE_CLAIM) || null } catch (_) { return null } })()
3292
+ const ahora = Date.now()
3293
+ let cambio = false
3294
+ for (const [pid, visto] of Object.entries(vivas)) {
3295
+ if (pid === reclamada || (ahora - visto) < VOLATILE_STALE_MS) continue
3296
+ try { await purgeProfile(pid) }
3297
+ catch (e) { console.warn('[identity] could not sweep an abandoned walk-in account:', e?.message || e) }
3298
+ delete vivas[pid]; cambio = true
3299
+ }
3300
+ if (cambio) saveVolatile(vivas)
3301
+
3302
+ const sigueAhi = reclamada && vivas[reclamada] && rawKv.getItem(`dotrino.identity.p.${reclamada}.login`)
3303
+ if (sigueAhi) {
3304
+ volatilePids.add(reclamada)
3305
+ let suyo = null
3306
+ try { suyo = JSON.parse(rawKv.getItem(`dotrino.identity.p.${reclamada}.me`) || 'null') } catch (_) {}
3307
+ volatileProfiles = [{ id: reclamada, name: suyo?.nickname || '', pubkey: suyo?.publickey || null }]
3308
+ currentPid = reclamada
3309
+ keepBeating(reclamada)
3310
+ } else if (reclamada) {
3311
+ try { sessionKv?.removeItem(VOLATILE_CLAIM) } catch (_) {}
3312
+ }
3313
+ }
3314
+
3315
+ await peers.setProfile?.(currentPid, { volatile: volatilePids.has(currentPid) })
3013
3316
 
3014
3317
  keypair = await loadOrCreateKeypair()
3015
3318
  publickeyJwkStr = JSON.stringify(keypair.publicJwk)
@@ -3097,6 +3400,13 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
3097
3400
  return { locked: !keypair?.privateKey }
3098
3401
  },
3099
3402
  sync,
3403
+ /**
3404
+ * Adoptar un aparato que acaba de entrar con usuario y contraseña. Lo normal es llamar
3405
+ * al handler `loginWithPassword`, que primero HABLA con la bóveda; esto es solo la
3406
+ * segunda mitad —instalar lo que salió de ahí—, y vive en el objeto del núcleo (no en
3407
+ * `handlers`) porque ninguna aplicación tiene por qué poder instalar una identidad.
3408
+ */
3409
+ adoptLogin,
3100
3410
  onSyncStatus (fn) { if (sync) sync.onStatus(fn) },
3101
3411
  onVaultEvent (fn) { vaultListeners.add(fn); return () => vaultListeners.delete(fn) }
3102
3412
  }
package/vault/index.html CHANGED
@@ -26,6 +26,7 @@
26
26
  "@dotrino/proxy-client": "./vendor/proxy-client/index.js",
27
27
  "@dotrino/vault": "./vendor/vault/index.js",
28
28
  "@dotrino/vault/password-logins": "./vendor/vault/passwordLogins.js",
29
+ "@dotrino/vault/login-client": "./vendor/vault/loginClient.js",
29
30
  "@dotrino/opaque": "./vendor/opaque/src/index.js",
30
31
  "@dotrino/identity/capabilities": "./capabilities.js",
31
32
  "@dotrino/identity/acta": "./acta.js",
@@ -32,12 +32,23 @@ let _idb = null
32
32
  let _writeChain = Promise.resolve()
33
33
  let _markDirty = null
34
34
  let _pid = null
35
+ /**
36
+ * La cuenta abierta es VOLÁTIL (se entró con usuario y contraseña sin «Recordar»): sus
37
+ * contactos viven en memoria y no se escriben en ninguna parte. Dejar el libro de contactos
38
+ * de alguien en el disco de un equipo prestado sería justo lo que esa forma de entrar
39
+ * promete no hacer.
40
+ */
41
+ let _volatile = false
35
42
 
36
43
  /** Registra el callback que marca el estado como "sucio" para el sync. */
37
44
  export function onDirty (fn) { _markDirty = fn }
38
45
 
39
46
  /** Multi-perfil: namespacea el peer book por perfil. El core lo llama antes de initPeerStorage. */
40
- export function setProfile (pid) { _pid = pid || null }
47
+ export function setProfile (pid, { volatile: esVolatil = false } = {}) {
48
+ _pid = pid || null
49
+ _volatile = !!esVolatil
50
+ if (_volatile) _peers = {} // se entra sin contactos y se sale sin dejarlos
51
+ }
41
52
  function peersKey () { return _pid ? `peers.${_pid}.v1` : IDB_PEERS_KEY }
42
53
 
43
54
  /** Migración: copia el peer book VIEJO (pre-multi-perfil, sin namespace) al perfil `pid`. */
@@ -91,6 +102,7 @@ function readLocalPeers () {
91
102
  export async function initPeerStorage () {
92
103
  try { if (typeof navigator !== 'undefined' && navigator.storage?.persist) await navigator.storage.persist() }
93
104
  catch (_) { /* best-effort */ }
105
+ if (_volatile) { _peers = {}; return _peers }
94
106
  try {
95
107
  _idb = await openIdb()
96
108
  const stored = await idbGet(_idb, peersKey()) // peer book DEL perfil activo (namespaceado)
@@ -106,6 +118,7 @@ export async function initPeerStorage () {
106
118
  }
107
119
 
108
120
  function persistPeers () {
121
+ if (_volatile) return _writeChain // cuenta volátil: en memoria y en ningún disco
109
122
  const key = peersKey()
110
123
  if (_fallback || !_idb) {
111
124
  try { localStorage.setItem(key, JSON.stringify(_peers)) }
@@ -147,5 +160,5 @@ export function upsertPeer (publickey, patch) {
147
160
  // Sólo para tests: resetea el estado del módulo (y cierra la conexión IDB).
148
161
  export function _resetForTest () {
149
162
  try { if (_idb && _idb.close) _idb.close() } catch (_) {}
150
- _peers = {}; _fallback = false; _idb = null; _writeChain = Promise.resolve(); _markDirty = null; _pid = null
163
+ _peers = {}; _fallback = false; _idb = null; _writeChain = Promise.resolve(); _markDirty = null; _pid = null; _volatile = false
151
164
  }
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/proxy-client@0.22.0 (dotrino-proxy-client/src/{index,client,signature,canonical,sealing,encpub,webrtc}.js).
1
+ Copia vendorizada de @dotrino/proxy-client@0.23.1 (dotrino-proxy-client/src/{index,client,signature,canonical,sealing,encpub,webrtc}.js).
2
2
  NO se edita a mano: la escribe `node vendor.mjs` y la vigila test/vendor-up-to-date.test.mjs.
3
3
  sealing.js resuelve @dotrino/identity/content de forma PEREZOSA (= ../../content.js
4
4
  por el import map): solo se carga si de verdad se sella algo.
@@ -1124,7 +1124,23 @@ export class WebSocketProxyClient {
1124
1124
  // tienen autoReconnect=false (puesto por close()), así que este guard no
1125
1125
  // filtra desconexiones pedidas por la app. Sin esto, un restart del proxy
1126
1126
  // deja a clientes de larga duración (bots, apps abiertas) zombis para siempre.
1127
- if (wasConnected && this.autoReconnect) {
1127
+ //
1128
+ // Y TAMBIÉN CUANDO EL INTENTO NI LLEGÓ A ABRIRSE (`reintentando`). Ese era el
1129
+ // agujero, y costó 36 horas de bóveda muda el 2026-09-16: un socket que falla al
1130
+ // conectar cierra con `_connected` en false, así que con solo `wasConnected` no se
1131
+ // programaba el siguiente intento. O sea que el cliente hacía UN reintento —el de
1132
+ // la caída— y si ese caía en un momento en que la red seguía mal, se rendía para
1133
+ // siempre, en silencio y con `maxReconnectAttempts` en 100000. Justo el caso normal:
1134
+ // cuando se cae la red, el primer reintento a los pocos segundos tampoco encuentra a
1135
+ // nadie.
1136
+ //
1137
+ // Los eventos que se veían: `disconnect reconnecting#1 disconnect` y nada más.
1138
+ //
1139
+ // `_reconnectAttempts` vuelve a 0 al abrir, así que esto NO convierte un `connect()`
1140
+ // inicial fallido en un bucle de fondo: ahí vale 0 y la promesa se rechaza como
1141
+ // siempre.
1142
+ const reintentando = this._reconnectAttempts > 0
1143
+ if ((wasConnected || reintentando) && this.autoReconnect) {
1128
1144
  this._scheduleReconnect()
1129
1145
  }
1130
1146
  })
@@ -1178,6 +1194,12 @@ export class WebSocketProxyClient {
1178
1194
  this._reconnectAttempts++
1179
1195
  this._emit('reconnecting', this._reconnectAttempts, this.maxReconnectAttempts)
1180
1196
  this._reconnectTimer = setTimeout(() => this._open(), this.reconnectDelay)
1197
+ // UN REINTENTO PENDIENTE NO MANTIENE VIVO EL PROCESO. Desde que se reintenta de verdad
1198
+ // —antes la cadena se cortaba sola al primer fallo—, este temporizador basta para que un
1199
+ // programa de Node que se olvidó de cerrar el cliente no termine nunca. Lo de siempre:
1200
+ // esto es mantenimiento de fondo, y quién se va es decisión de la app. En el navegador
1201
+ // `unref` no existe y no hace falta.
1202
+ this._reconnectTimer.unref?.()
1181
1203
  }
1182
1204
 
1183
1205
  _handleFrame (raw) {
@@ -1,7 +1,9 @@
1
- Copia vendorizada de @dotrino/vault@0.66.0 (dotrino-vault/lib/src/{index,enroll,protocol,passwordLogins,b64}.js).
1
+ Copia vendorizada de @dotrino/vault@0.67.0 (dotrino-vault/lib/src/{index,enroll,protocol,passwordLogins,loginClient,b64}.js).
2
2
  NO se edita a mano: la escribe `node vendor.mjs` y la vigila test/vendor-up-to-date.test.mjs.
3
3
  index.js importa ./enroll.js y ./protocol.js (relativos, van en esta misma copia),
4
4
  @dotrino/identity/{capabilities,acta} (= ../../{capabilities,acta}.js) y
5
5
  @dotrino/proxy-client (= ../proxy-client/), todos por el import map de index.html.
6
6
  passwordLogins.js es el aparato que se abre con usuario y contraseña: lo carga
7
7
  vault.js SOLO cuando esta pestaña es bóveda, porque arrastra el OPAQUE en WASM.
8
+ loginClient.js es la otra mitad —el que ENTRA con esa contraseña— y lo carga core.js
9
+ solo al entrar, por lo mismo: arrastra el OPAQUE.
@@ -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(() => {}))
@@ -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 }
@@ -25,6 +25,7 @@
25
25
  * final, porque hace falta la identidad que firma.
26
26
  */
27
27
  import { server as opaque, suiteId } 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
  *