@dotrino/vaultd 0.122.0 → 0.123.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/src/index.js +2 -2
- package/lib/src/loginClient.js +28 -8
- package/lib/src/passwordLogins.js +34 -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 +173 -7
- package/src/tui/i18n.js +81 -0
- package/src/vault.js +107 -15
- package/src/vaultControl.js +95 -1
package/lib/src/index.js
CHANGED
|
@@ -394,7 +394,7 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
394
394
|
async function handleLoginStart (from, p) {
|
|
395
395
|
if (!logins) return noLogins(from)
|
|
396
396
|
try {
|
|
397
|
-
const { lid, response } = logins.loginBegin({ user: p?.user, request: p?.request })
|
|
397
|
+
const { lid, response } = await logins.loginBegin({ user: p?.user, request: p?.request })
|
|
398
398
|
send(from, { type: MSG.LOGIN_RESPONSE, lid, response })
|
|
399
399
|
} catch (e) { loginError(from, e) }
|
|
400
400
|
}
|
|
@@ -402,7 +402,7 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
402
402
|
async function handleLoginFinish (from, p) {
|
|
403
403
|
if (!logins) return noLogins(from)
|
|
404
404
|
try {
|
|
405
|
-
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 })
|
|
406
406
|
const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
407
407
|
send(from, { type: MSG.LOGIN_OK, sid: r.sid, blob: r.blob, cert: r.cert, iss: r.iss || iss, acta })
|
|
408
408
|
} catch (e) { loginError(from, e) }
|
package/lib/src/loginClient.js
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
* Si algo de eso falla, se PARA con un `code` propio. No hay repliegue: entrar «a medias»
|
|
35
35
|
* en una cuenta es peor que no entrar.
|
|
36
36
|
*/
|
|
37
|
-
import { client as
|
|
37
|
+
import { client as opaquePorDefecto } from '@dotrino/opaque'
|
|
38
38
|
import { pubkeyId, verifyDeviceSig, signWithDevice } from '@dotrino/identity/capabilities'
|
|
39
39
|
import { checkVaultReply } from '@dotrino/identity/acta'
|
|
40
40
|
import { MSG } from './protocol.js'
|
|
@@ -82,11 +82,16 @@ const parseJson = (s) => { try { return JSON.parse(s) } catch (_) { return null
|
|
|
82
82
|
* @param {string} opts.password
|
|
83
83
|
* @param {string} [opts.label] de dónde se entra («el cyber de la esquina»): es lo que
|
|
84
84
|
* el dueño va a leer en su consola para decidir si cerrarlo.
|
|
85
|
+
* @param {object} [opts.opaque] el OPAQUE a usar. Por defecto el de casa, que va en este
|
|
86
|
+
* mismo hilo. Se inyecta porque en una EXTENSIÓN el WASM vive en una página sandbox, al
|
|
87
|
+
* otro lado de un `postMessage`, y entonces sus métodos devuelven promesas — igual que en
|
|
88
|
+
* `createLoginDesk`, y por el mismo motivo. Aquí se espera siempre: esperar a algo que no
|
|
89
|
+
* es una promesa no cuesta nada, y dos firmas serían dos clientes.
|
|
85
90
|
* @returns {Promise<{user:string,address:string,code:string,sid:string,cert:object,
|
|
86
91
|
* iss:string,acta:object,vaultToken:string,publickey:string,encPublickey:(string|null),
|
|
87
92
|
* keys:{sign:object,enc:(object|null)}}>}
|
|
88
93
|
*/
|
|
89
|
-
export async function loginWithPassword ({ transport, address, password, label = '', timeoutMs = REPLY_TIMEOUT_MS } = {}) {
|
|
94
|
+
export async function loginWithPassword ({ transport, address, password, label = '', timeoutMs = REPLY_TIMEOUT_MS, opaque = opaquePorDefecto } = {}) {
|
|
90
95
|
if (!transport || typeof transport.on !== 'function' || typeof transport.send !== 'function') {
|
|
91
96
|
throw err('no-transport', 'loginWithPassword: a connected transport is required')
|
|
92
97
|
}
|
|
@@ -104,7 +109,7 @@ export async function loginWithPassword ({ transport, address, password, label =
|
|
|
104
109
|
let ultimo = null
|
|
105
110
|
for (const token of tokens) {
|
|
106
111
|
try {
|
|
107
|
-
return await unIntento({ transport, token, user, code, dir, password, label, timeoutMs })
|
|
112
|
+
return await unIntento({ transport, token, user, code, dir, password, label, timeoutMs, opaque })
|
|
108
113
|
} catch (e) {
|
|
109
114
|
// Se para: son cosas del que entra, y probar con otra bóveda no las arregla.
|
|
110
115
|
if (e?.code === 'login-failed' || e?.code === 'too-many-tries' || e?.code === 'wrong-account' ||
|
|
@@ -115,15 +120,15 @@ export async function loginWithPassword ({ transport, address, password, label =
|
|
|
115
120
|
throw ultimo || err('no-vault', 'none of the vaults on that address could let you in')
|
|
116
121
|
}
|
|
117
122
|
|
|
118
|
-
async function unIntento ({ transport, token, user, code, dir, password, label, timeoutMs }) {
|
|
119
|
-
const start = opaque.loginStart({ password })
|
|
123
|
+
async function unIntento ({ transport, token, user, code, dir, password, label, timeoutMs, opaque }) {
|
|
124
|
+
const start = await opaque.loginStart({ password })
|
|
120
125
|
transport.send(token, { type: MSG.LOGIN_START, user, request: start.request })
|
|
121
126
|
const r1 = await waitFor(transport, token, (p) => p.type === MSG.LOGIN_RESPONSE || p.type === MSG.ERROR, timeoutMs)
|
|
122
127
|
if (r1.type === MSG.ERROR) throw deVuelta(r1)
|
|
123
128
|
|
|
124
129
|
let fin
|
|
125
130
|
try {
|
|
126
|
-
fin = opaque.loginFinish({ state: start.state, response: r1.response, password })
|
|
131
|
+
fin = await opaque.loginFinish({ state: start.state, response: r1.response, password })
|
|
127
132
|
} catch (_) {
|
|
128
133
|
// OPAQUE comprueba a las dos partes: aquí se sabe que el otro lado NO conoce el
|
|
129
134
|
// registro de este usuario. Contraseña equivocada, usuario que no existe o una bóveda
|
|
@@ -198,11 +203,26 @@ function deVuelta (p) {
|
|
|
198
203
|
* quedarse bloqueado esperándola. La sesión sigue abierta allí hasta que se cierre desde la
|
|
199
204
|
* consola, y eso ya está dicho en el diseño (§3.2).
|
|
200
205
|
*/
|
|
201
|
-
export async function closeLogin ({ transport, token, user, sid, publickey, privateJwk, privateKey, timeoutMs = REPLY_TIMEOUT_MS } = {}) {
|
|
206
|
+
export async function closeLogin ({ transport, token, user, sid, publickey, privateJwk, privateKey, sign, timeoutMs = REPLY_TIMEOUT_MS } = {}) {
|
|
202
207
|
if (!transport || typeof transport.send !== 'function') throw err('no-transport', 'closeLogin: transport required')
|
|
203
208
|
if (!token || !user || !sid || !publickey) throw err('bad-input', 'closeLogin: token, user, sid and publickey are required')
|
|
204
209
|
const data = { op: 'login.close', publickey, user, sid, ts: Date.now() }
|
|
205
|
-
|
|
210
|
+
// CON LA LLAVE O CON QUIEN LA TIENE. `sign(data)` es lo normal en el ecosistema —el
|
|
211
|
+
// transporte se identifica con `identity.signData` y no con una llave suelta— y aquí hace
|
|
212
|
+
// falta de verdad: en una extensión la llave vive en el service worker y quien tiene el
|
|
213
|
+
// socket es la página, así que o se firma a distancia o la llave privada tendría que
|
|
214
|
+
// cruzar para nada. Sin ninguna de las dos no se firma, y salir no se puede fingir.
|
|
215
|
+
//
|
|
216
|
+
// Se comprueba ANTES de firmar: sin nada con qué hacerlo, WebCrypto revienta con un error
|
|
217
|
+
// suyo que no dice qué falta («code: 0»), y el de arriba es el que se puede buscar.
|
|
218
|
+
if (typeof sign !== 'function' && !privateJwk && !privateKey) {
|
|
219
|
+
throw err('no-signature', 'closeLogin: give it the device key (privateJwk/privateKey) or a sign(data)')
|
|
220
|
+
}
|
|
221
|
+
const firmado = typeof sign === 'function'
|
|
222
|
+
? await sign(data)
|
|
223
|
+
: await signWithDevice({ privateJwk, privateKey, publickey, data })
|
|
224
|
+
const signature = typeof firmado === 'string' ? firmado : firmado?.signature
|
|
225
|
+
if (typeof signature !== 'string') throw err('no-signature', 'closeLogin: sign(data) returned no signature')
|
|
206
226
|
transport.send(token, { type: MSG.LOGIN_CLOSE, data, signature })
|
|
207
227
|
try {
|
|
208
228
|
const r = await waitFor(transport, token, (p) => p.type === MSG.LOGIN_CLOSED || p.type === MSG.ERROR, timeoutMs)
|
|
@@ -24,7 +24,7 @@
|
|
|
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
28
|
import { pubkeyId } from '@dotrino/identity/capabilities'
|
|
29
29
|
import { deviceIdOf, scopeToCaps, scopeToCn } from './enroll.js'
|
|
30
30
|
import { SCOPE } from './protocol.js'
|
|
@@ -181,10 +181,25 @@ export async function openDeviceKeys (exportKey, blob) {
|
|
|
181
181
|
* `load()` devuelve el estado guardado (o `null` la primera vez)
|
|
182
182
|
* `save(s)` lo guarda
|
|
183
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.
|
|
184
192
|
*/
|
|
185
|
-
export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
|
|
193
|
+
export function createLoginDesk ({ load, save, now = () => Date.now(), opaque = opaquePorDefecto } = {}) {
|
|
186
194
|
if (typeof load !== 'function' || typeof save !== 'function') throw new Error('createLoginDesk: load and save are required')
|
|
187
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
|
+
|
|
188
203
|
/** Intercambios a medias: viven en memoria, y por eso un reinicio los tira. */
|
|
189
204
|
const exchanges = new Map()
|
|
190
205
|
|
|
@@ -195,9 +210,9 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
|
|
|
195
210
|
}
|
|
196
211
|
|
|
197
212
|
/** La preparación del servidor nace con el primer usuario y no se toca nunca más. */
|
|
198
|
-
function setupOf (state) {
|
|
213
|
+
async function setupOf (state) {
|
|
199
214
|
if (state.setup) return state.setup
|
|
200
|
-
state.setup = opaque.createSetup()
|
|
215
|
+
state.setup = await opaque.createSetup()
|
|
201
216
|
return state.setup
|
|
202
217
|
}
|
|
203
218
|
|
|
@@ -234,30 +249,30 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
|
|
|
234
249
|
* ALTA, primer paso. La contraseña no llega hasta aquí: lo que llega es el mensaje de
|
|
235
250
|
* registro de OPAQUE, que no la lleva ni permite adivinarla.
|
|
236
251
|
*/
|
|
237
|
-
registerBegin ({ user, request, replace = false } = {}) {
|
|
252
|
+
async registerBegin ({ user, request, replace = false } = {}) {
|
|
238
253
|
const state = read()
|
|
239
254
|
const existing = userOf(state, user)
|
|
240
255
|
if (existing && !replace) throw err('user-exists', `there is already a login called "${user}"`)
|
|
241
256
|
if (!existing && replace) throw err('no-user', `there is no login called "${user}"`)
|
|
242
257
|
if (typeof request !== 'string' || !request) throw err('bad-input', 'request required')
|
|
243
|
-
const setup = setupOf(state)
|
|
244
|
-
const response = opaque.registrationResponse({ setup, request, credentialId: user })
|
|
258
|
+
const setup = await setupOf(state)
|
|
259
|
+
const response = await opaque.registrationResponse({ setup, request, credentialId: user })
|
|
245
260
|
save(state)
|
|
246
|
-
return { response, suite:
|
|
261
|
+
return { response, suite: await suite() }
|
|
247
262
|
},
|
|
248
263
|
|
|
249
264
|
/**
|
|
250
265
|
* ALTA, segundo paso. `blob` son las llaves privadas del aparato, ya cerradas por quien
|
|
251
266
|
* creó el aparato con la llave que sale de la contraseña: aquí no se abre nunca.
|
|
252
267
|
*/
|
|
253
|
-
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 } = {}) {
|
|
254
269
|
const state = read()
|
|
255
270
|
const existing = userOf(state, user)
|
|
256
271
|
if (existing && !replace) throw err('user-exists', `there is already a login called "${user}"`)
|
|
257
272
|
if (!existing && replace) throw err('no-user', `there is no login called "${user}"`)
|
|
258
273
|
if (typeof blob !== 'string' || !blob) throw err('bad-input', 'blob required (the device keys, sealed with the password)')
|
|
259
274
|
if (!replace && (typeof pub !== 'string' || !pub)) throw err('bad-input', 'pub required')
|
|
260
|
-
const record = opaque.registrationFinish({ upload })
|
|
275
|
+
const record = await opaque.registrationFinish({ upload })
|
|
261
276
|
const t = now()
|
|
262
277
|
state.users[user] = {
|
|
263
278
|
...(existing || {}),
|
|
@@ -267,7 +282,7 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
|
|
|
267
282
|
label: label || existing?.label || '',
|
|
268
283
|
record,
|
|
269
284
|
blob,
|
|
270
|
-
suite:
|
|
285
|
+
suite: await suite(),
|
|
271
286
|
createdAt: existing?.createdAt || t,
|
|
272
287
|
passwordChangedAt: t,
|
|
273
288
|
// Cambiar la contraseña cierra lo abierto: si alguien entró con la vieja, deja de
|
|
@@ -277,7 +292,7 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
|
|
|
277
292
|
nextTryAt: 0
|
|
278
293
|
}
|
|
279
294
|
save(state)
|
|
280
|
-
return { user, suite:
|
|
295
|
+
return { user, suite: await suite() }
|
|
281
296
|
},
|
|
282
297
|
|
|
283
298
|
/** El certificado del aparato, que firma la bóveda al admitirlo en el acta. Es público. */
|
|
@@ -298,7 +313,7 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
|
|
|
298
313
|
* registro inventado: desde fuera no se puede averiguar qué usuarios hay. Y cuenta
|
|
299
314
|
* contra el mismo freno, para que tampoco se pueda medir por el tiempo.
|
|
300
315
|
*/
|
|
301
|
-
loginBegin ({ user, request } = {}) {
|
|
316
|
+
async loginBegin ({ user, request } = {}) {
|
|
302
317
|
sweepExchanges()
|
|
303
318
|
const state = read()
|
|
304
319
|
if (typeof request !== 'string' || !request) throw err('bad-input', 'request required')
|
|
@@ -314,8 +329,8 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
|
|
|
314
329
|
u.fails = (u.fails || 0) + 1
|
|
315
330
|
u.nextTryAt = t + backoffFor(u.fails)
|
|
316
331
|
}
|
|
317
|
-
const setup = setupOf(state)
|
|
318
|
-
const { state: serverState, response } = opaque.loginStart({
|
|
332
|
+
const setup = await setupOf(state)
|
|
333
|
+
const { state: serverState, response } = await opaque.loginStart({
|
|
319
334
|
setup,
|
|
320
335
|
record: u?.record || null,
|
|
321
336
|
request,
|
|
@@ -332,7 +347,7 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
|
|
|
332
347
|
* INICIO DE SESIÓN, segundo paso. Si la contraseña es la buena, devuelve el paquete de
|
|
333
348
|
* llaves —que solo abre esa contraseña—, el certificado y el acta.
|
|
334
349
|
*/
|
|
335
|
-
loginEnd ({ lid, finalization, label = '' } = {}) {
|
|
350
|
+
async loginEnd ({ lid, finalization, label = '' } = {}) {
|
|
336
351
|
sweepExchanges()
|
|
337
352
|
const x = exchanges.get(lid)
|
|
338
353
|
if (!x) throw err('no-exchange', 'that login is not in flight any more: start again')
|
|
@@ -341,7 +356,7 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
|
|
|
341
356
|
const u = x.known ? state.users[x.user] : null
|
|
342
357
|
|
|
343
358
|
try {
|
|
344
|
-
opaque.loginFinish({ state: x.state, finalization })
|
|
359
|
+
await opaque.loginFinish({ state: x.state, finalization })
|
|
345
360
|
} catch (e) {
|
|
346
361
|
// Contraseña equivocada, usuario inexistente o mensaje alterado: el MISMO error.
|
|
347
362
|
// Distinguirlos diría qué usuarios existen. El intento ya está contado desde
|
|
@@ -438,7 +453,7 @@ export async function registerLogin ({
|
|
|
438
453
|
scope, unattended = false, replace = false
|
|
439
454
|
} = {}) {
|
|
440
455
|
if (replace) {
|
|
441
|
-
logins.registerFinish({ user, upload, blob, replace: true })
|
|
456
|
+
await logins.registerFinish({ user, upload, blob, replace: true })
|
|
442
457
|
return { ok: true, user, replaced: true }
|
|
443
458
|
}
|
|
444
459
|
// PERMISOS, no tipos: los del scope, más `unattended` si quien lo crea lo eligió
|
|
@@ -452,7 +467,7 @@ export async function registerLogin ({
|
|
|
452
467
|
throw Object.assign(new Error('this vault cannot add devices to the account record: no login was created'), { code: 'admit-unavailable' })
|
|
453
468
|
}
|
|
454
469
|
const deviceId = await deviceIdOf(pub)
|
|
455
|
-
logins.registerFinish({ user, upload, pub, encPub, deviceId, label, blob })
|
|
470
|
+
await logins.registerFinish({ user, upload, pub, encPub, deviceId, label, blob })
|
|
456
471
|
try {
|
|
457
472
|
const { cert } = await identity.signDelegation(pub, scopes, { label: label || `login:${user}` })
|
|
458
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.1",
|
|
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
|
+
}
|