@dotrino/identity 0.118.0 → 0.120.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/README.md CHANGED
@@ -246,6 +246,22 @@ Lo que hace que esto no sea una llave maestra de repuesto:
246
246
  Diseño y hacia dónde va (inicio de sesión y federación):
247
247
  [`dotrino-vault/docs/inicio-de-sesion.md`](../dotrino-vault/docs/inicio-de-sesion.md).
248
248
 
249
+ ### Canal de sesión de los agentes remotos (0.120.0+)
250
+
251
+ ```js
252
+ import { makeEphemeral, deriveKey, seal, open } from '@dotrino/identity/channel'
253
+ ```
254
+
255
+ ECDH P-256 efímero → HKDF → AES-256-GCM: las dos puntas acuerdan una llave de sesión y la
256
+ usan para muchos mensajes. Lo usa `@dotrino/remote-agent` (antes vivía allí). Para un sobre
257
+ suelto hacia una llave, `wrapForMember`/`openWrap` de `@dotrino/identity/content`.
258
+
259
+ ### Huella de una llave
260
+
261
+ `keyLabel(pub)` de `@dotrino/identity/keyid` da `AB12-CD34`, la que se enseña en todo el
262
+ ecosistema. Úsala en vez de recortar `pubkeyId` a mano. Desde 0.120.0 una llave ilegible
263
+ LANZA (antes devolvía `''`); sin llave sigue siendo `''`.
264
+
249
265
  ### Backup / migración
250
266
 
251
267
  - `id.exportIdentity()` → blob JSON con `privateJwk` (ECDSA), `encPrivateJwk` (ECDH), `me`, `peers`. **Sensible** — el host app es responsable de guardarlo de manera segura.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/identity",
3
- "version": "0.118.0",
3
+ "version": "0.120.0",
4
4
  "description": "Identidad y rating de usuarios compartidos entre apps de Dotrino (vault iframe + postMessage)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -34,6 +34,9 @@
34
34
  "./content": {
35
35
  "import": "./vault/content.js"
36
36
  },
37
+ "./channel": {
38
+ "import": "./vault/channel.js"
39
+ },
37
40
  "./assertion": {
38
41
  "import": "./vault/assertion.js"
39
42
  },
package/src/index.js CHANGED
@@ -601,6 +601,20 @@ export class Identity {
601
601
  /** Cambia la contraseña: hace falta la vieja, y lo que estuviera abierto se cierra. */
602
602
  async selfVaultLoginPasswd (opts) { return this._call('selfVaultLoginPasswd', opts || {}, 60000) }
603
603
 
604
+ // ----- el RESPALDO, cuando la bóveda es ESTA pestaña (solo desde la consola) -----
605
+ //
606
+ // Bóveda abierta y origen de la consola (`vault.dotrino.com`): cualquier otro recibe
607
+ // `code: 'not-the-console'`. El archivo vuelve YA CIFRADO con la contraseña dada.
608
+
609
+ /** Los cajones que hay: `[{ owner, count, device? }]`. */
610
+ async selfVaultBackupDrawers () { return this._call('selfVaultBackupDrawers', {}, 30000) }
611
+ /** Exporta todo (`drawers: null`) o esos cajones. Devuelve `{ file, summary }`. */
612
+ async selfVaultBackupExport (opts) { return this._call('selfVaultBackupExport', opts || {}, 120000) }
613
+ /** El plan de importar `file` (qué es nuevo, igual o choca). No escribe nada. */
614
+ async selfVaultBackupPlan (opts) { return this._call('selfVaultBackupPlan', opts || {}, 120000) }
615
+ /** Importa. Con un choque sin decidir lanza `BACKUP_CONFLICTS` y no escribe nada. */
616
+ async selfVaultBackupImport (opts) { return this._call('selfVaultBackupImport', opts || {}, 120000) }
617
+
604
618
  /** Cierra un inicio de sesión abierto (sin `sid`, todos los de ese usuario). */
605
619
  async selfVaultLoginClose (opts) { return this._call('selfVaultLoginClose', opts || {}) }
606
620
 
package/vault/acta.js CHANGED
@@ -35,7 +35,7 @@
35
35
  * Módulo PURO: sin kv, sin red, sin disco. Cripto de `./capabilities.js`.
36
36
  */
37
37
  import { canonicalStringify } from './core.js'
38
- import { signWithDevice, verifyDeviceSig, verifyDelegation, pubkeyId } from './capabilities.js'
38
+ import { signWithDevice, verifyDeviceSig, verifyDelegation, keyLabel } from './capabilities.js'
39
39
  import { cleanMachine } from './machine.js'
40
40
  export { cleanMachine }
41
41
 
@@ -368,8 +368,8 @@ export async function verifySealerLinkChain (chain, { expectedProfileId = null }
368
368
 
369
369
  /** Id legible de un miembro (mismo formato que el deviceId del emparejamiento). */
370
370
  export async function memberId (pub) {
371
- const id = (await pubkeyId(pub)).slice(0, 8).toUpperCase()
372
- return id.slice(0, 4) + '-' + id.slice(4, 8)
371
+ if (!pub) throw new Error('memberId: missing key')
372
+ return keyLabel(pub)
373
373
  }
374
374
 
375
375
  /** ¿Esta acta es un traspaso? (la firmó uno y nombra master a otro) */
@@ -0,0 +1,77 @@
1
+ /**
2
+ * channel.js — canal cifrado punto a punto entre el dispositivo enlazado y un agente
3
+ * remoto del ecosistema Dotrino (lo usa `@dotrino/remote-agent`; antes vivía allí). Isomórfico: usa WebCrypto
4
+ * (`globalThis.crypto.subtle`), disponible tanto en el navegador (PWA) como en
5
+ * Node ≥ 18.
6
+ *
7
+ * NO reimplementa la identidad ni la firma del ecosistema: la AUTORIZACIÓN la da
8
+ * el vault (cert de dispositivo verificado con `@dotrino/identity/capabilities`
9
+ * → cadena hasta la maestra). Este módulo solo levanta un secreto de sesión
10
+ * efímero (ECDH P-256 → HKDF → AES-256-GCM) para que el relay de transporte
11
+ * (`proxy.dotrino.com`) vea únicamente texto cifrado. La identidad de las dos
12
+ * puntas ya la garantizan las firmas del vault que envuelven las claves efímeras.
13
+ *
14
+ * Distinto de `content.js` a propósito: aquí la llave es de SESIÓN (se acuerda una vez
15
+ * y cifra muchos mensajes, con HKDF y la pública en raw), allí cada sobre lleva su
16
+ * efímera. El `info` de HKDF conserva su nombre: es parte del cable, y lo hablan también
17
+ * los puertos nativos de `dotrino-native`.
18
+ */
19
+
20
+ const subtle = () => globalThis.crypto.subtle
21
+
22
+ const b64 = {
23
+ enc (buf) {
24
+ const b = new Uint8Array(buf)
25
+ let s = ''
26
+ for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i])
27
+ return btoa(s)
28
+ },
29
+ dec (str) {
30
+ const bin = atob(str)
31
+ const out = new Uint8Array(bin.length)
32
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
33
+ return out
34
+ }
35
+ }
36
+
37
+ /** Genera un par ECDH P-256 efímero. Devuelve la pub en raw (base64) + privada. */
38
+ export async function makeEphemeral () {
39
+ const kp = await subtle().generateKey({ name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits'])
40
+ const raw = await subtle().exportKey('raw', kp.publicKey)
41
+ return { privateKey: kp.privateKey, pub: b64.enc(raw) }
42
+ }
43
+
44
+ /**
45
+ * Deriva la clave AES-256-GCM de la sesión a partir de la privada propia y la
46
+ * pub efímera (base64 raw) del otro extremo. `salt` liga la clave a la sesión.
47
+ */
48
+ export async function deriveKey (privateKey, otherPubB64, salt) {
49
+ const otherPub = await subtle().importKey(
50
+ 'raw', b64.dec(otherPubB64), { name: 'ECDH', namedCurve: 'P-256' }, false, []
51
+ )
52
+ const bits = await subtle().deriveBits({ name: 'ECDH', public: otherPub }, privateKey, 256)
53
+ const hkdfKey = await subtle().importKey('raw', bits, 'HKDF', false, ['deriveKey'])
54
+ return subtle().deriveKey(
55
+ { name: 'HKDF', hash: 'SHA-256', salt: new TextEncoder().encode(salt), info: new TextEncoder().encode('dotrino-remote-agent-e2e') },
56
+ hkdfKey,
57
+ { name: 'AES-GCM', length: 256 },
58
+ false,
59
+ ['encrypt', 'decrypt']
60
+ )
61
+ }
62
+
63
+ /** Cifra un objeto JSON → sobre { iv, ct } (ambos base64). */
64
+ export async function seal (key, obj) {
65
+ const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
66
+ const pt = new TextEncoder().encode(JSON.stringify(obj))
67
+ const ct = await subtle().encrypt({ name: 'AES-GCM', iv }, key, pt)
68
+ return { iv: b64.enc(iv), ct: b64.enc(ct) }
69
+ }
70
+
71
+ /** Descifra un sobre { iv, ct } → objeto JSON. Lanza si el tag GCM no valida. */
72
+ export async function open (key, env) {
73
+ const pt = await subtle().decrypt({ name: 'AES-GCM', iv: b64.dec(env.iv) }, key, b64.dec(env.ct))
74
+ return JSON.parse(new TextDecoder().decode(pt))
75
+ }
76
+
77
+ export { b64 }
package/vault/core.js CHANGED
@@ -23,7 +23,7 @@ import * as Acta from './acta.js'
23
23
  import * as Content from './content.js'
24
24
  import { syncPeers, stampOf } from './peerSync.js'
25
25
  import { assertionBody, cleanScopes, claimsAllowed, ASSERTION_DEFAULT_TTL_MS, ASSERTION_MAX_TTL_MS } from './assertion.js'
26
- import { pubkeyId as pubkeyIdOf, signWithDevice } from './capabilities.js'
26
+ import { pubkeyId as pubkeyIdOf, signWithDevice, keyLabel } from './capabilities.js'
27
27
  import { enrollDevice as remoteEnroll, requestSign as remoteSign, requestStore as remoteStore, requestDevices as remoteDevices, requestRenew as remoteRenew, requestAdmin as remoteAdmin, requestApproval as remoteApproval, requestRenounce as remoteRenounce, checkMembership as remoteCheck, registerLocalVault } from './remote.js'
28
28
 
29
29
  export const KEY_STORAGE = 'dotrino.identity.keypair'
@@ -1128,7 +1128,7 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
1128
1128
  } catch (_) { return null }
1129
1129
  }
1130
1130
 
1131
- const deviceIdDe = async (pub) => (await pubkeyIdOf(pub)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
1131
+ const deviceIdDe = keyLabel
1132
1132
 
1133
1133
  /**
1134
1134
  * @param {object} entrada lo que devuelve `loginWithPassword` del pilar
package/vault/index.html CHANGED
@@ -168,6 +168,7 @@
168
168
  "@dotrino/identity/machine": "./machine.js",
169
169
  "@dotrino/vault/password-logins": "./vendor/vault/passwordLogins.js",
170
170
  "@dotrino/vault/login-client": "./vendor/vault/loginClient.js",
171
+ "@dotrino/vault/backup": "./vendor/vault/core/backup.js",
171
172
  "@dotrino/opaque": "./vendor/opaque/src/index.js",
172
173
  "@dotrino/identity/capabilities": "./capabilities.js",
173
174
  "@dotrino/identity/acta": "./acta.js",
package/vault/keyid.js CHANGED
@@ -32,10 +32,9 @@ export async function pubkeyId (publicJwkStr) {
32
32
  */
33
33
  export async function keyLabel (publicJwkStr) {
34
34
  if (!publicJwkStr) return ''
35
- try {
36
- const id = (await pubkeyId(publicJwkStr)).slice(0, 8).toUpperCase()
37
- return id.slice(0, 4) + '-' + id.slice(4, 8)
38
- } catch (_) { return '' }
35
+ // Una llave que no se puede leer LANZA: un '' callado se pintaba como «sin aparato».
36
+ const id = (await pubkeyId(publicJwkStr)).slice(0, 8).toUpperCase()
37
+ return id.slice(0, 4) + '-' + id.slice(4, 8)
39
38
  }
40
39
 
41
40
  export default { pubkeyId, keyLabel }
package/vault/remote.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * No reimplementa cripto: usa `@dotrino/identity/capabilities`. Transporte:
14
14
  * `@dotrino/proxy-client` (importado perezosamente; solo se carga al emparejar).
15
15
  */
16
- import { makeDeviceKey, signWithDevice, verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from './capabilities.js'
16
+ import { makeDeviceKey, signWithDevice, verifyDeviceSig, makePairingCode, commitCode, keyLabel } from './capabilities.js'
17
17
  import { checkVaultReply } from './acta.js'
18
18
  import { cleanMachine, browserMachine } from './machine.js'
19
19
 
@@ -146,7 +146,7 @@ export async function enrollDevice ({ qr, device, onChallenge, label = '', conti
146
146
  // identidad (P) como `device` → el cert delega tu identidad y hay UNA sola (signData/
147
147
  // identify/cert son la misma P).
148
148
  const dev = device || await makeDeviceKey({ label })
149
- const deviceId = (await pubkeyId(dev.publickey)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
149
+ const deviceId = await keyLabel(dev.publickey)
150
150
  // El DISPOSITIVO genera el código y manda solo su COMPROMISO (no el código). El vault
151
151
  // aprende el código únicamente cuando vos lo tipeás en el PC → aprobar exige tener el dispositivo.
152
152
  const code = makePairingCode()
package/vault/vault.js CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  initPeerStorage, loadPeers, savePeers, setPeersDirect, upsertPeer, onDirty
14
14
  } from './peerStore.js'
15
15
  import { createIdentityCore } from './core.js'
16
- import { pubkeyId } from './capabilities.js'
16
+ import { keyLabel } from './capabilities.js'
17
17
  import { withExternalKeys, appBridge } from './externalKeys.js'
18
18
  import { nativeBackends } from './nativeStore.js'
19
19
  import { useBackend as usePeerBackend } from './peerStore.js'
@@ -320,7 +320,7 @@ import { useBackend as usePeerBackend } from './peerStore.js'
320
320
  if (!bySub.has(x.sub) || (x.exp || 0) > (bySub.get(x.sub).exp || 0)) bySub.set(x.sub, x)
321
321
  }
322
322
  const devices = await Promise.all([...bySub.values()].map(async (x) => ({
323
- deviceId: (await pubkeyId(x.sub)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2'),
323
+ deviceId: await keyLabel(x.sub),
324
324
  sub: x.sub, label: x.label || '', scope: x.scope, exp: x.exp, nonce: x.nonce
325
325
  })))
326
326
  return { devices, revoked: (revoked || []).map((r) => r.nonce || r) }
@@ -469,6 +469,45 @@ import { useBackend as usePeerBackend } from './peerStore.js'
469
469
  }
470
470
  }
471
471
  return { ...r, deviceId: fila?.deviceId || null }
472
+ },
473
+
474
+ // ----- EL RESPALDO (`dotrino-vault/docs/respaldo.md`), cuando la bóveda es ESTA pestaña -----
475
+ //
476
+ // Exportar e importar, con la bóveda abierta (lo comprueba el núcleo: `VAULT_LOCKED`).
477
+ // Y SOLO DESDE LA CONSOLA: este iframe atiende a cualquier `*.dotrino.com`, y quien exporta
478
+ // elige la contraseña del respaldo — o sea, puede leerlo todo. Un fallo en cualquiera de
479
+ // las apps no puede convertirse en sacar los secretos de la cuenta.
480
+ // El archivo se cierra AQUÍ: a la consola le llega ya cifrado.
481
+ selfVaultBackupDrawers: async ({ __origin } = {}) => {
482
+ soloConsola(__origin)
483
+ return pidaDaemon().backupDrawers()
484
+ },
485
+ selfVaultBackupExport: async ({ drawers = null, password, __origin } = {}) => {
486
+ soloConsola(__origin)
487
+ const { sealBackup } = await import('@dotrino/vault/backup')
488
+ const { payload, summary } = await pidaDaemon().exportBackup({ drawers })
489
+ return { file: await sealBackup(payload, password), summary }
490
+ },
491
+ selfVaultBackupPlan: async ({ file, password, drawers = null, map = {}, __origin } = {}) => {
492
+ soloConsola(__origin)
493
+ const { openBackup } = await import('@dotrino/vault/backup')
494
+ return pidaDaemon().planBackupImport(await openBackup(file, password), { drawers, map })
495
+ },
496
+ selfVaultBackupImport: async ({ file, password, drawers = null, map = {}, onConflict = null, decisions = {}, __origin } = {}) => {
497
+ soloConsola(__origin)
498
+ const { openBackup } = await import('@dotrino/vault/backup')
499
+ return pidaDaemon().importBackup(await openBackup(file, password), { drawers, map, onConflict, decisions })
500
+ }
501
+ }
502
+
503
+ /**
504
+ * ¿ES LA CONSOLA? `vault.dotrino.com`, o un origen de desarrollo (localhost / LAN privada),
505
+ * que es donde se prueba. Lo demás del ecosistema no puede pedir un respaldo.
506
+ */
507
+ const CONSOLE_ORIGIN = /^(https:\/\/vault\.dotrino\.com|https?:\/\/(localhost|127\.0\.0\.1|192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})(:\d+)?)$/
508
+ function soloConsola (origin) {
509
+ if (!CONSOLE_ORIGIN.test(String(origin || ''))) {
510
+ throw Object.assign(new Error('only the vault console (vault.dotrino.com) can back up or restore'), { code: 'not-the-console' })
472
511
  }
473
512
  }
474
513
 
@@ -1,3 +1,3 @@
1
- Copia vendorizada de @dotrino/passmanager@0.18.1 (dotrino-passmanager/lib/src/{crypto,fields,generate,import,index,match,model,pubkey,sealed/convert,sealed/device,sealed/entry,sealed/index,sealed/keys,sealed/recovery,sealed/store,session-cache,totp,transport/protocol,transport/proxy,transport/sealed,vault/approval,vault/errors,vault/guard,vault/interface,vault/local,vault/remote,vault/responder,vault/sealed-local,vault/sealed-responder,vault/sealed,webauthn}.js).
1
+ Copia vendorizada de @dotrino/passmanager@0.18.4 (dotrino-passmanager/lib/src/{crypto,fields,generate,import,index,match,model,pubkey,sealed/convert,sealed/device,sealed/entry,sealed/index,sealed/keys,sealed/recovery,sealed/store,session-cache,totp,transport/protocol,transport/proxy,transport/sealed,vault/approval,vault/errors,vault/guard,vault/interface,vault/local,vault/remote,vault/responder,vault/sealed-local,vault/sealed-responder,vault/sealed,webauthn}.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
  El sobre de las contraseñas, el mismo que usan el binario y la extensión. Importa @dotrino/identity/content.
@@ -324,6 +324,10 @@ export function mergePatch (base, changes = {}) {
324
324
  ...(changes.totp !== undefined ? { totp: changes.totp } : {}),
325
325
  ...(changes.notes !== undefined ? { notes: changes.notes } : {}),
326
326
  ...vaciados,
327
+ // El contador de una passkey sube en cada firma: cambiarlo no toca la llave.
328
+ ...(Number.isInteger(changes.signCount) && base.webauthn
329
+ ? { webauthn: { ...base.webauthn, signCount: changes.signCount } }
330
+ : {}),
327
331
  sites,
328
332
  fields: quedan,
329
333
  id: base.id,
@@ -11,6 +11,12 @@
11
11
  // · una NEGATIVA no queda recordada como aprobación: la siguiente vuelve a preguntar
12
12
  // · dos peticiones a la vez producen UN aviso, no dos
13
13
  // · lo aprobado vive en memoria y muere con la bóveda: no se guarda en ninguna parte
14
+ // · y además CADUCA: una hora deslizante, como dotrino-env (dueño, 2026-10-16). Cada uso
15
+ // la renueva; una hora sin usarla y se vuelve a preguntar. Antes duraba hasta reiniciar
16
+ // la bóveda, o sea días, y un sí de la mañana servía para todo lo de la semana.
17
+
18
+ /** Cuánto vale un sí sin usarse: una hora, deslizante. */
19
+ export const GRANT_TTL_MS = 60 * 60 * 1000
14
20
 
15
21
  export class ApprovalGate {
16
22
  /**
@@ -22,27 +28,49 @@ export class ApprovalGate {
22
28
  * aprobación es del aparato y no de cada credencial.
23
29
  * `remember` si un sí vale para los siguientes. En `false` la puerta sigue
24
30
  * juntando las simultáneas, pero cada petición nueva vuelve a preguntar.
31
+ * `ttlMs` cuánto vale un sí sin usarse (deslizante: cada uso lo renueva). Una hora.
32
+ * `now` el reloj, para las pruebas.
25
33
  */
26
- constructor ({ ask, scope, remember = true } = {}) {
34
+ constructor ({ ask, scope, remember = true, ttlMs = GRANT_TTL_MS, now = Date.now } = {}) {
27
35
  this.ask = ask || (async () => false)
28
36
  this.scope = scope || (() => null)
29
37
  this.remember = remember !== false
30
- this._granted = new Set()
38
+ this.ttlMs = ttlMs
39
+ this.now = now
40
+ this._granted = new Map() // llave → cuándo se usó por última vez
31
41
  this._asking = new Map()
32
42
  }
33
43
 
34
- /** ¿Pasa? Pregunta si hace falta, y recuerda el sí si le toca recordarlo. */
44
+ /** ¿Sigue vivo el sí de esa llave? Si sí, se renueva (deslizante); si caducó, se olvida. */
45
+ #fresh (key) {
46
+ const last = this._granted.get(key)
47
+ if (last == null) return false
48
+ if (this.now() - last > this.ttlMs) { this._granted.delete(key); return false }
49
+ this._granted.set(key, this.now())
50
+ return true
51
+ }
52
+
53
+ /**
54
+ * ¿Pasa? Pregunta si hace falta, y recuerda el sí si le toca recordarlo.
55
+ *
56
+ * `req.remember === false`: ESTA petición pregunta siempre, y su sí no vale para las
57
+ * siguientes. Es la de lo que no se deshace (borrar o pisar una passkey): que el sí a
58
+ * leer, o a borrar otra cosa, la dejara pasar es borrar sin que nadie lo vea
59
+ * (pasó el 2026-10-16: un borrado aprobado dejó pasar el siguiente sin aviso).
60
+ */
35
61
  async allow (req = {}) {
36
62
  const key = this.scope(req)
37
- if (this.remember && key != null && this._granted.has(key)) return true
63
+ const remember = this.remember && req.remember !== false
64
+ if (remember && key != null && this.#fresh(key)) return true
38
65
 
39
66
  // Dos pestañas abriendo el mismo sitio no deben hacer sonar el teléfono dos veces.
40
- const junta = key == null ? `${req.op || ''}:${req.payload?.id || ''}` : String(key)
67
+ // Lo que pregunta siempre se junta solo con la MISMA petición, no con todo el aparato.
68
+ const junta = key == null || !remember ? `${key ?? ''}:${req.op || ''}:${req.payload?.id || ''}` : String(key)
41
69
  if (this._asking.has(junta)) return this._asking.get(junta)
42
70
 
43
71
  const p = Promise.resolve(this.ask(req))
44
72
  .then(ok => {
45
- if (ok && this.remember && key != null) this._granted.add(key)
73
+ if (ok && remember && key != null) this._granted.set(key, this.now())
46
74
  return !!ok
47
75
  })
48
76
  .finally(() => this._asking.delete(junta))
@@ -52,7 +80,8 @@ export class ApprovalGate {
52
80
  }
53
81
 
54
82
  granted (key) {
55
- return this._granted.has(key)
83
+ const last = this._granted.get(key)
84
+ return last != null && this.now() - last <= this.ttlMs
56
85
  }
57
86
 
58
87
  /** Retirar lo aprobado sin apagar la bóveda. Sin argumento, todo. */
@@ -107,7 +107,11 @@ export class SealedResponder {
107
107
  // `allow`, no `ask`: es el que RECUERDA el sí y junta las simultáneas. `ask` es la
108
108
  // función cruda de preguntar, y usarla directamente hacía sonar el teléfono en cada
109
109
  // petición — que es como se enseña a aprobar sin mirar.
110
- const ok = await this.gate.allow({ op, payload, pubkey })
110
+ //
111
+ // Salvo borrar o pisar una passkey: eso no se deshace, así que pregunta CADA vez y
112
+ // ningún sí anterior del aparato lo cubre.
113
+ const remember = !(await this.replacesPasskey(op, payload))
114
+ const ok = await this.gate.allow({ op, payload, pubkey, remember })
111
115
  if (!ok) { anota('rejected'); return this.#send(from, pubkey, replyError(rid, CODES.NOT_APPROVED, 'not approved on the trusted device')) }
112
116
  }
113
117
  const result = await this.#run(op, payload, pubkey)
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/proxy-client@0.36.1 (dotrino-proxy-client/src/{binpack,canonical,client,encpub,index,parts,sealing,signature,stats-log,stats,webrtc}.js).
1
+ Copia vendorizada de @dotrino/proxy-client@0.36.2 (dotrino-proxy-client/src/{binpack,canonical,client,encpub,errors,index,parts,sealing,signature,stats-log,stats,webrtc}.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
  sealing.js resuelve @dotrino/identity/content de forma PEREZOSA (= ../../content.js
4
4
  por el import map): solo se carga si de verdad se sella algo.
@@ -5,17 +5,8 @@ import { WebRTCManager, RTC_TAG, DEFAULT_ICE_SERVERS, loadNodePeerConnection, re
5
5
  import { TrafficStats, utf8Length, registerTransport, unregisterTransport } from './stats.js'
6
6
  import { splitParts, splitBinParts, isPart, Reassembler, DIRECT_PART_CHARS, PROXY_PART_CHARS, QUEUE_MAX_CHARS, RELAY_MAX_CHARS, DIRECT_PART_BYTES, PROXY_PART_BYTES, QUEUE_MAX_BYTES, RELAY_MAX_BYTES } from './parts.js'
7
7
  import { pack, unpack, hasBinary, encodeFrame, decodeFrame } from './binpack.js'
8
+ import { errorWithCode } from './errors.js'
8
9
 
9
- /**
10
- * Error con un `code` estable.
11
- *
12
- * El contrato es el CODE, no la frase: comprobar por el texto cruza procesos y
13
- * se rompe en silencio en cuanto alguien lo reescribe o lo traduce.
14
- *
15
- * @param {string} mensaje
16
- * @param {string} code
17
- * @returns {Error & { code: string }}
18
- */
19
10
  /**
20
11
  * EL SALUDO: «este token es de esta identidad».
21
12
  *
@@ -32,12 +23,6 @@ const HELLO_TAG = '__cc_hello__'
32
23
  */
33
24
  const SPEAKS = ['binary']
34
25
 
35
- function errorCon (mensaje, code) {
36
- const e = /** @type {Error & { code: string }} */ (new Error(mensaje))
37
- e.code = code
38
- return e
39
- }
40
-
41
26
  /**
42
27
  * Dotrino WebSocket proxy client.
43
28
  * Minimal API: connection + token + messages + channels (publish/list/count/disconnect)
@@ -280,7 +265,7 @@ export class WebSocketProxyClient {
280
265
  // las peticiones pendientes). Sin esto, quien esperaba un `publish` se comía
281
266
  // los 10 s completos del timeout para enterarse de algo que ya se sabía, y
282
267
  // el temporizador mantenía vivo el proceso en Node ese rato.
283
- this._rejectAllPending(errorCon('Connection closed', 'CONNECTION_CLOSED'))
268
+ this._rejectAllPending(errorWithCode('Connection closed', 'CONNECTION_CLOSED'))
284
269
  }
285
270
 
286
271
  /** Corta en seco todo lo que esperaba respuesta, con su motivo. */
@@ -351,7 +336,7 @@ export class WebSocketProxyClient {
351
336
  // abierto es no guardar ninguno: la app manda por donde le sale y el proxio lo lee
352
337
  // igual. Para sellar por token está `sendSealedTo`.
353
338
  if (this.requireSealed && !this._isSealed(payload)) {
354
- throw errorCon(
339
+ throw errorWithCode(
355
340
  'requireSealed: refusing to send a directed message in the clear — use sendSealedTo()',
356
341
  'unsealed')
357
342
  }
@@ -527,7 +512,7 @@ export class WebSocketProxyClient {
527
512
  const m = this._measure(payload)
528
513
  const queueMax = m.bin ? QUEUE_MAX_BYTES : QUEUE_MAX_CHARS
529
514
  if (m.len > queueMax) {
530
- throw errorCon(`sendSealed: ${m.len} ${m.bin ? 'bytes' : 'chars'} is more than can wait in the proxy (${queueMax}); send it by token while both ends are connected`, 'too-big-for-queue')
515
+ throw errorWithCode(`sendSealed: ${m.len} ${m.bin ? 'bytes' : 'chars'} is more than can wait in the proxy (${queueMax}); send it by token while both ends are connected`, 'too-big-for-queue')
531
516
  }
532
517
  // Por pubkey todo va por el proxio: si no admite bytes, se dice antes de mandar nada. Y
533
518
  // si alguno de los destinatarios dijo que no los entiende, tampoco.
@@ -576,7 +561,7 @@ export class WebSocketProxyClient {
576
561
  // solo sobre solo lo abriría una—, así que ahí se exige decirlo.
577
562
  if (!peerPubkey && tokens.length === 1) peerPubkey = this.pubkeyOfToken(tokens[0])
578
563
  if (!peerPubkey) {
579
- throw errorCon(
564
+ throw errorWithCode(
580
565
  'sendSealedTo: nobody has said whose this token is — greet it (helloTo) or pass peerPubkey',
581
566
  'no-peer-identity')
582
567
  }
@@ -607,7 +592,7 @@ export class WebSocketProxyClient {
607
592
  // directo. Se pide el canal para que el siguiente intento lo encuentre abierto.
608
593
  if (!direct && m.len > relayMax) {
609
594
  this._upgradeDirect(tokens)
610
- throw errorCon(`sendSealedTo: ${m.len} ${m.bin ? 'bytes' : 'chars'} is more than goes through the proxy live (${relayMax}); it needs a direct channel`, 'too-big-for-relay')
595
+ throw errorWithCode(`sendSealedTo: ${m.len} ${m.bin ? 'bytes' : 'chars'} is more than goes through the proxy live (${relayMax}); it needs a direct channel`, 'too-big-for-relay')
611
596
  }
612
597
  // Si el token muere a mitad, lo que quede se iría a la cola por pubkey; solo se permite
613
598
  // si el mensaje entero cabe ahí. Si no, se dice (`too-big-for-queue`), no se deja.
@@ -650,7 +635,7 @@ export class WebSocketProxyClient {
650
635
  async _sendPiece (token, sobre, peerPubkey, queueFallback, relayFallback = true) {
651
636
  if (this._rtc && await this._rtc.sendDirect(token, this._wire(sobre))) return
652
637
  if (!relayFallback) {
653
- throw errorCon('the direct channel closed in the middle of a message too big for the proxy', 'direct-lost')
638
+ throw errorWithCode('the direct channel closed in the middle of a message too big for the proxy', 'direct-lost')
654
639
  }
655
640
  if (sobre instanceof Uint8Array) this._needBinaryProxy()
656
641
  this._sendToTokenOrQueue(token, sobre, peerPubkey, { queueFallback, skipDirect: true })
@@ -673,7 +658,7 @@ export class WebSocketProxyClient {
673
658
  */
674
659
  sendToOrQueue (token, payload, { peerPubkey } = /** @type {any} */ ({})) {
675
660
  if (!token || !peerPubkey) {
676
- throw errorCon('sendToOrQueue: needs the token and whose it is (peerPubkey)', 'no-peer-identity')
661
+ throw errorWithCode('sendToOrQueue: needs the token and whose it is (peerPubkey)', 'no-peer-identity')
677
662
  }
678
663
  this._sendToTokenOrQueue(token, payload, peerPubkey)
679
664
  }
@@ -691,7 +676,7 @@ export class WebSocketProxyClient {
691
676
  */
692
677
  _sendToTokenOrQueue (token, sobre, peerPubkey, { queueFallback = true, skipDirect = false } = {}) {
693
678
  if (this.requireSealed && !this._isSealed(sobre)) {
694
- throw errorCon('requireSealed: refusing to send a directed message in the clear — use sendSealedTo()', 'unsealed')
679
+ throw errorWithCode('requireSealed: refusing to send a directed message in the clear — use sendSealedTo()', 'unsealed')
695
680
  }
696
681
  const messageStr = this._wire(sobre)
697
682
  if (!skipDirect && this._rtc && this._rtc.trySend(token, messageStr)) return
@@ -747,7 +732,7 @@ export class WebSocketProxyClient {
747
732
  */
748
733
  helloTo (to) {
749
734
  if (!this.myPublickey) {
750
- throw errorCon('helloTo: identify first — a greeting with no identity says nothing', 'not-identified')
735
+ throw errorWithCode('helloTo: identify first — a greeting with no identity says nothing', 'not-identified')
751
736
  }
752
737
  const tokens = Array.isArray(to) ? to : [to]
753
738
  for (const t of tokens) {
@@ -789,7 +774,7 @@ export class WebSocketProxyClient {
789
774
  _needPeerBinary (targets) {
790
775
  for (const t of targets) {
791
776
  if (t && this.speaks(t, 'binary') === false) {
792
- throw errorCon('the other side does not take binary messages (it needs @dotrino/proxy-client >= 0.36.0)', 'peer-no-binary')
777
+ throw errorWithCode('the other side does not take binary messages (it needs @dotrino/proxy-client >= 0.36.0)', 'peer-no-binary')
793
778
  }
794
779
  }
795
780
  }
@@ -815,7 +800,7 @@ export class WebSocketProxyClient {
815
800
 
816
801
  /** Sella con lo que haya: la bóveda de la app (`sealing`) o las primitivas del pilar. */
817
802
  async _seal (payload, peerEncPub, peerPubkey) {
818
- if (!peerEncPub) throw errorCon('seal: missing peerEncPub', 'unsealed')
803
+ if (!peerEncPub) throw errorWithCode('seal: missing peerEncPub', 'unsealed')
819
804
  return this.sealing
820
805
  ? this.sealing.seal(payload, peerEncPub, { publickey: peerPubkey })
821
806
  : seal(payload, peerEncPub)
@@ -856,7 +841,7 @@ export class WebSocketProxyClient {
856
841
  /** Un mensaje con bytes por el proxio necesita uno que admita tramas binarias (websocket-proxy ≥ 1.6.0). */
857
842
  _needBinaryProxy () {
858
843
  if (!Array.isArray(this.caps) || !this.caps.includes('binary')) {
859
- throw errorCon('this proxy does not take binary frames (it needs websocket-proxy >= 1.6.0)', 'no-binary-support')
844
+ throw errorWithCode('this proxy does not take binary frames (it needs websocket-proxy >= 1.6.0)', 'no-binary-support')
860
845
  }
861
846
  }
862
847
 
@@ -867,7 +852,7 @@ export class WebSocketProxyClient {
867
852
  if (!p) {
868
853
  p = (async () => {
869
854
  const o = this.sealing ? await this.sealing.open(k, meta) : await open(k, this.myEncPrivateKey)
870
- if (!o || typeof o.cek !== 'string') throw errorCon('the sealed key of a message has no key inside', 'bad-pack')
855
+ if (!o || typeof o.cek !== 'string') throw errorWithCode('the sealed key of a message has no key inside', 'bad-pack')
871
856
  return o.cek
872
857
  })()
873
858
  this._msgKeys.set(id, p)
@@ -909,7 +894,7 @@ export class WebSocketProxyClient {
909
894
  */
910
895
  async encPubOf (publickey) {
911
896
  if (typeof publickey !== 'string' || !publickey) {
912
- throw errorCon('encPubOf: missing publickey', 'encpub-shape')
897
+ throw errorWithCode('encPubOf: missing publickey', 'encpub-shape')
913
898
  }
914
899
  const cacheada = this._encPubs.get(publickey)
915
900
  if (cacheada) return cacheada
@@ -931,7 +916,7 @@ export class WebSocketProxyClient {
931
916
  const propia = await this.encPubResolver(publickey)
932
917
  if (propia) {
933
918
  if (!isEncPub(propia)) {
934
- throw errorCon('encPubResolver returned something that is not a P-256 public JWK', 'encpub-unverified')
919
+ throw errorWithCode('encPubResolver returned something that is not a P-256 public JWK', 'encpub-unverified')
935
920
  }
936
921
  this._encPubs.set(publickey, propia)
937
922
  return propia
@@ -941,7 +926,7 @@ export class WebSocketProxyClient {
941
926
  // esperar diez segundos a un timeout. Un proxio que no manda `caps` es de antes de
942
927
  // que esto existiera y no se sabe: se pregunta igual, y contesta la pregunta.
943
928
  if (Array.isArray(this.caps) && !this.caps.includes('encpub')) {
944
- throw errorCon(
929
+ throw errorWithCode(
945
930
  `este proxio (${this.url}) no sirve llaves de cifrado: hace falta websocket-proxy >= 1.1.0`,
946
931
  'no-encpub-support')
947
932
  }
@@ -950,7 +935,7 @@ export class WebSocketProxyClient {
950
935
  // la app puede tener el mismo JWK escrito de otra forma.
951
936
  const statement = (res.keys || []).find((k) => samePubkey(k?.data?.publickey, publickey))
952
937
  if (!statement) {
953
- throw errorCon('no encryption key announced for that identity — it cannot be sealed to yet', 'no-encpub')
938
+ throw errorWithCode('no encryption key announced for that identity — it cannot be sealed to yet', 'no-encpub')
954
939
  }
955
940
  // AQUÍ ES DONDE EL PROXIO DEJA DE IMPORTAR: la firma se comprueba contra la pubkey a
956
941
  // la que vamos a escribir. Si cambió la llave por la suya, esto lanza.
@@ -1015,7 +1000,7 @@ export class WebSocketProxyClient {
1015
1000
  }
1016
1001
  let opened
1017
1002
  try {
1018
- if (!this._isSealed(env.k)) throw errorCon('the key of a sealed message is not sealed', 'bad-pack')
1003
+ if (!this._isSealed(env.k)) throw errorWithCode('the key of a sealed message is not sealed', 'bad-pack')
1019
1004
  opened = unpack(await decryptBytes(await this._openMessageKey(env.k, meta), env))
1020
1005
  } catch (e) {
1021
1006
  this._emit('error', { type: 'undecipherable', from, error: e })
@@ -1567,7 +1552,7 @@ export class WebSocketProxyClient {
1567
1552
  if (ws !== this.ws || this._connected) return
1568
1553
  this.ws = null // a partir de acá, los eventos tardíos de `ws` se ignoran
1569
1554
  try { ws.close() } catch (_) {}
1570
- const err = errorCon(`could not open the connection to ${this.url} in ${Math.round(this.connectTimeout / 1000)}s`, 'CONNECT_TIMEOUT')
1555
+ const err = errorWithCode(`could not open the connection to ${this.url} in ${Math.round(this.connectTimeout / 1000)}s`, 'CONNECT_TIMEOUT')
1571
1556
  this._emit('error', { type: 'connect_timeout', error: err })
1572
1557
  if (this._connectReject) {
1573
1558
  this._connectReject(err)
@@ -1781,7 +1766,7 @@ export class WebSocketProxyClient {
1781
1766
 
1782
1767
  _sendRaw (frame) {
1783
1768
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
1784
- throw errorCon('WebSocket not connected', 'NOT_CONNECTED')
1769
+ throw errorWithCode('WebSocket not connected', 'NOT_CONNECTED')
1785
1770
  }
1786
1771
  if (frame.message instanceof Uint8Array) {
1787
1772
  this._needBinaryProxy()
@@ -1860,7 +1845,7 @@ export class WebSocketProxyClient {
1860
1845
  const out = { ...frame, id }
1861
1846
  const timer = setTimeout(() => {
1862
1847
  this._pending.delete(id)
1863
- reject(errorCon(`Timeout waiting for ${expectedType}`, 'REQUEST_TIMEOUT'))
1848
+ reject(errorWithCode(`Timeout waiting for ${expectedType}`, 'REQUEST_TIMEOUT'))
1864
1849
  }, 10000)
1865
1850
  this._pending.set(id, { resolve, reject, timer, expectedType, channelKey })
1866
1851
  try {
@@ -1891,7 +1876,7 @@ export class WebSocketProxyClient {
1891
1876
  this._pending.delete(id)
1892
1877
  // Con su `code`: quien llama distingue los casos por él, no por la frase (que el proxio
1893
1878
  // puede cambiar o traducir). Antes se perdía aquí y solo quedaba comparar el texto.
1894
- const e = errorCon(data.error || 'Server error', data.code || null)
1879
+ const e = errorWithCode(data.error || 'Server error', data.code || null)
1895
1880
  // «Ahora no» (429) y cuánto esperar, cuando el proxio lo dice.
1896
1881
  if (data.status) e.status = data.status
1897
1882
  if (data.retry_after_ms) e.retryAfterMs = data.retry_after_ms