@dotrino/vault 0.94.0 → 0.95.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/browser.js +42 -11
- package/src/core/libVersion.js +1 -1
- package/src/enroll.js +7 -1
- package/src/service.js +25 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vault",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.95.0",
|
|
4
4
|
"description": "La bóveda de Dotrino como librería: el MISMO núcleo que el binario corriendo en el navegador (`@dotrino/vault/browser`), el mostrador de emparejamiento y el cliente de SERVICIO (Node): un proyecto se enrola una vez y jala sus credenciales del vault en vez del .env (`import '@dotrino/vault/config'`).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/browser.js
CHANGED
|
@@ -38,6 +38,7 @@ const SELF_CERT_EVERY_MS = 60_000
|
|
|
38
38
|
* antes de correr el núcleo (`{ state, drop }` o `null`). Se traen UNA vez.
|
|
39
39
|
* @param {Function} [opts.onPendingChange] un aparato espera aprobación, o dejó de esperar
|
|
40
40
|
* @param {Function} [opts.onAdopted]
|
|
41
|
+
* @param {Function} [opts.onStopped] `(error)`: la bóveda se paró sola (la cuenta de la pestaña cambió)
|
|
41
42
|
* @param {Function} [opts.log]
|
|
42
43
|
* @param {object} [opts.opaque] el servidor de OPAQUE, si el del paquete no se puede cargar aquí
|
|
43
44
|
* (la extensión: su CSP no deja WASM en la página de la bóveda).
|
|
@@ -46,12 +47,18 @@ const SELF_CERT_EVERY_MS = 60_000
|
|
|
46
47
|
* @param {Function} [opts.openDisk] `(name) => disk`. Solo para las pruebas (Node no tiene
|
|
47
48
|
* IndexedDB); por defecto, `openBrowserDisk`.
|
|
48
49
|
*/
|
|
49
|
-
export async function startBrowserVault ({ core, proxyUrl = DEFAULT_PROXY, makeClient = null, legacyLogins = null, onPendingChange = () => {}, onAdopted = () => {}, log = console.log, openDisk = openBrowserDisk, opaque = null, servePasswords = true } = {}) {
|
|
50
|
+
export async function startBrowserVault ({ core, proxyUrl = DEFAULT_PROXY, makeClient = null, legacyLogins = null, onPendingChange = () => {}, onAdopted = () => {}, onStopped = () => {}, log = console.log, openDisk = openBrowserDisk, opaque = null, servePasswords = true } = {}) {
|
|
50
51
|
if (!core?.handlers) throw Object.assign(new Error('startBrowserVault: the identity core is required'), { code: 'no-identity' })
|
|
51
52
|
const identity = new IdentityFacade(core)
|
|
52
53
|
if (!identity.me?.publickey) throw Object.assign(new Error('no identity: create or unlock your profile before using this browser as a vault'), { code: 'no-identity' })
|
|
53
54
|
const { id: pid } = await identity.currentProfile()
|
|
54
55
|
if (!pid) throw Object.assign(new Error('no profile: the vault belongs to an account'), { code: 'no-profile' })
|
|
56
|
+
// UNA BÓVEDA QUE NO PUEDE SELLAR SU ACTA NO ES UNA BÓVEDA. Arrancaba igual —un aparato
|
|
57
|
+
// entrado con usuario y contraseña es miembro, no selladora— y el fallo salía después, al
|
|
58
|
+
// aprobar a alguien («not the master, nor a co-sealer»), con el código ya en pantalla.
|
|
59
|
+
const info = await identity.profileActa()
|
|
60
|
+
if (!info?.acta) throw Object.assign(new Error('this account has no record yet: open the profile first'), { code: 'no-record' })
|
|
61
|
+
if (!info.isMaster) throw Object.assign(new Error('this browser cannot be the vault of the account open here: its key does not seal the account record'), { code: 'not-sealer' })
|
|
55
62
|
|
|
56
63
|
const disk = await openDisk(`dotrino-vault.${pid}`)
|
|
57
64
|
|
|
@@ -95,6 +102,15 @@ export async function startBrowserVault ({ core, proxyUrl = DEFAULT_PROXY, makeC
|
|
|
95
102
|
// perfil firmándose a sí misma con lo que el acta le deja hoy. Se rehace cada minuto
|
|
96
103
|
// porque el acta cambia (un permiso que te quitas, un sellado nuevo).
|
|
97
104
|
const master = identity.me.publickey
|
|
105
|
+
// LA BÓVEDA ES DE UNA CUENTA. Si en esta pestaña se abre otra (crear perfil, entrar con
|
|
106
|
+
// contraseña), la identidad cambia por debajo y la bóveda firmaría como la nueva con el
|
|
107
|
+
// disco de la vieja: se para antes de firmar nada.
|
|
108
|
+
const sameAccount = async () => {
|
|
109
|
+
const { id } = await identity.currentProfile()
|
|
110
|
+
if (id !== pid || identity.me?.publickey !== master) {
|
|
111
|
+
throw Object.assign(new Error('the account open in this tab changed: this vault was started for another one; reload the tab'), { code: 'profile-changed' })
|
|
112
|
+
}
|
|
113
|
+
}
|
|
98
114
|
const selfCert = async () => {
|
|
99
115
|
const acta = (await identity.profileActa().catch(() => null))?.acta || null
|
|
100
116
|
if (!acta) return null
|
|
@@ -119,15 +135,36 @@ export async function startBrowserVault ({ core, proxyUrl = DEFAULT_PROXY, makeC
|
|
|
119
135
|
log('[vault] this browser administers the account: admin added to its own key')
|
|
120
136
|
}
|
|
121
137
|
const open = (onReply) => vault.localChannel(onReply)
|
|
138
|
+
let stopped = false
|
|
139
|
+
let refresh = null
|
|
140
|
+
const shutdown = async () => {
|
|
141
|
+
if (stopped) return
|
|
142
|
+
stopped = true
|
|
143
|
+
clearInterval(refresh)
|
|
144
|
+
try { core.setSelfVault?.(null) } catch (_) {}
|
|
145
|
+
vault.close()
|
|
146
|
+
try { await disk.flush() } catch (e) { log('[vault] some changes could not be saved: ' + (e?.message || e)) }
|
|
147
|
+
disk.close()
|
|
148
|
+
}
|
|
122
149
|
const present = async () => {
|
|
150
|
+
if (stopped) return
|
|
123
151
|
try {
|
|
152
|
+
await sameAccount()
|
|
124
153
|
await ensureAdmin()
|
|
125
154
|
const cert = await selfCert()
|
|
126
155
|
core.setSelfVault?.(cert ? { cert, proxy: proxyUrl, open } : null)
|
|
127
|
-
} catch (e) {
|
|
156
|
+
} catch (e) {
|
|
157
|
+
if (e?.code === 'profile-changed') {
|
|
158
|
+
log('[vault] ' + e.message)
|
|
159
|
+
await shutdown()
|
|
160
|
+
try { onStopped(e) } catch (_) {}
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
log('[vault] the console in this browser cannot administer this vault yet: ' + (e?.message || e))
|
|
164
|
+
}
|
|
128
165
|
}
|
|
129
166
|
await present()
|
|
130
|
-
|
|
167
|
+
if (!stopped) refresh = setInterval(present, SELF_CERT_EVERY_MS)
|
|
131
168
|
|
|
132
169
|
return {
|
|
133
170
|
...vault,
|
|
@@ -135,7 +172,7 @@ export async function startBrowserVault ({ core, proxyUrl = DEFAULT_PROXY, makeC
|
|
|
135
172
|
proxy: proxyUrl,
|
|
136
173
|
disk,
|
|
137
174
|
/** Aprueba TIPEANDO el código que muestra el aparato (el núcleo comprueba el compromiso). */
|
|
138
|
-
approve: async (_deviceId, code) => { try { return await vault.approveDevice(code) } finally { notify() } },
|
|
175
|
+
approve: async (_deviceId, code) => { try { await sameAccount(); return await vault.approveDevice(code) } finally { notify() } },
|
|
139
176
|
reject: (deviceId) => { try { return vault.rejectDevice(deviceId) } finally { notify() } },
|
|
140
177
|
/**
|
|
141
178
|
* Las máquinas con papel de firma de esta cuenta (excluye los navegadores enrolados con
|
|
@@ -154,13 +191,7 @@ export async function startBrowserVault ({ core, proxyUrl = DEFAULT_PROXY, makeC
|
|
|
154
191
|
},
|
|
155
192
|
revokeDevice: (sub) => vault.revokeDevice({ sub }),
|
|
156
193
|
revoke: (nonce) => vault.revokeDevice(nonce),
|
|
157
|
-
|
|
158
|
-
clearInterval(refresh)
|
|
159
|
-
try { core.setSelfVault?.(null) } catch (_) {}
|
|
160
|
-
vault.close()
|
|
161
|
-
try { await disk.flush() } catch (e) { log('[vault] some changes could not be saved: ' + (e?.message || e)) }
|
|
162
|
-
disk.close()
|
|
163
|
-
}
|
|
194
|
+
close: shutdown
|
|
164
195
|
}
|
|
165
196
|
}
|
|
166
197
|
|
package/src/core/libVersion.js
CHANGED
package/src/enroll.js
CHANGED
|
@@ -370,7 +370,13 @@ export function createEnrollDesk ({
|
|
|
370
370
|
// llegue el acta sellada, que es la que este dispositivo dijo que iba a entregar.
|
|
371
371
|
if (intent === 'adopt' && typeof d.profileId === 'string') pend.profileId = d.profileId
|
|
372
372
|
|
|
373
|
-
|
|
373
|
+
// DE QUÉ CUENTA ES ESTA BÓVEDA, demostrable: la cadena de selladoras (su raíz la firma la
|
|
374
|
+
// llave del perfil). Va DENTRO del reto firmado para que el aparato pueda negarse a entrar
|
|
375
|
+
// en otra cuenta ANTES de enseñar su código —la réplica que se pagó para una cuenta, por
|
|
376
|
+
// ejemplo— en vez de descubrirlo cuando ya lo aprobaron. Quien no la mira, no la necesita.
|
|
377
|
+
let chain = null
|
|
378
|
+
try { chain = (await identity.sealerChain?.()) || null } catch (e) { log('[vault] could not build the sealer chain for the challenge:', e.message) }
|
|
379
|
+
await tell(from, { type: MSG_ENROLL_CHALLENGE, deviceId, chain }, pend.sn)
|
|
374
380
|
fire(onChallenge, { deviceId, scope: pend.scope, label: pend.label || '', machine: pend.machine || null, mode: pend.mode || 'join' })
|
|
375
381
|
fire(onPendingChange)
|
|
376
382
|
return { deviceId }
|
package/src/service.js
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
makeDeviceKey, makeDeviceEncKey, importDeviceEncKey, signWithDevice,
|
|
26
26
|
verifyDeviceSig, makePairingCode, commitCode, pubkeyId, keyLabel } from '@dotrino/identity/capabilities'
|
|
27
27
|
import { openWrap, wrapForMember, decryptWithCek } from '@dotrino/identity/content'
|
|
28
|
-
import { verifyActa, sealKeyAt, memberCan, checkVaultReply } from '@dotrino/identity/acta'
|
|
28
|
+
import { verifyActa, sealKeyAt, memberCan, checkVaultReply, verifySealerChain, canSeal } from '@dotrino/identity/acta'
|
|
29
29
|
import { MSG, secretsScope, isValidSecretsNs, VAULT_PROTOCOL, VAULT_SPEAKS } from './protocol.js'
|
|
30
30
|
import { PKG_VERSION } from './version.js'
|
|
31
31
|
import { makeEphemeralKey, openSealed } from './sealed.js'
|
|
@@ -391,7 +391,26 @@ export async function vaultAccountName (qr) {
|
|
|
391
391
|
return (await vaultHello(qr)).acct || ''
|
|
392
392
|
}
|
|
393
393
|
|
|
394
|
-
|
|
394
|
+
/**
|
|
395
|
+
* ¿Es esta bóveda de la cuenta `account` (`pubkeyId` de la llave del perfil)? Lo dice la cadena de
|
|
396
|
+
* selladoras que viaja en el reto: la raíz la firma la llave del perfil y cada eslabón lo sella
|
|
397
|
+
* quien el anterior autorizaba, así que no se puede fingir otra cuenta. Y la bóveda que contesta
|
|
398
|
+
* tiene que ser selladora de la última acta. Devuelve `null` si sí, o el motivo.
|
|
399
|
+
*/
|
|
400
|
+
async function notFromAccount (chain, iss, account) {
|
|
401
|
+
if (!Array.isArray(chain) || !chain.length) return 'the vault did not say which account it belongs to (it may need an update)'
|
|
402
|
+
const c = await verifySealerChain(chain)
|
|
403
|
+
if (!c.ok) return 'the vault account could not be verified: ' + c.reason
|
|
404
|
+
if (!canSeal(chain[chain.length - 1], iss)) return 'the vault that answered does not seal that account'
|
|
405
|
+
const got = await pubkeyId(c.profileId)
|
|
406
|
+
return got === account ? null : `the invitation is from account ${got}, not from ${account}`
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* `account`: si viene, el aparato SOLO entra en esa cuenta. Se comprueba al llegar el reto,
|
|
411
|
+
* antes de enseñar el código: con otra cuenta no hay código que aprobar (`wrong-account`).
|
|
412
|
+
*/
|
|
413
|
+
export async function enrollWithVault ({ qr, label = 'agent', expectedScope = null, onCode, approveTimeoutMs = 180000, withEncKey = true, account = null } = {}) {
|
|
395
414
|
// `parseInvite` y NO `JSON.parse`: el vault no imprime JSON desde hace rato.
|
|
396
415
|
// `dotrino-vault pair` emite la URL del QR y el código compacto (`c…`/`t…`, ver
|
|
397
416
|
// invite.js); `parseInvite` acepta todas las formas, incluida la vieja.
|
|
@@ -426,6 +445,10 @@ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = nu
|
|
|
426
445
|
// (otra bóveda de la cuenta, o cualquiera que viera pasar el alta) se descarta.
|
|
427
446
|
if ((p.type === MSG.ENROLL_CHALLENGE || p.type === MSG.ERROR) && !await fromMyVault(p, qr)) return
|
|
428
447
|
if (p.type === MSG.ENROLL_CHALLENGE) {
|
|
448
|
+
if (account) {
|
|
449
|
+
const why = await notFromAccount(p.signed?.chain, qr.iss, account)
|
|
450
|
+
if (why) { cleanup(); reject(Object.assign(new Error(why), { code: 'wrong-account' })); return }
|
|
451
|
+
}
|
|
429
452
|
const show = onCode || (({ deviceId, code }) => console.log(`[vault] device ${deviceId} · approve it on the vault: dotrino-vault approve ${code}`))
|
|
430
453
|
show({ deviceId, code })
|
|
431
454
|
} else if (p.type === MSG.ENROLLED) { cleanup(); resolve(p) } else if (p.type === MSG.ERROR) { cleanup(); reject(vaultError(p)) }
|