@dotrino/identity 0.94.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.94.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",
@@ -66,6 +66,7 @@
66
66
  "url": "git+https://github.com/imdotrino/dotrino-identity.git"
67
67
  },
68
68
  "devDependencies": {
69
+ "@dotrino/opaque": "0.1.0",
69
70
  "fake-indexeddb": "^6.2.5",
70
71
  "typescript": "5.9.3",
71
72
  "@types/node": "^22.0.0"
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
@@ -25,6 +25,9 @@
25
25
  { "imports": {
26
26
  "@dotrino/proxy-client": "./vendor/proxy-client/index.js",
27
27
  "@dotrino/vault": "./vendor/vault/index.js",
28
+ "@dotrino/vault/password-logins": "./vendor/vault/passwordLogins.js",
29
+ "@dotrino/vault/login-client": "./vendor/vault/loginClient.js",
30
+ "@dotrino/opaque": "./vendor/opaque/src/index.js",
28
31
  "@dotrino/identity/capabilities": "./capabilities.js",
29
32
  "@dotrino/identity/acta": "./acta.js",
30
33
  "@dotrino/identity/content": "./content.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
  }
package/vault/vault.js CHANGED
@@ -215,13 +215,50 @@ import { pubkeyId } from './capabilities.js'
215
215
  joinProfile: (acta) => handlers.joinProfile({ acta })
216
216
  }
217
217
 
218
+ /**
219
+ * EL ESCRITORIO DE LOS INICIOS DE SESIÓN CON CONTRASEÑA, con el almacén de este navegador.
220
+ *
221
+ * La bóveda no decide dónde se guarda nada: en el navegador no hay archivos, así que el
222
+ * estado lo pone quien la monta —esto—. Sin escritorio la pestaña contesta
223
+ * `logins-unavailable`, que es distinto de «contraseña incorrecta» y por eso se ve.
224
+ *
225
+ * Cuelga del PERFIL ACTIVO: los inicios de sesión son de una cuenta, no de este
226
+ * navegador, y dos cuentas en el mismo equipo no comparten usuarios.
227
+ *
228
+ * Se carga solo aquí, y no arriba, porque arrastra el OPAQUE en WASM (~270 KB): el
229
+ * iframe lo cargan las ~30 apps del ecosistema y solo esta pestaña es bóveda.
230
+ *
231
+ * Lo que guarda aguanta lo mismo que el disco del daemon: con una copia de este
232
+ * `localStorage` se pueden probar contraseñas sin límite contra el registro de OPAQUE.
233
+ * Lo único que aguanta ahí es que la contraseña sea larga (`temporary-access.md` §4).
234
+ */
235
+ async function makeLoginDesk () {
236
+ const { createLoginDesk } = await import('@dotrino/vault/password-logins')
237
+ const { id: pid } = await handlers.currentProfile()
238
+ if (!pid) throw new Error('no profile: password logins belong to an account')
239
+ const key = `dotrino.identity.p.${pid}.self-vault.logins`
240
+ return createLoginDesk({
241
+ load: () => {
242
+ const raw = kv.getItem(key)
243
+ // Si está y no parsea, REVIENTA aquí a propósito: devolver `null` haría nacer el
244
+ // escritorio vacío, y el primer guardado se llevaría por delante todos los inicios
245
+ // de sesión de esta cuenta sin que nadie se enterara.
246
+ return raw ? JSON.parse(raw) : null
247
+ },
248
+ save: (s) => kv.setItem(key, JSON.stringify(s))
249
+ })
250
+ }
251
+
218
252
  async function startSelfDaemon () {
219
253
  if (daemon) return
220
254
  try {
221
255
  // Import dinámico: aísla fallos del vendor del arranque del vault (cargado por
222
256
  // todas las apps). El import map de index.html resuelve @dotrino/vault.
223
257
  const { startDeviceVault } = await import('@dotrino/vault')
224
- daemon = await startDeviceVault(selfIdentity, selfProxyUrl ? { proxyUrl: selfProxyUrl } : undefined)
258
+ daemon = await startDeviceVault(selfIdentity, {
259
+ ...(selfProxyUrl ? { proxyUrl: selfProxyUrl } : {}),
260
+ logins: await makeLoginDesk()
261
+ })
225
262
  daemon.onPendingChange(() => broadcast('selfVault', { pending: daemon.listPending() }))
226
263
  broadcast('selfVault', { running: true })
227
264
  } catch (e) { daemon = null; broadcast('selfVault', { error: e?.message || String(e) }) }
@@ -0,0 +1,6 @@
1
+ Copia vendorizada de @dotrino/opaque@0.1.0 (dotrino-opaque/{src/index,build/opaque,build/wasm-bytes}.js).
2
+ NO se edita a mano: la escribe `node vendor.mjs` y la vigila test/vendor-up-to-date.test.mjs.
3
+ src/index.js importa ../build/{opaque,wasm-bytes}.js, así que la copia CONSERVA
4
+ esas dos carpetas: aplanarla rompería el import relativo.
5
+ El WASM viaja dentro del JS (base64) porque el binario del vault es un ejecutable
6
+ único; aquí eso significa que se baja como script, sin un fetch aparte.