@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 +2 -1
- package/src/index.js +22 -0
- package/vault/core.js +319 -9
- package/vault/index.html +3 -0
- package/vault/peerStore.js +15 -2
- package/vault/vault.js +38 -1
- package/vault/vendor/opaque/VERSION.txt +6 -0
- package/vault/vendor/opaque/build/opaque.js +588 -0
- package/vault/vendor/opaque/build/wasm-bytes.js +2 -0
- package/vault/vendor/opaque/src/index.js +123 -0
- package/vault/vendor/proxy-client/VERSION.txt +1 -1
- package/vault/vendor/proxy-client/client.js +23 -1
- package/vault/vendor/vault/VERSION.txt +5 -1
- package/vault/vendor/vault/b64.js +36 -0
- package/vault/vendor/vault/index.js +126 -3
- package/vault/vendor/vault/loginClient.js +216 -0
- package/vault/vendor/vault/passwordLogins.js +469 -0
- package/vault/vendor/vault/protocol.js +13 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/identity",
|
|
3
|
-
"version": "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:
|
|
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 = () => {
|
|
214
|
-
|
|
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
|
-
|
|
493
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
package/vault/peerStore.js
CHANGED
|
@@ -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
|
|
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,
|
|
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.
|