@dotrino/vaultd 0.121.0 → 0.123.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/index.js +31 -2
- package/lib/src/loginClient.js +216 -0
- package/lib/src/passwordLogins.js +72 -19
- package/package.json +2 -2
- package/src/ctl.js +42 -24
- package/src/daemon.js +9 -0
- package/src/passwords.js +113 -55
- package/src/replica.js +38 -3
- package/src/secretsStore.js +14 -0
- package/src/tui/app.js +1 -1
- package/src/tui/i18n.js +6 -0
- package/src/vault.js +119 -17
package/lib/src/index.js
CHANGED
|
@@ -31,6 +31,10 @@ import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS, FRESH_WINDOW_MS } from './
|
|
|
31
31
|
// Las constantes del protocolo salen del MISMO módulo que usa el daemon: si la lista
|
|
32
32
|
// local se queda corta, el dispositivo deja de handle mensajes sin que nadie lo note.
|
|
33
33
|
import { MSG, SCOPE } from './protocol.js'
|
|
34
|
+
// El canal donde se anuncia esta cuenta y la huella que lo nombra: la MISMA pieza que usa
|
|
35
|
+
// el daemon y que lee quien entra con usuario y contraseña. Un canal que se escriba distinto
|
|
36
|
+
// en cada bóveda no es el mismo canal.
|
|
37
|
+
import { vaultChannel, accountFingerprint } from './passwordLogins.js'
|
|
34
38
|
|
|
35
39
|
const SIGN_SCOPE = SCOPE.SIGN
|
|
36
40
|
const RENEW_TTL_MS = DEVICE_TTL_MS // la renovación extiende la misma ventana (30 días)
|
|
@@ -100,10 +104,35 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
100
104
|
})()
|
|
101
105
|
|
|
102
106
|
const selfCert = await getSelfCert()
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* ANUNCIARSE EN EL CANAL DE LA CUENTA, igual que el daemon del PC (`src/transport.js`).
|
|
110
|
+
*
|
|
111
|
+
* Sin esto, una bóveda-pestaña no existe para quien entra con usuario y contraseña: ese
|
|
112
|
+
* equipo no tiene ninguna llave, solo el código de la dirección, y el canal es lo único
|
|
113
|
+
* que puede convertirlo en «con quién hablo». Faltaba, así que quien tenía su bóveda solo
|
|
114
|
+
* en una pestaña no era encontrable por dirección — y la pestaña sí sabe atender el
|
|
115
|
+
* inicio de sesión desde que existe `logins`.
|
|
116
|
+
*
|
|
117
|
+
* Va DESPUÉS de identificarse y en cada token nuevo, porque el canal guarda el token y el
|
|
118
|
+
* token cambia con cada reconexión. Y no bloquea el arranque: una bóveda que no consigue
|
|
119
|
+
* anunciarse sigue sirviendo a sus aparatos, que es casi todo lo que hace.
|
|
120
|
+
*/
|
|
121
|
+
const announce = async () => {
|
|
122
|
+
if (!logins || !client.token || typeof client.publish !== 'function') return
|
|
123
|
+
try {
|
|
124
|
+
const canal = vaultChannel(await accountFingerprint(identity))
|
|
125
|
+
await client.publish(canal)
|
|
126
|
+
} catch (e) {
|
|
127
|
+
console.warn('[device-vault] could not announce this account on the proxy:', e?.message || e)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
103
131
|
const identify = async () => {
|
|
104
132
|
if (!client.token) return
|
|
105
133
|
// El sobre lo arma el pilar (`identifyAs`), que le pone el destinatario.
|
|
106
134
|
await client.identifyAs({ publickey: iss, sign: (d) => identity.signData(d), cert: selfCert })
|
|
135
|
+
await announce()
|
|
107
136
|
}
|
|
108
137
|
await identify()
|
|
109
138
|
client.on('token', () => identify().catch(() => {}))
|
|
@@ -365,7 +394,7 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
365
394
|
async function handleLoginStart (from, p) {
|
|
366
395
|
if (!logins) return noLogins(from)
|
|
367
396
|
try {
|
|
368
|
-
const { lid, response } = logins.loginBegin({ user: p?.user, request: p?.request })
|
|
397
|
+
const { lid, response } = await logins.loginBegin({ user: p?.user, request: p?.request })
|
|
369
398
|
send(from, { type: MSG.LOGIN_RESPONSE, lid, response })
|
|
370
399
|
} catch (e) { loginError(from, e) }
|
|
371
400
|
}
|
|
@@ -373,7 +402,7 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
373
402
|
async function handleLoginFinish (from, p) {
|
|
374
403
|
if (!logins) return noLogins(from)
|
|
375
404
|
try {
|
|
376
|
-
const r = logins.loginEnd({ lid: p?.lid, finalization: p?.finalization, label: p?.label })
|
|
405
|
+
const r = await logins.loginEnd({ lid: p?.lid, finalization: p?.finalization, label: p?.label })
|
|
377
406
|
const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
378
407
|
send(from, { type: MSG.LOGIN_OK, sid: r.sid, blob: r.blob, cert: r.cert, iss: r.iss || iss, acta })
|
|
379
408
|
} catch (e) { loginError(from, e) }
|
|
@@ -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 }
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
* binario, `lib/src/index.js` en la pestaña—. Lo único que no es puro es `registerLogin`, al
|
|
25
25
|
* final, porque hace falta la identidad que firma.
|
|
26
26
|
*/
|
|
27
|
-
import { server as
|
|
27
|
+
import { server as opaquePorDefecto, suiteId as suitePorDefecto } from '@dotrino/opaque'
|
|
28
|
+
import { pubkeyId } from '@dotrino/identity/capabilities'
|
|
28
29
|
import { deviceIdOf, scopeToCaps, scopeToCn } from './enroll.js'
|
|
29
30
|
import { SCOPE } from './protocol.js'
|
|
30
31
|
import { bytesToB64url, b64urlToBytes } from './b64.js'
|
|
@@ -53,6 +54,43 @@ export function accountCode (fingerprint) {
|
|
|
53
54
|
}
|
|
54
55
|
export const loginAddress = (user, fingerprint) => `${user}@${accountCode(fingerprint)}`
|
|
55
56
|
|
|
57
|
+
/**
|
|
58
|
+
* LA HUELLA DE LA CUENTA, que es de la CUENTA y no de la bóveda que la atiende.
|
|
59
|
+
*
|
|
60
|
+
* Sale del `profileId` del acta —la llave del génesis, la que no cambia nunca—, y por eso
|
|
61
|
+
* la dirección sobrevive a que la bóveda se mude, se replique o adopte la cuenta de otro
|
|
62
|
+
* aparato. Derivarla de la llave de la bóveda daba una dirección distinta en cada réplica
|
|
63
|
+
* y ninguna en la segunda bóveda de un multivault.
|
|
64
|
+
*
|
|
65
|
+
* Es la MISMA cuenta para todas las bóvedas de esa cuenta, que es justo lo que hace que el
|
|
66
|
+
* canal las junte a todas.
|
|
67
|
+
*/
|
|
68
|
+
export async function accountFingerprint (identity) {
|
|
69
|
+
const pid = (await identity?.profileActa?.().catch(() => null))?.acta?.profileId
|
|
70
|
+
if (!pid) throw err('no-acta', 'this vault has no account record yet: there is no address to build')
|
|
71
|
+
return (await pubkeyId(pid)).slice(0, 16)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Lee `nombre@AB12-CD34-EF56`. Es lo que TECLEA una persona en un equipo prestado, así que
|
|
76
|
+
* se le perdona el formato —mayúsculas, espacios, guiones de más o de menos— pero no el
|
|
77
|
+
* contenido: el código son 12 dígitos hexadecimales, ni uno más ni uno menos.
|
|
78
|
+
*
|
|
79
|
+
* Lo que NO se perdona tiene su razón: un código corto de menos no es «casi» la dirección,
|
|
80
|
+
* es otra cuenta con la que se colisiona antes, y aceptarlo sería quitarle los bits que lo
|
|
81
|
+
* atan a tu cuenta (§3.2 de `temporary-access.md`).
|
|
82
|
+
*/
|
|
83
|
+
export function parseLoginAddress (address) {
|
|
84
|
+
const raw = String(address || '').trim()
|
|
85
|
+
const at = raw.lastIndexOf('@')
|
|
86
|
+
if (at <= 0) throw err('bad-address', 'an address looks like name@AB12-CD34-EF56')
|
|
87
|
+
const user = raw.slice(0, at).trim().toLowerCase()
|
|
88
|
+
const hex = raw.slice(at + 1).replace(/[^0-9a-fA-F]/g, '').toUpperCase()
|
|
89
|
+
if (!isValidUser(user)) throw err('bad-user', 'a user name is lowercase letters, digits, dot, dash or underscore (1-32)')
|
|
90
|
+
if (hex.length !== ACCOUNT_CODE_HEX) throw err('bad-address', `the account code is ${ACCOUNT_CODE_HEX} hex digits: AB12-CD34-EF56`)
|
|
91
|
+
return { user, code: accountCode(hex), address: `${user}@${accountCode(hex)}` }
|
|
92
|
+
}
|
|
93
|
+
|
|
56
94
|
/**
|
|
57
95
|
* EL CANAL DONDE SE ANUNCIA CADA BÓVEDA DE ESA CUENTA, réplicas incluidas.
|
|
58
96
|
*
|
|
@@ -143,10 +181,25 @@ export async function openDeviceKeys (exportKey, blob) {
|
|
|
143
181
|
* `load()` devuelve el estado guardado (o `null` la primera vez)
|
|
144
182
|
* `save(s)` lo guarda
|
|
145
183
|
* `now()` el reloj, para poder probar el freno sin esperar una hora
|
|
184
|
+
* `opaque` la mitad de SERVIDOR de OPAQUE. Por defecto la del paquete, que es WASM y
|
|
185
|
+
* corre en el mismo hilo. Se puede inyectar OTRA porque en una extensión MV3 el WASM
|
|
186
|
+
* vive al otro lado de un `postMessage` —en una página sandbox, para no tener que
|
|
187
|
+
* abrirle la CSP a toda la extensión—, y desde allí solo puede contestar una promesa.
|
|
188
|
+
*
|
|
189
|
+
* **Por eso los métodos que tocan OPAQUE son ASÍNCRONOS**, también con la implementación de
|
|
190
|
+
* casa: una firma que cambia según quién la use son dos mostradores, y de eso va la regla
|
|
191
|
+
* de las tres versiones.
|
|
146
192
|
*/
|
|
147
|
-
export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
|
|
193
|
+
export function createLoginDesk ({ load, save, now = () => Date.now(), opaque = opaquePorDefecto } = {}) {
|
|
148
194
|
if (typeof load !== 'function' || typeof save !== 'function') throw new Error('createLoginDesk: load and save are required')
|
|
149
195
|
|
|
196
|
+
/**
|
|
197
|
+
* QUÉ SUITE se está usando, dicha por quien hace el cálculo. Con el OPAQUE de casa es la
|
|
198
|
+
* del paquete; con uno inyectado, la suya — preguntárselo a él es lo único que no miente,
|
|
199
|
+
* porque el registro que queda guardado lo hizo él.
|
|
200
|
+
*/
|
|
201
|
+
const suite = () => (typeof opaque.suiteId === 'function' ? opaque.suiteId() : suitePorDefecto())
|
|
202
|
+
|
|
150
203
|
/** Intercambios a medias: viven en memoria, y por eso un reinicio los tira. */
|
|
151
204
|
const exchanges = new Map()
|
|
152
205
|
|
|
@@ -157,9 +210,9 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
|
|
|
157
210
|
}
|
|
158
211
|
|
|
159
212
|
/** La preparación del servidor nace con el primer usuario y no se toca nunca más. */
|
|
160
|
-
function setupOf (state) {
|
|
213
|
+
async function setupOf (state) {
|
|
161
214
|
if (state.setup) return state.setup
|
|
162
|
-
state.setup = opaque.createSetup()
|
|
215
|
+
state.setup = await opaque.createSetup()
|
|
163
216
|
return state.setup
|
|
164
217
|
}
|
|
165
218
|
|
|
@@ -196,30 +249,30 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
|
|
|
196
249
|
* ALTA, primer paso. La contraseña no llega hasta aquí: lo que llega es el mensaje de
|
|
197
250
|
* registro de OPAQUE, que no la lleva ni permite adivinarla.
|
|
198
251
|
*/
|
|
199
|
-
registerBegin ({ user, request, replace = false } = {}) {
|
|
252
|
+
async registerBegin ({ user, request, replace = false } = {}) {
|
|
200
253
|
const state = read()
|
|
201
254
|
const existing = userOf(state, user)
|
|
202
255
|
if (existing && !replace) throw err('user-exists', `there is already a login called "${user}"`)
|
|
203
256
|
if (!existing && replace) throw err('no-user', `there is no login called "${user}"`)
|
|
204
257
|
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 })
|
|
258
|
+
const setup = await setupOf(state)
|
|
259
|
+
const response = await opaque.registrationResponse({ setup, request, credentialId: user })
|
|
207
260
|
save(state)
|
|
208
|
-
return { response, suite:
|
|
261
|
+
return { response, suite: await suite() }
|
|
209
262
|
},
|
|
210
263
|
|
|
211
264
|
/**
|
|
212
265
|
* ALTA, segundo paso. `blob` son las llaves privadas del aparato, ya cerradas por quien
|
|
213
266
|
* creó el aparato con la llave que sale de la contraseña: aquí no se abre nunca.
|
|
214
267
|
*/
|
|
215
|
-
registerFinish ({ user, upload, pub, encPub = null, deviceId = null, label = '', blob, replace = false } = {}) {
|
|
268
|
+
async registerFinish ({ user, upload, pub, encPub = null, deviceId = null, label = '', blob, replace = false } = {}) {
|
|
216
269
|
const state = read()
|
|
217
270
|
const existing = userOf(state, user)
|
|
218
271
|
if (existing && !replace) throw err('user-exists', `there is already a login called "${user}"`)
|
|
219
272
|
if (!existing && replace) throw err('no-user', `there is no login called "${user}"`)
|
|
220
273
|
if (typeof blob !== 'string' || !blob) throw err('bad-input', 'blob required (the device keys, sealed with the password)')
|
|
221
274
|
if (!replace && (typeof pub !== 'string' || !pub)) throw err('bad-input', 'pub required')
|
|
222
|
-
const record = opaque.registrationFinish({ upload })
|
|
275
|
+
const record = await opaque.registrationFinish({ upload })
|
|
223
276
|
const t = now()
|
|
224
277
|
state.users[user] = {
|
|
225
278
|
...(existing || {}),
|
|
@@ -229,7 +282,7 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
|
|
|
229
282
|
label: label || existing?.label || '',
|
|
230
283
|
record,
|
|
231
284
|
blob,
|
|
232
|
-
suite:
|
|
285
|
+
suite: await suite(),
|
|
233
286
|
createdAt: existing?.createdAt || t,
|
|
234
287
|
passwordChangedAt: t,
|
|
235
288
|
// Cambiar la contraseña cierra lo abierto: si alguien entró con la vieja, deja de
|
|
@@ -239,7 +292,7 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
|
|
|
239
292
|
nextTryAt: 0
|
|
240
293
|
}
|
|
241
294
|
save(state)
|
|
242
|
-
return { user, suite:
|
|
295
|
+
return { user, suite: await suite() }
|
|
243
296
|
},
|
|
244
297
|
|
|
245
298
|
/** El certificado del aparato, que firma la bóveda al admitirlo en el acta. Es público. */
|
|
@@ -260,7 +313,7 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
|
|
|
260
313
|
* registro inventado: desde fuera no se puede averiguar qué usuarios hay. Y cuenta
|
|
261
314
|
* contra el mismo freno, para que tampoco se pueda medir por el tiempo.
|
|
262
315
|
*/
|
|
263
|
-
loginBegin ({ user, request } = {}) {
|
|
316
|
+
async loginBegin ({ user, request } = {}) {
|
|
264
317
|
sweepExchanges()
|
|
265
318
|
const state = read()
|
|
266
319
|
if (typeof request !== 'string' || !request) throw err('bad-input', 'request required')
|
|
@@ -276,8 +329,8 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
|
|
|
276
329
|
u.fails = (u.fails || 0) + 1
|
|
277
330
|
u.nextTryAt = t + backoffFor(u.fails)
|
|
278
331
|
}
|
|
279
|
-
const setup = setupOf(state)
|
|
280
|
-
const { state: serverState, response } = opaque.loginStart({
|
|
332
|
+
const setup = await setupOf(state)
|
|
333
|
+
const { state: serverState, response } = await opaque.loginStart({
|
|
281
334
|
setup,
|
|
282
335
|
record: u?.record || null,
|
|
283
336
|
request,
|
|
@@ -294,7 +347,7 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
|
|
|
294
347
|
* INICIO DE SESIÓN, segundo paso. Si la contraseña es la buena, devuelve el paquete de
|
|
295
348
|
* llaves —que solo abre esa contraseña—, el certificado y el acta.
|
|
296
349
|
*/
|
|
297
|
-
loginEnd ({ lid, finalization, label = '' } = {}) {
|
|
350
|
+
async loginEnd ({ lid, finalization, label = '' } = {}) {
|
|
298
351
|
sweepExchanges()
|
|
299
352
|
const x = exchanges.get(lid)
|
|
300
353
|
if (!x) throw err('no-exchange', 'that login is not in flight any more: start again')
|
|
@@ -303,7 +356,7 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
|
|
|
303
356
|
const u = x.known ? state.users[x.user] : null
|
|
304
357
|
|
|
305
358
|
try {
|
|
306
|
-
opaque.loginFinish({ state: x.state, finalization })
|
|
359
|
+
await opaque.loginFinish({ state: x.state, finalization })
|
|
307
360
|
} catch (e) {
|
|
308
361
|
// Contraseña equivocada, usuario inexistente o mensaje alterado: el MISMO error.
|
|
309
362
|
// Distinguirlos diría qué usuarios existen. El intento ya está contado desde
|
|
@@ -400,7 +453,7 @@ export async function registerLogin ({
|
|
|
400
453
|
scope, unattended = false, replace = false
|
|
401
454
|
} = {}) {
|
|
402
455
|
if (replace) {
|
|
403
|
-
logins.registerFinish({ user, upload, blob, replace: true })
|
|
456
|
+
await logins.registerFinish({ user, upload, blob, replace: true })
|
|
404
457
|
return { ok: true, user, replaced: true }
|
|
405
458
|
}
|
|
406
459
|
// PERMISOS, no tipos: los del scope, más `unattended` si quien lo crea lo eligió
|
|
@@ -414,7 +467,7 @@ export async function registerLogin ({
|
|
|
414
467
|
throw Object.assign(new Error('this vault cannot add devices to the account record: no login was created'), { code: 'admit-unavailable' })
|
|
415
468
|
}
|
|
416
469
|
const deviceId = await deviceIdOf(pub)
|
|
417
|
-
logins.registerFinish({ user, upload, pub, encPub, deviceId, label, blob })
|
|
470
|
+
await logins.registerFinish({ user, upload, pub, encPub, deviceId, label, blob })
|
|
418
471
|
try {
|
|
419
472
|
const { cert } = await identity.signDelegation(pub, scopes, { label: label || `login:${user}` })
|
|
420
473
|
const cn = scopeToCn(scopes)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.123.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": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"@dotrino/compat": "0.4.0",
|
|
25
25
|
"@dotrino/identity": "0.92.0",
|
|
26
26
|
"@dotrino/opaque": "0.1.0",
|
|
27
|
-
"@dotrino/passmanager": "
|
|
27
|
+
"@dotrino/passmanager": "0.12.0",
|
|
28
28
|
"@dotrino/proxy-client": "0.23.1",
|
|
29
29
|
"@dotrino/roadmap": "0.3.3",
|
|
30
30
|
"@dotrino/store": "0.11.0",
|
package/src/ctl.js
CHANGED
|
@@ -676,6 +676,42 @@ async function findMember (id) {
|
|
|
676
676
|
* dotrino-vault caps <ID> -desatendido ← vuelve a pedir permiso (el defecto)
|
|
677
677
|
*/
|
|
678
678
|
|
|
679
|
+
/**
|
|
680
|
+
* LAS PALABRAS DE LOS PERMISOS, en un solo sitio.
|
|
681
|
+
*
|
|
682
|
+
* Las usa `caps <ID> ±permiso` y también `logins add`, que da de alta un aparato que se abre
|
|
683
|
+
* con usuario y contraseña: los dos hablan de lo mismo, así que tienen que llamarlo igual.
|
|
684
|
+
* Tenerlo dos veces es cómo se acaba aceptando `+aprueba` en un comando y no en el otro.
|
|
685
|
+
*/
|
|
686
|
+
const CAP_BY_WORD = {
|
|
687
|
+
firma: 'sign', guarda: 'store', lee: 'read', administra: 'admin', aprueba: 'approve',
|
|
688
|
+
// `sella`: SELLAR EL ACTA. Es lo que convierte a otra bóveda en respaldo de esta —
|
|
689
|
+
// podrá admitir aparatos y cambiar permisos si esta se pierde. No es un traspaso:
|
|
690
|
+
// quien manda sigue mandando. Como `administra`, no se empareja: se concede aquí.
|
|
691
|
+
sella: 'sealer', sealer: 'sealer',
|
|
692
|
+
// `replica`: reparte los sobres que esta bóveda ya firmó, cuando ella no está. No
|
|
693
|
+
// tiene maestra y no puede abrir nada. A diferencia de `sella`, SÍ se empareja.
|
|
694
|
+
replica: 'replica', replicador: 'replica',
|
|
695
|
+
// `contraseñas`: el gestor (la extensión, la app del teléfono) puede PEDIR
|
|
696
|
+
// credenciales de a una. Se acepta con y sin tilde: nadie escribe la ñ en una CLI.
|
|
697
|
+
contrasenas: 'passwords', 'contraseñas': 'passwords',
|
|
698
|
+
// `passkeys`: además de las contraseñas, puede abrir la LLAVE PRIVADA de una passkey.
|
|
699
|
+
// Va aparte porque una passkey copiada sirve hasta que la borres en cada sitio donde
|
|
700
|
+
// la registraste — de una contraseña, al menos, se puede cambiar
|
|
701
|
+
// (`dotrino-passmanager/docs/sealed-passwords.md` §2.8). Sin `contraseñas` no
|
|
702
|
+
// significa nada: la passkey vive en una entrada de contraseñas.
|
|
703
|
+
passkeys: 'passkeys', llaves: 'passkeys',
|
|
704
|
+
// `desatendido`: RECIBE CLAVES PRIVADAS SIN QUE NADIE APRUEBE. Sin él, la bóveda no
|
|
705
|
+
// entrega nada hasta que un aparato con `aprueba` lo firme (una vez por arranque).
|
|
706
|
+
//
|
|
707
|
+
// No se llama `permiso` —como la marca local de antes— a propósito: aquella significaba
|
|
708
|
+
// «este aparato PIDE permiso» y esta significa lo contrario. Reusar la palabra con el
|
|
709
|
+
// sentido invertido es la clase de trampa que se paga una madrugada.
|
|
710
|
+
desatendido: 'unattended', unattended: 'unattended',
|
|
711
|
+
sign: 'sign', store: 'store', read: 'read', admin: 'admin', approve: 'approve',
|
|
712
|
+
passwords: 'passwords'
|
|
713
|
+
}
|
|
714
|
+
|
|
679
715
|
/** `dotrino-vault caps <ID> ±permiso` — cambia lo que puede hacer un dispositivo. */
|
|
680
716
|
async function cmdCaps (args = []) {
|
|
681
717
|
const [id, ...changes] = args
|
|
@@ -683,28 +719,6 @@ async function cmdCaps (args = []) {
|
|
|
683
719
|
console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra|+aprueba|-aprueba|+contrasenas|-contrasenas|+sella|-sella|+desatendido|-desatendido')
|
|
684
720
|
process.exit(2)
|
|
685
721
|
}
|
|
686
|
-
const CAP_BY_WORD = {
|
|
687
|
-
firma: 'sign', guarda: 'store', lee: 'read', administra: 'admin', aprueba: 'approve',
|
|
688
|
-
// `sella`: SELLAR EL ACTA. Es lo que convierte a otra bóveda en respaldo de esta —
|
|
689
|
-
// podrá admitir aparatos y cambiar permisos si esta se pierde. No es un traspaso:
|
|
690
|
-
// quien manda sigue mandando. Como `administra`, no se empareja: se concede aquí.
|
|
691
|
-
sella: 'sealer', sealer: 'sealer',
|
|
692
|
-
// `replica`: reparte los sobres que esta bóveda ya firmó, cuando ella no está. No
|
|
693
|
-
// tiene maestra y no puede abrir nada. A diferencia de `sella`, SÍ se empareja.
|
|
694
|
-
replica: 'replica', replicador: 'replica',
|
|
695
|
-
// `contraseñas`: el gestor (la extensión, la app del teléfono) puede PEDIR
|
|
696
|
-
// credenciales de a una. Se acepta con y sin tilde: nadie escribe la ñ en una CLI.
|
|
697
|
-
contrasenas: 'passwords', 'contraseñas': 'passwords',
|
|
698
|
-
// `desatendido`: RECIBE CLAVES PRIVADAS SIN QUE NADIE APRUEBE. Sin él, la bóveda no
|
|
699
|
-
// entrega nada hasta que un aparato con `aprueba` lo firme (una vez por arranque).
|
|
700
|
-
//
|
|
701
|
-
// No se llama `permiso` —como la marca local de antes— a propósito: aquella significaba
|
|
702
|
-
// «este aparato PIDE permiso» y esta significa lo contrario. Reusar la palabra con el
|
|
703
|
-
// sentido invertido es la clase de trampa que se paga una madrugada.
|
|
704
|
-
desatendido: 'unattended', unattended: 'unattended',
|
|
705
|
-
sign: 'sign', store: 'store', read: 'read', admin: 'admin', approve: 'approve',
|
|
706
|
-
passwords: 'passwords'
|
|
707
|
-
}
|
|
708
722
|
const s = requireDaemon()
|
|
709
723
|
const m = await findMember(id)
|
|
710
724
|
|
|
@@ -1558,7 +1572,9 @@ async function loginHere (opaqueClient, user, password) {
|
|
|
1558
1572
|
const LOGINS_USAGE = `uso: dotrino-vault logins <orden>
|
|
1559
1573
|
|
|
1560
1574
|
logins [ls] los aparatos que se abren con usuario y contraseña
|
|
1561
|
-
logins add <usuario> [nombre del equipo]
|
|
1575
|
+
logins add <usuario> [nombre del equipo] [±permiso …]
|
|
1576
|
+
permisos como en «caps» (+aprueba, -guarda, +desatendido…).
|
|
1577
|
+
Sin decir nada: firma, lee y guarda
|
|
1562
1578
|
logins passwd <usuario> cambia la contraseña (cierra lo que estuviera abierto)
|
|
1563
1579
|
logins close <usuario> [sid] cierra una sesión abierta (sin sid, todas)
|
|
1564
1580
|
logins unblock <usuario> quita la espera de los intentos fallidos
|
|
@@ -1924,7 +1940,9 @@ function help () {
|
|
|
1924
1940
|
revoke <ID|nonce> quita un dispositivo (con el ID, todos sus certificados)
|
|
1925
1941
|
logins los aparatos que se abren con usuario y contraseña (para un equipo
|
|
1926
1942
|
prestado, donde no puedes emparejar nada)
|
|
1927
|
-
logins add <usuario> [nombre del equipo]
|
|
1943
|
+
logins add <usuario> [nombre del equipo] [±permiso …]
|
|
1944
|
+
permisos como en «caps» (+aprueba, -guarda, +desatendido…).
|
|
1945
|
+
Sin decir nada: firma, lee y guarda
|
|
1928
1946
|
crea uno. La contraseña se teclea aquí y no sale de esta terminal:
|
|
1929
1947
|
la bóveda guarda un paquete de llaves que solo ella abre
|
|
1930
1948
|
logins passwd <usuario> cambia la contraseña (cierra lo que estuviera abierto)
|
package/src/daemon.js
CHANGED
|
@@ -467,6 +467,15 @@ export async function runDaemon () {
|
|
|
467
467
|
for (const f of r.failed) console.error('[vault] %s: could not reseal (%s)', f.owner, f.error)
|
|
468
468
|
note += ` · ⚠ ${r.failed.length} cajón(es) NO se pudieron reenvolver (${r.failed.map((f) => f.owner).join(', ')}): sus aparatos no podrán leerlos`
|
|
469
469
|
}
|
|
470
|
+
// Y LAS CONTRASEÑAS, por lo mismo y en el mismo momento: convertirlas al formato
|
|
471
|
+
// sellado es un acto único que necesita la frase, y repasar sus envolturas hace
|
|
472
|
+
// falta cada vez que cambia quién puede leer. Un aparato que entró después de
|
|
473
|
+
// guardada una contraseña no puede abrirla hasta que alguien se la envuelva, y
|
|
474
|
+
// con la bóveda cerrada nadie puede.
|
|
475
|
+
const p = await mgr.get(id)?.sealPasswords?.(ak)
|
|
476
|
+
if (p?.converted) note += ` · ${p.converted} contraseña(s) pasadas al formato sellado`
|
|
477
|
+
if (p?.wrapped || p?.dropped) note += ` · contraseñas: llavero al día${p.dropped ? ` (${p.dropped} envoltura(s) retirada(s))` : ''}`
|
|
478
|
+
if (p?.convertError) note += ` · ⚠ las contraseñas NO se pudieron convertir (${p.convertError})`
|
|
470
479
|
} catch (e) {
|
|
471
480
|
console.error('[vault] could not rebuild the keyring on unlock:', e.message)
|
|
472
481
|
note += ` · ⚠ el llavero NO se pudo rehacer (${e.message}): los aparatos no podrán leer sus cajones`
|
package/src/passwords.js
CHANGED
|
@@ -1,97 +1,155 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* LA BÓVEDA DE CONTRASEÑAS del dueño, atendida por el vault.
|
|
2
|
+
* LA BÓVEDA DE CONTRASEÑAS del dueño, atendida por el vault — Y QUE EL VAULT NO PUEDE LEER.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Hasta 0.122 esto tenía una llave propia (`passwordsKey()`) guardada **dentro del mismo
|
|
5
|
+
* archivo que las entradas**, cifrada con la llave de la máquina. O sea: el demonio con el
|
|
6
|
+
* perfil CERRADO seguía descifrando contraseñas, y cualquiera con una copia del disco las
|
|
7
|
+
* leía todas. Era el mismo esquema que tuvo la maestra hasta que se selló.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* Ahora la bóveda es un cartero también para las contraseñas
|
|
10
|
+
* (`dotrino-passmanager/docs/sealed-passwords.md`): guarda sobres dirigidos a los aparatos
|
|
11
|
+
* y envolturas que no puede abrir. Lo que sí hace, y es lo que sostiene el modelo:
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
13
|
+
* · dice A QUIÉN hay que envolver cada llave —sale del acta, y **la bóveda nunca está en
|
|
14
|
+
* esa lista**: ni su maestra, ni su llave de comunicación;
|
|
15
|
+
* · comprueba que quien escribe sea un aparato del acta con `passwords`, por su firma;
|
|
16
|
+
* · filtra por destinatario al entregar, y decide si hace falta un dedo encima.
|
|
14
17
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
18
|
+
* Lo que SIGUE igual: el protocolo es de a una credencial, `list` no existe en remoto, y la
|
|
19
|
+
* aprobación es la misma del resto del vault (`caps +aprueba`).
|
|
17
20
|
*/
|
|
18
21
|
|
|
19
|
-
import {
|
|
22
|
+
import { SealedStore, SealedResponder } from '@dotrino/passmanager'
|
|
23
|
+
import { verifyDeviceSig } from '@dotrino/identity/capabilities'
|
|
20
24
|
|
|
21
25
|
export const PASSWORDS_CAP = 'passwords'
|
|
26
|
+
export const PASSKEYS_CAP = 'passkeys'
|
|
22
27
|
|
|
23
28
|
/**
|
|
24
29
|
* @param {object} opts
|
|
25
30
|
* `client` cliente del proxio del vault, ya identificado
|
|
26
|
-
* `store` `{ get(k), set(k,v) }` donde viven
|
|
27
|
-
* `
|
|
28
|
-
*
|
|
29
|
-
* `
|
|
30
|
-
* `
|
|
31
|
-
*
|
|
32
|
-
*
|
|
31
|
+
* `store` `{ get(k), set(k,v) }` donde viven los sobres
|
|
32
|
+
* `members(kind)` → `[{ pub, encPub }]` a quién envolverle cada llave (`main`/`passkeys`),
|
|
33
|
+
* SIN la bóveda. Lo pone `vault.js`, que es quien lee el acta.
|
|
34
|
+
* `recoveryPub()` la pública de la copia de recuperación (la que abre la frase)
|
|
35
|
+
* `canWrite(pubkey)` ¿ese aparato puede ESCRIBIR contraseñas? (acta: `passwords`)
|
|
36
|
+
* `isAllowed(pubkey)` ¿puede pedir? — lo decide el acta, no esto
|
|
37
|
+
* `encPubOf(pubkey)` su llave de cifrado, para sellarle la respuesta
|
|
38
|
+
* `needsApproval(pubkey)` (async) si ese aparato tiene que pedir aprobación (`unattended`)
|
|
33
39
|
* `approve({ pubkey, op })` pide el visto bueno (el teléfono) y espera
|
|
34
|
-
* `devices()` / `unlink(pubkey)` administración, opcional
|
|
35
40
|
* `audit(op, info)` la bitácora del vault
|
|
36
41
|
*/
|
|
37
42
|
export function createPasswordDesk (opts = {}) {
|
|
38
43
|
const {
|
|
39
|
-
client, store,
|
|
44
|
+
client, store,
|
|
45
|
+
members = () => [],
|
|
46
|
+
recoveryPub = () => null,
|
|
47
|
+
canWrite = () => false,
|
|
40
48
|
isAllowed = () => false,
|
|
41
49
|
encPubOf = () => null,
|
|
42
50
|
needsApproval = () => true,
|
|
43
51
|
approve = async () => false,
|
|
44
|
-
devices = null,
|
|
45
|
-
unlink = null,
|
|
46
52
|
audit = () => {},
|
|
47
53
|
log = () => {},
|
|
48
54
|
} = opts
|
|
49
55
|
|
|
50
|
-
const
|
|
51
|
-
|
|
56
|
+
const sealed = new SealedStore(store, {
|
|
57
|
+
recipients: (kind) => members(kind),
|
|
58
|
+
/**
|
|
59
|
+
* ¿LA FIRMÓ QUIEN DICE, Y PUEDE ESCRIBIR? Es lo único que la bóveda puede comprobar
|
|
60
|
+
* sobre una entrada cuyo contenido no ve. Las dos mitades importan: la firma ata el
|
|
61
|
+
* sobre a una llave, y el acta dice si esa llave tiene algo que hacer aquí.
|
|
62
|
+
*/
|
|
63
|
+
verifyAuthor: async ({ body, author }) => {
|
|
64
|
+
if (!canWrite(author?.pub)) return false
|
|
65
|
+
return verifyDeviceSig({ publickey: author.pub, data: body, signature: author.sig })
|
|
66
|
+
},
|
|
67
|
+
})
|
|
52
68
|
|
|
53
|
-
const responder = new
|
|
69
|
+
const responder = new SealedResponder({
|
|
54
70
|
client,
|
|
55
|
-
|
|
71
|
+
store: sealed,
|
|
72
|
+
recipients: async () => ({
|
|
73
|
+
recoveryPub: recoveryPub(),
|
|
74
|
+
main: await members('main'),
|
|
75
|
+
passkeys: await members('passkeys'),
|
|
76
|
+
}),
|
|
56
77
|
isAllowed,
|
|
57
78
|
encPubOf,
|
|
58
|
-
// Dos condiciones, y las dos tienen que darse:
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
// · y que lo que pide sea PRIVADO — una contraseña, un código de dos pasos, o un
|
|
63
|
-
// campo que el usuario marcó. Rellenar un nombre no es sacar un secreto, y pedir
|
|
64
|
-
// permiso para todo enseña a decir que sí sin mirar.
|
|
65
|
-
//
|
|
66
|
-
// El segundo criterio se toma del propio responder (`wantsPrivate`) en vez de
|
|
67
|
-
// reescribirlo aquí: si cada bóveda tuviera su idea de qué es privado, serían tres
|
|
68
|
-
// bóvedas distintas otra vez.
|
|
69
|
-
needsApproval: async (op, payload, pubkey) =>
|
|
70
|
-
// `await` OBLIGATORIO: `needsApproval` pasó a ser asíncrona (lee el acta en vivo), y
|
|
71
|
-
// una promesa SIEMPRE es verdadera. Sin el await esto decía «sí, pide aprobación»
|
|
72
|
-
// para todo el mundo, y en el otro sentido —si algún día devuelve false— diría que
|
|
73
|
-
// no hace falta. Un `&&` sobre una promesa es una comprobación que no comprueba.
|
|
74
|
-
await needsApproval(pubkey) && await responder.wantsPrivate(op, payload),
|
|
79
|
+
// Dos condiciones, y las dos tienen que darse: que ese APARATO esté marcado para
|
|
80
|
+
// aprobar, y que lo que pide sea PRIVADO. Rellenar un nombre no es sacar un secreto, y
|
|
81
|
+
// pedir permiso para todo enseña a decir que sí sin mirar.
|
|
82
|
+
needsApproval: async (pubkey) => await needsApproval(pubkey),
|
|
75
83
|
approve,
|
|
76
|
-
admin: devices && unlink ? { devices, unlink } : null,
|
|
77
84
|
onRequest: (r) => {
|
|
78
|
-
// La bitácora NO lleva qué credencial se pidió: eso es contenido del usuario
|
|
79
|
-
// el criterio del vault es apuntar la operación, no el payload.
|
|
85
|
+
// La bitácora NO lleva qué credencial se pidió: eso es contenido del usuario.
|
|
80
86
|
audit('passwords', { op: r.op, outcome: r.outcome, device: (r.from || '').slice(0, 24) })
|
|
81
|
-
if (r.outcome !== '
|
|
82
|
-
log(`[vault] passwords: ${r.op} ${r.outcome}`)
|
|
83
|
-
}
|
|
87
|
+
if (r.outcome !== 'ok') log(`[vault] passwords: ${r.op} ${r.outcome}`)
|
|
84
88
|
},
|
|
85
89
|
})
|
|
86
90
|
|
|
87
91
|
return {
|
|
88
92
|
start () { responder.start(); return this },
|
|
89
93
|
stop () { responder.stop() },
|
|
90
|
-
/**
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
94
|
+
/**
|
|
95
|
+
* EL CANDADO DEL PERFIL YA NO PINTA NADA AQUÍ, y eso es la noticia: esta bóveda no
|
|
96
|
+
* tiene ninguna llave que cerrar. Se conserva el método porque quien lo llama no tiene
|
|
97
|
+
* por qué saberlo, y porque decirlo en la bitácora ayuda a entender el cambio.
|
|
98
|
+
*/
|
|
99
|
+
lock () { audit('passwords.lock', { sealed: true }) },
|
|
100
|
+
unlock () {},
|
|
101
|
+
/** Para la consola local y las pruebas. Aquí tampoco se abre nada. */
|
|
102
|
+
sealed,
|
|
95
103
|
responder,
|
|
96
104
|
}
|
|
97
105
|
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* CONVERTIR LO QUE HAY (§2.7). Es un acto único y es **segundo trabajo de la maestra**: se
|
|
109
|
+
* hace al ABRIR la bóveda, que es cuando la frase está a mano y hay con qué reescribirlo
|
|
110
|
+
* todo.
|
|
111
|
+
*
|
|
112
|
+
* Durante este paso —y solo durante este paso— la bóveda ve los valores: los tiene que
|
|
113
|
+
* abrir con la llave vieja para volver a cerrarlos uno por uno, con una llave por escritura
|
|
114
|
+
* envuelta a los aparatos. Después ya no puede, y por eso lo último que hace es **borrar la
|
|
115
|
+
* llave vieja**: mientras siga ahí, el agujero sigue abierto.
|
|
116
|
+
*
|
|
117
|
+
* @param {object} o
|
|
118
|
+
* `store` el almacén con las entradas viejas y donde irán los sobres
|
|
119
|
+
* `sealed` la `SealedStore` de esta bóveda
|
|
120
|
+
* `cek` la llave vieja (`CryptoKey`), o `null` si ya no hay nada que convertir
|
|
121
|
+
* `recipients` `{ recoveryPub, main, passkeys }`
|
|
122
|
+
* `author` `{ publickey, sign(body) }` — la maestra, que firma lo que escribe
|
|
123
|
+
* `dropOldKey()` borra la llave vieja del disco. Se llama AL FINAL y solo si todo salió
|
|
124
|
+
*/
|
|
125
|
+
export async function convertPasswords ({ store, sealed, cek, recipients, author, dropOldKey, log = () => {} } = {}) {
|
|
126
|
+
const { openEntry } = await import('@dotrino/passmanager')
|
|
127
|
+
const { buildSealedEntry, buildProfileKey } = await import('@dotrino/passmanager/sealed')
|
|
128
|
+
|
|
129
|
+
const viejas = (await store.get('passmanager/entries/v1')) || []
|
|
130
|
+
if (await sealed.sealed()) return { already: true, entries: 0 }
|
|
131
|
+
|
|
132
|
+
// 1. La llave del perfil, de la que salen el índice de sitios y los resúmenes.
|
|
133
|
+
const pk = await buildProfileKey({ recipients })
|
|
134
|
+
await sealed.setProfile({ envelope: pk.envelope, wraps: pk.wraps })
|
|
135
|
+
const { profileKeys } = await import('@dotrino/passmanager/sealed')
|
|
136
|
+
const keys = await profileKeys(pk.base)
|
|
137
|
+
|
|
138
|
+
// 2. Cada entrada, reescrita: un sobre por campo, la vista sellada, sus huellas.
|
|
139
|
+
let hechas = 0
|
|
140
|
+
for (const vieja of viejas) {
|
|
141
|
+
if (!cek) break
|
|
142
|
+
const abierta = await openEntry(cek, vieja)
|
|
143
|
+
const sobre = await buildSealedEntry({ plain: abierta, keys, recipients, author })
|
|
144
|
+
await sealed.putSealed(sobre)
|
|
145
|
+
hechas++
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 3. Y se va lo viejo: las entradas con la llave que las abría, y la llave misma.
|
|
149
|
+
if (hechas === viejas.length) {
|
|
150
|
+
await store.set('passmanager/entries/v1', [])
|
|
151
|
+
await dropOldKey?.()
|
|
152
|
+
}
|
|
153
|
+
log(`[vault] passwords: ${hechas} entries converted to the sealed format`)
|
|
154
|
+
return { already: false, entries: hechas }
|
|
155
|
+
}
|
package/src/replica.js
CHANGED
|
@@ -66,9 +66,44 @@ export function mergeBundle (viejo, nuevo) {
|
|
|
66
66
|
if (gb > ga) entries[k] = e
|
|
67
67
|
else if (gb === ga && String(e.e?.ct ?? e.pubv ?? '') < String(a.e?.ct ?? a.pubv ?? '')) entries[k] = e
|
|
68
68
|
}
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
const wraps = mergeWraps(viejo.wraps, nuevo.wraps)
|
|
70
|
+
// `ns`/`dev` son la envoltura VIGENTE de cada lado, y salen de la lista ya mezclada: con
|
|
71
|
+
// `{...viejo, ...nuevo}` se quedaba la del empujón aunque su generación fuera menor, o
|
|
72
|
+
// sea justo lo contrario de lo que decide la lista.
|
|
73
|
+
return {
|
|
74
|
+
...viejo,
|
|
75
|
+
...nuevo,
|
|
76
|
+
entries,
|
|
77
|
+
wraps,
|
|
78
|
+
ns: wraps.ns[wraps.ns.length - 1] || null,
|
|
79
|
+
dev: wraps.dev[wraps.dev.length - 1] || null
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* `wraps` es `{ ns: [...], dev: [...] }`, NO una lista.
|
|
85
|
+
*
|
|
86
|
+
* Aquí se esparcía como si fuera una lista, así que cada empujón sobre un sobre que ya
|
|
87
|
+
* existía moría con «(viejo.wraps || []) is not iterable» — y solo en producción: el
|
|
88
|
+
* primer guardado sale por `if (!viejo) return nuevo` y nunca llega aquí. El replicador de
|
|
89
|
+
* Cepi lleva así desde que se desplegó, guardando el primer sobre de cada cajón y
|
|
90
|
+
* rechazando en silencio todas las actualizaciones.
|
|
91
|
+
*
|
|
92
|
+
* La forma la fija quien lo produce (`secretsStore.js`, `sealedBundleFor`) y quien lo lee
|
|
93
|
+
* (`lib/src/service.js`, que recorre `wraps.ns` y `wraps.dev`). Los dos estaban de acuerdo;
|
|
94
|
+
* el único equivocado era esto. Y de haber funcionado habría sido peor: escribía `wraps`
|
|
95
|
+
* como lista y el servicio se habría quedado sin envolturas, que es no poder abrir nada.
|
|
96
|
+
*
|
|
97
|
+
* Las de cada lado se SUMAN por generación: son de aparatos distintos y ninguna estorba.
|
|
98
|
+
*/
|
|
99
|
+
function mergeWraps (a, b) {
|
|
100
|
+
const out = {}
|
|
101
|
+
for (const side of ['ns', 'dev']) {
|
|
102
|
+
const byGen = new Map()
|
|
103
|
+
for (const w of [...(a?.[side] || []), ...(b?.[side] || [])]) byGen.set(w.gen, w)
|
|
104
|
+
out[side] = [...byGen.values()].sort((x, y) => x.gen - y.gen)
|
|
105
|
+
}
|
|
106
|
+
return out
|
|
72
107
|
}
|
|
73
108
|
|
|
74
109
|
/** La clave de un sobre: es POR (cajón, aparato), porque va tallado a quien lo pide. */
|
package/src/secretsStore.js
CHANGED
|
@@ -1145,6 +1145,20 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
1145
1145
|
* decidir si hay que migrarla. Preguntarlo intentando reenvolver un cajón mezcla dos
|
|
1146
1146
|
* cosas y deja el fallo diciendo «wrong password» sobre el cajón, que no es donde está.
|
|
1147
1147
|
*/
|
|
1148
|
+
/**
|
|
1149
|
+
* ABRE UNA ENVOLTURA DE RECUPERACIÓN y devuelve lo que lleva dentro.
|
|
1150
|
+
*
|
|
1151
|
+
* Es para OTROS almacenes de este mismo perfil —hoy la bóveda de contraseñas—, que
|
|
1152
|
+
* comparten ESTA copia de recuperación en vez de estrenar una segunda. Una segunda
|
|
1153
|
+
* sería otra llave que custodiar, otra que rotar y otra que se queda atrás.
|
|
1154
|
+
*
|
|
1155
|
+
* Sigue siendo la única puerta a un valor en claro, y sigue pidiendo la frase.
|
|
1156
|
+
*/
|
|
1157
|
+
async openRecoveryWrap (wrap, adminKey = null) {
|
|
1158
|
+
const priv = await openRecovery(adminKey)
|
|
1159
|
+
return sealer.openWrapWith(priv, wrap)
|
|
1160
|
+
},
|
|
1161
|
+
|
|
1148
1162
|
async recoveryOpensWith (key) {
|
|
1149
1163
|
needSealer('check the recovery key')
|
|
1150
1164
|
if (!data.recovery?.priv) throw new NeedsPassword('this store has no recovery key yet')
|
package/src/tui/app.js
CHANGED
|
@@ -403,7 +403,7 @@ function pairModeRows (st, t) {
|
|
|
403
403
|
* esté nombrado se añade al final: un permiso puede salir en mal sitio, nunca desaparecer.
|
|
404
404
|
* `test/tui-permisos.test.mjs` exige además que todos tengan nombre en los dos idiomas.
|
|
405
405
|
*/
|
|
406
|
-
const ORDEN = ['sign', 'store', 'read', 'admin', 'approve', 'passwords', 'sealer', 'unattended', 'replica']
|
|
406
|
+
const ORDEN = ['sign', 'store', 'read', 'admin', 'approve', 'passwords', 'passkeys', 'sealer', 'unattended', 'replica']
|
|
407
407
|
const CAPS_ORDER = [...ORDEN.filter((c) => DEVICE_CAPS.includes(c)),
|
|
408
408
|
...DEVICE_CAPS.filter((c) => !ORDEN.includes(c))]
|
|
409
409
|
|
package/src/tui/i18n.js
CHANGED
|
@@ -60,6 +60,7 @@ const es = {
|
|
|
60
60
|
read: 'lee',
|
|
61
61
|
admin: 'administra',
|
|
62
62
|
passwords: 'contrasenas',
|
|
63
|
+
passkeys: 'passkeys',
|
|
63
64
|
approve: 'aprueba',
|
|
64
65
|
sealer: 'sella',
|
|
65
66
|
unattended: 'desatendido',
|
|
@@ -75,6 +76,9 @@ const es = {
|
|
|
75
76
|
read: 'ver lo que guardaste',
|
|
76
77
|
admin: 'conectar y quitar aparatos desde ahí, sin venir aquí. No puede cambiar permisos',
|
|
77
78
|
passwords: 'pedir una contraseña por sitio, nunca la lista entera',
|
|
79
|
+
// Una passkey copiada sirve hasta que la borres en cada sitio: de una contraseña, al
|
|
80
|
+
// menos, se puede cambiar. Por eso es una decisión aparte.
|
|
81
|
+
passkeys: 'abrir además las passkeys guardadas, que son llaves y no se pueden cambiar. Sin «contrasenas» no hace nada',
|
|
78
82
|
approve: 'dar el visto bueno cuando otro aparato pide claves privadas',
|
|
79
83
|
// Sellar solo significa algo en un aparato que HAGA de bóveda (otra máquina con el
|
|
80
84
|
// daemon, o un navegador que se puso de bóveda). En un teléfono normal se concede y no
|
|
@@ -419,6 +423,7 @@ const en = {
|
|
|
419
423
|
read: 'read',
|
|
420
424
|
admin: 'admin',
|
|
421
425
|
passwords: 'passwords',
|
|
426
|
+
passkeys: 'passkeys',
|
|
422
427
|
approve: 'approve',
|
|
423
428
|
sealer: 'sealer',
|
|
424
429
|
unattended: 'unattended',
|
|
@@ -432,6 +437,7 @@ const en = {
|
|
|
432
437
|
read: 'see what you saved',
|
|
433
438
|
admin: 'connect and remove devices from there, without coming here. It cannot change permissions',
|
|
434
439
|
passwords: 'ask for one password per site, never the whole list',
|
|
440
|
+
passkeys: 'also open saved passkeys, which are keys and cannot be changed. Without «passwords» it does nothing',
|
|
435
441
|
approve: 'give the go-ahead when another device asks for private keys',
|
|
436
442
|
sealer: 'admit devices and change permissions on its own when this vault is away. Only useful on another VAULT',
|
|
437
443
|
unattended: 'take private keys without asking you. Off, it must be approved from a device with «approve» every time it starts',
|
package/src/vault.js
CHANGED
|
@@ -15,7 +15,7 @@ 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
|
+
import { createLoginDesk, registerLogin, vaultChannel, accountFingerprint } from '../lib/src/passwordLogins.js'
|
|
19
19
|
import * as ActaPilar from '@dotrino/identity/acta'
|
|
20
20
|
import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS, scopeToCaps, scopeToCn } from '../lib/src/enroll.js'
|
|
21
21
|
import { createAdminDesk, authorBody } from '../lib/src/admin.js'
|
|
@@ -234,6 +234,25 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
234
234
|
/** Los aparatos que el acta autoriza a pedir credenciales (`caps <ID> +contraseñas`). */
|
|
235
235
|
const passwordDevices = () => (actaCache?.members || []).filter((m) => Acta.memberCan(actaCache, m.pub, 'passwords'))
|
|
236
236
|
|
|
237
|
+
/**
|
|
238
|
+
* A QUIÉN SE LE ENVUELVE LA LLAVE DE UNA CONTRASEÑA — Y LA BÓVEDA NUNCA ESTÁ EN LA LISTA.
|
|
239
|
+
*
|
|
240
|
+
* Es la comprobación que sostiene todo el modelo sellado: si la maestra o la llave de
|
|
241
|
+
* comunicación de esta máquina recibieran envoltura, el demonio volvería a poder leer las
|
|
242
|
+
* contraseñas y no habríamos arreglado nada —solo lo habríamos escrito más bonito—. Por
|
|
243
|
+
* eso se filtran explícitamente aquí, que es donde se construye la lista, y no en quien la
|
|
244
|
+
* consume (`sealed-passwords.md` §2.1).
|
|
245
|
+
*
|
|
246
|
+
* `main` son los aparatos con `contraseñas`; `passkeys`, los que además pueden abrir la
|
|
247
|
+
* privada de una passkey (§2.8). Un miembro sin llave de cifrado no puede recibir nada: se
|
|
248
|
+
* queda fuera y se ve en `incompleteMembers`.
|
|
249
|
+
*/
|
|
250
|
+
const noEsLaBoveda = (pub) => pub !== master && pub !== commKey.pub()
|
|
251
|
+
const passwordRecipients = (kind = 'main') => (actaCache?.members || [])
|
|
252
|
+
.filter((m) => m.encPub && noEsLaBoveda(m.pub) && Acta.memberCan(actaCache, m.pub, 'passwords'))
|
|
253
|
+
.filter((m) => kind !== 'passkeys' || Acta.memberCan(actaCache, m.pub, 'passkeys'))
|
|
254
|
+
.map((m) => ({ pub: m.pub, encPub: m.encPub }))
|
|
255
|
+
|
|
237
256
|
/**
|
|
238
257
|
* El acta vigente, en caché.
|
|
239
258
|
*
|
|
@@ -249,20 +268,27 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
249
268
|
}
|
|
250
269
|
|
|
251
270
|
/**
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
271
|
+
* LA LLAVE VIEJA DE LA BÓVEDA DE CONTRASEÑAS, la que había que quitar.
|
|
272
|
+
*
|
|
273
|
+
* Vivía DENTRO del mismo archivo que las entradas, cifrada con la llave de la máquina y
|
|
274
|
+
* no con la contraseña del perfil. O sea: el demonio con el perfil cerrado seguía
|
|
275
|
+
* descifrando, y una copia del disco las abría todas. Esto ya no la crea — solo la LEE,
|
|
276
|
+
* y solo para convertir lo que quedó escrito con ella; en cuanto la conversión termina,
|
|
277
|
+
* `soltarLlaveVieja` la borra del archivo.
|
|
255
278
|
*/
|
|
256
|
-
async function
|
|
279
|
+
async function llaveViejaDeContrasenas () {
|
|
257
280
|
const d = readPasswordsFile()
|
|
258
|
-
if (d?.cek)
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
281
|
+
if (!d?.cek) return null
|
|
282
|
+
const raw = Uint8Array.from(Buffer.from(d.cek, 'base64'))
|
|
283
|
+
return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt'])
|
|
284
|
+
}
|
|
285
|
+
const soltarLlaveVieja = () => {
|
|
286
|
+
const d = readPasswordsFile()
|
|
287
|
+
if (!d?.cek) return
|
|
288
|
+
delete d.cek
|
|
289
|
+
writePasswordsFile(d)
|
|
290
|
+
log('[vault] passwords: the old vault key is gone from the disk')
|
|
291
|
+
audit('passwords.converted', {})
|
|
266
292
|
}
|
|
267
293
|
|
|
268
294
|
const approvalsSweeper = setInterval(() => {
|
|
@@ -300,7 +326,17 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
300
326
|
} catch (_) {}
|
|
301
327
|
|
|
302
328
|
const master = await masterPubkeyOf(identity)
|
|
303
|
-
|
|
329
|
+
/**
|
|
330
|
+
* LA HUELLA DE LA CUENTA, no la de esta bóveda. Sale del `profileId` del acta (la pieza
|
|
331
|
+
* compartida la calcula), y con ella se arman la dirección `nombre@AB12-CD34-EF56` y el
|
|
332
|
+
* canal donde se anuncia esta cuenta.
|
|
333
|
+
*
|
|
334
|
+
* Antes salía de la llave de ESTA bóveda. Coinciden mientras la cuenta haya nacido aquí,
|
|
335
|
+
* y dejan de coincidir en cuanto hay una réplica o la bóveda adoptó la cuenta de otro
|
|
336
|
+
* aparato: cada bóveda daba entonces una dirección distinta para la misma cuenta, y el
|
|
337
|
+
* canal las separaba justo cuando lo que hace falta es que se junten.
|
|
338
|
+
*/
|
|
339
|
+
const fp = await accountFingerprint(identity)
|
|
304
340
|
|
|
305
341
|
/**
|
|
306
342
|
* El acta tiene que nombrar una llave de sellado QUE SEA NUESTRA. Si no nombra ninguna
|
|
@@ -1111,7 +1147,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1111
1147
|
|
|
1112
1148
|
async function handleLoginStart (from, p) {
|
|
1113
1149
|
try {
|
|
1114
|
-
const { lid, response } = logins.loginBegin({ user: p?.user, request: p?.request })
|
|
1150
|
+
const { lid, response } = await logins.loginBegin({ user: p?.user, request: p?.request })
|
|
1115
1151
|
audit('login.start', { user: String(p?.user || '').slice(0, 32) })
|
|
1116
1152
|
reply(from, { type: MSG.LOGIN_RESPONSE, lid, response })
|
|
1117
1153
|
} catch (e) { loginError(from, e) }
|
|
@@ -1119,7 +1155,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1119
1155
|
|
|
1120
1156
|
async function handleLoginFinish (from, p) {
|
|
1121
1157
|
try {
|
|
1122
|
-
const r = logins.loginEnd({ lid: p?.lid, finalization: p?.finalization, label: p?.label })
|
|
1158
|
+
const r = await logins.loginEnd({ lid: p?.lid, finalization: p?.finalization, label: p?.label })
|
|
1123
1159
|
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
1124
1160
|
audit('login.ok', { user: r.user, sid: r.sid.slice(0, 8) })
|
|
1125
1161
|
log('[vault] login: %s entered (session %s)', r.user, r.sid.slice(0, 8))
|
|
@@ -1861,7 +1897,13 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1861
1897
|
passwords = createPasswordDesk({
|
|
1862
1898
|
client,
|
|
1863
1899
|
store: passwordsStore,
|
|
1864
|
-
|
|
1900
|
+
// LA BÓVEDA NO TIENE LLAVE. Lo que pone aquí es a quién envolverle las que hacen los
|
|
1901
|
+
// aparatos, y la pública de la copia de recuperación —la que abre la frase del perfil
|
|
1902
|
+
// y es la única forma de volver a repartir una entrada si un día no queda ningún
|
|
1903
|
+
// aparato—. Ninguna de las dos abre nada por sí sola.
|
|
1904
|
+
members: (kind) => passwordRecipients(kind),
|
|
1905
|
+
recoveryPub: () => secrets.recoveryPub(),
|
|
1906
|
+
canWrite: (pub) => !!actaCache && !!pub && Acta.memberCan(actaCache, pub, 'passwords'),
|
|
1865
1907
|
// UNA sola condición, y es del ACTA: el aparato tiene la capacidad `passwords`.
|
|
1866
1908
|
// Quitársela —o quitar el aparato— le corta esto en la siguiente pasada, sin
|
|
1867
1909
|
// que haya una segunda lista que acordarse de tocar.
|
|
@@ -3082,6 +3124,64 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
3082
3124
|
}
|
|
3083
3125
|
}
|
|
3084
3126
|
|
|
3127
|
+
/**
|
|
3128
|
+
* LAS CONTRASEÑAS, AL FORMATO SELLADO (§2.7) Y CON SUS ENVOLTURAS AL DÍA (§2.5).
|
|
3129
|
+
*
|
|
3130
|
+
* Las dos cosas se hacen aquí, al ABRIR el perfil, porque las dos necesitan lo mismo: la
|
|
3131
|
+
* frase. Convertir hay que hacerlo una vez; repasar las envolturas, cada vez que cambie
|
|
3132
|
+
* quién puede leer — y este es el único momento en que hay con qué.
|
|
3133
|
+
*
|
|
3134
|
+
* La bóveda ve los valores SOLO mientras convierte, que es cuando tiene que reescribirlos.
|
|
3135
|
+
* Después no puede: lo último que hace la conversión es borrar la llave vieja.
|
|
3136
|
+
*/
|
|
3137
|
+
async function sealPasswords (adminKey = null) {
|
|
3138
|
+
if (!passwords) return null
|
|
3139
|
+
const recipients = {
|
|
3140
|
+
recoveryPub: secrets.recoveryPub(),
|
|
3141
|
+
main: passwordRecipients('main'),
|
|
3142
|
+
passkeys: passwordRecipients('passkeys')
|
|
3143
|
+
}
|
|
3144
|
+
if (!recipients.recoveryPub) {
|
|
3145
|
+
log('[vault] passwords: no recovery key yet — nothing can be sealed until there is one')
|
|
3146
|
+
return null
|
|
3147
|
+
}
|
|
3148
|
+
const out = {}
|
|
3149
|
+
try {
|
|
3150
|
+
const { convertPasswords } = await import('./passwords.js')
|
|
3151
|
+
const r = await convertPasswords({
|
|
3152
|
+
store: passwordsStore,
|
|
3153
|
+
sealed: passwords.sealed,
|
|
3154
|
+
cek: await llaveViejaDeContrasenas(),
|
|
3155
|
+
recipients,
|
|
3156
|
+
// LA MAESTRA FIRMA LO QUE ESCRIBE, como cualquier aparato que guarda una entrada.
|
|
3157
|
+
// Firmar no es leer: lo que se le envuelve a esta bóveda sigue siendo nada.
|
|
3158
|
+
author: { publickey: master, sign: (body) => identity.signData(body) },
|
|
3159
|
+
dropOldKey: soltarLlaveVieja,
|
|
3160
|
+
log
|
|
3161
|
+
})
|
|
3162
|
+
out.converted = r?.entries || 0
|
|
3163
|
+
if (r?.already) out.converted = 0
|
|
3164
|
+
} catch (e) {
|
|
3165
|
+
log(`[vault] passwords: could not convert to the sealed format (${e.message})`)
|
|
3166
|
+
out.convertError = e.message
|
|
3167
|
+
}
|
|
3168
|
+
try {
|
|
3169
|
+
const r = await passwords.sealed.rewrapAll({
|
|
3170
|
+
openRecovery: (wrap) => secrets.openRecoveryWrap(wrap, adminKey)
|
|
3171
|
+
})
|
|
3172
|
+
out.wrapped = r.wrapped
|
|
3173
|
+
out.dropped = r.dropped
|
|
3174
|
+
if (r.wrapped || r.dropped) {
|
|
3175
|
+
log(`[vault] passwords: keyring rebuilt (${r.wrapped} wrap(s), ${r.dropped} stale one(s) dropped)`)
|
|
3176
|
+
}
|
|
3177
|
+
if (r.failed?.length) log(`[vault] passwords: ${r.failed.length} generation(s) could NOT be rewrapped`)
|
|
3178
|
+
} catch (e) {
|
|
3179
|
+
log(`[vault] passwords: could not rebuild the keyring (${e.message})`)
|
|
3180
|
+
out.rewrapError = e.message
|
|
3181
|
+
}
|
|
3182
|
+
return out
|
|
3183
|
+
}
|
|
3184
|
+
|
|
3085
3185
|
async function resealAll (adminKey = null) {
|
|
3086
3186
|
// ANTES de reenvolver nada: si la copia de recuperación se quedó bajo la llave de la
|
|
3087
3187
|
// máquina, se pasa a la frase. Sin esto, todo lo de abajo falla con «wrong password».
|
|
@@ -3548,6 +3648,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
3548
3648
|
* diseño dice que sí (§8.2). Lo de siempre: dos sitios decidiendo lo mismo.
|
|
3549
3649
|
*/
|
|
3550
3650
|
resealAll,
|
|
3651
|
+
/** Convierte las contraseñas al formato sellado y repasa sus envolturas (§2.5, §2.7). */
|
|
3652
|
+
sealPasswords,
|
|
3551
3653
|
delegateRewrap,
|
|
3552
3654
|
incompleteMembers,
|
|
3553
3655
|
migrateSecrets: (membersOf, adminKey) => secrets.migrate(
|