@dotrino/vaultd 0.119.0 → 0.121.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/lib/src/b64.js +36 -0
- package/lib/src/index.js +97 -3
- package/lib/src/invite.js +3 -29
- package/lib/src/passwordLogins.js +431 -0
- package/lib/src/protocol.js +13 -0
- package/package.json +3 -2
- package/src/ctl.js +204 -0
- package/src/daemon.js +67 -0
- package/src/transport.js +68 -1
- package/src/vault.js +119 -2
package/lib/src/b64.js
ADDED
|
@@ -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
|
+
}
|
package/lib/src/index.js
CHANGED
|
@@ -46,11 +46,16 @@ export { deviceIdOf }
|
|
|
46
46
|
* `me.publickey`, `signData`, `signDelegation`, `listDelegations`, `revokeDelegation`.
|
|
47
47
|
* @param {object} [opts]
|
|
48
48
|
* @param {string} [opts.proxyUrl='wss://proxy.dotrino.com']
|
|
49
|
+
* @param {object} [opts.logins] escritorio de `createLoginDesk` (`./password-logins`) para
|
|
50
|
+
* ENTRAR CON USUARIO Y CONTRASEÑA. Lo monta quien levanta esta bóveda, porque en el
|
|
51
|
+
* navegador no hay archivos y el estado tiene que guardarlo él. Sin él, esta bóveda
|
|
52
|
+
* contesta `logins-unavailable` — no se inventa un almacén.
|
|
49
53
|
* @returns {Promise<object>} handle: { iss, proxy, client, startPairing, stopPairing,
|
|
50
|
-
* approve, reject, listPending, listMachines, revoke, getSelfCert,
|
|
51
|
-
*
|
|
54
|
+
* approve, reject, listPending, listMachines, revoke, getSelfCert, listLogins,
|
|
55
|
+
* loginRegisterBegin, loginRegisterFinish, loginBegin, loginEnd, closeLogin,
|
|
56
|
+
* clearLoginBlock, removeLogin, onPendingChange, onAdopted, close }
|
|
52
57
|
*/
|
|
53
|
-
export async function startDeviceVault (identity, { proxyUrl, client: injectedClient } = {}) {
|
|
58
|
+
export async function startDeviceVault (identity, { proxyUrl, client: injectedClient, logins = null } = {}) {
|
|
54
59
|
const iss = identity.me?.publickey
|
|
55
60
|
if (!iss) throw new Error('no identity: create/unlock your identity before using this device as a vault')
|
|
56
61
|
const proxy = proxyUrl || 'wss://proxy.dotrino.com'
|
|
@@ -328,8 +333,72 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
328
333
|
else if (p.type === MSG.GET) handle('get', handleGet(_from, p), _from)
|
|
329
334
|
else if (p.type === MSG.STORE) handle('store', handleStore(_from, p), _from)
|
|
330
335
|
else if (p.type === MSG.CHECK) handle('check', handleCheck(_from, p), _from)
|
|
336
|
+
// ENTRAR CON USUARIO Y CONTRASEÑA. La misma pieza que usa el binario
|
|
337
|
+
// (`passwordLogins.js`), con el almacén que le ponga quien monta esta bóveda: en el
|
|
338
|
+
// navegador no hay archivos, así que lo pone el iframe de identidad.
|
|
339
|
+
else if (p.type === MSG.LOGIN_START) handle('login', handleLoginStart(_from, p), _from)
|
|
340
|
+
else if (p.type === MSG.LOGIN_FINISH) handle('login', handleLoginFinish(_from, p), _from)
|
|
341
|
+
else if (p.type === MSG.LOGIN_CLOSE) handle('login', handleLoginClose(_from, p), _from)
|
|
331
342
|
})
|
|
332
343
|
|
|
344
|
+
// --- ENTRAR CON USUARIO Y CONTRASEÑA ---------------------------------------------------
|
|
345
|
+
//
|
|
346
|
+
// Sin almacén no se inventa nada: se contesta que aquí no hay inicios de sesión. Es una
|
|
347
|
+
// bóveda que no atiende esto, no una que dice que la contraseña está mal.
|
|
348
|
+
const noLogins = (from) => send(from, { type: MSG.ERROR, error: 'this vault does not keep password logins', code: 'logins-unavailable' })
|
|
349
|
+
|
|
350
|
+
/** Lo mismo desde la consola: sin almacén no se administra nada, y se dice. */
|
|
351
|
+
function needLogins () {
|
|
352
|
+
if (!logins) throw Object.assign(new Error('this vault does not keep password logins: no store was given to startDeviceVault'), { code: 'logins-unavailable' })
|
|
353
|
+
return logins
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function loginError (from, e) {
|
|
357
|
+
const code = e?.code
|
|
358
|
+
if (code === 'too-many-tries') return send(from, { type: MSG.ERROR, error: e.message, code, waitMs: e.waitMs || 0 })
|
|
359
|
+
if (code === 'login-failed' || code === 'no-exchange' || code === 'bad-input' || code === 'bad-user') {
|
|
360
|
+
return send(from, { type: MSG.ERROR, error: e.message, code })
|
|
361
|
+
}
|
|
362
|
+
send(from, { type: MSG.ERROR, error: 'login failed', code: 'login-error' })
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function handleLoginStart (from, p) {
|
|
366
|
+
if (!logins) return noLogins(from)
|
|
367
|
+
try {
|
|
368
|
+
const { lid, response } = logins.loginBegin({ user: p?.user, request: p?.request })
|
|
369
|
+
send(from, { type: MSG.LOGIN_RESPONSE, lid, response })
|
|
370
|
+
} catch (e) { loginError(from, e) }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function handleLoginFinish (from, p) {
|
|
374
|
+
if (!logins) return noLogins(from)
|
|
375
|
+
try {
|
|
376
|
+
const r = logins.loginEnd({ lid: p?.lid, finalization: p?.finalization, label: p?.label })
|
|
377
|
+
const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
378
|
+
send(from, { type: MSG.LOGIN_OK, sid: r.sid, blob: r.blob, cert: r.cert, iss: r.iss || iss, acta })
|
|
379
|
+
} catch (e) { loginError(from, e) }
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Salir va firmado con la llave que acaba de abrir: cerrar la de otro sería echarlo. */
|
|
383
|
+
async function handleLoginClose (from, p) {
|
|
384
|
+
if (!logins) return noLogins(from)
|
|
385
|
+
const d = p?.data
|
|
386
|
+
if (typeof d?.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) {
|
|
387
|
+
return send(from, { type: MSG.ERROR, error: 'stale request', code: 'stale' })
|
|
388
|
+
}
|
|
389
|
+
if (d?.op !== 'login.close' || typeof d.publickey !== 'string' || typeof d.user !== 'string' || typeof d.sid !== 'string') {
|
|
390
|
+
return send(from, { type: MSG.ERROR, error: 'unauthorized: shape', code: 'bad-input' })
|
|
391
|
+
}
|
|
392
|
+
if (!(await verifyDeviceSig({ publickey: d.publickey, data: d, signature: p.signature }))) {
|
|
393
|
+
return send(from, { type: MSG.ERROR, error: 'unauthorized: bad-signature', code: 'bad-signature' })
|
|
394
|
+
}
|
|
395
|
+
const mine = logins.list().find((x) => x.user === d.user)
|
|
396
|
+
if (!mine || mine.pub !== d.publickey) {
|
|
397
|
+
return send(from, { type: MSG.ERROR, error: 'unauthorized: that key does not own this login', code: 'not-yours' })
|
|
398
|
+
}
|
|
399
|
+
send(from, { type: MSG.LOGIN_CLOSED, ok: logins.closeSession({ user: d.user, sid: d.sid }).ok })
|
|
400
|
+
}
|
|
401
|
+
|
|
333
402
|
/**
|
|
334
403
|
* Máquinas enroladas bajo esta identidad (P), vigentes, con scope de firma y label
|
|
335
404
|
* propio (excluye navegadores enrolados con label 'cli', que no atienden peticiones).
|
|
@@ -368,6 +437,31 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
368
437
|
// exactamente esto.
|
|
369
438
|
revoke: (nonce) => desk.revoke(nonce),
|
|
370
439
|
getSelfCert,
|
|
440
|
+
// ENTRAR CON USUARIO Y CONTRASEÑA, desde la consola de esta bóveda. Es la misma pieza
|
|
441
|
+
// que el binario (`registerLogin`), para que el aparato que sale de aquí sea idéntico.
|
|
442
|
+
// Sin almacén no hay nada que administrar, y se dice en vez de contestar una lista vacía.
|
|
443
|
+
listLogins: () => needLogins().list(),
|
|
444
|
+
loginRegisterBegin: (opts) => needLogins().registerBegin(opts),
|
|
445
|
+
// El alta se trae cuando se usa: arrastra el OPAQUE en WASM (unos 260 KB) y la mayoría
|
|
446
|
+
// de las apps que usan este módulo no crean ningún inicio de sesión.
|
|
447
|
+
loginRegisterFinish: async (opts) => {
|
|
448
|
+
const { registerLogin } = await import('./passwordLogins.js')
|
|
449
|
+
return registerLogin({ ...opts, identity, logins: needLogins() })
|
|
450
|
+
},
|
|
451
|
+
// Entrar desde la propia consola, sin pasar por el proxio. Hace falta para cambiar la
|
|
452
|
+
// contraseña: el paquete de llaves se abre con la vieja y se vuelve a cerrar con la nueva.
|
|
453
|
+
loginBegin: ({ user, request }) => needLogins().loginBegin({ user, request }),
|
|
454
|
+
loginEnd: ({ lid, finalization, label }) => needLogins().loginEnd({ lid, finalization, label }),
|
|
455
|
+
closeLogin: ({ user, sid }) => needLogins().closeSession({ user, sid }),
|
|
456
|
+
clearLoginBlock: ({ user }) => needLogins().clearBlock({ user }),
|
|
457
|
+
removeLogin: ({ user }) => {
|
|
458
|
+
const store = needLogins()
|
|
459
|
+
const found = store.list().find((x) => x.user === user)
|
|
460
|
+
if (!found) return { ok: false }
|
|
461
|
+
store.remove({ user })
|
|
462
|
+
// Quitarlo de aquí no lo saca del acta: lo suyo es revocar el aparato, y eso ya existe.
|
|
463
|
+
return { ok: true, pub: found.pub, deviceId: found.deviceId }
|
|
464
|
+
},
|
|
371
465
|
onPendingChange (fn) { _onPendingChange = fn || (() => {}) },
|
|
372
466
|
/** Camino A: la cuenta del aparato quedó adoptada por esta bóveda. */
|
|
373
467
|
onAdopted (fn) { _onAdopted = fn || (() => {}) },
|
package/lib/src/invite.js
CHANGED
|
@@ -80,37 +80,11 @@ export const PAIR_URL = 'https://vault.dotrino.com/d#v='
|
|
|
80
80
|
|
|
81
81
|
// ---------------------------------------------------------------------------
|
|
82
82
|
// Conversiones (sin dependencias: esto corre en el navegador, en Node y dentro
|
|
83
|
-
// del binario SEA)
|
|
83
|
+
// del binario SEA). El base64url vive en `b64.js`, porque el paquete de llaves de
|
|
84
|
+
// los inicios de sesión usa el mismo.
|
|
84
85
|
// ---------------------------------------------------------------------------
|
|
85
86
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
function bytesToB64url (bytes) {
|
|
89
|
-
let out = ''
|
|
90
|
-
for (let i = 0; i < bytes.length; i += 3) {
|
|
91
|
-
const a = bytes[i]; const b = bytes[i + 1]; const c = bytes[i + 2]
|
|
92
|
-
out += B64_STD[a >> 2]
|
|
93
|
-
out += B64_STD[((a & 3) << 4) | ((b ?? 0) >> 4)]
|
|
94
|
-
if (b === undefined) break
|
|
95
|
-
out += B64_STD[((b & 15) << 2) | ((c ?? 0) >> 6)]
|
|
96
|
-
if (c === undefined) break
|
|
97
|
-
out += B64_STD[c & 63]
|
|
98
|
-
}
|
|
99
|
-
return out.replace(/\+/g, '-').replace(/\//g, '_')
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function b64urlToBytes (s) {
|
|
103
|
-
const clean = String(s).replace(/-/g, '+').replace(/_/g, '/').replace(/[^A-Za-z0-9+/]/g, '')
|
|
104
|
-
const out = []
|
|
105
|
-
let acc = 0; let bits = 0
|
|
106
|
-
for (const ch of clean) {
|
|
107
|
-
const v = B64_STD.indexOf(ch)
|
|
108
|
-
if (v < 0) return null
|
|
109
|
-
acc = (acc << 6) | v; bits += 6
|
|
110
|
-
if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 0xff) }
|
|
111
|
-
}
|
|
112
|
-
return Uint8Array.from(out)
|
|
113
|
-
}
|
|
87
|
+
import { bytesToB64url, b64urlToBytes } from './b64.js'
|
|
114
88
|
|
|
115
89
|
const utf8 = (s) => new TextEncoder().encode(s)
|
|
116
90
|
const fromUtf8 = (b) => new TextDecoder().decode(b)
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EL APARATO QUE SE ABRE CON USUARIO Y CONTRASEÑA.
|
|
3
|
+
*
|
|
4
|
+
* Es un miembro del acta como cualquier otro —su llave de firma, su llave de cifrado, sus
|
|
5
|
+
* permisos—, y lo único distinto es dónde vive su llave privada: aquí, **cifrada con algo
|
|
6
|
+
* que solo sale de la contraseña**. Diseño en
|
|
7
|
+
* `dotrino-passmanager/docs/temporary-access.md`.
|
|
8
|
+
*
|
|
9
|
+
* Lo que esta pieza guarda y lo que NO:
|
|
10
|
+
*
|
|
11
|
+
* · el registro OPAQUE del usuario y la preparación del servidor — con ellos NO se puede
|
|
12
|
+
* comprobar una contraseña sin el protocolo, ni sacarla de ahí;
|
|
13
|
+
* · el paquete con las llaves privadas del aparato, **cerrado por quien lo creó** con la
|
|
14
|
+
* llave que sale de la contraseña (`exportKey`). La bóveda no lo abre nunca: lo guarda
|
|
15
|
+
* y lo devuelve cuando alguien demuestra saber la contraseña;
|
|
16
|
+
* · el certificado del aparato y su acta, que son públicos.
|
|
17
|
+
*
|
|
18
|
+
* **La bóveda nunca ve la contraseña.** OPAQUE (`@dotrino/opaque`, RFC 9807) la comprueba
|
|
19
|
+
* sin recibirla y sin entregar nada con qué adivinarla desde fuera: solo se puede probar en
|
|
20
|
+
* línea, contra el freno de abajo.
|
|
21
|
+
*
|
|
22
|
+
* El escritorio (`createLoginDesk`) es puro: recibe el estado, lo devuelve cambiado y no toca
|
|
23
|
+
* el disco ni la red. Quien lo guarda y quien lo sirve es cada bóveda —`src/vault.js` en el
|
|
24
|
+
* binario, `lib/src/index.js` en la pestaña—. Lo único que no es puro es `registerLogin`, al
|
|
25
|
+
* final, porque hace falta la identidad que firma.
|
|
26
|
+
*/
|
|
27
|
+
import { server as opaque, suiteId } from '@dotrino/opaque'
|
|
28
|
+
import { deviceIdOf, scopeToCaps, scopeToCn } from './enroll.js'
|
|
29
|
+
import { SCOPE } from './protocol.js'
|
|
30
|
+
import { bytesToB64url, b64urlToBytes } from './b64.js'
|
|
31
|
+
|
|
32
|
+
/** Un usuario es la parte de antes de la `@` en `nombre@AB12-CD34-EF56`. */
|
|
33
|
+
export const isValidUser = (u) => typeof u === 'string' && /^[a-z0-9][a-z0-9._-]{0,31}$/.test(u)
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* LA DIRECCIÓN: `nombre@AB12-CD34-EF56` (`temporary-access.md` §3.2). Lo de después de la
|
|
37
|
+
* `@` son los primeros **48 bits de la huella de la cuenta** — el mismo `pubkeyId` que ya
|
|
38
|
+
* identifica cualquier llave, con un grupo más que `keyLabel`.
|
|
39
|
+
*
|
|
40
|
+
* Se ata a la CUENTA y no a una máquina porque la cuenta no cambia nunca y la bóveda puede
|
|
41
|
+
* mudarse o tener réplicas. Y son 48 bits y no 32 porque este código es lo único que ata la
|
|
42
|
+
* dirección a la cuenta de verdad: fabricar otra cuenta con la misma huella corta cuesta
|
|
43
|
+
* siglos con 48 bits y horas con los 32 de `keyLabel`.
|
|
44
|
+
*
|
|
45
|
+
* Vive aquí, y no en cada bóveda, porque una dirección que se escriba distinta en el binario
|
|
46
|
+
* y en la extensión no es la misma dirección.
|
|
47
|
+
*/
|
|
48
|
+
export const ACCOUNT_CODE_HEX = 12
|
|
49
|
+
export function accountCode (fingerprint) {
|
|
50
|
+
const hex = String(fingerprint || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase()
|
|
51
|
+
if (hex.length < ACCOUNT_CODE_HEX) throw err('bad-account', 'the account fingerprint is too short to build an address')
|
|
52
|
+
return hex.slice(0, ACCOUNT_CODE_HEX).match(/.{4}/g).join('-')
|
|
53
|
+
}
|
|
54
|
+
export const loginAddress = (user, fingerprint) => `${user}@${accountCode(fingerprint)}`
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* EL CANAL DONDE SE ANUNCIA CADA BÓVEDA DE ESA CUENTA, réplicas incluidas.
|
|
58
|
+
*
|
|
59
|
+
* Quien entra con usuario y contraseña no tiene ninguna llave todavía, así que no puede
|
|
60
|
+
* escribirle a la bóveda por su pubkey: no la sabe. Lo único que tiene es el código de la
|
|
61
|
+
* dirección, y con él lista este canal y encuentra a quien atiende.
|
|
62
|
+
*
|
|
63
|
+
* **El canal no prueba nada**: cualquiera puede publicarse en uno. Lo que ata la bóveda a
|
|
64
|
+
* la cuenta es el acta que enseña después — su huella tiene que empezar por ese mismo
|
|
65
|
+
* código. Por eso son 48 bits, y por eso mirar este canal solo deja ver que detrás de un
|
|
66
|
+
* código hay una bóveda encendida: ni de quién es, ni qué guarda.
|
|
67
|
+
*/
|
|
68
|
+
export const vaultChannel = (fingerprint) => `dotrino-vault-${accountCode(fingerprint)}`
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* EL FRENO. Cinco intentos y, a partir de ahí, una espera que se DUPLICA con cada fallo
|
|
72
|
+
* (decidido por el dueño el 2026-09-17). Se eligió frente a bloquear la cuenta porque un
|
|
73
|
+
* bloqueo deja que cualquiera que sepa tu usuario te impida entrar a propósito.
|
|
74
|
+
*
|
|
75
|
+
* **Se cuenta al EMPEZAR el intento, no al terminarlo**, y no es un detalle: en OPAQUE quien
|
|
76
|
+
* prueba una contraseña se entera ÉL SOLO al recibir la respuesta, sin mandar el último
|
|
77
|
+
* mensaje. Contando al final, quien prueba contraseñas no gastaría ni un intento y el freno
|
|
78
|
+
* no frenaría nada. Un inicio de sesión que termina bien reinicia la cuenta.
|
|
79
|
+
*
|
|
80
|
+
* El precio, dicho claro: quien sepa tu usuario puede gastarte los intentos y hacerte
|
|
81
|
+
* esperar. Por eso hay tope de una hora y `clearBlock`, que lo quita desde la máquina de la
|
|
82
|
+
* bóveda. No hay forma de evitarlo del todo: para saber si una contraseña es la buena hay
|
|
83
|
+
* que dejar intentar.
|
|
84
|
+
*/
|
|
85
|
+
export const FREE_TRIES = 5
|
|
86
|
+
export const BACKOFF_BASE_MS = 60_000
|
|
87
|
+
export const BACKOFF_CAP_MS = 60 * 60_000
|
|
88
|
+
export const backoffFor = (fails) =>
|
|
89
|
+
fails < FREE_TRIES ? 0 : Math.min(BACKOFF_BASE_MS * 2 ** (fails - FREE_TRIES), BACKOFF_CAP_MS)
|
|
90
|
+
|
|
91
|
+
/** Un intercambio de inicio de sesión a medias no puede quedarse abierto para siempre. */
|
|
92
|
+
export const EXCHANGE_TTL_MS = 2 * 60_000
|
|
93
|
+
|
|
94
|
+
const err = (code, message) => Object.assign(new Error(message), { code })
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// EL PAQUETE DE LLAVES
|
|
98
|
+
//
|
|
99
|
+
// Lo cierra y lo abre QUIEN SABE LA CONTRASEÑA: la CLI al crear el aparato, y el
|
|
100
|
+
// navegador al entrar. La bóveda solo lo guarda y lo devuelve — nunca tiene con qué
|
|
101
|
+
// abrirlo, y por eso esto no está en ninguna de las tres bóvedas sino aquí, donde las
|
|
102
|
+
// tres lo importan y cierran el paquete EXACTAMENTE igual.
|
|
103
|
+
//
|
|
104
|
+
// AES-GCM con los primeros 32 bytes de la llave que sale de la contraseña (`exportKey`
|
|
105
|
+
// de OPAQUE). El formato lleva marca de versión porque este blob se guarda durante años
|
|
106
|
+
// y lo leen tres programas distintos: sin ella, cambiarlo sería adivinar.
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
const BLOB_V = 'k1'
|
|
110
|
+
|
|
111
|
+
/** La llave AES que sale del `exportKey` del intercambio. No se guarda en ningún sitio. */
|
|
112
|
+
async function keyFrom (exportKey, use) {
|
|
113
|
+
const raw = b64urlToBytes(exportKey)
|
|
114
|
+
if (!raw || raw.length < 32) throw err('bad-key', 'the key that opens the package is not valid')
|
|
115
|
+
return crypto.subtle.importKey('raw', raw.subarray(0, 32), 'AES-GCM', false, [use])
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Cierra las llaves privadas del aparato. `keys` es JSON: `{ sign, enc }` en JWK. */
|
|
119
|
+
export async function sealDeviceKeys (exportKey, keys) {
|
|
120
|
+
const key = await keyFrom(exportKey, 'encrypt')
|
|
121
|
+
const iv = crypto.getRandomValues(new Uint8Array(12))
|
|
122
|
+
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(JSON.stringify(keys)))
|
|
123
|
+
return [BLOB_V, bytesToB64url(iv), bytesToB64url(new Uint8Array(ct))].join('.')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Abre el paquete. Una contraseña equivocada NO llega hasta aquí —OPAQUE la para antes—,
|
|
128
|
+
* así que si esto falla es que el paquete está roto o es de otra versión, y se dice.
|
|
129
|
+
*/
|
|
130
|
+
export async function openDeviceKeys (exportKey, blob) {
|
|
131
|
+
const [v, iv, ct] = String(blob || '').split('.')
|
|
132
|
+
if (v !== BLOB_V || !iv || !ct) throw err('bad-blob', 'the key package is not in a format this version understands')
|
|
133
|
+
const key = await keyFrom(exportKey, 'decrypt')
|
|
134
|
+
let plain
|
|
135
|
+
try {
|
|
136
|
+
plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: b64urlToBytes(iv) }, key, b64urlToBytes(ct))
|
|
137
|
+
} catch (_) { throw err('bad-blob', 'the key package did not open with that key') }
|
|
138
|
+
return JSON.parse(new TextDecoder().decode(plain))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @param {object} opts
|
|
143
|
+
* `load()` devuelve el estado guardado (o `null` la primera vez)
|
|
144
|
+
* `save(s)` lo guarda
|
|
145
|
+
* `now()` el reloj, para poder probar el freno sin esperar una hora
|
|
146
|
+
*/
|
|
147
|
+
export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
|
|
148
|
+
if (typeof load !== 'function' || typeof save !== 'function') throw new Error('createLoginDesk: load and save are required')
|
|
149
|
+
|
|
150
|
+
/** Intercambios a medias: viven en memoria, y por eso un reinicio los tira. */
|
|
151
|
+
const exchanges = new Map()
|
|
152
|
+
|
|
153
|
+
const read = () => {
|
|
154
|
+
const s = load()
|
|
155
|
+
if (s && typeof s === 'object') return s
|
|
156
|
+
return { v: 1, setup: null, users: {} }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** La preparación del servidor nace con el primer usuario y no se toca nunca más. */
|
|
160
|
+
function setupOf (state) {
|
|
161
|
+
if (state.setup) return state.setup
|
|
162
|
+
state.setup = opaque.createSetup()
|
|
163
|
+
return state.setup
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const userOf = (state, user) => {
|
|
167
|
+
if (!isValidUser(user)) throw err('bad-user', 'a user name is lowercase letters, digits, dot, dash or underscore (1-32)')
|
|
168
|
+
return state.users[user] || null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const sweepExchanges = () => {
|
|
172
|
+
const t = now()
|
|
173
|
+
for (const [lid, x] of exchanges) if (t - x.at > EXCHANGE_TTL_MS) exchanges.delete(lid)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
/** Lo que se puede enseñar: ni el registro, ni el paquete de llaves, ni la preparación. */
|
|
178
|
+
list () {
|
|
179
|
+
const state = read()
|
|
180
|
+
return Object.entries(state.users).map(([user, u]) => ({
|
|
181
|
+
user,
|
|
182
|
+
deviceId: u.deviceId || null,
|
|
183
|
+
label: u.label || '',
|
|
184
|
+
pub: u.pub,
|
|
185
|
+
createdAt: u.createdAt || 0,
|
|
186
|
+
passwordChangedAt: u.passwordChangedAt || u.createdAt || 0,
|
|
187
|
+
fails: u.fails || 0,
|
|
188
|
+
blockedUntil: u.nextTryAt || 0,
|
|
189
|
+
sessions: Object.entries(u.sessions || {}).map(([sid, s]) => ({
|
|
190
|
+
sid, openedAt: s.openedAt, lastUsedAt: s.lastUsedAt || s.openedAt, label: s.label || ''
|
|
191
|
+
})).sort((a, b) => b.lastUsedAt - a.lastUsedAt)
|
|
192
|
+
})).sort((a, b) => a.user.localeCompare(b.user))
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* ALTA, primer paso. La contraseña no llega hasta aquí: lo que llega es el mensaje de
|
|
197
|
+
* registro de OPAQUE, que no la lleva ni permite adivinarla.
|
|
198
|
+
*/
|
|
199
|
+
registerBegin ({ user, request, replace = false } = {}) {
|
|
200
|
+
const state = read()
|
|
201
|
+
const existing = userOf(state, user)
|
|
202
|
+
if (existing && !replace) throw err('user-exists', `there is already a login called "${user}"`)
|
|
203
|
+
if (!existing && replace) throw err('no-user', `there is no login called "${user}"`)
|
|
204
|
+
if (typeof request !== 'string' || !request) throw err('bad-input', 'request required')
|
|
205
|
+
const setup = setupOf(state)
|
|
206
|
+
const response = opaque.registrationResponse({ setup, request, credentialId: user })
|
|
207
|
+
save(state)
|
|
208
|
+
return { response, suite: suiteId() }
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* ALTA, segundo paso. `blob` son las llaves privadas del aparato, ya cerradas por quien
|
|
213
|
+
* creó el aparato con la llave que sale de la contraseña: aquí no se abre nunca.
|
|
214
|
+
*/
|
|
215
|
+
registerFinish ({ user, upload, pub, encPub = null, deviceId = null, label = '', blob, replace = false } = {}) {
|
|
216
|
+
const state = read()
|
|
217
|
+
const existing = userOf(state, user)
|
|
218
|
+
if (existing && !replace) throw err('user-exists', `there is already a login called "${user}"`)
|
|
219
|
+
if (!existing && replace) throw err('no-user', `there is no login called "${user}"`)
|
|
220
|
+
if (typeof blob !== 'string' || !blob) throw err('bad-input', 'blob required (the device keys, sealed with the password)')
|
|
221
|
+
if (!replace && (typeof pub !== 'string' || !pub)) throw err('bad-input', 'pub required')
|
|
222
|
+
const record = opaque.registrationFinish({ upload })
|
|
223
|
+
const t = now()
|
|
224
|
+
state.users[user] = {
|
|
225
|
+
...(existing || {}),
|
|
226
|
+
pub: replace ? existing.pub : pub,
|
|
227
|
+
encPub: replace ? existing.encPub : encPub,
|
|
228
|
+
deviceId: replace ? existing.deviceId : deviceId,
|
|
229
|
+
label: label || existing?.label || '',
|
|
230
|
+
record,
|
|
231
|
+
blob,
|
|
232
|
+
suite: suiteId(),
|
|
233
|
+
createdAt: existing?.createdAt || t,
|
|
234
|
+
passwordChangedAt: t,
|
|
235
|
+
// Cambiar la contraseña cierra lo abierto: si alguien entró con la vieja, deja de
|
|
236
|
+
// valer. Y el freno se reinicia, que si no la cuenta vieja castigaría a la nueva.
|
|
237
|
+
sessions: {},
|
|
238
|
+
fails: 0,
|
|
239
|
+
nextTryAt: 0
|
|
240
|
+
}
|
|
241
|
+
save(state)
|
|
242
|
+
return { user, suite: suiteId() }
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
/** El certificado del aparato, que firma la bóveda al admitirlo en el acta. Es público. */
|
|
246
|
+
setCert ({ user, cert, iss = null } = {}) {
|
|
247
|
+
const state = read()
|
|
248
|
+
const u = userOf(state, user)
|
|
249
|
+
if (!u) throw err('no-user', `there is no login called "${user}"`)
|
|
250
|
+
u.cert = cert
|
|
251
|
+
if (iss) u.iss = iss
|
|
252
|
+
save(state)
|
|
253
|
+
return { ok: true }
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* INICIO DE SESIÓN, primer paso.
|
|
258
|
+
*
|
|
259
|
+
* Un usuario que no existe recibe una respuesta **igual por fuera**, hecha con un
|
|
260
|
+
* registro inventado: desde fuera no se puede averiguar qué usuarios hay. Y cuenta
|
|
261
|
+
* contra el mismo freno, para que tampoco se pueda medir por el tiempo.
|
|
262
|
+
*/
|
|
263
|
+
loginBegin ({ user, request } = {}) {
|
|
264
|
+
sweepExchanges()
|
|
265
|
+
const state = read()
|
|
266
|
+
if (typeof request !== 'string' || !request) throw err('bad-input', 'request required')
|
|
267
|
+
const u = isValidUser(user) ? state.users[user] || null : null
|
|
268
|
+
const t = now()
|
|
269
|
+
if (u?.nextTryAt && u.nextTryAt > t) {
|
|
270
|
+
throw Object.assign(err('too-many-tries', 'too many tries: wait before trying again'), { waitMs: u.nextTryAt - t })
|
|
271
|
+
}
|
|
272
|
+
// EL INTENTO SE CUENTA AQUÍ. Ver el comentario del freno: quien prueba una contraseña
|
|
273
|
+
// puede no mandar nunca el último mensaje, así que si se contara al final no se
|
|
274
|
+
// contaría nada. `loginEnd` lo reinicia cuando el intento sale bien.
|
|
275
|
+
if (u) {
|
|
276
|
+
u.fails = (u.fails || 0) + 1
|
|
277
|
+
u.nextTryAt = t + backoffFor(u.fails)
|
|
278
|
+
}
|
|
279
|
+
const setup = setupOf(state)
|
|
280
|
+
const { state: serverState, response } = opaque.loginStart({
|
|
281
|
+
setup,
|
|
282
|
+
record: u?.record || null,
|
|
283
|
+
request,
|
|
284
|
+
credentialId: String(user || ''),
|
|
285
|
+
identifiers: {}
|
|
286
|
+
})
|
|
287
|
+
const lid = crypto.randomUUID()
|
|
288
|
+
exchanges.set(lid, { user: String(user || ''), known: !!u, state: serverState, at: t })
|
|
289
|
+
save(state)
|
|
290
|
+
return { lid, response }
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* INICIO DE SESIÓN, segundo paso. Si la contraseña es la buena, devuelve el paquete de
|
|
295
|
+
* llaves —que solo abre esa contraseña—, el certificado y el acta.
|
|
296
|
+
*/
|
|
297
|
+
loginEnd ({ lid, finalization, label = '' } = {}) {
|
|
298
|
+
sweepExchanges()
|
|
299
|
+
const x = exchanges.get(lid)
|
|
300
|
+
if (!x) throw err('no-exchange', 'that login is not in flight any more: start again')
|
|
301
|
+
exchanges.delete(lid)
|
|
302
|
+
const state = read()
|
|
303
|
+
const u = x.known ? state.users[x.user] : null
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
opaque.loginFinish({ state: x.state, finalization })
|
|
307
|
+
} catch (e) {
|
|
308
|
+
// Contraseña equivocada, usuario inexistente o mensaje alterado: el MISMO error.
|
|
309
|
+
// Distinguirlos diría qué usuarios existen. El intento ya está contado desde
|
|
310
|
+
// `loginBegin`, así que aquí no se vuelve a contar.
|
|
311
|
+
throw err('login-failed', 'wrong user or password')
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const t = now()
|
|
315
|
+
const sid = crypto.randomUUID()
|
|
316
|
+
u.fails = 0
|
|
317
|
+
u.nextTryAt = 0
|
|
318
|
+
u.sessions = { ...(u.sessions || {}), [sid]: { openedAt: t, lastUsedAt: t, label: String(label || '').slice(0, 60) } }
|
|
319
|
+
save(state)
|
|
320
|
+
return { sid, user: x.user, blob: u.blob, cert: u.cert || null, iss: u.iss || null, pub: u.pub }
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* QUITAR LA ESPERA, desde la máquina de la bóveda. Existe porque el freno se cuenta al
|
|
325
|
+
* empezar el intento (ver arriba): quien sepa tu usuario puede gastártelos, y el dueño
|
|
326
|
+
* tiene que poder devolverte la entrada sin cambiar la contraseña.
|
|
327
|
+
*/
|
|
328
|
+
clearBlock ({ user } = {}) {
|
|
329
|
+
const state = read()
|
|
330
|
+
const u = userOf(state, user)
|
|
331
|
+
if (!u) throw err('no-user', `there is no login called "${user}"`)
|
|
332
|
+
u.fails = 0
|
|
333
|
+
u.nextTryAt = 0
|
|
334
|
+
save(state)
|
|
335
|
+
return { ok: true }
|
|
336
|
+
},
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Un inicio de sesión NO vence solo: lo decide el cliente (dueño, 2026-09-17). Se cierra
|
|
340
|
+
* al salir, al cambiar la contraseña, al quitar el aparato, o desde la consola — que es
|
|
341
|
+
* la única forma de cortar el que quedó abierto en un equipo prestado.
|
|
342
|
+
*/
|
|
343
|
+
closeSession ({ user, sid } = {}) {
|
|
344
|
+
const state = read()
|
|
345
|
+
const u = userOf(state, user)
|
|
346
|
+
if (!u || !u.sessions?.[sid]) return { ok: false }
|
|
347
|
+
delete u.sessions[sid]
|
|
348
|
+
save(state)
|
|
349
|
+
return { ok: true }
|
|
350
|
+
},
|
|
351
|
+
|
|
352
|
+
/** «Se usó»: es lo que deja ver en la consola cuál sigue vivo y cuál se olvidó abierto. */
|
|
353
|
+
touch ({ user, sid } = {}) {
|
|
354
|
+
const state = read()
|
|
355
|
+
const u = userOf(state, user)
|
|
356
|
+
if (!u || !u.sessions?.[sid]) return { ok: false }
|
|
357
|
+
u.sessions[sid].lastUsedAt = now()
|
|
358
|
+
save(state)
|
|
359
|
+
return { ok: true }
|
|
360
|
+
},
|
|
361
|
+
|
|
362
|
+
/** ¿Tiene esta llave algún inicio de sesión abierto? Lo pregunta quien sirve al aparato. */
|
|
363
|
+
hasOpenSession ({ pub } = {}) {
|
|
364
|
+
const state = read()
|
|
365
|
+
for (const u of Object.values(state.users)) {
|
|
366
|
+
if (u.pub === pub && Object.keys(u.sessions || {}).length) return true
|
|
367
|
+
}
|
|
368
|
+
return false
|
|
369
|
+
},
|
|
370
|
+
|
|
371
|
+
/** Quitar el aparato de aquí. Sacarlo del acta es aparte, y también hay que hacerlo. */
|
|
372
|
+
remove ({ user } = {}) {
|
|
373
|
+
const state = read()
|
|
374
|
+
if (!userOf(state, user)) return { ok: false }
|
|
375
|
+
delete state.users[user]
|
|
376
|
+
save(state)
|
|
377
|
+
return { ok: true }
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* EL ALTA ENTERA: guardar el registro, firmar el certificado y meter al aparato en el acta.
|
|
384
|
+
*
|
|
385
|
+
* Vive aquí —y no en cada bóveda— porque las tres versiones tienen que crear exactamente el
|
|
386
|
+
* mismo aparato: el binario, la pestaña y la extensión. Lo que cambia entre ellas es lo que
|
|
387
|
+
* hacen DESPUÉS (la bitácora, avisar a los demás aparatos), y eso se queda fuera.
|
|
388
|
+
*
|
|
389
|
+
* `replace: true` es cambiar la contraseña: el aparato, su llave y su certificado siguen
|
|
390
|
+
* siendo los mismos; lo que cambia es con qué se abre el paquete.
|
|
391
|
+
*
|
|
392
|
+
* @param {object} opts
|
|
393
|
+
* `identity` la identidad que firma (`signDelegation`, `admitMember`)
|
|
394
|
+
* `logins` el escritorio de `createLoginDesk`
|
|
395
|
+
* `scope` permisos del aparato; por defecto firmar, leer y guardar
|
|
396
|
+
* `unattended` si además puede trabajar sin que nadie apruebe
|
|
397
|
+
*/
|
|
398
|
+
export async function registerLogin ({
|
|
399
|
+
identity, logins, user, upload, pub, encPub = null, label = '', blob,
|
|
400
|
+
scope, unattended = false, replace = false
|
|
401
|
+
} = {}) {
|
|
402
|
+
if (replace) {
|
|
403
|
+
logins.registerFinish({ user, upload, blob, replace: true })
|
|
404
|
+
return { ok: true, user, replaced: true }
|
|
405
|
+
}
|
|
406
|
+
// PERMISOS, no tipos: los del scope, más `unattended` si quien lo crea lo eligió
|
|
407
|
+
// (temporary-access.md §3.1). `passwords` no entra todavía — hasta que existan las
|
|
408
|
+
// contraseñas selladas, este aparato se llevaría TODAS (sealed-passwords.md).
|
|
409
|
+
const scopes = Array.isArray(scope) && scope.length ? scope : [SCOPE.SIGN, SCOPE.READ, SCOPE.STORE]
|
|
410
|
+
if (scopes.includes(SCOPE.PASSWORDS)) {
|
|
411
|
+
throw Object.assign(new Error('a password login cannot take `contrasenas` yet: the sealed passwords come first'), { code: 'passwords-not-yet' })
|
|
412
|
+
}
|
|
413
|
+
if (typeof identity?.admitMember !== 'function') {
|
|
414
|
+
throw Object.assign(new Error('this vault cannot add devices to the account record: no login was created'), { code: 'admit-unavailable' })
|
|
415
|
+
}
|
|
416
|
+
const deviceId = await deviceIdOf(pub)
|
|
417
|
+
logins.registerFinish({ user, upload, pub, encPub, deviceId, label, blob })
|
|
418
|
+
try {
|
|
419
|
+
const { cert } = await identity.signDelegation(pub, scopes, { label: label || `login:${user}` })
|
|
420
|
+
const cn = scopeToCn(scopes)
|
|
421
|
+
const caps = [...new Set([...scopeToCaps(scopes), ...(unattended ? ['unattended'] : [])])]
|
|
422
|
+
await identity.admitMember({ pub, encPub, label: label || `login:${user}`, cn, caps, cert })
|
|
423
|
+
logins.setCert({ user, cert, iss: identity.me?.publickey || null })
|
|
424
|
+
return { ok: true, user, deviceId, cert, caps }
|
|
425
|
+
} catch (e) {
|
|
426
|
+
// NADA DE MEDIAS ALTAS: si no entra en el acta, no queda un usuario que pueda entrar a
|
|
427
|
+
// una cuenta que no lo reconoce. Se deshace y se dice.
|
|
428
|
+
logins.remove({ user })
|
|
429
|
+
throw e
|
|
430
|
+
}
|
|
431
|
+
}
|
package/lib/src/protocol.js
CHANGED
|
@@ -90,6 +90,19 @@ export const MSG = Object.freeze({
|
|
|
90
90
|
// firmados de antes y no se pueden falsificar) y la réplica acusa hasta qué `seq` tiene.
|
|
91
91
|
REPLICA_PUSH: 'vault.replica.push', // master → réplica: { body:{seq,acta,secrets,ts}, signature }
|
|
92
92
|
REPLICA_ACK: 'vault.replica.ack', // réplica → master: { body:{seq,ts}, signature }
|
|
93
|
+
// ENTRAR CON USUARIO Y CONTRASEÑA (`src/passwordLogins.js`,
|
|
94
|
+
// `dotrino-passmanager/docs/temporary-access.md`). Es lo ÚNICO que se atiende sin
|
|
95
|
+
// certificado y sin firma: quien pregunta todavía no tiene llaves — vienen dentro del
|
|
96
|
+
// paquete que solo abre su contraseña. La contraseña NO viaja: viajan los mensajes de
|
|
97
|
+
// OPAQUE, que no la llevan ni dejan adivinarla.
|
|
98
|
+
LOGIN_START: 'vault.login.start', // aparato → vault: { user, request }
|
|
99
|
+
LOGIN_RESPONSE: 'vault.login.response', // vault → aparato: { lid, response }
|
|
100
|
+
LOGIN_FINISH: 'vault.login.finish', // aparato → vault: { lid, finalization, label }
|
|
101
|
+
LOGIN_OK: 'vault.login.ok', // vault → aparato: { sid, blob, cert, iss, acta }
|
|
102
|
+
// Salir SÍ va firmado, con la llave que acaba de abrir: cerrar la sesión de otro sería
|
|
103
|
+
// una forma de echar a alguien de su cuenta.
|
|
104
|
+
LOGIN_CLOSE: 'vault.login.close', // aparato → vault: { data:{op,user,sid,publickey,ts}, signature }
|
|
105
|
+
LOGIN_CLOSED: 'vault.login.closed', // vault → aparato: { ok }
|
|
93
106
|
ERROR: 'vault.error' // vault → dispositivo: { error }
|
|
94
107
|
})
|
|
95
108
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.121.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
|
|
6
6
|
"bin": {
|
|
@@ -23,8 +23,9 @@
|
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@dotrino/compat": "0.4.0",
|
|
25
25
|
"@dotrino/identity": "0.92.0",
|
|
26
|
+
"@dotrino/opaque": "0.1.0",
|
|
26
27
|
"@dotrino/passmanager": "^0.10.1",
|
|
27
|
-
"@dotrino/proxy-client": "0.
|
|
28
|
+
"@dotrino/proxy-client": "0.23.1",
|
|
28
29
|
"@dotrino/roadmap": "0.3.3",
|
|
29
30
|
"@dotrino/store": "0.11.0",
|
|
30
31
|
"ws": "^8.18.0"
|
package/src/ctl.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* reject <id> rechaza un dispositivo pendiente
|
|
14
14
|
* devices lista dispositivos enrolados / revocados
|
|
15
15
|
* revoke <nonce> revoca un dispositivo (y le ordena autoborrarse)
|
|
16
|
+
* logins … aparatos que se abren con usuario y contraseña (equipo prestado)
|
|
16
17
|
* profile … perfiles (varias identidades en el mismo PC) y su contraseña
|
|
17
18
|
* unlock / lock candado del perfil (la contraseña solo hace falta para EDITAR)
|
|
18
19
|
* logs últimos logs del servicio
|
|
@@ -1479,6 +1480,199 @@ async function profileRequest (op, extra = {}) {
|
|
|
1479
1480
|
console.error('El daemon no respondió.'); process.exit(1)
|
|
1480
1481
|
}
|
|
1481
1482
|
|
|
1483
|
+
// ---------------------------------------------------------------------------
|
|
1484
|
+
// ENTRAR CON USUARIO Y CONTRASEÑA (`docs/temporary-access.md`)
|
|
1485
|
+
//
|
|
1486
|
+
// LA CONTRASEÑA NO SALE DE ESTA TERMINAL. Aquí se hace la mitad del cliente de OPAQUE, se
|
|
1487
|
+
// generan las llaves del aparato y se cierran con la llave que sale de la contraseña; al
|
|
1488
|
+
// daemon solo le llegan mensajes del protocolo y un paquete que no puede abrir. Por eso el
|
|
1489
|
+
// alta va en dos viajes y cambiar la contraseña en cuatro, y no en uno: si el daemon
|
|
1490
|
+
// hiciera la parte del cliente, la contraseña tendría que cruzar hasta él.
|
|
1491
|
+
// ---------------------------------------------------------------------------
|
|
1492
|
+
|
|
1493
|
+
const loginsFile = path.join(dir, 'logins-list.json')
|
|
1494
|
+
let loginsSeq = 0
|
|
1495
|
+
|
|
1496
|
+
/**
|
|
1497
|
+
* Un viaje al daemon. La respuesta lleva el `id` de la petición, para no quedarse con la
|
|
1498
|
+
* del intento anterior, y se BORRA al leerla: puede traer el paquete de llaves del aparato.
|
|
1499
|
+
*/
|
|
1500
|
+
async function loginsRequest (op, extra = {}) {
|
|
1501
|
+
const s = requireDaemon()
|
|
1502
|
+
try { fs.rmSync(loginsFile, { force: true }) } catch (_) {}
|
|
1503
|
+
const id = `${process.pid}-${++loginsSeq}`
|
|
1504
|
+
writeReq('logins-request.json', { op, id, ...extra })
|
|
1505
|
+
sendSignal(s.pid, 'SIGUSR2')
|
|
1506
|
+
for (let i = 0; i < 100; i++) {
|
|
1507
|
+
await sleep(100)
|
|
1508
|
+
const d = ipcRead(loginsFile, null)
|
|
1509
|
+
if (!d?.at || (d.req && d.req !== id)) continue
|
|
1510
|
+
try { fs.rmSync(loginsFile, { force: true }) } catch (_) {}
|
|
1511
|
+
if (d.ok === false) {
|
|
1512
|
+
if (d.code === 'PROFILE_LOCKED') { console.error('Perfil bloqueado. Ábrelo con: dotrino-vault unlock'); process.exit(1) }
|
|
1513
|
+
// `too-many-tries` trae cuánto falta: es el único dato que cambia lo que se hace.
|
|
1514
|
+
if (d.code === 'too-many-tries') console.error('Demasiados intentos. Espera %d s, o quita la espera con: dotrino-vault logins unblock <usuario>', Math.ceil((d.waitMs || 0) / 1000))
|
|
1515
|
+
else console.error('%s', d.error)
|
|
1516
|
+
process.exit(1)
|
|
1517
|
+
}
|
|
1518
|
+
return d
|
|
1519
|
+
}
|
|
1520
|
+
console.error('El daemon no respondió.'); process.exit(1)
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
/**
|
|
1524
|
+
* Pide la contraseña dos veces y comprueba que coinciden.
|
|
1525
|
+
*
|
|
1526
|
+
* El mínimo no es una manía: quien tenga una COPIA DEL DISCO de la bóveda puede probar
|
|
1527
|
+
* contraseñas contra el registro de OPAQUE sin límite y sin que nadie se entere
|
|
1528
|
+
* (`temporary-access.md` §4). Contra eso lo único que aguanta es la contraseña.
|
|
1529
|
+
*/
|
|
1530
|
+
const MIN_PASSWORD = 12
|
|
1531
|
+
async function askNewPassword (user) {
|
|
1532
|
+
const pw = await askPassword(`Contraseña para ${user} (mínimo ${MIN_PASSWORD} caracteres): `)
|
|
1533
|
+
if (pw.length < MIN_PASSWORD) {
|
|
1534
|
+
console.error('Demasiado corta. Con una copia del disco de esta bóveda se pueden probar contraseñas sin límite, así que lo único que aguanta es su largo.')
|
|
1535
|
+
process.exit(2)
|
|
1536
|
+
}
|
|
1537
|
+
if (await askPassword('Repítela: ') !== pw) { console.error('No coinciden.'); process.exit(2) }
|
|
1538
|
+
return pw
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
/**
|
|
1542
|
+
* Entrar de verdad, desde aquí: es la única forma de conseguir la llave que abre el paquete.
|
|
1543
|
+
*
|
|
1544
|
+
* Con la contraseña equivocada quien se entera es ESTA punta, al recibir la respuesta —así
|
|
1545
|
+
* funciona OPAQUE, y por eso los intentos se cuentan al empezar—. Se dice y se para: sin la
|
|
1546
|
+
* contraseña vieja no hay nada que volver a cerrar.
|
|
1547
|
+
*/
|
|
1548
|
+
async function loginHere (opaqueClient, user, password) {
|
|
1549
|
+
const start = opaqueClient.loginStart({ password })
|
|
1550
|
+
const begun = await loginsRequest('login-begin', { user, request: start.request })
|
|
1551
|
+
let fin
|
|
1552
|
+
try { fin = opaqueClient.loginFinish({ state: start.state, response: begun.response, password }) }
|
|
1553
|
+
catch (e) { console.error('Contraseña incorrecta (o ese usuario no existe).'); process.exit(1) }
|
|
1554
|
+
const done = await loginsRequest('login-end', { lid: begun.lid, finalization: fin.finalization, label: 'consola' })
|
|
1555
|
+
return { ...done, exportKey: fin.exportKey }
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
const LOGINS_USAGE = `uso: dotrino-vault logins <orden>
|
|
1559
|
+
|
|
1560
|
+
logins [ls] los aparatos que se abren con usuario y contraseña
|
|
1561
|
+
logins add <usuario> [nombre del equipo]
|
|
1562
|
+
logins passwd <usuario> cambia la contraseña (cierra lo que estuviera abierto)
|
|
1563
|
+
logins close <usuario> [sid] cierra una sesión abierta (sin sid, todas)
|
|
1564
|
+
logins unblock <usuario> quita la espera de los intentos fallidos
|
|
1565
|
+
logins rm <usuario> lo quita y saca su llave del acta`
|
|
1566
|
+
|
|
1567
|
+
async function cmdLogins (rest) {
|
|
1568
|
+
const [sub, ...args] = rest
|
|
1569
|
+
const user = args[0]
|
|
1570
|
+
const needUser = () => { if (!user) { console.error(LOGINS_USAGE); process.exit(2) } return user }
|
|
1571
|
+
|
|
1572
|
+
switch (sub || 'ls') {
|
|
1573
|
+
case 'ls': {
|
|
1574
|
+
const { logins, fingerprint } = await loginsRequest('list')
|
|
1575
|
+
if (!logins.length) {
|
|
1576
|
+
console.log('No hay ningún inicio de sesión con contraseña.')
|
|
1577
|
+
console.log('Crea uno con: dotrino-vault logins add <usuario>')
|
|
1578
|
+
return
|
|
1579
|
+
}
|
|
1580
|
+
const { loginAddress } = await import('../lib/src/passwordLogins.js')
|
|
1581
|
+
console.log('Inicios de sesión con contraseña: %d', logins.length)
|
|
1582
|
+
for (const l of logins) {
|
|
1583
|
+
const espera = l.blockedUntil > Date.now() ? ` ${R}espera ${Math.ceil((l.blockedUntil - Date.now()) / 1000)} s${Z}` : ''
|
|
1584
|
+
console.log(' · %s%s%s %s%s creado %s%s', B, loginAddress(l.user, fingerprint), Z, l.deviceId || '????-????',
|
|
1585
|
+
l.label ? ' «' + l.label + '»' : '', fechaCorta(l.createdAt), espera)
|
|
1586
|
+
for (const s of l.sessions || []) {
|
|
1587
|
+
console.log(' %sabierto%s %s %s usado %s', D, Z, s.sid.slice(0, 8), s.label || '(sin nombre)', fechaCorta(s.lastUsedAt))
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
console.log('\nCerrar una sesión: dotrino-vault logins close <usuario> [sid]')
|
|
1591
|
+
return
|
|
1592
|
+
}
|
|
1593
|
+
case 'add': {
|
|
1594
|
+
needUser()
|
|
1595
|
+
const label = args.slice(1).join(' ').trim() || 'equipo prestado'
|
|
1596
|
+
const { client: opaqueClient } = await import('@dotrino/opaque')
|
|
1597
|
+
const { makeDeviceKey, makeDeviceEncKey } = await import('@dotrino/identity/capabilities')
|
|
1598
|
+
const { sealDeviceKeys } = await import('../lib/src/passwordLogins.js')
|
|
1599
|
+
const password = await askNewPassword(user)
|
|
1600
|
+
|
|
1601
|
+
const start = opaqueClient.registrationStart({ password })
|
|
1602
|
+
const begun = await loginsRequest('register-begin', { user, request: start.request })
|
|
1603
|
+
// La bóveda contesta con su mitad; el registro se termina AQUÍ, con la contraseña.
|
|
1604
|
+
const fin = opaqueClient.registrationFinish({ state: start.state, response: begun.response, password })
|
|
1605
|
+
// Las llaves del aparato NACEN en esta terminal y salen de aquí ya cerradas.
|
|
1606
|
+
const device = await makeDeviceKey({ label })
|
|
1607
|
+
const enc = await makeDeviceEncKey()
|
|
1608
|
+
const blob = await sealDeviceKeys(fin.exportKey, { sign: device.privateJwk, enc: enc.privateJwk })
|
|
1609
|
+
const r = await loginsRequest('register-finish', {
|
|
1610
|
+
user, upload: fin.upload, pub: device.publickey, encPub: enc.publickey, label, blob
|
|
1611
|
+
})
|
|
1612
|
+
const { loginAddress } = await import('../lib/src/passwordLogins.js')
|
|
1613
|
+
console.log('\nListo. Se entra con esta dirección y esa contraseña:')
|
|
1614
|
+
console.log(' %s%s%s', B, loginAddress(user, r.fingerprint), Z)
|
|
1615
|
+
console.log(' aparato : %s «%s»', r.deviceId, label)
|
|
1616
|
+
console.log(' permisos: %s', (r.caps || []).join(', ') || '(ninguno)')
|
|
1617
|
+
console.log('\nLa dirección no es secreta: dice DÓNDE está tu cuenta, no quién eres.')
|
|
1618
|
+
return
|
|
1619
|
+
}
|
|
1620
|
+
// CAMBIAR LA CONTRASEÑA ES ABRIR Y VOLVER A CERRAR. El aparato, su llave y su
|
|
1621
|
+
// certificado siguen siendo los mismos: lo único que cambia es con qué se abre el
|
|
1622
|
+
// paquete. Por eso hace falta la vieja — sin ella no hay nada que volver a cerrar.
|
|
1623
|
+
case 'passwd': {
|
|
1624
|
+
needUser()
|
|
1625
|
+
const { client: opaqueClient } = await import('@dotrino/opaque')
|
|
1626
|
+
const { sealDeviceKeys, openDeviceKeys } = await import('../lib/src/passwordLogins.js')
|
|
1627
|
+
const old = await askPassword(`Contraseña ACTUAL de ${user}: `)
|
|
1628
|
+
const entered = await loginHere(opaqueClient, user, old)
|
|
1629
|
+
const keys = await openDeviceKeys(entered.exportKey, entered.blob)
|
|
1630
|
+
|
|
1631
|
+
const password = await askNewPassword(user)
|
|
1632
|
+
const start = opaqueClient.registrationStart({ password })
|
|
1633
|
+
const begun = await loginsRequest('register-begin', { user, request: start.request, replace: true })
|
|
1634
|
+
const fin = opaqueClient.registrationFinish({ state: start.state, response: begun.response, password })
|
|
1635
|
+
await loginsRequest('register-finish', {
|
|
1636
|
+
user, upload: fin.upload, blob: await sealDeviceKeys(fin.exportKey, keys), replace: true
|
|
1637
|
+
})
|
|
1638
|
+
console.log('Listo: %s entra con la contraseña nueva, y lo que estuviera abierto se cerró.', user)
|
|
1639
|
+
return
|
|
1640
|
+
}
|
|
1641
|
+
case 'close': {
|
|
1642
|
+
needUser()
|
|
1643
|
+
const sid = args[1]
|
|
1644
|
+
if (sid) {
|
|
1645
|
+
const r = await loginsRequest('close', { user, sid })
|
|
1646
|
+
console.log(r.ok ? `Sesión cerrada: ${sid}` : 'Esa sesión ya no estaba abierta.')
|
|
1647
|
+
return
|
|
1648
|
+
}
|
|
1649
|
+
const { logins } = await loginsRequest('list')
|
|
1650
|
+
const fila = logins.find((x) => x.user === user)
|
|
1651
|
+
if (!fila) { console.error('No hay ningún inicio de sesión con ese usuario. Míralos con: dotrino-vault logins'); process.exit(1) }
|
|
1652
|
+
const abiertas = fila.sessions || []
|
|
1653
|
+
if (!abiertas.length) { console.log('No tiene ninguna sesión abierta.'); return }
|
|
1654
|
+
for (const s of abiertas) await loginsRequest('close', { user, sid: s.sid })
|
|
1655
|
+
console.log('Cerradas %d sesión(es) de %s. Para entrar otra vez hace falta la contraseña.', abiertas.length, user)
|
|
1656
|
+
return
|
|
1657
|
+
}
|
|
1658
|
+
case 'unblock': {
|
|
1659
|
+
needUser()
|
|
1660
|
+
await loginsRequest('unblock', { user })
|
|
1661
|
+
console.log('Listo: %s puede volver a intentarlo. La contraseña no cambió.', user)
|
|
1662
|
+
return
|
|
1663
|
+
}
|
|
1664
|
+
case 'rm': {
|
|
1665
|
+
needUser()
|
|
1666
|
+
const r = await loginsRequest('rm', { user })
|
|
1667
|
+
if (!r.ok) { console.error('No hay ningún inicio de sesión con ese usuario. Míralos con: dotrino-vault logins'); process.exit(1) }
|
|
1668
|
+
console.log('Quitado %s (aparato %s). Su llave sale del acta y se autoborrará al reconectar.', user, r.deviceId || '????-????')
|
|
1669
|
+
return
|
|
1670
|
+
}
|
|
1671
|
+
default:
|
|
1672
|
+
console.error(LOGINS_USAGE); process.exit(2)
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1482
1676
|
function reportProfiles (d) {
|
|
1483
1677
|
if (d.error) {
|
|
1484
1678
|
// Los dos rechazos del candado se dicen con palabras y con el dato que hace falta; el
|
|
@@ -1728,6 +1922,15 @@ function help () {
|
|
|
1728
1922
|
+sella = OTRA BÓVEDA que puede sellar el acta de esta cuenta, para
|
|
1729
1923
|
que perder una máquina no se la lleve. No es un traspaso
|
|
1730
1924
|
revoke <ID|nonce> quita un dispositivo (con el ID, todos sus certificados)
|
|
1925
|
+
logins los aparatos que se abren con usuario y contraseña (para un equipo
|
|
1926
|
+
prestado, donde no puedes emparejar nada)
|
|
1927
|
+
logins add <usuario> [nombre del equipo]
|
|
1928
|
+
crea uno. La contraseña se teclea aquí y no sale de esta terminal:
|
|
1929
|
+
la bóveda guarda un paquete de llaves que solo ella abre
|
|
1930
|
+
logins passwd <usuario> cambia la contraseña (cierra lo que estuviera abierto)
|
|
1931
|
+
logins close <usuario> [sid] cierra una sesión abierta (sin sid, todas)
|
|
1932
|
+
logins unblock <usuario> quita la espera de los intentos fallidos
|
|
1933
|
+
logins rm <usuario> lo quita y saca su llave del acta
|
|
1731
1934
|
atrest status de dónde sale la clave que cifra el disco (esta máquina, o un KMS)
|
|
1732
1935
|
atrest test comprueba que el KMS envuelve y desenvuelve, SIN tocar los datos
|
|
1733
1936
|
atrest rekey <f> cambia de proveedor: descifra con la vieja y recifra con la nueva
|
|
@@ -1863,6 +2066,7 @@ export async function runCtl (argv) {
|
|
|
1863
2066
|
case 'label': return cmdLabel(rest)
|
|
1864
2067
|
case 'caps': return cmdCaps(rest)
|
|
1865
2068
|
case 'revoke': return cmdRevoke(rest[0])
|
|
2069
|
+
case 'logins': return cmdLogins(rest)
|
|
1866
2070
|
case 'secret': return cmdSecret(rest)
|
|
1867
2071
|
// `approval` se quitó el 2026-09-01 (ahora es el permiso `unattended`), pero la ruta se
|
|
1868
2072
|
// quedó apuntando a una función que ya no existía: reventaba con un ReferenceError. Se
|
package/src/daemon.js
CHANGED
|
@@ -324,6 +324,12 @@ export async function runDaemon () {
|
|
|
324
324
|
let lastSecretValue = null
|
|
325
325
|
/** Y las versiones anteriores que se acaban de pedir. Mismo camino, mismo volcado. */
|
|
326
326
|
let lastSecretHistory = null
|
|
327
|
+
// ENTRAR CON USUARIO Y CONTRASEÑA, desde esta máquina (`dotrino-vault logins`).
|
|
328
|
+
//
|
|
329
|
+
// La respuesta NO se llama `logins.json`: ese nombre ya es el almacén del escritorio en
|
|
330
|
+
// el dir del perfil, y pisarlo sería borrar los inicios de sesión al listarlos.
|
|
331
|
+
const loginsReqFile = path.join(dir, 'logins-request.json')
|
|
332
|
+
const loginsFile = path.join(dir, 'logins-list.json')
|
|
327
333
|
const profileReqFile = path.join(dir, 'profile-request.json')
|
|
328
334
|
const dumpReqFile = path.join(dir, 'dump-request.json')
|
|
329
335
|
const meReqFile = path.join(dir, 'me-request.json')
|
|
@@ -366,6 +372,54 @@ export async function runDaemon () {
|
|
|
366
372
|
*/
|
|
367
373
|
const wipe = (k) => { try { if (k instanceof Uint8Array) k.fill(0) } catch (_) {} }
|
|
368
374
|
|
|
375
|
+
/**
|
|
376
|
+
* Los inicios de sesión con contraseña de ESTA máquina (`dotrino-vault logins`).
|
|
377
|
+
*
|
|
378
|
+
* LA CONTRASEÑA NO PASA POR AQUÍ, y esa es la razón de que el alta vaya en dos viajes en
|
|
379
|
+
* vez de uno: quien la teclea es la CLI, que hace su mitad de OPAQUE, genera las llaves
|
|
380
|
+
* del aparato y las cierra con la llave que sale de la contraseña. Aquí solo entran
|
|
381
|
+
* mensajes del protocolo y un paquete que esta máquina no puede abrir.
|
|
382
|
+
*/
|
|
383
|
+
async function handleLoginsRequest (req) {
|
|
384
|
+
const vault = targetOf(req)
|
|
385
|
+
if (!vault) throw Object.assign(new Error('profile locked'), { code: 'PROFILE_LOCKED' })
|
|
386
|
+
// La huella de la CUENTA viaja en cada respuesta: es la mitad de después de la `@` de
|
|
387
|
+
// la dirección, y quien pregunta no puede sacarla por su cuenta sin adivinar a qué
|
|
388
|
+
// perfil apuntaba la petición.
|
|
389
|
+
return { fingerprint: vault.fingerprint || null, ...(await loginsOp(vault, req)) }
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function loginsOp (vault, req) {
|
|
393
|
+
switch (req.op) {
|
|
394
|
+
case 'list': return { logins: vault.listLogins() }
|
|
395
|
+
case 'register-begin': return vault.loginRegisterBegin({ user: req.user, request: req.request, replace: !!req.replace })
|
|
396
|
+
case 'register-finish': return vault.loginRegisterFinish({
|
|
397
|
+
user: req.user, upload: req.upload, pub: req.pub, encPub: req.encPub || null,
|
|
398
|
+
label: req.label || '', blob: req.blob, scope: req.scope, unattended: !!req.unattended, replace: !!req.replace
|
|
399
|
+
})
|
|
400
|
+
// Entrar desde la propia máquina de la bóveda. Lo usa `logins passwd`: para volver a
|
|
401
|
+
// cerrar el paquete de llaves hay que ABRIRLO antes, y eso solo puede hacerlo quien
|
|
402
|
+
// sabe la contraseña vieja. Pasa por el mismo freno que cualquier otro intento.
|
|
403
|
+
case 'login-begin': return vault.loginBegin({ user: req.user, request: req.request })
|
|
404
|
+
case 'login-end': return vault.loginEnd({ lid: req.lid, finalization: req.finalization, label: req.label || '' })
|
|
405
|
+
case 'close': return vault.closeLogin({ user: req.user, sid: req.sid })
|
|
406
|
+
case 'unblock': return vault.clearLoginBlock({ user: req.user })
|
|
407
|
+
// QUITARLO ES QUITARLO: el usuario se va y su llave sale del acta. Borrar solo el
|
|
408
|
+
// inicio de sesión dejaba un miembro que ya no puede entrar pero sigue siendo de la
|
|
409
|
+
// cuenta — media baja, que es peor que ninguna.
|
|
410
|
+
case 'rm': {
|
|
411
|
+
const r = await vault.removeLogin({ user: req.user })
|
|
412
|
+
if (r.ok && r.pub) {
|
|
413
|
+
try { await vault.revokeDevice({ sub: r.pub }) } catch (e) {
|
|
414
|
+
throw Object.assign(new Error(`the login is gone but its key is still in the record: ${e.message}`), { code: 'REVOKE_FAILED' })
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return r
|
|
418
|
+
}
|
|
419
|
+
default: throw Object.assign(new Error(`unknown logins op: ${req.op}`), { code: 'BAD_OP' })
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
369
423
|
async function handleProfileRequest (req) {
|
|
370
424
|
// Resolver el destino de una orden de perfil CUENTA COMO USO: estira el plazo del
|
|
371
425
|
// bloqueo automático igual que cualquier otra cosa que se haga desde la consola.
|
|
@@ -662,6 +716,19 @@ export async function runDaemon () {
|
|
|
662
716
|
console.error('[vault] secret failed:', e.message)
|
|
663
717
|
} finally { wipe(ak) }
|
|
664
718
|
}
|
|
719
|
+
// Inicios de sesión con contraseña (`dotrino-vault logins`). La respuesta lleva el
|
|
720
|
+
// `id` de la petición, para que quien espera no lea la de la vuelta anterior.
|
|
721
|
+
const lreq = readJsonSafe(loginsReqFile)
|
|
722
|
+
if (lreq?.op) {
|
|
723
|
+
rm(loginsReqFile)
|
|
724
|
+
const answer = (extra) => ipcWrite(loginsFile, { at: Date.now(), req: lreq.id || null, ...extra })
|
|
725
|
+
try { answer({ ok: true, ...(await handleLoginsRequest(lreq)) }) } catch (e) {
|
|
726
|
+
console.error('[vault] logins: %s', e.message)
|
|
727
|
+
// `waitMs` viaja con el error: cuánto falta es el único dato que cambia lo que
|
|
728
|
+
// hace quien lo lee, y sin él «demasiados intentos» no dice nada accionable.
|
|
729
|
+
answer({ ok: false, error: e.message, code: e.code || 'LOGINS_FAILED', ...(e.waitMs ? { waitMs: e.waitMs } : {}) })
|
|
730
|
+
}
|
|
731
|
+
}
|
|
665
732
|
// Perfiles / candado.
|
|
666
733
|
const preq = readJsonSafe(profileReqFile)
|
|
667
734
|
if (preq?.op) {
|
package/src/transport.js
CHANGED
|
@@ -36,7 +36,7 @@ export async function masterPubkeyOf (identity) {
|
|
|
36
36
|
* @param {string} [opts.url] URL del proxy (default wss://proxy.dotrino.com).
|
|
37
37
|
* @returns {Promise<{ client, token:string, identify():Promise<void> }>}
|
|
38
38
|
*/
|
|
39
|
-
export async function createTransport ({ identity, dir, url = DEFAULT_PROXY, commKey = null, log = () => {}, makeClient = null }) {
|
|
39
|
+
export async function createTransport ({ identity, dir, url = DEFAULT_PROXY, commKey = null, log = () => {}, makeClient = null, announce = null }) {
|
|
40
40
|
installNodeGlobals(dir)
|
|
41
41
|
// Import dinámico DESPUÉS de instalar los globals que el paquete usa.
|
|
42
42
|
// `WebSocketProxyClient` (la clase) y NO el helper `getWebSocketProxyClient`:
|
|
@@ -160,12 +160,34 @@ export async function createTransport ({ identity, dir, url = DEFAULT_PROXY, com
|
|
|
160
160
|
* intento se dice en voz alta: una bóveda inalcanzable en silencio es el fallo que ya
|
|
161
161
|
* costó un día entero (`CLAUDE.md`, el apagón del 1-2 de septiembre).
|
|
162
162
|
*/
|
|
163
|
+
/**
|
|
164
|
+
* EL ANUNCIO DE LA CUENTA (`temporary-access.md` §3.2).
|
|
165
|
+
*
|
|
166
|
+
* Quien entra con usuario y contraseña no tiene ninguna llave todavía: no sabe la pubkey
|
|
167
|
+
* de esta bóveda y no puede escribirle. Lo único que trae es el código de la dirección
|
|
168
|
+
* (`nombre@AB12-CD34-EF56`), y con él lista este canal para encontrar a quien atiende esa
|
|
169
|
+
* cuenta — esta bóveda, o cualquiera de sus réplicas.
|
|
170
|
+
*
|
|
171
|
+
* Va PEGADO a `identify` y no en el arranque, porque la pertenencia a un canal es del
|
|
172
|
+
* token, y el token cambia en cada reconexión: anunciarse una sola vez deja a la bóveda
|
|
173
|
+
* fuera del canal en cuanto se cae el socket, y eso se ve desde fuera como «esa cuenta no
|
|
174
|
+
* existe». Si el anuncio falla, la bóveda sigue sirviendo a todo lo demás y se dice en voz
|
|
175
|
+
* alta: lo que se pierde es que la encuentren por su dirección, no que deje de funcionar.
|
|
176
|
+
*/
|
|
177
|
+
const announceAccount = async () => {
|
|
178
|
+
if (!announce || !identificado) return
|
|
179
|
+
try { await client.publish(announce) } catch (e) {
|
|
180
|
+
log(`[vault] could not announce this account on the proxy (${e.message}) — a borrowed machine will not find this vault by its address`)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
163
184
|
let reintento = null
|
|
164
185
|
let fallos = 0
|
|
165
186
|
const identifyWithRetry = async () => {
|
|
166
187
|
if (reintento) { clearTimeout(reintento); reintento = null }
|
|
167
188
|
try {
|
|
168
189
|
await identify()
|
|
190
|
+
await announceAccount()
|
|
169
191
|
if (fallos) log(`[vault] identified on the proxy after ${fallos + 1} attempt(s)`)
|
|
170
192
|
fallos = 0
|
|
171
193
|
} catch (e) {
|
|
@@ -180,5 +202,50 @@ export async function createTransport ({ identity, dir, url = DEFAULT_PROXY, com
|
|
|
180
202
|
// Re-identificar al reconectar (el token cambia).
|
|
181
203
|
client.on('token', () => { fallos = 0; identifyWithRetry() })
|
|
182
204
|
|
|
205
|
+
/**
|
|
206
|
+
* QUE NO SE PUEDA VOLVER A QUEDAR MUDA.
|
|
207
|
+
*
|
|
208
|
+
* El 16 de septiembre esta bóveda se cayó del proxio y no volvió en 36 HORAS, con el
|
|
209
|
+
* proceso vivo y sin una sola línea en el log: el cliente del transporte se rendía tras
|
|
210
|
+
* un reintento fallido (arreglado en `@dotrino/proxy-client` 0.23.0). Desde fuera eso se
|
|
211
|
+
* ve igual que «no pasa nada»: el teléfono no timbra y nadie sabe por qué.
|
|
212
|
+
*
|
|
213
|
+
* El arreglo del pilar quita ESA causa. Esto quita el SILENCIO, que es lo que costó las
|
|
214
|
+
* 36 horas — con cualquier causa, futura incluida:
|
|
215
|
+
*
|
|
216
|
+
* · se dice cuándo se cae y cuándo vuelve, una vez cada cosa (no una línea cada 4 s);
|
|
217
|
+
* · y si lleva 5 minutos fuera, se vuelve a marcar en vez de esperar sentada.
|
|
218
|
+
*
|
|
219
|
+
* `connect()` es seguro de repetir: si ya está conectado devuelve el token y no abre
|
|
220
|
+
* nada.
|
|
221
|
+
*/
|
|
222
|
+
const RECHECK_MS = 60_000
|
|
223
|
+
const PATIENCE_MS = 5 * 60_000
|
|
224
|
+
let downSince = 0
|
|
225
|
+
let saidDown = false
|
|
226
|
+
client.on('disconnect', ({ code, reason } = {}) => {
|
|
227
|
+
if (!downSince) downSince = Date.now()
|
|
228
|
+
if (saidDown) return
|
|
229
|
+
saidDown = true
|
|
230
|
+
log(`[vault] off the proxy (${code || '?'}${reason ? ': ' + reason : ''}) — nobody can reach this vault while this lasts`)
|
|
231
|
+
})
|
|
232
|
+
client.on('reconnect_failed', (attempts) => {
|
|
233
|
+
log(`[vault] the transport gave up reconnecting after ${attempts} attempt(s) — this vault is unreachable until it is restarted`)
|
|
234
|
+
})
|
|
235
|
+
const watchdog = setInterval(async () => {
|
|
236
|
+
if (identificado && client.token) {
|
|
237
|
+
if (downSince) log(`[vault] back on the proxy after ${Math.round((Date.now() - downSince) / 1000)}s`)
|
|
238
|
+
downSince = 0; saidDown = false
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
if (!downSince) downSince = Date.now()
|
|
242
|
+
const downMs = Date.now() - downSince
|
|
243
|
+
if (downMs < PATIENCE_MS) return
|
|
244
|
+
log(`[vault] ${Math.round(downMs / 60000)} min off the proxy — dialling again`)
|
|
245
|
+
try { await client.connect() } catch (e) { log(`[vault] could not reconnect: ${e.message}`) }
|
|
246
|
+
await identifyWithRetry()
|
|
247
|
+
}, RECHECK_MS)
|
|
248
|
+
watchdog.unref?.()
|
|
249
|
+
|
|
183
250
|
return { client, token: client.token, identify: identifyWithRetry, isIdentified: () => identificado }
|
|
184
251
|
}
|
package/src/vault.js
CHANGED
|
@@ -15,8 +15,9 @@ import nodeCrypto from 'node:crypto'
|
|
|
15
15
|
import path from 'node:path'
|
|
16
16
|
import { Identity } from '@dotrino/identity/node'
|
|
17
17
|
import { verifyChain as verifyChainRaw, pubkeyId, verifyDeviceSig } from '@dotrino/identity/capabilities'
|
|
18
|
+
import { createLoginDesk, registerLogin, vaultChannel } from '../lib/src/passwordLogins.js'
|
|
18
19
|
import * as ActaPilar from '@dotrino/identity/acta'
|
|
19
|
-
import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS } from '../lib/src/enroll.js'
|
|
20
|
+
import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS, scopeToCaps, scopeToCn } from '../lib/src/enroll.js'
|
|
20
21
|
import { createAdminDesk, authorBody } from '../lib/src/admin.js'
|
|
21
22
|
import { shouldNotifyRevoked } from '../lib/src/revocation.js'
|
|
22
23
|
// El sobre del gestor, el mismo que usan la bóveda-en-pestaña y la extensión.
|
|
@@ -218,6 +219,18 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
218
219
|
},
|
|
219
220
|
}
|
|
220
221
|
|
|
222
|
+
// ENTRAR CON USUARIO Y CONTRASEÑA (`passwordLogins.js`). Va cifrado en reposo con la llave
|
|
223
|
+
// de la MÁQUINA y no con la de la contraseña del perfil, por lo mismo que la llave de
|
|
224
|
+
// comunicación: entrar tiene que funcionar con el perfil cerrado. Lo que hay dentro no
|
|
225
|
+
// abre nada por sí solo — el registro de OPAQUE no deja probar contraseñas sin el
|
|
226
|
+
// protocolo, y el paquete de llaves está cerrado con la llave que sale de la contraseña.
|
|
227
|
+
const loginsFile = path.join(dir, 'logins.json')
|
|
228
|
+
const loginsAtRest = atRestFor(dir)
|
|
229
|
+
const logins = createLoginDesk({
|
|
230
|
+
load: () => { try { return JSON.parse(loginsAtRest.decrypt(fs.readFileSync(loginsFile, 'utf8'))) } catch (_) { return null } },
|
|
231
|
+
save: (state) => fs.writeFileSync(loginsFile, loginsAtRest.encrypt(JSON.stringify(state)), { mode: 0o600 })
|
|
232
|
+
})
|
|
233
|
+
|
|
221
234
|
/** Los aparatos que el acta autoriza a pedir credenciales (`caps <ID> +contraseñas`). */
|
|
222
235
|
const passwordDevices = () => (actaCache?.members || []).filter((m) => Acta.memberCan(actaCache, m.pub, 'passwords'))
|
|
223
236
|
|
|
@@ -362,7 +375,12 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
362
375
|
}
|
|
363
376
|
await ensureCommKeyInActa()
|
|
364
377
|
|
|
365
|
-
|
|
378
|
+
// `announce`: el canal de ESTA cuenta, para que una máquina prestada —que no tiene
|
|
379
|
+
// ninguna llave y solo sabe la dirección `nombre@AB12-CD34-EF56`— pueda encontrar a quien
|
|
380
|
+
// la atiende. Lo construye la pieza compartida, porque quien lo busca es la extensión.
|
|
381
|
+
const { client, identify: reidentificar, isIdentified } = await createTransport({
|
|
382
|
+
identity, dir, url: proxyUrl, commKey, log, announce: vaultChannel(fp)
|
|
383
|
+
})
|
|
366
384
|
|
|
367
385
|
// El registro público de cadenas de selladores: deposita, si hay a dónde, los eslabones
|
|
368
386
|
// que le dicen a un tercero si esta cuenta sigue sellada por quien él cree. Ver
|
|
@@ -1071,6 +1089,66 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1071
1089
|
* nombre de SU propia llave, que no le sirve contra nadie más (`verifyRevoke` exige que
|
|
1072
1090
|
* el aviso nombre al aparato que lo recibe).
|
|
1073
1091
|
*/
|
|
1092
|
+
// --- ENTRAR CON USUARIO Y CONTRASEÑA ---------------------------------------------------
|
|
1093
|
+
//
|
|
1094
|
+
// Los tres únicos mensajes que se atienden SIN certificado, porque quien pregunta todavía
|
|
1095
|
+
// no tiene llaves: están dentro del paquete que solo abre su contraseña. La bóveda no ve
|
|
1096
|
+
// la contraseña en ningún momento (OPAQUE), y responde igual exista o no el usuario.
|
|
1097
|
+
//
|
|
1098
|
+
// Funcionan con el perfil CERRADO: el paquete se devuelve tal cual, sin abrir nada. Lo que
|
|
1099
|
+
// sí exige la bóveda abierta es CREAR el aparato, porque eso sella el acta.
|
|
1100
|
+
|
|
1101
|
+
function loginError (from, e) {
|
|
1102
|
+
if (e?.code === 'too-many-tries') {
|
|
1103
|
+
return reply(from, { type: MSG.ERROR, error: e.message, code: e.code, waitMs: e.waitMs || 0 })
|
|
1104
|
+
}
|
|
1105
|
+
if (e?.code === 'login-failed' || e?.code === 'no-exchange' || e?.code === 'bad-input' || e?.code === 'bad-user') {
|
|
1106
|
+
return reply(from, { type: MSG.ERROR, error: e.message, code: e.code })
|
|
1107
|
+
}
|
|
1108
|
+
log('[vault] login: %s', e?.message || e)
|
|
1109
|
+
reply(from, { type: MSG.ERROR, error: 'login failed', code: 'login-error' })
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
async function handleLoginStart (from, p) {
|
|
1113
|
+
try {
|
|
1114
|
+
const { lid, response } = logins.loginBegin({ user: p?.user, request: p?.request })
|
|
1115
|
+
audit('login.start', { user: String(p?.user || '').slice(0, 32) })
|
|
1116
|
+
reply(from, { type: MSG.LOGIN_RESPONSE, lid, response })
|
|
1117
|
+
} catch (e) { loginError(from, e) }
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
async function handleLoginFinish (from, p) {
|
|
1121
|
+
try {
|
|
1122
|
+
const r = logins.loginEnd({ lid: p?.lid, finalization: p?.finalization, label: p?.label })
|
|
1123
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
1124
|
+
audit('login.ok', { user: r.user, sid: r.sid.slice(0, 8) })
|
|
1125
|
+
log('[vault] login: %s entered (session %s)', r.user, r.sid.slice(0, 8))
|
|
1126
|
+
reply(from, { type: MSG.LOGIN_OK, sid: r.sid, blob: r.blob, cert: r.cert, iss: r.iss || identity.me?.publickey || null, acta: record })
|
|
1127
|
+
} catch (e) { loginError(from, e) }
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/**
|
|
1131
|
+
* SALIR. Va firmado con la llave del propio aparato —la que acaba de abrir— porque cerrar
|
|
1132
|
+
* la sesión de otro sería echarlo de su cuenta.
|
|
1133
|
+
*/
|
|
1134
|
+
async function handleLoginClose (from, p) {
|
|
1135
|
+
if (!isFresh(p?.data)) return staleReply(from)
|
|
1136
|
+
const d = p.data
|
|
1137
|
+
if (d?.op !== 'login.close' || typeof d.publickey !== 'string' || typeof d.user !== 'string' || typeof d.sid !== 'string') {
|
|
1138
|
+
return reply(from, { type: MSG.ERROR, error: 'unauthorized: shape', code: 'bad-input' })
|
|
1139
|
+
}
|
|
1140
|
+
if (!(await verifyDeviceSig({ publickey: d.publickey, data: d, signature: p.signature }))) {
|
|
1141
|
+
return reply(from, { type: MSG.ERROR, error: 'unauthorized: bad-signature', code: 'bad-signature' })
|
|
1142
|
+
}
|
|
1143
|
+
const mine = logins.list().find((x) => x.user === d.user)
|
|
1144
|
+
if (!mine || mine.pub !== d.publickey) {
|
|
1145
|
+
return reply(from, { type: MSG.ERROR, error: 'unauthorized: that key does not own this login', code: 'not-yours' })
|
|
1146
|
+
}
|
|
1147
|
+
const r = logins.closeSession({ user: d.user, sid: d.sid })
|
|
1148
|
+
audit('login.close', { user: d.user, sid: d.sid.slice(0, 8), by: 'device' })
|
|
1149
|
+
reply(from, { type: MSG.LOGIN_CLOSED, ok: r.ok })
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1074
1152
|
async function handleCheck (from, p) {
|
|
1075
1153
|
if (!isFresh(p?.data)) return staleReply(from)
|
|
1076
1154
|
const pub = p.data.publickey
|
|
@@ -1632,6 +1710,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1632
1710
|
if (payload.type === MSG.RENOUNCE) return await handleRenounce(from, payload)
|
|
1633
1711
|
if (payload.type === MSG.ADMIN_EVENT) return await handleAdminEvent(payload)
|
|
1634
1712
|
if (payload.type === MSG.REPLICA_ACK) return await handleReplicaAck(from, payload)
|
|
1713
|
+
if (payload.type === MSG.LOGIN_START) return await handleLoginStart(from, payload)
|
|
1714
|
+
if (payload.type === MSG.LOGIN_FINISH) return await handleLoginFinish(from, payload)
|
|
1715
|
+
if (payload.type === MSG.LOGIN_CLOSE) return await handleLoginClose(from, payload)
|
|
1635
1716
|
} catch (e) {
|
|
1636
1717
|
reply(from, { type: MSG.ERROR, error: e.message })
|
|
1637
1718
|
}
|
|
@@ -3375,6 +3456,42 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
3375
3456
|
// llave. Lo que no puede es listarla un aparato.
|
|
3376
3457
|
passwordDevices,
|
|
3377
3458
|
passwordsVault: () => passwords?.vault || null,
|
|
3459
|
+
// --- ENTRAR CON USUARIO Y CONTRASEÑA -------------------------------------------------
|
|
3460
|
+
//
|
|
3461
|
+
// El alta va en DOS pasos porque la contraseña no pasa por aquí: quien crea el aparato
|
|
3462
|
+
// (la CLI, en esta máquina) hace su mitad de OPAQUE, genera las llaves del aparato y las
|
|
3463
|
+
// cierra con la llave que sale de la contraseña. La bóveda solo guarda lo que no puede
|
|
3464
|
+
// abrir, sella el acta y firma el certificado — y eso último sí pide el perfil abierto.
|
|
3465
|
+
listLogins: () => logins.list(),
|
|
3466
|
+
loginRegisterBegin: ({ user, request, replace = false }) => logins.registerBegin({ user, request, replace }),
|
|
3467
|
+
// El alta es la MISMA pieza que usa la bóveda-pestaña (`registerLogin`): aquí solo se
|
|
3468
|
+
// añade lo que es del binario — la bitácora y el aviso a los demás aparatos.
|
|
3469
|
+
loginRegisterFinish: async (opts) => {
|
|
3470
|
+
const r = await registerLogin({ ...opts, identity, logins })
|
|
3471
|
+
if (r.replaced) { audit('login.passwd', { user: r.user }); return r }
|
|
3472
|
+
audit('login.create', { user: r.user, device: r.deviceId, caps: r.caps })
|
|
3473
|
+
await notifyMembers('enrolled', { deviceId: r.deviceId, by: 'login' })
|
|
3474
|
+
return r
|
|
3475
|
+
},
|
|
3476
|
+
// ENTRAR DESDE ESTA MISMA MÁQUINA. Es el mismo intercambio que hace un navegador por el
|
|
3477
|
+
// proxio, y pasa por el mismo freno: lo usa `logins passwd`, que necesita abrir el
|
|
3478
|
+
// paquete de llaves con la contraseña vieja antes de volver a cerrarlo con la nueva.
|
|
3479
|
+
loginBegin: ({ user, request }) => logins.loginBegin({ user, request }),
|
|
3480
|
+
loginEnd: ({ lid, finalization, label }) => logins.loginEnd({ lid, finalization, label }),
|
|
3481
|
+
closeLogin: ({ user, sid }) => {
|
|
3482
|
+
const r = logins.closeSession({ user, sid })
|
|
3483
|
+
if (r.ok) audit('login.close', { user, sid: String(sid).slice(0, 8), by: 'console' })
|
|
3484
|
+
return r
|
|
3485
|
+
},
|
|
3486
|
+
clearLoginBlock: ({ user }) => logins.clearBlock({ user }),
|
|
3487
|
+
removeLogin: async ({ user }) => {
|
|
3488
|
+
const found = logins.list().find((x) => x.user === user)
|
|
3489
|
+
if (!found) return { ok: false }
|
|
3490
|
+
logins.remove({ user })
|
|
3491
|
+
audit('login.remove', { user, device: found.deviceId })
|
|
3492
|
+
// Quitarlo de aquí no lo saca del acta: lo suyo es revocar el aparato, y eso ya existe.
|
|
3493
|
+
return { ok: true, pub: found.pub, deviceId: found.deviceId }
|
|
3494
|
+
},
|
|
3378
3495
|
listApprovals: () => approvals.list(),
|
|
3379
3496
|
// LAS CONCESIONES VIVAS, para verlas y para cortarlas. Se enseñan en la máquina del
|
|
3380
3497
|
// dueño (TUI/CLI), donde el contexto ya es suyo y no hay nada que sellar.
|