@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vault",
3
- "version": "0.94.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) { log('[vault] the console in this browser cannot administer this vault yet: ' + (e?.message || 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
- const refresh = setInterval(present, SELF_CERT_EVERY_MS)
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
- async close () {
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
 
@@ -5,4 +5,4 @@
5
5
  * si no coincide con la del paquete: una versión anunciada que no es la que corre es peor
6
6
  * que no anunciar ninguna.
7
7
  */
8
- export const LIB_VERSION = '0.94.0'
8
+ export const LIB_VERSION = '0.95.0'
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
- await tell(from, { type: MSG_ENROLL_CHALLENGE, deviceId }, pend.sn)
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
- export async function enrollWithVault ({ qr, label = 'agent', expectedScope = null, onCode, approveTimeoutMs = 180000, withEncKey = true } = {}) {
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)) }