@dotrino/identity 0.93.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.
@@ -0,0 +1,123 @@
1
+ /**
2
+ * @dotrino/opaque — OPAQUE (RFC 9807) para el ecosistema Dotrino.
3
+ *
4
+ * Comprueba una contraseña SIN que el servidor la vea nunca y sin entregar nada con qué
5
+ * adivinarla desde fuera. Lo usan las tres versiones del vault (el binario, la pestaña y la
6
+ * extensión) y el gestor, para el aparato que se abre con usuario y contraseña.
7
+ *
8
+ * Aquí no hay criptografía propia: el protocolo es `opaque-ke` (Meta, auditado por NCC
9
+ * Group) compilado a WASM desde su código fuente en nuestro CI. Este archivo solo pone
10
+ * nombres y comprueba lo que entra.
11
+ *
12
+ * Todo lo que entra y sale son cadenas base64url. Los errores llevan `code`:
13
+ * · `bad-input` — falta un dato o no se puede leer
14
+ * · `login-failed` — contraseña equivocada, usuario inexistente, identificadores que no
15
+ * casan o un mensaje alterado. A propósito son el MISMO código: distinguirlos
16
+ * le diría a quien prueba qué usuarios existen.
17
+ * · `protocol` — cualquier otro fallo del protocolo
18
+ * · `internal` — un fallo que no viene del protocolo (no debería pasar)
19
+ */
20
+ import * as wasm from '../build/opaque.js'
21
+ import wasmBytes from '../build/wasm-bytes.js'
22
+
23
+ export class OpaqueError extends Error {
24
+ constructor (code, message) {
25
+ super(message)
26
+ this.name = 'OpaqueError'
27
+ this.code = code
28
+ }
29
+ }
30
+
31
+ let ready = false
32
+ function init () {
33
+ if (ready) return
34
+ const bin = Uint8Array.from(atob(wasmBytes), (c) => c.charCodeAt(0))
35
+ wasm.initSync({ module: bin })
36
+ ready = true
37
+ }
38
+
39
+ const CODE = /^(bad-input|login-failed|protocol): ([\s\S]*)$/
40
+
41
+ function call (fn, ...args) {
42
+ init()
43
+ try {
44
+ return fn(...args)
45
+ } catch (e) {
46
+ const msg = String(e?.message ?? e)
47
+ const m = CODE.exec(msg)
48
+ if (m) throw new OpaqueError(m[1], m[2])
49
+ throw new OpaqueError('internal', msg)
50
+ }
51
+ }
52
+
53
+ function need (name, v) {
54
+ if (typeof v !== 'string' || !v) throw new OpaqueError('bad-input', `${name} is required`)
55
+ return v
56
+ }
57
+
58
+ /**
59
+ * Los identificadores que se atan al intercambio. Si se usan, las DOS puntas tienen que
60
+ * pasar los mismos; si no casan, el inicio falla como una contraseña equivocada.
61
+ */
62
+ function idents (identifiers) {
63
+ if (identifiers == null || typeof identifiers !== 'object') throw new OpaqueError('bad-input', 'identifiers must be an object')
64
+ const { client, server } = identifiers
65
+ for (const [k, v] of [['identifiers.client', client], ['identifiers.server', server]]) {
66
+ if (v !== undefined && (typeof v !== 'string' || !v)) throw new OpaqueError('bad-input', `${k} must be a non-empty string`)
67
+ }
68
+ return [client, server]
69
+ }
70
+
71
+ /** La suite y sus parámetros. Se guarda junto a cada registro. */
72
+ export function suiteId () {
73
+ return call(wasm.suiteId)
74
+ }
75
+
76
+ export const server = {
77
+ /** La preparación del servidor: SECRETA, una por bóveda. Sin ella no se puede comprobar nada. */
78
+ createSetup () {
79
+ return call(wasm.serverCreateSetup)
80
+ },
81
+ publicKey ({ setup } = {}) {
82
+ return call(wasm.serverPublicKey, need('setup', setup))
83
+ },
84
+ registrationResponse ({ setup, request, credentialId } = {}) {
85
+ return call(wasm.serverRegistrationResponse, need('setup', setup), need('request', request), need('credentialId', credentialId))
86
+ },
87
+ /** Lo que se guarda del usuario (el «registro»). */
88
+ registrationFinish ({ upload } = {}) {
89
+ return call(wasm.serverRegistrationFinish, need('upload', upload))
90
+ },
91
+ /**
92
+ * `record` tiene que venir SIEMPRE: el registro, o `null` si el usuario no existe. Con
93
+ * `null` responde igual, así que desde fuera no se sabe qué usuarios hay.
94
+ */
95
+ loginStart ({ setup, record, request, credentialId, identifiers = {} } = {}) {
96
+ if (record !== null && (typeof record !== 'string' || !record)) {
97
+ throw new OpaqueError('bad-input', 'record must be the stored record, or null for an unknown user')
98
+ }
99
+ return call(wasm.serverLoginStart, need('setup', setup), record ?? undefined, need('request', request), need('credentialId', credentialId), ...idents(identifiers))
100
+ },
101
+ loginFinish ({ state, finalization, identifiers = {} } = {}) {
102
+ return call(wasm.serverLoginFinish, need('state', state), need('finalization', finalization), ...idents(identifiers))
103
+ },
104
+ }
105
+
106
+ export const client = {
107
+ registrationStart ({ password } = {}) {
108
+ return call(wasm.clientRegistrationStart, need('password', password))
109
+ },
110
+ /** Devuelve `upload` (para el servidor) y `exportKey`, que solo sale de la contraseña. */
111
+ registrationFinish ({ state, response, password, identifiers = {} } = {}) {
112
+ return call(wasm.clientRegistrationFinish, need('state', state), need('response', response), need('password', password), ...idents(identifiers))
113
+ },
114
+ loginStart ({ password } = {}) {
115
+ return call(wasm.clientLoginStart, need('password', password))
116
+ },
117
+ /** Devuelve `finalization` (para el servidor), `sessionKey` y el mismo `exportKey` del registro. */
118
+ loginFinish ({ state, response, password, identifiers = {} } = {}) {
119
+ return call(wasm.clientLoginFinish, need('state', state), need('response', response), need('password', password), ...idents(identifiers))
120
+ },
121
+ }
122
+
123
+ export default { suiteId, server, client, OpaqueError }
@@ -1,5 +1,7 @@
1
- Copia vendorizada de @dotrino/vault@0.64.0 (dotrino-vault/lib/src/{index,enroll,protocol}.js).
1
+ Copia vendorizada de @dotrino/vault@0.66.0 (dotrino-vault/lib/src/{index,enroll,protocol,passwordLogins,b64}.js).
2
2
  NO se edita a mano: la escribe `node vendor.mjs` y la vigila test/vendor-up-to-date.test.mjs.
3
3
  index.js importa ./enroll.js y ./protocol.js (relativos, van en esta misma copia),
4
4
  @dotrino/identity/{capabilities,acta} (= ../../{capabilities,acta}.js) y
5
5
  @dotrino/proxy-client (= ../proxy-client/), todos por el import map de index.html.
6
+ passwordLogins.js es el aparato que se abre con usuario y contraseña: lo carga
7
+ vault.js SOLO cuando esta pestaña es bóveda, porque arrastra el OPAQUE en WASM.
@@ -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
+ }
@@ -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
@@ -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 || (() => {}) },