@dotrino/vaultd 0.118.0 → 0.121.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/src/b64.js ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * b64.js — base64url a mano, sin `Buffer` ni `btoa`.
3
+ *
4
+ * Vive aparte porque lo usan piezas que corren en los tres sitios: el binario (Node), la
5
+ * pestaña y la extensión. `Buffer` no existe en el navegador y `btoa` no existe en algunos
6
+ * workers, así que la única forma de tener UNA implementación es esta.
7
+ */
8
+
9
+ const B64_STD = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
10
+
11
+ export function bytesToB64url (bytes) {
12
+ let out = ''
13
+ for (let i = 0; i < bytes.length; i += 3) {
14
+ const a = bytes[i]; const b = bytes[i + 1]; const c = bytes[i + 2]
15
+ out += B64_STD[a >> 2]
16
+ out += B64_STD[((a & 3) << 4) | ((b ?? 0) >> 4)]
17
+ if (b === undefined) break
18
+ out += B64_STD[((b & 15) << 2) | ((c ?? 0) >> 6)]
19
+ if (c === undefined) break
20
+ out += B64_STD[c & 63]
21
+ }
22
+ return out.replace(/\+/g, '-').replace(/\//g, '_')
23
+ }
24
+
25
+ export function b64urlToBytes (s) {
26
+ const clean = String(s).replace(/-/g, '+').replace(/_/g, '/').replace(/[^A-Za-z0-9+/]/g, '')
27
+ const out = []
28
+ let acc = 0; let bits = 0
29
+ for (const ch of clean) {
30
+ const v = B64_STD.indexOf(ch)
31
+ if (v < 0) return null
32
+ acc = (acc << 6) | v; bits += 6
33
+ if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 0xff) }
34
+ }
35
+ return Uint8Array.from(out)
36
+ }
package/lib/src/enroll.js CHANGED
@@ -349,23 +349,50 @@ export function createEnrollDesk ({
349
349
  return { ok: true, deviceId: pend.deviceId, adopting: true }
350
350
  }
351
351
 
352
- const { cert } = await identity.signDelegation(pend.dpub, pend.scope, { ttlMs: pend.ttlMs, label: pend.label })
353
-
354
352
  // Aprobar un emparejamiento ES admitir al dispositivo en el perfil: el cert es la
355
353
  // credencial y el acta es la política, y no tiene sentido emitir una sin la otra.
356
354
  // Las capacidades salen del scope que se pidió al emparejar (cert ∩ acta, §2.3).
357
- let record = null
355
+ //
356
+ // NADA DE REPLIEGUES (2026-09-17). Esto antes estaba envuelto en un `catch` que solo
357
+ // anotaba el error, y además se saltaba en silencio si la identidad no sabía admitir o si
358
+ // el scope no daba ninguna capacidad: el aparato recibía su certificado SIN estar en el
359
+ // acta, y el fallo aparecía después y en otro sitio. Ahora, si no se puede admitir, no
360
+ // se entrega nada y se dice.
361
+ if (typeof identity.admitMember !== 'function') {
362
+ throw Object.assign(new Error('this vault cannot add devices to the account record: no certificate was issued'), { code: 'admit-unavailable' })
363
+ }
364
+ // PERMISOS, no tipos (2026-08-22): las capacidades son las del scope ENTERO. Un
365
+ // cajón (`secrets:<ns>`) suma `secrets` y fija el CN; no borra lo demás — un bot
366
+ // con `sign,secrets:eco` firma como aparato del acta Y lee solo su cajón.
367
+ const cn = scopeToCn(pend.scope)
368
+ const caps = [...new Set([...scopeToCaps(pend.scope), ...(cn ? ['secrets'] : [])])]
369
+ if (!caps.length) {
370
+ throw Object.assign(new Error('the pairing scope grants no permission: no certificate was issued'), { code: 'empty-scope' })
371
+ }
372
+
373
+ const { cert } = await identity.signDelegation(pend.dpub, pend.scope, { ttlMs: pend.ttlMs, label: pend.label })
374
+
358
375
  try {
359
- if (typeof identity.admitMember === 'function') {
360
- // PERMISOS, no tipos (2026-08-22): las capacidades son las del scope ENTERO. Un
361
- // cajón (`secrets:<ns>`) suma `secrets` y fija el CN; no borra lo demás — un bot
362
- // con `sign,secrets:eco` firma como aparato del acta Y lee solo su cajón.
363
- const cn = scopeToCn(pend.scope)
364
- const caps = [...new Set([...scopeToCaps(pend.scope), ...(cn ? ['secrets'] : [])])]
365
- if (caps.length) await identity.admitMember({ pub: pend.dpub, encPub: pend.encPub || null, label: pend.label || '', cn, caps, cert, continuity: pend.continuity || null })
376
+ await identity.admitMember({ pub: pend.dpub, encPub: pend.encPub || null, label: pend.label || '', cn, caps, cert, continuity: pend.continuity || null })
377
+ } catch (e) {
378
+ // El certificado ya está firmado y no se entrega: se revoca, para que no quede uno
379
+ // válido suelto. Si ni eso sale, se dice también.
380
+ let revoked = true
381
+ try { await identity.revokeDelegation(cert.nonce) } catch (re) {
382
+ revoked = false
383
+ log('[vault] could not revoke the undelivered certificate %s: %s', cert.nonce, re.message)
366
384
  }
367
- record = (await identity.profileActa?.())?.acta || null
368
- } catch (e) { log('[vault] could not admit into the record:', e.message) }
385
+ audit('rejected', { what: 'approve', device: pend.deviceId, reason: 'admit-failed' })
386
+ reply(pend.from, { type: MSG_ERROR, error: 'the vault could not add this device to the account: pairing failed, try again' })
387
+ pending.delete(pend.token)
388
+ fire(onPendingChange)
389
+ log('[vault] could not add %s to the record, no certificate was delivered: %s', pend.deviceId, e.message)
390
+ throw Object.assign(
391
+ new Error(`could not add the device to the account record (${e.message}): no certificate was delivered${revoked ? '' : ', and the signed one could not be revoked'}`),
392
+ { code: 'admit-failed', cause: e }
393
+ )
394
+ }
395
+ const record = (await identity.profileActa?.())?.acta || null
369
396
 
370
397
  audit('enroll', { device: pend.deviceId, label: pend.label || '', scope: pend.scope })
371
398
  // Echamos el código tipeado junto al cert: el DISPOSITIVO acepta solo si coincide
package/lib/src/index.js CHANGED
@@ -46,11 +46,16 @@ export { deviceIdOf }
46
46
  * `me.publickey`, `signData`, `signDelegation`, `listDelegations`, `revokeDelegation`.
47
47
  * @param {object} [opts]
48
48
  * @param {string} [opts.proxyUrl='wss://proxy.dotrino.com']
49
+ * @param {object} [opts.logins] escritorio de `createLoginDesk` (`./password-logins`) para
50
+ * ENTRAR CON USUARIO Y CONTRASEÑA. Lo monta quien levanta esta bóveda, porque en el
51
+ * navegador no hay archivos y el estado tiene que guardarlo él. Sin él, esta bóveda
52
+ * contesta `logins-unavailable` — no se inventa un almacén.
49
53
  * @returns {Promise<object>} handle: { iss, proxy, client, startPairing, stopPairing,
50
- * approve, reject, listPending, listMachines, revoke, getSelfCert, onPendingChange,
51
- * onAdopted, close }
54
+ * approve, reject, listPending, listMachines, revoke, getSelfCert, listLogins,
55
+ * loginRegisterBegin, loginRegisterFinish, loginBegin, loginEnd, closeLogin,
56
+ * clearLoginBlock, removeLogin, onPendingChange, onAdopted, close }
52
57
  */
53
- export async function startDeviceVault (identity, { proxyUrl, client: injectedClient } = {}) {
58
+ export async function startDeviceVault (identity, { proxyUrl, client: injectedClient, logins = null } = {}) {
54
59
  const iss = identity.me?.publickey
55
60
  if (!iss) throw new Error('no identity: create/unlock your identity before using this device as a vault')
56
61
  const proxy = proxyUrl || 'wss://proxy.dotrino.com'
@@ -328,8 +333,72 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
328
333
  else if (p.type === MSG.GET) handle('get', handleGet(_from, p), _from)
329
334
  else if (p.type === MSG.STORE) handle('store', handleStore(_from, p), _from)
330
335
  else if (p.type === MSG.CHECK) handle('check', handleCheck(_from, p), _from)
336
+ // ENTRAR CON USUARIO Y CONTRASEÑA. La misma pieza que usa el binario
337
+ // (`passwordLogins.js`), con el almacén que le ponga quien monta esta bóveda: en el
338
+ // navegador no hay archivos, así que lo pone el iframe de identidad.
339
+ else if (p.type === MSG.LOGIN_START) handle('login', handleLoginStart(_from, p), _from)
340
+ else if (p.type === MSG.LOGIN_FINISH) handle('login', handleLoginFinish(_from, p), _from)
341
+ else if (p.type === MSG.LOGIN_CLOSE) handle('login', handleLoginClose(_from, p), _from)
331
342
  })
332
343
 
344
+ // --- ENTRAR CON USUARIO Y CONTRASEÑA ---------------------------------------------------
345
+ //
346
+ // Sin almacén no se inventa nada: se contesta que aquí no hay inicios de sesión. Es una
347
+ // bóveda que no atiende esto, no una que dice que la contraseña está mal.
348
+ const noLogins = (from) => send(from, { type: MSG.ERROR, error: 'this vault does not keep password logins', code: 'logins-unavailable' })
349
+
350
+ /** Lo mismo desde la consola: sin almacén no se administra nada, y se dice. */
351
+ function needLogins () {
352
+ if (!logins) throw Object.assign(new Error('this vault does not keep password logins: no store was given to startDeviceVault'), { code: 'logins-unavailable' })
353
+ return logins
354
+ }
355
+
356
+ function loginError (from, e) {
357
+ const code = e?.code
358
+ if (code === 'too-many-tries') return send(from, { type: MSG.ERROR, error: e.message, code, waitMs: e.waitMs || 0 })
359
+ if (code === 'login-failed' || code === 'no-exchange' || code === 'bad-input' || code === 'bad-user') {
360
+ return send(from, { type: MSG.ERROR, error: e.message, code })
361
+ }
362
+ send(from, { type: MSG.ERROR, error: 'login failed', code: 'login-error' })
363
+ }
364
+
365
+ async function handleLoginStart (from, p) {
366
+ if (!logins) return noLogins(from)
367
+ try {
368
+ const { lid, response } = logins.loginBegin({ user: p?.user, request: p?.request })
369
+ send(from, { type: MSG.LOGIN_RESPONSE, lid, response })
370
+ } catch (e) { loginError(from, e) }
371
+ }
372
+
373
+ async function handleLoginFinish (from, p) {
374
+ if (!logins) return noLogins(from)
375
+ try {
376
+ const r = logins.loginEnd({ lid: p?.lid, finalization: p?.finalization, label: p?.label })
377
+ const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
378
+ send(from, { type: MSG.LOGIN_OK, sid: r.sid, blob: r.blob, cert: r.cert, iss: r.iss || iss, acta })
379
+ } catch (e) { loginError(from, e) }
380
+ }
381
+
382
+ /** Salir va firmado con la llave que acaba de abrir: cerrar la de otro sería echarlo. */
383
+ async function handleLoginClose (from, p) {
384
+ if (!logins) return noLogins(from)
385
+ const d = p?.data
386
+ if (typeof d?.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) {
387
+ return send(from, { type: MSG.ERROR, error: 'stale request', code: 'stale' })
388
+ }
389
+ if (d?.op !== 'login.close' || typeof d.publickey !== 'string' || typeof d.user !== 'string' || typeof d.sid !== 'string') {
390
+ return send(from, { type: MSG.ERROR, error: 'unauthorized: shape', code: 'bad-input' })
391
+ }
392
+ if (!(await verifyDeviceSig({ publickey: d.publickey, data: d, signature: p.signature }))) {
393
+ return send(from, { type: MSG.ERROR, error: 'unauthorized: bad-signature', code: 'bad-signature' })
394
+ }
395
+ const mine = logins.list().find((x) => x.user === d.user)
396
+ if (!mine || mine.pub !== d.publickey) {
397
+ return send(from, { type: MSG.ERROR, error: 'unauthorized: that key does not own this login', code: 'not-yours' })
398
+ }
399
+ send(from, { type: MSG.LOGIN_CLOSED, ok: logins.closeSession({ user: d.user, sid: d.sid }).ok })
400
+ }
401
+
333
402
  /**
334
403
  * Máquinas enroladas bajo esta identidad (P), vigentes, con scope de firma y label
335
404
  * propio (excluye navegadores enrolados con label 'cli', que no atienden peticiones).
@@ -368,6 +437,31 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
368
437
  // exactamente esto.
369
438
  revoke: (nonce) => desk.revoke(nonce),
370
439
  getSelfCert,
440
+ // ENTRAR CON USUARIO Y CONTRASEÑA, desde la consola de esta bóveda. Es la misma pieza
441
+ // que el binario (`registerLogin`), para que el aparato que sale de aquí sea idéntico.
442
+ // Sin almacén no hay nada que administrar, y se dice en vez de contestar una lista vacía.
443
+ listLogins: () => needLogins().list(),
444
+ loginRegisterBegin: (opts) => needLogins().registerBegin(opts),
445
+ // El alta se trae cuando se usa: arrastra el OPAQUE en WASM (unos 260 KB) y la mayoría
446
+ // de las apps que usan este módulo no crean ningún inicio de sesión.
447
+ loginRegisterFinish: async (opts) => {
448
+ const { registerLogin } = await import('./passwordLogins.js')
449
+ return registerLogin({ ...opts, identity, logins: needLogins() })
450
+ },
451
+ // Entrar desde la propia consola, sin pasar por el proxio. Hace falta para cambiar la
452
+ // contraseña: el paquete de llaves se abre con la vieja y se vuelve a cerrar con la nueva.
453
+ loginBegin: ({ user, request }) => needLogins().loginBegin({ user, request }),
454
+ loginEnd: ({ lid, finalization, label }) => needLogins().loginEnd({ lid, finalization, label }),
455
+ closeLogin: ({ user, sid }) => needLogins().closeSession({ user, sid }),
456
+ clearLoginBlock: ({ user }) => needLogins().clearBlock({ user }),
457
+ removeLogin: ({ user }) => {
458
+ const store = needLogins()
459
+ const found = store.list().find((x) => x.user === user)
460
+ if (!found) return { ok: false }
461
+ store.remove({ user })
462
+ // Quitarlo de aquí no lo saca del acta: lo suyo es revocar el aparato, y eso ya existe.
463
+ return { ok: true, pub: found.pub, deviceId: found.deviceId }
464
+ },
371
465
  onPendingChange (fn) { _onPendingChange = fn || (() => {}) },
372
466
  /** Camino A: la cuenta del aparato quedó adoptada por esta bóveda. */
373
467
  onAdopted (fn) { _onAdopted = fn || (() => {}) },
package/lib/src/invite.js CHANGED
@@ -80,37 +80,11 @@ export const PAIR_URL = 'https://vault.dotrino.com/d#v='
80
80
 
81
81
  // ---------------------------------------------------------------------------
82
82
  // Conversiones (sin dependencias: esto corre en el navegador, en Node y dentro
83
- // del binario SEA)
83
+ // del binario SEA). El base64url vive en `b64.js`, porque el paquete de llaves de
84
+ // los inicios de sesión usa el mismo.
84
85
  // ---------------------------------------------------------------------------
85
86
 
86
- const B64_STD = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
87
-
88
- function bytesToB64url (bytes) {
89
- let out = ''
90
- for (let i = 0; i < bytes.length; i += 3) {
91
- const a = bytes[i]; const b = bytes[i + 1]; const c = bytes[i + 2]
92
- out += B64_STD[a >> 2]
93
- out += B64_STD[((a & 3) << 4) | ((b ?? 0) >> 4)]
94
- if (b === undefined) break
95
- out += B64_STD[((b & 15) << 2) | ((c ?? 0) >> 6)]
96
- if (c === undefined) break
97
- out += B64_STD[c & 63]
98
- }
99
- return out.replace(/\+/g, '-').replace(/\//g, '_')
100
- }
101
-
102
- function b64urlToBytes (s) {
103
- const clean = String(s).replace(/-/g, '+').replace(/_/g, '/').replace(/[^A-Za-z0-9+/]/g, '')
104
- const out = []
105
- let acc = 0; let bits = 0
106
- for (const ch of clean) {
107
- const v = B64_STD.indexOf(ch)
108
- if (v < 0) return null
109
- acc = (acc << 6) | v; bits += 6
110
- if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 0xff) }
111
- }
112
- return Uint8Array.from(out)
113
- }
87
+ import { bytesToB64url, b64urlToBytes } from './b64.js'
114
88
 
115
89
  const utf8 = (s) => new TextEncoder().encode(s)
116
90
  const fromUtf8 = (b) => new TextDecoder().decode(b)