@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
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @dotrino/opaque — OPAQUE (RFC 9807) para el ecosistema Dotrino.
|
|
3
|
+
*
|
|
4
|
+
* Comprueba una contraseña SIN que el servidor la vea nunca y sin entregar nada con qué
|
|
5
|
+
* adivinarla desde fuera. Lo usan las tres versiones del vault (el binario, la pestaña y la
|
|
6
|
+
* extensión) y el gestor, para el aparato que se abre con usuario y contraseña.
|
|
7
|
+
*
|
|
8
|
+
* Aquí no hay criptografía propia: el protocolo es `opaque-ke` (Meta, auditado por NCC
|
|
9
|
+
* Group) compilado a WASM desde su código fuente en nuestro CI. Este archivo solo pone
|
|
10
|
+
* nombres y comprueba lo que entra.
|
|
11
|
+
*
|
|
12
|
+
* Todo lo que entra y sale son cadenas base64url. Los errores llevan `code`:
|
|
13
|
+
* · `bad-input` — falta un dato o no se puede leer
|
|
14
|
+
* · `login-failed` — contraseña equivocada, usuario inexistente, identificadores que no
|
|
15
|
+
* casan o un mensaje alterado. A propósito son el MISMO código: distinguirlos
|
|
16
|
+
* le diría a quien prueba qué usuarios existen.
|
|
17
|
+
* · `protocol` — cualquier otro fallo del protocolo
|
|
18
|
+
* · `internal` — un fallo que no viene del protocolo (no debería pasar)
|
|
19
|
+
*/
|
|
20
|
+
import * as wasm from '../build/opaque.js'
|
|
21
|
+
import wasmBytes from '../build/wasm-bytes.js'
|
|
22
|
+
|
|
23
|
+
export class OpaqueError extends Error {
|
|
24
|
+
constructor (code, message) {
|
|
25
|
+
super(message)
|
|
26
|
+
this.name = 'OpaqueError'
|
|
27
|
+
this.code = code
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let ready = false
|
|
32
|
+
function init () {
|
|
33
|
+
if (ready) return
|
|
34
|
+
const bin = Uint8Array.from(atob(wasmBytes), (c) => c.charCodeAt(0))
|
|
35
|
+
wasm.initSync({ module: bin })
|
|
36
|
+
ready = true
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const CODE = /^(bad-input|login-failed|protocol): ([\s\S]*)$/
|
|
40
|
+
|
|
41
|
+
function call (fn, ...args) {
|
|
42
|
+
init()
|
|
43
|
+
try {
|
|
44
|
+
return fn(...args)
|
|
45
|
+
} catch (e) {
|
|
46
|
+
const msg = String(e?.message ?? e)
|
|
47
|
+
const m = CODE.exec(msg)
|
|
48
|
+
if (m) throw new OpaqueError(m[1], m[2])
|
|
49
|
+
throw new OpaqueError('internal', msg)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function need (name, v) {
|
|
54
|
+
if (typeof v !== 'string' || !v) throw new OpaqueError('bad-input', `${name} is required`)
|
|
55
|
+
return v
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Los identificadores que se atan al intercambio. Si se usan, las DOS puntas tienen que
|
|
60
|
+
* pasar los mismos; si no casan, el inicio falla como una contraseña equivocada.
|
|
61
|
+
*/
|
|
62
|
+
function idents (identifiers) {
|
|
63
|
+
if (identifiers == null || typeof identifiers !== 'object') throw new OpaqueError('bad-input', 'identifiers must be an object')
|
|
64
|
+
const { client, server } = identifiers
|
|
65
|
+
for (const [k, v] of [['identifiers.client', client], ['identifiers.server', server]]) {
|
|
66
|
+
if (v !== undefined && (typeof v !== 'string' || !v)) throw new OpaqueError('bad-input', `${k} must be a non-empty string`)
|
|
67
|
+
}
|
|
68
|
+
return [client, server]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** La suite y sus parámetros. Se guarda junto a cada registro. */
|
|
72
|
+
export function suiteId () {
|
|
73
|
+
return call(wasm.suiteId)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const server = {
|
|
77
|
+
/** La preparación del servidor: SECRETA, una por bóveda. Sin ella no se puede comprobar nada. */
|
|
78
|
+
createSetup () {
|
|
79
|
+
return call(wasm.serverCreateSetup)
|
|
80
|
+
},
|
|
81
|
+
publicKey ({ setup } = {}) {
|
|
82
|
+
return call(wasm.serverPublicKey, need('setup', setup))
|
|
83
|
+
},
|
|
84
|
+
registrationResponse ({ setup, request, credentialId } = {}) {
|
|
85
|
+
return call(wasm.serverRegistrationResponse, need('setup', setup), need('request', request), need('credentialId', credentialId))
|
|
86
|
+
},
|
|
87
|
+
/** Lo que se guarda del usuario (el «registro»). */
|
|
88
|
+
registrationFinish ({ upload } = {}) {
|
|
89
|
+
return call(wasm.serverRegistrationFinish, need('upload', upload))
|
|
90
|
+
},
|
|
91
|
+
/**
|
|
92
|
+
* `record` tiene que venir SIEMPRE: el registro, o `null` si el usuario no existe. Con
|
|
93
|
+
* `null` responde igual, así que desde fuera no se sabe qué usuarios hay.
|
|
94
|
+
*/
|
|
95
|
+
loginStart ({ setup, record, request, credentialId, identifiers = {} } = {}) {
|
|
96
|
+
if (record !== null && (typeof record !== 'string' || !record)) {
|
|
97
|
+
throw new OpaqueError('bad-input', 'record must be the stored record, or null for an unknown user')
|
|
98
|
+
}
|
|
99
|
+
return call(wasm.serverLoginStart, need('setup', setup), record ?? undefined, need('request', request), need('credentialId', credentialId), ...idents(identifiers))
|
|
100
|
+
},
|
|
101
|
+
loginFinish ({ state, finalization, identifiers = {} } = {}) {
|
|
102
|
+
return call(wasm.serverLoginFinish, need('state', state), need('finalization', finalization), ...idents(identifiers))
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const client = {
|
|
107
|
+
registrationStart ({ password } = {}) {
|
|
108
|
+
return call(wasm.clientRegistrationStart, need('password', password))
|
|
109
|
+
},
|
|
110
|
+
/** Devuelve `upload` (para el servidor) y `exportKey`, que solo sale de la contraseña. */
|
|
111
|
+
registrationFinish ({ state, response, password, identifiers = {} } = {}) {
|
|
112
|
+
return call(wasm.clientRegistrationFinish, need('state', state), need('response', response), need('password', password), ...idents(identifiers))
|
|
113
|
+
},
|
|
114
|
+
loginStart ({ password } = {}) {
|
|
115
|
+
return call(wasm.clientLoginStart, need('password', password))
|
|
116
|
+
},
|
|
117
|
+
/** Devuelve `finalization` (para el servidor), `sessionKey` y el mismo `exportKey` del registro. */
|
|
118
|
+
loginFinish ({ state, response, password, identifiers = {} } = {}) {
|
|
119
|
+
return call(wasm.clientLoginFinish, need('state', state), need('response', response), need('password', password), ...idents(identifiers))
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export default { suiteId, server, client, OpaqueError }
|
|
@@ -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,5 +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
|
+
passwordLogins.js es el aparato que se abre con usuario y contraseña: lo carga
|
|
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.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* b64.js — base64url a mano, sin `Buffer` ni `btoa`.
|
|
3
|
+
*
|
|
4
|
+
* Vive aparte porque lo usan piezas que corren en los tres sitios: el binario (Node), la
|
|
5
|
+
* pestaña y la extensión. `Buffer` no existe en el navegador y `btoa` no existe en algunos
|
|
6
|
+
* workers, así que la única forma de tener UNA implementación es esta.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const B64_STD = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
|
10
|
+
|
|
11
|
+
export function bytesToB64url (bytes) {
|
|
12
|
+
let out = ''
|
|
13
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
14
|
+
const a = bytes[i]; const b = bytes[i + 1]; const c = bytes[i + 2]
|
|
15
|
+
out += B64_STD[a >> 2]
|
|
16
|
+
out += B64_STD[((a & 3) << 4) | ((b ?? 0) >> 4)]
|
|
17
|
+
if (b === undefined) break
|
|
18
|
+
out += B64_STD[((b & 15) << 2) | ((c ?? 0) >> 6)]
|
|
19
|
+
if (c === undefined) break
|
|
20
|
+
out += B64_STD[c & 63]
|
|
21
|
+
}
|
|
22
|
+
return out.replace(/\+/g, '-').replace(/\//g, '_')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function b64urlToBytes (s) {
|
|
26
|
+
const clean = String(s).replace(/-/g, '+').replace(/_/g, '/').replace(/[^A-Za-z0-9+/]/g, '')
|
|
27
|
+
const out = []
|
|
28
|
+
let acc = 0; let bits = 0
|
|
29
|
+
for (const ch of clean) {
|
|
30
|
+
const v = B64_STD.indexOf(ch)
|
|
31
|
+
if (v < 0) return null
|
|
32
|
+
acc = (acc << 6) | v; bits += 6
|
|
33
|
+
if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 0xff) }
|
|
34
|
+
}
|
|
35
|
+
return Uint8Array.from(out)
|
|
36
|
+
}
|
|
@@ -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)
|
|
@@ -46,11 +50,16 @@ export { deviceIdOf }
|
|
|
46
50
|
* `me.publickey`, `signData`, `signDelegation`, `listDelegations`, `revokeDelegation`.
|
|
47
51
|
* @param {object} [opts]
|
|
48
52
|
* @param {string} [opts.proxyUrl='wss://proxy.dotrino.com']
|
|
53
|
+
* @param {object} [opts.logins] escritorio de `createLoginDesk` (`./password-logins`) para
|
|
54
|
+
* ENTRAR CON USUARIO Y CONTRASEÑA. Lo monta quien levanta esta bóveda, porque en el
|
|
55
|
+
* navegador no hay archivos y el estado tiene que guardarlo él. Sin él, esta bóveda
|
|
56
|
+
* contesta `logins-unavailable` — no se inventa un almacén.
|
|
49
57
|
* @returns {Promise<object>} handle: { iss, proxy, client, startPairing, stopPairing,
|
|
50
|
-
* approve, reject, listPending, listMachines, revoke, getSelfCert,
|
|
51
|
-
*
|
|
58
|
+
* approve, reject, listPending, listMachines, revoke, getSelfCert, listLogins,
|
|
59
|
+
* loginRegisterBegin, loginRegisterFinish, loginBegin, loginEnd, closeLogin,
|
|
60
|
+
* clearLoginBlock, removeLogin, onPendingChange, onAdopted, close }
|
|
52
61
|
*/
|
|
53
|
-
export async function startDeviceVault (identity, { proxyUrl, client: injectedClient } = {}) {
|
|
62
|
+
export async function startDeviceVault (identity, { proxyUrl, client: injectedClient, logins = null } = {}) {
|
|
54
63
|
const iss = identity.me?.publickey
|
|
55
64
|
if (!iss) throw new Error('no identity: create/unlock your identity before using this device as a vault')
|
|
56
65
|
const proxy = proxyUrl || 'wss://proxy.dotrino.com'
|
|
@@ -95,10 +104,35 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
95
104
|
})()
|
|
96
105
|
|
|
97
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
|
+
|
|
98
131
|
const identify = async () => {
|
|
99
132
|
if (!client.token) return
|
|
100
133
|
// El sobre lo arma el pilar (`identifyAs`), que le pone el destinatario.
|
|
101
134
|
await client.identifyAs({ publickey: iss, sign: (d) => identity.signData(d), cert: selfCert })
|
|
135
|
+
await announce()
|
|
102
136
|
}
|
|
103
137
|
await identify()
|
|
104
138
|
client.on('token', () => identify().catch(() => {}))
|
|
@@ -328,8 +362,72 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
328
362
|
else if (p.type === MSG.GET) handle('get', handleGet(_from, p), _from)
|
|
329
363
|
else if (p.type === MSG.STORE) handle('store', handleStore(_from, p), _from)
|
|
330
364
|
else if (p.type === MSG.CHECK) handle('check', handleCheck(_from, p), _from)
|
|
365
|
+
// ENTRAR CON USUARIO Y CONTRASEÑA. La misma pieza que usa el binario
|
|
366
|
+
// (`passwordLogins.js`), con el almacén que le ponga quien monta esta bóveda: en el
|
|
367
|
+
// navegador no hay archivos, así que lo pone el iframe de identidad.
|
|
368
|
+
else if (p.type === MSG.LOGIN_START) handle('login', handleLoginStart(_from, p), _from)
|
|
369
|
+
else if (p.type === MSG.LOGIN_FINISH) handle('login', handleLoginFinish(_from, p), _from)
|
|
370
|
+
else if (p.type === MSG.LOGIN_CLOSE) handle('login', handleLoginClose(_from, p), _from)
|
|
331
371
|
})
|
|
332
372
|
|
|
373
|
+
// --- ENTRAR CON USUARIO Y CONTRASEÑA ---------------------------------------------------
|
|
374
|
+
//
|
|
375
|
+
// Sin almacén no se inventa nada: se contesta que aquí no hay inicios de sesión. Es una
|
|
376
|
+
// bóveda que no atiende esto, no una que dice que la contraseña está mal.
|
|
377
|
+
const noLogins = (from) => send(from, { type: MSG.ERROR, error: 'this vault does not keep password logins', code: 'logins-unavailable' })
|
|
378
|
+
|
|
379
|
+
/** Lo mismo desde la consola: sin almacén no se administra nada, y se dice. */
|
|
380
|
+
function needLogins () {
|
|
381
|
+
if (!logins) throw Object.assign(new Error('this vault does not keep password logins: no store was given to startDeviceVault'), { code: 'logins-unavailable' })
|
|
382
|
+
return logins
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function loginError (from, e) {
|
|
386
|
+
const code = e?.code
|
|
387
|
+
if (code === 'too-many-tries') return send(from, { type: MSG.ERROR, error: e.message, code, waitMs: e.waitMs || 0 })
|
|
388
|
+
if (code === 'login-failed' || code === 'no-exchange' || code === 'bad-input' || code === 'bad-user') {
|
|
389
|
+
return send(from, { type: MSG.ERROR, error: e.message, code })
|
|
390
|
+
}
|
|
391
|
+
send(from, { type: MSG.ERROR, error: 'login failed', code: 'login-error' })
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function handleLoginStart (from, p) {
|
|
395
|
+
if (!logins) return noLogins(from)
|
|
396
|
+
try {
|
|
397
|
+
const { lid, response } = logins.loginBegin({ user: p?.user, request: p?.request })
|
|
398
|
+
send(from, { type: MSG.LOGIN_RESPONSE, lid, response })
|
|
399
|
+
} catch (e) { loginError(from, e) }
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function handleLoginFinish (from, p) {
|
|
403
|
+
if (!logins) return noLogins(from)
|
|
404
|
+
try {
|
|
405
|
+
const r = logins.loginEnd({ lid: p?.lid, finalization: p?.finalization, label: p?.label })
|
|
406
|
+
const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
407
|
+
send(from, { type: MSG.LOGIN_OK, sid: r.sid, blob: r.blob, cert: r.cert, iss: r.iss || iss, acta })
|
|
408
|
+
} catch (e) { loginError(from, e) }
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Salir va firmado con la llave que acaba de abrir: cerrar la de otro sería echarlo. */
|
|
412
|
+
async function handleLoginClose (from, p) {
|
|
413
|
+
if (!logins) return noLogins(from)
|
|
414
|
+
const d = p?.data
|
|
415
|
+
if (typeof d?.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) {
|
|
416
|
+
return send(from, { type: MSG.ERROR, error: 'stale request', code: 'stale' })
|
|
417
|
+
}
|
|
418
|
+
if (d?.op !== 'login.close' || typeof d.publickey !== 'string' || typeof d.user !== 'string' || typeof d.sid !== 'string') {
|
|
419
|
+
return send(from, { type: MSG.ERROR, error: 'unauthorized: shape', code: 'bad-input' })
|
|
420
|
+
}
|
|
421
|
+
if (!(await verifyDeviceSig({ publickey: d.publickey, data: d, signature: p.signature }))) {
|
|
422
|
+
return send(from, { type: MSG.ERROR, error: 'unauthorized: bad-signature', code: 'bad-signature' })
|
|
423
|
+
}
|
|
424
|
+
const mine = logins.list().find((x) => x.user === d.user)
|
|
425
|
+
if (!mine || mine.pub !== d.publickey) {
|
|
426
|
+
return send(from, { type: MSG.ERROR, error: 'unauthorized: that key does not own this login', code: 'not-yours' })
|
|
427
|
+
}
|
|
428
|
+
send(from, { type: MSG.LOGIN_CLOSED, ok: logins.closeSession({ user: d.user, sid: d.sid }).ok })
|
|
429
|
+
}
|
|
430
|
+
|
|
333
431
|
/**
|
|
334
432
|
* Máquinas enroladas bajo esta identidad (P), vigentes, con scope de firma y label
|
|
335
433
|
* propio (excluye navegadores enrolados con label 'cli', que no atienden peticiones).
|
|
@@ -368,6 +466,31 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
368
466
|
// exactamente esto.
|
|
369
467
|
revoke: (nonce) => desk.revoke(nonce),
|
|
370
468
|
getSelfCert,
|
|
469
|
+
// ENTRAR CON USUARIO Y CONTRASEÑA, desde la consola de esta bóveda. Es la misma pieza
|
|
470
|
+
// que el binario (`registerLogin`), para que el aparato que sale de aquí sea idéntico.
|
|
471
|
+
// Sin almacén no hay nada que administrar, y se dice en vez de contestar una lista vacía.
|
|
472
|
+
listLogins: () => needLogins().list(),
|
|
473
|
+
loginRegisterBegin: (opts) => needLogins().registerBegin(opts),
|
|
474
|
+
// El alta se trae cuando se usa: arrastra el OPAQUE en WASM (unos 260 KB) y la mayoría
|
|
475
|
+
// de las apps que usan este módulo no crean ningún inicio de sesión.
|
|
476
|
+
loginRegisterFinish: async (opts) => {
|
|
477
|
+
const { registerLogin } = await import('./passwordLogins.js')
|
|
478
|
+
return registerLogin({ ...opts, identity, logins: needLogins() })
|
|
479
|
+
},
|
|
480
|
+
// Entrar desde la propia consola, sin pasar por el proxio. Hace falta para cambiar la
|
|
481
|
+
// contraseña: el paquete de llaves se abre con la vieja y se vuelve a cerrar con la nueva.
|
|
482
|
+
loginBegin: ({ user, request }) => needLogins().loginBegin({ user, request }),
|
|
483
|
+
loginEnd: ({ lid, finalization, label }) => needLogins().loginEnd({ lid, finalization, label }),
|
|
484
|
+
closeLogin: ({ user, sid }) => needLogins().closeSession({ user, sid }),
|
|
485
|
+
clearLoginBlock: ({ user }) => needLogins().clearBlock({ user }),
|
|
486
|
+
removeLogin: ({ user }) => {
|
|
487
|
+
const store = needLogins()
|
|
488
|
+
const found = store.list().find((x) => x.user === user)
|
|
489
|
+
if (!found) return { ok: false }
|
|
490
|
+
store.remove({ user })
|
|
491
|
+
// Quitarlo de aquí no lo saca del acta: lo suyo es revocar el aparato, y eso ya existe.
|
|
492
|
+
return { ok: true, pub: found.pub, deviceId: found.deviceId }
|
|
493
|
+
},
|
|
371
494
|
onPendingChange (fn) { _onPendingChange = fn || (() => {}) },
|
|
372
495
|
/** Camino A: la cuenta del aparato quedó adoptada por esta bóveda. */
|
|
373
496
|
onAdopted (fn) { _onAdopted = fn || (() => {}) },
|
|
@@ -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 }
|