@dotrino/identity 0.95.0 → 0.96.1
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 +1 -1
- package/src/index.js +22 -0
- package/vault/core.js +330 -9
- package/vault/index.html +1 -0
- package/vault/peerStore.js +15 -2
- package/vault/vendor/proxy-client/VERSION.txt +1 -1
- package/vault/vendor/proxy-client/client.js +23 -1
- package/vault/vendor/vault/VERSION.txt +3 -1
- package/vault/vendor/vault/index.js +29 -0
- package/vault/vendor/vault/loginClient.js +216 -0
- package/vault/vendor/vault/passwordLogins.js +38 -0
package/package.json
CHANGED
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,26 @@ 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'
|
|
50
|
+
/**
|
|
51
|
+
* EL PILAR DE LA BÓVEDA, EN UNA VARIABLE Y NO ESCRITO EN EL `import`. No es un capricho de
|
|
52
|
+
* estilo: **este archivo lo empaqueta cada app** —`capabilities.js` tira de él— y
|
|
53
|
+
* `@dotrino/vault` no es dependencia de ninguna, ni debe serlo: arrastraría el OPAQUE en
|
|
54
|
+
* WASM (~270 KB) a las treinta. Con el nombre escrito, el empaquetador intenta resolverlo
|
|
55
|
+
* al construir y **el build de la app se cae**, aunque nunca vaya a ejecutar esa línea.
|
|
56
|
+
*
|
|
57
|
+
* Lo que hay detrás solo corre en el iframe de identidad, que lo resuelve con su import map
|
|
58
|
+
* (o en Node, donde el paquete está instalado).
|
|
59
|
+
*/
|
|
60
|
+
const PILAR_VAULT = '@dotrino/vault'
|
|
47
61
|
|
|
48
62
|
// ----- crypto helpers (puros) -----
|
|
49
63
|
|
|
@@ -192,11 +206,54 @@ function sanitizeProfilePatch (patch = {}) {
|
|
|
192
206
|
*
|
|
193
207
|
* Si no se inyecta, no se concede nada nuevo: sin forma de preguntar, la respuesta es no.
|
|
194
208
|
*/
|
|
195
|
-
export async function createIdentityCore ({ kv:
|
|
209
|
+
export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null, keyStore: hostKeyStore = null, sessionKv = null, removeAccountOnExpulsion = true, keyLock = null, askConsent = null }) {
|
|
196
210
|
const {
|
|
197
211
|
initPeerStorage, loadPeers, savePeers, setPeersDirect, upsertPeer, onDirty
|
|
198
212
|
} = peers
|
|
199
213
|
|
|
214
|
+
// ----- CUENTAS DE PASO: entrar en un equipo que NO es tuyo -----
|
|
215
|
+
//
|
|
216
|
+
// Una cuenta de paso es la que se abre con usuario y contraseña SIN marcar «Recordar»
|
|
217
|
+
// (`temporary-access.md` §3.4). No es una cuenta de este navegador: es de ESTA PESTAÑA, y
|
|
218
|
+
// desaparece —entera, con su llave— en cuanto la pestaña se va.
|
|
219
|
+
//
|
|
220
|
+
// Lo primero que se intentó fue tenerla solo en memoria, que suena mejor y no sirve: el
|
|
221
|
+
// iframe de identidad muere con cada navegación, así que entrar y pulsar el primer enlace
|
|
222
|
+
// te dejaba fuera otra vez. Así que se guarda como cualquier otra —la llave, `CryptoKey`
|
|
223
|
+
// NO EXTRAÍBLE en IndexedDB, nunca en claro— y lo que cambia es QUIÉN la ve y CUÁNTO dura:
|
|
224
|
+
//
|
|
225
|
+
// · **no entra en la lista de perfiles del disco**, así que ninguna otra pestaña la ve
|
|
226
|
+
// ni puede cambiarse a ella; la lista de esta pestaña la lleva en memoria;
|
|
227
|
+
// · **no toca el puntero del perfil activo**: al cerrar, este navegador vuelve a la
|
|
228
|
+
// cuenta que tenía, sin haberse enterado;
|
|
229
|
+
// · **la reclama la pestaña** (`sessionStorage`, que es por pestaña y sobrevive a
|
|
230
|
+
// navegar) y la mantiene viva un latido cada 20 s;
|
|
231
|
+
// · **el primer arranque que vea una sin latido la borra**, con sus llaves.
|
|
232
|
+
//
|
|
233
|
+
// Lo que eso NO tapa, dicho claro: si el navegador se cierra de golpe y nadie vuelve a
|
|
234
|
+
// abrir Dotrino en esa máquina, la llave se queda ahí —cifrada y no extraíble— hasta que
|
|
235
|
+
// alguien lo haga, y ese alguien la borra antes de poder usarla. Salir la borra en el acto.
|
|
236
|
+
const VOLATILE_STORAGE = 'dotrino.identity.volatile' // { <pid>: último latido }
|
|
237
|
+
const VOLATILE_CLAIM = 'dotrino.identity.volatile.claim' // en sessionKv: la de ESTA pestaña
|
|
238
|
+
const VOLATILE_STALE_MS = 90_000
|
|
239
|
+
const VOLATILE_BEAT_MS = 20_000
|
|
240
|
+
const volatilePids = new Set()
|
|
241
|
+
let volatileProfiles = []
|
|
242
|
+
let volatileBeat = null
|
|
243
|
+
const rawKv = hostKv
|
|
244
|
+
const keyStore = hostKeyStore
|
|
245
|
+
|
|
246
|
+
const loadVolatile = () => { try { return JSON.parse(hostKv.getItem(VOLATILE_STORAGE) || '{}') || {} } catch (_) { return {} } }
|
|
247
|
+
const saveVolatile = (m) => hostKv.setItem(VOLATILE_STORAGE, JSON.stringify(m))
|
|
248
|
+
const beatVolatile = (pid) => { const m = loadVolatile(); m[pid] = Date.now(); saveVolatile(m) }
|
|
249
|
+
const keepBeating = (pid) => {
|
|
250
|
+
if (volatileBeat) clearInterval(volatileBeat)
|
|
251
|
+
beatVolatile(pid)
|
|
252
|
+
volatileBeat = setInterval(() => beatVolatile(pid), VOLATILE_BEAT_MS)
|
|
253
|
+
// En el navegador, que el latido no impida cerrar nada.
|
|
254
|
+
try { volatileBeat.unref?.() } catch (_) {}
|
|
255
|
+
}
|
|
256
|
+
|
|
200
257
|
// ----- multi-perfil: kv SCOPEADO por el perfil activo -----
|
|
201
258
|
// Todas las claves `dotrino.identity.*` (keypair, me, nonces, delegations, vault.*) se
|
|
202
259
|
// namespacean transparentemente bajo `dotrino.identity.p.<currentPid>.*`. Las dos claves
|
|
@@ -210,8 +267,16 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
210
267
|
setItem: (k, v) => rawKv.setItem(_scoped(k), v),
|
|
211
268
|
removeItem: (k) => rawKv.removeItem(_scoped(k))
|
|
212
269
|
}
|
|
213
|
-
const loadProfiles = () => {
|
|
214
|
-
|
|
270
|
+
const loadProfiles = () => {
|
|
271
|
+
let list = []
|
|
272
|
+
try { list = JSON.parse(hostKv.getItem(PROFILES_STORAGE) || '[]') || [] } catch { list = [] }
|
|
273
|
+
return volatileProfiles.length ? [...list, ...volatileProfiles] : list
|
|
274
|
+
}
|
|
275
|
+
/** Lo volátil se queda en memoria; al disco va solo el resto. */
|
|
276
|
+
const saveProfiles = (list) => {
|
|
277
|
+
if (volatilePids.size) volatileProfiles = list.filter((p) => volatilePids.has(p.id))
|
|
278
|
+
hostKv.setItem(PROFILES_STORAGE, JSON.stringify(list.filter((p) => !volatilePids.has(p.id))))
|
|
279
|
+
}
|
|
215
280
|
|
|
216
281
|
// ----- la MARCA de «este perfil nació para adoptar la cuenta de una bóveda» -----
|
|
217
282
|
// Unirse a otra cuenta borra la que este perfil tenía, así que no puede pasar por
|
|
@@ -489,8 +554,11 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
489
554
|
*/
|
|
490
555
|
async function openProfileInMemory (pid) {
|
|
491
556
|
currentPid = pid
|
|
492
|
-
|
|
493
|
-
|
|
557
|
+
// Una cuenta volátil no deja rastro: si escribiera aquí, al cerrar la pestaña el
|
|
558
|
+
// puntero señalaría a una cuenta que ya no existe y las demás pestañas verían cambiar
|
|
559
|
+
// la suya sin haber tocado nada.
|
|
560
|
+
if (!volatilePids.has(pid)) rawKv.setItem(CURRENT_STORAGE, pid)
|
|
561
|
+
await peers.setProfile?.(pid, { volatile: volatilePids.has(pid) })
|
|
494
562
|
await initPeerStorage()
|
|
495
563
|
keypair = await loadOrCreateKeypair(); publickeyJwkStr = JSON.stringify(keypair.publicJwk)
|
|
496
564
|
encKeypair = await loadOrCreateEncKeypair(); encPublickeyJwkStr = JSON.stringify(encKeypair.publicJwk)
|
|
@@ -636,7 +704,11 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
636
704
|
async function purgeProfile (id) {
|
|
637
705
|
const list = loadProfiles().filter((p) => p.id !== id)
|
|
638
706
|
saveProfiles(list)
|
|
639
|
-
|
|
707
|
+
// TODO lo que escribe una cuenta. Faltaban tres —el historial de actas, lo que le
|
|
708
|
+
// concediste a cada aplicación y la marca de haber entrado con contraseña—, y en una
|
|
709
|
+
// cuenta de paso eso no es basura: es rastro en un equipo que no es tuyo.
|
|
710
|
+
for (const s of ['keypair', 'enc-keypair', 'me', 'nonces', 'delegations', 'revocations',
|
|
711
|
+
'vault.device', 'vault.cert', 'acta', 'acta.history', 'renounced', 'grants', 'login', 'pendingJoin']) {
|
|
640
712
|
rawKv.removeItem(`dotrino.identity.p.${id}.${s}`)
|
|
641
713
|
}
|
|
642
714
|
// …y sus CryptoKeys no extractables del keyStore (IndexedDB).
|
|
@@ -935,6 +1007,152 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
935
1007
|
return { gen, sinLlave }
|
|
936
1008
|
}
|
|
937
1009
|
|
|
1010
|
+
// ----- ENTRAR CON USUARIO Y CONTRASEÑA (`temporary-access.md` §3.4) -----
|
|
1011
|
+
//
|
|
1012
|
+
// Lo que llega de `@dotrino/vault/login-client` es un APARATO entero: sus dos llaves
|
|
1013
|
+
// privadas, el papel que firmó la bóveda y el acta. Aquí se le da sitio en este navegador
|
|
1014
|
+
// como una cuenta más, y desde ese momento este navegador ES ese aparato: firma con su
|
|
1015
|
+
// llave y lo que puede lo dice el acta, igual que una máquina enrolada.
|
|
1016
|
+
//
|
|
1017
|
+
// No se parece a `createProfile` en lo importante: ahí nace una llave nueva, aquí se
|
|
1018
|
+
// ADOPTA una que ya existe y ya está en el acta. Por eso las llaves se escriben ANTES de
|
|
1019
|
+
// abrir la cuenta — si se abriera primero, `loadOrCreateKeypair` estrenaría una llave que
|
|
1020
|
+
// esa cuenta no reconoce y el aparato quedaría fuera de su propio perfil.
|
|
1021
|
+
|
|
1022
|
+
/** Lo que quedó anotado de un inicio de sesión con contraseña, para cerrarlo o enseñarlo. */
|
|
1023
|
+
const loginMetaOf = (pid) => {
|
|
1024
|
+
try {
|
|
1025
|
+
const raw = pid === currentPid
|
|
1026
|
+
? kv.getItem(LOGIN_STORAGE)
|
|
1027
|
+
: rawKv.getItem(`dotrino.identity.p.${pid}.login`)
|
|
1028
|
+
return raw ? JSON.parse(raw) : null
|
|
1029
|
+
} catch (_) { return null }
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
const deviceIdDe = async (pub) => (await pubkeyIdOf(pub)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* @param {object} entrada lo que devuelve `loginWithPassword` del pilar
|
|
1036
|
+
* @param {boolean} remember `false` = cuenta VOLÁTIL (se va con la pestaña)
|
|
1037
|
+
*/
|
|
1038
|
+
async function adoptLogin (entrada, { remember = false, proxy = null } = {}) {
|
|
1039
|
+
// LO MÍNIMO QUE ESTA CASA TIENE QUE COMPROBAR ANTES DE ADOPTAR NADA. Quien entró ya
|
|
1040
|
+
// verificó la conversación entera (OPAQUE, la dirección, el papel); esto es otra cosa y
|
|
1041
|
+
// es suya: que la llave que se va a instalar SEA de esta cuenta. Sin ello, el navegador
|
|
1042
|
+
// se quedaría con una identidad que ningún acta reconoce — y sin forma de notarlo.
|
|
1043
|
+
const v = await Acta.verifyActa({ acta: entrada?.acta })
|
|
1044
|
+
if (!v.ok) throw Object.assign(new Error('the account record does not verify: ' + v.reason), { code: 'bad-acta' })
|
|
1045
|
+
if (!(entrada.acta.members || []).some((m) => m?.pub === entrada.publickey)) {
|
|
1046
|
+
throw Object.assign(new Error('that key is not a member of the account record'), { code: 'not-a-member' })
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
const pid = 'p' + crypto.randomUUID().slice(0, 8)
|
|
1050
|
+
const veniaDe = currentPid
|
|
1051
|
+
if (!remember) {
|
|
1052
|
+
volatilePids.add(pid)
|
|
1053
|
+
try { sessionKv?.setItem(VOLATILE_CLAIM, pid) } catch (_) {}
|
|
1054
|
+
keepBeating(pid) // mientras esta pestaña viva, nadie la barre
|
|
1055
|
+
}
|
|
1056
|
+
currentPid = pid // desde aquí, `kv` escribe en el namespace de la cuenta nueva
|
|
1057
|
+
|
|
1058
|
+
try {
|
|
1059
|
+
// Las públicas se toman TAL CUAL las escribe el acta: es la cadena exacta con la que
|
|
1060
|
+
// esa llave es miembro, y una que se re-serialice distinta deja de coincidir.
|
|
1061
|
+
//
|
|
1062
|
+
// `key_ops` se quita: dice para qué la generó QUIEN la creó (la llave de cifrado nace
|
|
1063
|
+
// con `deriveBits` a secas), y aquí hace falta además `deriveKey`. Es lo mismo que ya
|
|
1064
|
+
// se hace al generar una propia — quién puede hacer qué con esta llave lo decide esta
|
|
1065
|
+
// casa, no una etiqueta que viajó dentro del paquete.
|
|
1066
|
+
const sinTopes = (jwk) => { const { key_ops: _o, ...resto } = jwk || {}; return resto }
|
|
1067
|
+
await adoptJwkPair('sign', KEY_STORAGE, sinTopes(entrada.keys.sign), JSON.parse(entrada.publickey))
|
|
1068
|
+
if (entrada.keys.enc && entrada.encPublickey) {
|
|
1069
|
+
await adoptJwkPair('enc', ENC_KEY_STORAGE, sinTopes(entrada.keys.enc), JSON.parse(entrada.encPublickey))
|
|
1070
|
+
}
|
|
1071
|
+
saveActa(entrada.acta)
|
|
1072
|
+
kv.setItem(ACTA_HISTORY_STORAGE, '[]')
|
|
1073
|
+
kv.setItem(VAULT_DEVICE_STORAGE, JSON.stringify({ useIdentityKey: true, publickey: entrada.publickey }))
|
|
1074
|
+
kv.setItem(VAULT_CERT_STORAGE, JSON.stringify({
|
|
1075
|
+
cert: entrada.cert, master: entrada.iss, proxy: proxy || DEFAULT_PROXY,
|
|
1076
|
+
deviceId: await deviceIdDe(entrada.publickey), pairedAt: Date.now()
|
|
1077
|
+
}))
|
|
1078
|
+
kv.setItem(LOGIN_STORAGE, JSON.stringify({
|
|
1079
|
+
user: entrada.user, address: entrada.address, code: entrada.code, sid: entrada.sid,
|
|
1080
|
+
vault: entrada.iss, proxy: proxy || DEFAULT_PROXY, volatile: !remember, at: Date.now()
|
|
1081
|
+
}))
|
|
1082
|
+
|
|
1083
|
+
await openProfileInMemory(pid) // carga las llaves recién adoptadas; no estrena ninguna
|
|
1084
|
+
if (publickeyJwkStr !== entrada.publickey) {
|
|
1085
|
+
throw Object.assign(new Error('the adopted key is not the one the record names'), { code: 'bad-keys' })
|
|
1086
|
+
}
|
|
1087
|
+
me = { publickey: publickeyJwkStr, encryptionPubkey: encPublickeyJwkStr, nickname: entrada.user }
|
|
1088
|
+
saveMe(me)
|
|
1089
|
+
const list = loadProfiles()
|
|
1090
|
+
list.push({ id: pid, name: entrada.user, pubkey: publickeyJwkStr })
|
|
1091
|
+
saveProfiles(list)
|
|
1092
|
+
emitVault({ phase: 'login', address: entrada.address, volatile: !remember })
|
|
1093
|
+
return { id: pid, name: entrada.user, address: entrada.address, user: entrada.user, sid: entrada.sid, volatile: !remember }
|
|
1094
|
+
} catch (e) {
|
|
1095
|
+
// NADA DE MEDIAS CUENTAS: si algo falla, no se queda una identidad a medio adoptar en
|
|
1096
|
+
// este navegador. Se deshace y se vuelve a donde estabas.
|
|
1097
|
+
try { await purgeProfile(pid) } catch (_) {}
|
|
1098
|
+
forgetVolatile(pid)
|
|
1099
|
+
if (veniaDe && loadProfiles().some((x) => x.id === veniaDe)) await openProfileInMemory(veniaDe)
|
|
1100
|
+
throw e
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/** Deja de tener por nuestra una cuenta de paso: ni en la lista, ni reclamada, ni latiendo. */
|
|
1105
|
+
function forgetVolatile (pid) {
|
|
1106
|
+
volatilePids.delete(pid)
|
|
1107
|
+
volatileProfiles = volatileProfiles.filter((x) => x.id !== pid)
|
|
1108
|
+
const m = loadVolatile()
|
|
1109
|
+
if (m[pid]) { delete m[pid]; saveVolatile(m) }
|
|
1110
|
+
try { if (sessionKv?.getItem(VOLATILE_CLAIM) === pid) sessionKv.removeItem(VOLATILE_CLAIM) } catch (_) {}
|
|
1111
|
+
if (volatileBeat) { clearInterval(volatileBeat); volatileBeat = null }
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/** Borra una cuenta de paso —con sus llaves— y vuelve a la que este navegador tenía. */
|
|
1115
|
+
async function dropVolatile (pid) {
|
|
1116
|
+
forgetVolatile(pid)
|
|
1117
|
+
await purgeProfile(pid)
|
|
1118
|
+
const list = loadProfiles()
|
|
1119
|
+
const back = hostKv.getItem(CURRENT_STORAGE)
|
|
1120
|
+
const destino = list.find((x) => x.id === back) || list[0]
|
|
1121
|
+
if (destino) await openProfileInMemory(destino.id)
|
|
1122
|
+
return { ok: true, current: destino?.id || null }
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* SALIR: se le dice a la bóveda que suelte la plaza y se borra la cuenta de aquí.
|
|
1127
|
+
*
|
|
1128
|
+
* Avisar es «mejor esfuerzo» —con la bóveda apagada, salir de este equipo no puede quedarse
|
|
1129
|
+
* colgado esperándola—, pero borrar la cuenta de este navegador no lo es: eso pasa siempre.
|
|
1130
|
+
* La plaza que quede abierta allí se cierra desde la consola, y así está dicho en el diseño.
|
|
1131
|
+
*/
|
|
1132
|
+
async function closeLoginOn (meta) {
|
|
1133
|
+
if (!meta?.user || !meta?.sid) return { ok: false, reason: 'sin-inicio-de-sesion' }
|
|
1134
|
+
let client = null
|
|
1135
|
+
try {
|
|
1136
|
+
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
1137
|
+
const { closeLogin } = await import(/* @vite-ignore */ `${PILAR_VAULT}/login-client`)
|
|
1138
|
+
const { vaultChannel } = await import(/* @vite-ignore */ `${PILAR_VAULT}/password-logins`)
|
|
1139
|
+
client = new WebSocketProxyClient({ url: meta.proxy || DEFAULT_PROXY, enableWebRTC: false, autoReconnect: false })
|
|
1140
|
+
await client.connect()
|
|
1141
|
+
// El token de la bóveda cambia con cada reconexión suya, así que se vuelve a mirar el
|
|
1142
|
+
// canal en vez de guardarlo: el de hace una hora ya no es de nadie.
|
|
1143
|
+
for (const token of await client.list(vaultChannel(meta.code))) {
|
|
1144
|
+
const r = await closeLogin({
|
|
1145
|
+
transport: client, token, user: meta.user, sid: meta.sid,
|
|
1146
|
+
publickey: publickeyJwkStr, privateKey: keypair?.privateKey
|
|
1147
|
+
})
|
|
1148
|
+
if (r.ok) return r
|
|
1149
|
+
}
|
|
1150
|
+
return { ok: false, reason: 'no-vault' }
|
|
1151
|
+
} catch (e) {
|
|
1152
|
+
return { ok: false, reason: e?.code || 'no-answer' }
|
|
1153
|
+
} finally { try { client?.close() } catch (_) {} }
|
|
1154
|
+
}
|
|
1155
|
+
|
|
938
1156
|
/**
|
|
939
1157
|
* UNIRSE a la cuenta de la bóveda con la que te acabas de emparejar (camino B de
|
|
940
1158
|
* `dotrino-vault/docs/vinculacion-de-cuentas.md`). NO es adoptar una versión nueva de TU
|
|
@@ -1538,7 +1756,10 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1538
1756
|
// nada que lea datos o firme).
|
|
1539
1757
|
const LOCK_EXEMPT = new Set([
|
|
1540
1758
|
'profileLockStatus', 'unlockProfile', 'listProfiles', 'currentProfile',
|
|
1541
|
-
|
|
1759
|
+
// Entrar con usuario y contraseña abre OTRA cuenta: que la de aquí esté bajo llave no
|
|
1760
|
+
// tiene nada que ver, y exigir abrirla primero dejaría fuera a quien viene a entrar en
|
|
1761
|
+
// la suya desde un equipo prestado.
|
|
1762
|
+
'switchProfile', 'createProfile', 'loginWithPassword',
|
|
1542
1763
|
'profileActa', 'profileMembers', 'myMembership', 'isMaster', 'sealerChain'
|
|
1543
1764
|
])
|
|
1544
1765
|
|
|
@@ -2029,7 +2250,14 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
2029
2250
|
vault = !!v?.cert
|
|
2030
2251
|
approve = vault && (v.cert.scope || []).includes('vault:approve')
|
|
2031
2252
|
} catch (_) {}
|
|
2032
|
-
|
|
2253
|
+
// Un inicio de sesión con contraseña se enseña como lo que es: el menú del botón de
|
|
2254
|
+
// perfil pone su «Salir» al lado, que es lo único que lo cierra desde aquí.
|
|
2255
|
+
const login = loginMetaOf(p.id)
|
|
2256
|
+
return {
|
|
2257
|
+
id: p.id, name: p.name || '', pubkey: p.pubkey || null, avatar, current: p.id === currentPid,
|
|
2258
|
+
pendingJoin: !!p.pendingJoin, vault, approve,
|
|
2259
|
+
...(login ? { login: { user: login.user, address: login.address, volatile: !!login.volatile } } : {})
|
|
2260
|
+
}
|
|
2033
2261
|
})
|
|
2034
2262
|
},
|
|
2035
2263
|
async currentProfile () {
|
|
@@ -2060,6 +2288,14 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
2060
2288
|
},
|
|
2061
2289
|
async switchProfile ({ id } = {}) {
|
|
2062
2290
|
if (!loadProfiles().find((p) => p.id === id)) throw new Error('profile does not exist')
|
|
2291
|
+
// Una cuenta volátil no puede ser «la activa» de este navegador: vive en la memoria de
|
|
2292
|
+
// esta pestaña y no sobreviviría a la recarga que viene justo después.
|
|
2293
|
+
if (volatilePids.has(id)) {
|
|
2294
|
+
throw Object.assign(new Error('that account only lives in this tab'), { code: 'volatile-profile' })
|
|
2295
|
+
}
|
|
2296
|
+
// Y cambiarse DESDE una volátil es dejarla: se suelta la plaza en la bóveda en vez de
|
|
2297
|
+
// dejar un inicio de sesión abierto sin nadie dentro.
|
|
2298
|
+
if (volatilePids.has(currentPid)) { try { await handlers.logoutLogin({}) } catch (_) {} }
|
|
2063
2299
|
rawKv.setItem(CURRENT_STORAGE, id) // la app recarga la página → re-init con el nuevo perfil
|
|
2064
2300
|
return { id }
|
|
2065
2301
|
},
|
|
@@ -2080,6 +2316,53 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
2080
2316
|
return purgeProfile(id)
|
|
2081
2317
|
},
|
|
2082
2318
|
|
|
2319
|
+
// ----- ENTRAR CON USUARIO Y CONTRASEÑA -----
|
|
2320
|
+
|
|
2321
|
+
/**
|
|
2322
|
+
* ENTRAR en un aparato de tu cuenta desde un navegador que no te conoce, con
|
|
2323
|
+
* `nombre@AB12-CD34-EF56` y la contraseña. Al volver, ESTE navegador es ese aparato y la
|
|
2324
|
+
* app tiene que RECARGAR, como con cualquier cambio de cuenta.
|
|
2325
|
+
*
|
|
2326
|
+
* `remember: false` (lo normal en un equipo prestado) deja la cuenta en MEMORIA: se va
|
|
2327
|
+
* al cerrar o recargar esta pestaña, y el disco de esa máquina queda como estaba. Con
|
|
2328
|
+
* `true` se guarda como cualquier otra cuenta de este navegador —la llave como
|
|
2329
|
+
* `CryptoKey` no extraíble, nunca en claro— hasta que salgas.
|
|
2330
|
+
*
|
|
2331
|
+
* La contraseña NO viaja: lo que viaja son los mensajes de OPAQUE, que no la llevan ni
|
|
2332
|
+
* dejan adivinarla (`@dotrino/vault/login-client`).
|
|
2333
|
+
*/
|
|
2334
|
+
async loginWithPassword ({ address, password, remember = false, label = '', proxyUrl = null } = {}) {
|
|
2335
|
+
const url = proxyUrl || DEFAULT_PROXY
|
|
2336
|
+
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
2337
|
+
const { loginWithPassword: entrar } = await import(/* @vite-ignore */ `${PILAR_VAULT}/login-client`)
|
|
2338
|
+
const client = new WebSocketProxyClient({ url, enableWebRTC: false, autoReconnect: false })
|
|
2339
|
+
await client.connect()
|
|
2340
|
+
let entrada
|
|
2341
|
+
try {
|
|
2342
|
+
entrada = await entrar({ transport: client, address, password, label })
|
|
2343
|
+
} finally { try { client.close() } catch (_) {} }
|
|
2344
|
+
return adoptLogin(entrada, { remember, proxy: url })
|
|
2345
|
+
},
|
|
2346
|
+
|
|
2347
|
+
/**
|
|
2348
|
+
* SALIR del inicio de sesión con contraseña: se le dice a la bóveda que suelte la plaza
|
|
2349
|
+
* y la cuenta desaparece de este navegador. Sin `id`, el activo.
|
|
2350
|
+
*/
|
|
2351
|
+
async logoutLogin ({ id = null } = {}) {
|
|
2352
|
+
const pid = id || currentPid
|
|
2353
|
+
const meta = loginMetaOf(pid)
|
|
2354
|
+
if (!meta) throw Object.assign(new Error('that account was not entered with a password'), { code: 'not-a-login' })
|
|
2355
|
+
if (pid !== currentPid) throw Object.assign(new Error('switch to that account before leaving it'), { code: 'not-current' })
|
|
2356
|
+
const avisado = await closeLoginOn(meta)
|
|
2357
|
+
if (volatilePids.has(pid)) {
|
|
2358
|
+
const r = await dropVolatile(pid) // borra la cuenta entera, llaves incluidas
|
|
2359
|
+
return { ok: true, told: avisado.ok, current: r.current }
|
|
2360
|
+
}
|
|
2361
|
+
const r = await purgeProfile(pid)
|
|
2362
|
+
if (r.current && r.current !== pid) await openProfileInMemory(r.current)
|
|
2363
|
+
return { ok: true, told: avisado.ok, current: r.current }
|
|
2364
|
+
},
|
|
2365
|
+
|
|
2083
2366
|
// ----- ACTA DE PERFIL -----
|
|
2084
2367
|
// Quién es de este perfil y qué puede hacer cada uno. Solo el master sella; los demás
|
|
2085
2368
|
// adoptan. Ver `dotrino-vault/docs/acta-de-perfil.md`.
|
|
@@ -3009,7 +3292,38 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
3009
3292
|
rawKv.setItem(CURRENT_STORAGE, currentPid)
|
|
3010
3293
|
}
|
|
3011
3294
|
}
|
|
3012
|
-
|
|
3295
|
+
// ----- CUENTAS DE PASO: barrer las abandonadas y recuperar la de ESTA pestaña -----
|
|
3296
|
+
//
|
|
3297
|
+
// Va después de elegir el perfil activo y antes de abrir ninguna llave: si esta pestaña
|
|
3298
|
+
// reclama una, es ESA la que se abre, y las que nadie mantiene vivas se van antes de que
|
|
3299
|
+
// nada las pueda usar.
|
|
3300
|
+
{
|
|
3301
|
+
const vivas = loadVolatile()
|
|
3302
|
+
const reclamada = (() => { try { return sessionKv?.getItem(VOLATILE_CLAIM) || null } catch (_) { return null } })()
|
|
3303
|
+
const ahora = Date.now()
|
|
3304
|
+
let cambio = false
|
|
3305
|
+
for (const [pid, visto] of Object.entries(vivas)) {
|
|
3306
|
+
if (pid === reclamada || (ahora - visto) < VOLATILE_STALE_MS) continue
|
|
3307
|
+
try { await purgeProfile(pid) }
|
|
3308
|
+
catch (e) { console.warn('[identity] could not sweep an abandoned walk-in account:', e?.message || e) }
|
|
3309
|
+
delete vivas[pid]; cambio = true
|
|
3310
|
+
}
|
|
3311
|
+
if (cambio) saveVolatile(vivas)
|
|
3312
|
+
|
|
3313
|
+
const sigueAhi = reclamada && vivas[reclamada] && rawKv.getItem(`dotrino.identity.p.${reclamada}.login`)
|
|
3314
|
+
if (sigueAhi) {
|
|
3315
|
+
volatilePids.add(reclamada)
|
|
3316
|
+
let suyo = null
|
|
3317
|
+
try { suyo = JSON.parse(rawKv.getItem(`dotrino.identity.p.${reclamada}.me`) || 'null') } catch (_) {}
|
|
3318
|
+
volatileProfiles = [{ id: reclamada, name: suyo?.nickname || '', pubkey: suyo?.publickey || null }]
|
|
3319
|
+
currentPid = reclamada
|
|
3320
|
+
keepBeating(reclamada)
|
|
3321
|
+
} else if (reclamada) {
|
|
3322
|
+
try { sessionKv?.removeItem(VOLATILE_CLAIM) } catch (_) {}
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3325
|
+
|
|
3326
|
+
await peers.setProfile?.(currentPid, { volatile: volatilePids.has(currentPid) })
|
|
3013
3327
|
|
|
3014
3328
|
keypair = await loadOrCreateKeypair()
|
|
3015
3329
|
publickeyJwkStr = JSON.stringify(keypair.publicJwk)
|
|
@@ -3097,6 +3411,13 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
3097
3411
|
return { locked: !keypair?.privateKey }
|
|
3098
3412
|
},
|
|
3099
3413
|
sync,
|
|
3414
|
+
/**
|
|
3415
|
+
* Adoptar un aparato que acaba de entrar con usuario y contraseña. Lo normal es llamar
|
|
3416
|
+
* al handler `loginWithPassword`, que primero HABLA con la bóveda; esto es solo la
|
|
3417
|
+
* segunda mitad —instalar lo que salió de ahí—, y vive en el objeto del núcleo (no en
|
|
3418
|
+
* `handlers`) porque ninguna aplicación tiene por qué poder instalar una identidad.
|
|
3419
|
+
*/
|
|
3420
|
+
adoptLogin,
|
|
3100
3421
|
onSyncStatus (fn) { if (sync) sync.onStatus(fn) },
|
|
3101
3422
|
onVaultEvent (fn) { vaultListeners.add(fn); return () => vaultListeners.delete(fn) }
|
|
3102
3423
|
}
|
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",
|
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
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Copia vendorizada de @dotrino/proxy-client@0.
|
|
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
|
-
|
|
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.
|
|
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
|
*
|