@dotrino/identity 0.96.1 → 0.98.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 +1 -1
- package/src/index.js +30 -0
- package/vault/acta.js +11 -2
- package/vault/session.js +1 -1
- package/vault/vault.js +113 -1
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -561,6 +561,36 @@ export class Identity {
|
|
|
561
561
|
/** Suscribe a eventos del self-vault ('selfVault'): { running?, pending?, error? }. */
|
|
562
562
|
onSelfVault (handler) { return this.on('selfVault', handler) }
|
|
563
563
|
|
|
564
|
+
// ----- entrar con usuario y contraseña, cuando la bóveda es ESTA pestaña -----
|
|
565
|
+
//
|
|
566
|
+
// La pestaña ya atendía el inicio de sesión desde el primer día; lo que faltaba era poder
|
|
567
|
+
// CREARLO, y sin eso solo el binario daba de alta el aparato — que rompe la regla de las
|
|
568
|
+
// tres versiones. Todo esto exige que esta pestaña sea la bóveda activa; si no lo es, se
|
|
569
|
+
// dice con `code: 'not-the-vault'` en vez de contestar una lista vacía.
|
|
570
|
+
|
|
571
|
+
/** Los aparatos que se abren con usuario y contraseña, con sus sesiones abiertas. */
|
|
572
|
+
async selfVaultLogins () { return this._call('selfVaultLogins') }
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Da de alta uno. La contraseña **no sale del iframe**: se comprueba con OPAQUE y de ella
|
|
576
|
+
* deriva la llave que cierra el paquete con las privadas del aparato nuevo.
|
|
577
|
+
*
|
|
578
|
+
* Devuelve su dirección `nombre@AB12-CD34-EF56`, que es lo que hay que teclear al entrar.
|
|
579
|
+
*/
|
|
580
|
+
async selfVaultLoginAdd (opts) { return this._call('selfVaultLoginAdd', opts || {}, 60000) }
|
|
581
|
+
|
|
582
|
+
/** Cambia la contraseña: hace falta la vieja, y lo que estuviera abierto se cierra. */
|
|
583
|
+
async selfVaultLoginPasswd (opts) { return this._call('selfVaultLoginPasswd', opts || {}, 60000) }
|
|
584
|
+
|
|
585
|
+
/** Cierra un inicio de sesión abierto (sin `sid`, todos los de ese usuario). */
|
|
586
|
+
async selfVaultLoginClose (opts) { return this._call('selfVaultLoginClose', opts || {}) }
|
|
587
|
+
|
|
588
|
+
/** Quita la espera que dejan los intentos fallidos. */
|
|
589
|
+
async selfVaultLoginUnblock (user) { return this._call('selfVaultLoginUnblock', { user }) }
|
|
590
|
+
|
|
591
|
+
/** Lo quita, y saca su llave del acta: las dos cosas, o ninguna. */
|
|
592
|
+
async selfVaultLoginRemove (user) { return this._call('selfVaultLoginRemove', { user }, 30000) }
|
|
593
|
+
|
|
564
594
|
// ----- multi-perfil por dispositivo -----
|
|
565
595
|
// Podés tener varios perfiles (identidades) en el mismo navegador, cada uno conectado o no
|
|
566
596
|
// a su propio vault. Crear/cambiar setea el perfil activo; la app RECARGA la página y toma
|
package/vault/acta.js
CHANGED
|
@@ -103,7 +103,7 @@ const conCampoSellador = (acta) => Number(acta?.v) < V_SIN_CAMPO_SELLADOR
|
|
|
103
103
|
* el rol de master, que no se delega. Así un dispositivo con `admin` robado hace daño
|
|
104
104
|
* acotado y **reversible** (se le revoca), en vez de poder dejarte fuera de tu cuenta.
|
|
105
105
|
*/
|
|
106
|
-
export const CAPS = Object.freeze(['sign', 'store', 'read', 'secrets', 'admin', 'approve', 'passwords', 'sealer', 'unattended', 'replica'])
|
|
106
|
+
export const CAPS = Object.freeze(['sign', 'store', 'read', 'secrets', 'admin', 'approve', 'passwords', 'passkeys', 'sealer', 'unattended', 'replica'])
|
|
107
107
|
|
|
108
108
|
/** Capacidades de un DISPOSITIVO (sin CN): acceso a todo lo del usuario. */
|
|
109
109
|
/**
|
|
@@ -117,6 +117,15 @@ export const CAPS = Object.freeze(['sign', 'store', 'read', 'secrets', 'admin',
|
|
|
117
117
|
* pedirle algo a la bóveda es exactamente lo que decide el acta — tener dos registros
|
|
118
118
|
* de lo mismo obliga a acordarse de los dos al quitar un aparato.
|
|
119
119
|
*
|
|
120
|
+
* `passkeys` es ABRIR LA PRIVADA DE UNA PASSKEY. Una passkey no se parece a una
|
|
121
|
+
* contraseña: es una llave privada que firma el reto de un sitio, y si se copia sirve
|
|
122
|
+
* **hasta que la borres en cada sitio** donde la registraste — no hay nada que «cambiar».
|
|
123
|
+
* Por eso su envoltura solo se le hace a quien tenga `passwords` **y** esto
|
|
124
|
+
* (`dotrino-passmanager/docs/sealed-passwords.md` §2.8).
|
|
125
|
+
*
|
|
126
|
+
* Sin `passwords` no significa nada: la passkey vive en una entrada de contraseñas. Y una
|
|
127
|
+
* SESIÓN no lo lleva nunca, como `passwords` (`session.js`).
|
|
128
|
+
*
|
|
120
129
|
* `replica` es REPARTIR, NO DECIDIR. Un replicador no tiene maestra: guarda el acta y los
|
|
121
130
|
* sobres —que ya vienen sellados a su destinatario, así que tampoco puede abrirlos— y los
|
|
122
131
|
* entrega cuando la bóveda no está. Firma su respuesta con su propia llave de aparato, y
|
|
@@ -140,7 +149,7 @@ export const CAPS = Object.freeze(['sign', 'store', 'read', 'secrets', 'admin',
|
|
|
140
149
|
* de la cuenta, se ve en la pantalla de permisos como los demás, y se quita quitándolo —
|
|
141
150
|
* sin acordarse de un segundo registro escondido.
|
|
142
151
|
*/
|
|
143
|
-
export const DEVICE_CAPS = Object.freeze(['sign', 'store', 'read', 'admin', 'approve', 'passwords', 'sealer', 'unattended', 'replica'])
|
|
152
|
+
export const DEVICE_CAPS = Object.freeze(['sign', 'store', 'read', 'admin', 'approve', 'passwords', 'passkeys', 'sealer', 'unattended', 'replica'])
|
|
144
153
|
|
|
145
154
|
/**
|
|
146
155
|
* Lo que recibe un dispositivo recién emparejado. `admin` **no está**: no se
|
package/vault/session.js
CHANGED
|
@@ -57,7 +57,7 @@ export const SESSION_SCOPES = Object.freeze(['id:whoami', 'vault:store'])
|
|
|
57
57
|
* blanca a propósito: si mañana alguien añade un alcance a `SESSION_SCOPES` sin pensarlo,
|
|
58
58
|
* esto sigue cortando lo que no puede pasar.
|
|
59
59
|
*/
|
|
60
|
-
export const SESSION_FORBIDDEN = Object.freeze(['secrets', 'admin', 'approve', 'sealer', 'passwords', 'unattended', 'replica'])
|
|
60
|
+
export const SESSION_FORBIDDEN = Object.freeze(['secrets', 'admin', 'approve', 'sealer', 'passwords', 'passkeys', 'unattended', 'replica'])
|
|
61
61
|
|
|
62
62
|
/** Qué capacidad del acta hace falta para conceder cada alcance de sesión. */
|
|
63
63
|
const SCOPE_NEEDS = Object.freeze({ 'id:whoami': null, 'vault:store': 'store' })
|
package/vault/vault.js
CHANGED
|
@@ -383,7 +383,119 @@ import { pubkeyId } from './capabilities.js'
|
|
|
383
383
|
},
|
|
384
384
|
// Presencia online (ping/pong) de las máquinas enroladas. Requiere que ESTE
|
|
385
385
|
// iframe sea el daemon activo (tiene el cliente del proxy); si no, devuelve [].
|
|
386
|
-
selfVaultProbe: async ({ pubkeys }) => ({ online: [...(await probeOnline(pubkeys || []))] })
|
|
386
|
+
selfVaultProbe: async ({ pubkeys }) => ({ online: [...(await probeOnline(pubkeys || []))] }),
|
|
387
|
+
|
|
388
|
+
// ----- ENTRAR CON USUARIO Y CONTRASEÑA, desde la PESTAÑA -----
|
|
389
|
+
//
|
|
390
|
+
// La bóveda-pestaña ya sabía ATENDER un inicio de sesión desde el primer día; lo que no
|
|
391
|
+
// había era forma de CREAR uno: el mostrador estaba montado y sus operaciones no
|
|
392
|
+
// asomaban por aquí, así que solo el binario podía dar de alta el aparato. Eso rompe la
|
|
393
|
+
// regla de las tres versiones (`sealed-passwords.md` §2.7), y esto la cumple.
|
|
394
|
+
//
|
|
395
|
+
// El OPAQUE de las dos puntas corre AQUÍ DENTRO: el alta necesita la mitad del cliente
|
|
396
|
+
// —la que tiene la contraseña— y la mitad del servidor, y las dos están en esta pestaña
|
|
397
|
+
// cuando es bóveda. La contraseña cruza el `postMessage` como ya lo hace la del perfil
|
|
398
|
+
// (`unlockProfile`), y no sale de este origen.
|
|
399
|
+
selfVaultLogins: async () => (daemon ? daemon.listLogins() : []),
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* DAR DE ALTA un aparato que se abre con usuario y contraseña.
|
|
403
|
+
*
|
|
404
|
+
* Las llaves del aparato NACEN aquí y salen ya cerradas con lo que deriva la contraseña:
|
|
405
|
+
* la bóveda guarda un paquete que no puede abrir. Es el mismo camino que `logins add`
|
|
406
|
+
* del binario, con la misma pieza compartida.
|
|
407
|
+
*/
|
|
408
|
+
selfVaultLoginAdd: async ({ user, password, label = '', scope = null, unattended = false } = {}) => {
|
|
409
|
+
const d = pidaDaemon()
|
|
410
|
+
const { client: opaque } = await import('@dotrino/opaque')
|
|
411
|
+
const { makeDeviceKey, makeDeviceEncKey } = await import('@dotrino/identity/capabilities')
|
|
412
|
+
const { sealDeviceKeys, loginAddress, accountFingerprint } = await import('@dotrino/vault/password-logins')
|
|
413
|
+
if (typeof password !== 'string' || password.length < 12) {
|
|
414
|
+
throw Object.assign(new Error('the password must be at least 12 characters'), { code: 'weak-password' })
|
|
415
|
+
}
|
|
416
|
+
const nombre = String(label || 'equipo prestado')
|
|
417
|
+
const reg = opaque.registrationStart({ password })
|
|
418
|
+
const { response } = d.loginRegisterBegin({ user, request: reg.request })
|
|
419
|
+
const fin = opaque.registrationFinish({ state: reg.state, response, password })
|
|
420
|
+
const device = await makeDeviceKey({ label: nombre })
|
|
421
|
+
const enc = await makeDeviceEncKey()
|
|
422
|
+
const blob = await sealDeviceKeys(fin.exportKey, { sign: device.privateJwk, enc: enc.encPrivateJwk })
|
|
423
|
+
const r = await d.loginRegisterFinish({
|
|
424
|
+
user, upload: fin.upload, pub: device.publickey, encPub: enc.encPublickey,
|
|
425
|
+
label: nombre, blob, ...(Array.isArray(scope) && scope.length ? { scope } : {}), unattended: !!unattended
|
|
426
|
+
})
|
|
427
|
+
return { ...r, address: loginAddress(user, await accountFingerprint(selfIdentity)) }
|
|
428
|
+
},
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* CAMBIAR LA CONTRASEÑA es abrir y volver a cerrar: el aparato, su llave y su papel
|
|
432
|
+
* siguen siendo los mismos. Por eso hace falta la vieja — sin ella no hay nada que
|
|
433
|
+
* volver a cerrar — y por eso lo que estuviera abierto se cierra.
|
|
434
|
+
*/
|
|
435
|
+
selfVaultLoginPasswd: async ({ user, oldPassword, newPassword } = {}) => {
|
|
436
|
+
const d = pidaDaemon()
|
|
437
|
+
const { client: opaque } = await import('@dotrino/opaque')
|
|
438
|
+
const { sealDeviceKeys, openDeviceKeys } = await import('@dotrino/vault/password-logins')
|
|
439
|
+
if (typeof newPassword !== 'string' || newPassword.length < 12) {
|
|
440
|
+
throw Object.assign(new Error('the password must be at least 12 characters'), { code: 'weak-password' })
|
|
441
|
+
}
|
|
442
|
+
const start = opaque.loginStart({ password: oldPassword })
|
|
443
|
+
const begun = d.loginBegin({ user, request: start.request })
|
|
444
|
+
let fin
|
|
445
|
+
try { fin = opaque.loginFinish({ state: start.state, response: begun.response, password: oldPassword }) }
|
|
446
|
+
catch (_) { throw Object.assign(new Error('wrong password'), { code: 'login-failed' }) }
|
|
447
|
+
const entered = d.loginEnd({ lid: begun.lid, finalization: fin.finalization, label: 'consola' })
|
|
448
|
+
const keys = await openDeviceKeys(fin.exportKey, entered.blob)
|
|
449
|
+
|
|
450
|
+
const reg = opaque.registrationStart({ password: newPassword })
|
|
451
|
+
const { response } = d.loginRegisterBegin({ user, request: reg.request, replace: true })
|
|
452
|
+
const nueva = opaque.registrationFinish({ state: reg.state, response, password: newPassword })
|
|
453
|
+
await d.loginRegisterFinish({
|
|
454
|
+
user, upload: nueva.upload, blob: await sealDeviceKeys(nueva.exportKey, keys), replace: true
|
|
455
|
+
})
|
|
456
|
+
return { ok: true, user }
|
|
457
|
+
},
|
|
458
|
+
|
|
459
|
+
/** Cerrar un inicio de sesión abierto (sin `sid`, todos los de ese usuario). */
|
|
460
|
+
selfVaultLoginClose: async ({ user, sid = null } = {}) => {
|
|
461
|
+
const d = pidaDaemon()
|
|
462
|
+
if (sid) return d.closeLogin({ user, sid })
|
|
463
|
+
const fila = d.listLogins().find((x) => x.user === user)
|
|
464
|
+
for (const s of fila?.sessions || []) d.closeLogin({ user, sid: s.sid })
|
|
465
|
+
return { ok: true, closed: (fila?.sessions || []).length }
|
|
466
|
+
},
|
|
467
|
+
|
|
468
|
+
/** Quitar la espera de los intentos fallidos, desde la máquina de la bóveda. */
|
|
469
|
+
selfVaultLoginUnblock: async ({ user } = {}) => pidaDaemon().clearLoginBlock({ user }),
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* QUITARLO. Se va de aquí **y su llave sale del acta**: borrar solo el inicio de sesión
|
|
473
|
+
* dejaba un miembro que ya no puede entrar y sigue siendo de la cuenta.
|
|
474
|
+
*/
|
|
475
|
+
selfVaultLoginRemove: async ({ user } = {}) => {
|
|
476
|
+
const d = pidaDaemon()
|
|
477
|
+
const fila = d.listLogins().find((x) => x.user === user)
|
|
478
|
+
const r = d.removeLogin({ user })
|
|
479
|
+
if (r?.ok && fila?.pub) {
|
|
480
|
+
try { await handlers.revokeDevice({ sub: fila.pub }) } catch (e) {
|
|
481
|
+
throw Object.assign(new Error(`the login is gone but its key is still in the record: ${e.message}`), { code: 'revoke-failed' })
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return { ...r, deviceId: fila?.deviceId || null }
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* El mostrador solo existe mientras ESTA pestaña sea la bóveda activa. Se dice con esas
|
|
490
|
+
* palabras porque es lo que hay que hacer: abrirla y dejarla visible.
|
|
491
|
+
*/
|
|
492
|
+
function pidaDaemon () {
|
|
493
|
+
if (!daemon) {
|
|
494
|
+
throw Object.assign(
|
|
495
|
+
new Error('this tab is not the active vault: open it as a visible tab and turn on «this device is a vault»'),
|
|
496
|
+
{ code: 'not-the-vault' })
|
|
497
|
+
}
|
|
498
|
+
return daemon
|
|
387
499
|
}
|
|
388
500
|
|
|
389
501
|
window.addEventListener('message', async (event) => {
|