@dotrino/identity 0.90.0 → 0.92.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/identity",
3
- "version": "0.90.0",
3
+ "version": "0.92.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",
package/src/index.d.ts CHANGED
@@ -165,6 +165,11 @@ export class Identity {
165
165
  vaultStatus (): Promise<any>
166
166
  vaultUnpair (): Promise<any>
167
167
  vaultSign (payload: any): Promise<{ signature: string; publickey: string }>
168
+ /**
169
+ * El almacén de hilos EN la bóveda, cifrado de punta a punta con la clave de contenido del
170
+ * perfil. Sin esa clave lanza `code: 'no-content-key'` en vez de mandarlo en claro (≥ 0.91.0);
171
+ * sin emparejar, `not-paired`; si la bóveda no contesta, `vault-no-reply`.
172
+ */
168
173
  vaultStore (method: string, args?: any): Promise<any>
169
174
  listVaultDevices (): Promise<{ devices: any[]; revoked: any[] }>
170
175
  /**
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, pubkeyId } from './capabilities.js'
38
+ import { signWithDevice, verifyDeviceSig, verifyDelegation, pubkeyId } from './capabilities.js'
39
39
 
40
40
  export const ACTA_V = 5
41
41
 
@@ -649,6 +649,52 @@ export async function verifyActa ({ acta, expectedProfileId } = {}) {
649
649
  return mal ? { ok: false, reason: mal } : { ok: true }
650
650
  }
651
651
 
652
+ /**
653
+ * ¿ES DE FIAR LO QUE CONTESTA LA BÓVEDA CON LA QUE TE EMPAREJASTE? El papel y el acta que
654
+ * llegan al enrolarse o al renovar, juzgados contra la llave de ESA bóveda (`vault`: la
655
+ * `iss` del QR, o la que quedó guardada al enrolarse).
656
+ *
657
+ * Antes cada cliente comparaba `acta.profileId` con esa llave. Eso solo es verdad en una
658
+ * cuenta de una bóveda que además nació en ella: el `profileId` es la llave del GÉNESIS, y
659
+ * en una cuenta que la bóveda ADOPTÓ, o en la SEGUNDA bóveda de un multivault, la llave de
660
+ * la bóveda es otra. Ahí ningún aparato podía entrar ni renovar. Y encima no protegía: el
661
+ * acta no se verificaba, así que bastaba con escribir ese `profileId` en una inventada.
662
+ *
663
+ * Lo que se comprueba ahora, y por qué alcanza:
664
+ * 1. el acta está bien firmada por quien dice haberla sellado;
665
+ * 2. esa bóveda PUEDE SELLAR esta acta — es de la cuenta, con el permiso, y una llave
666
+ * está en una sola cuenta, así que esto también fija la cuenta;
667
+ * 3. el papel lo firmó ESA bóveda y no otra selladora: la respuesta es suya, porque nadie
668
+ * por el camino puede firmar con su llave;
669
+ * 4. y el papel vale según esa acta (para esta llave, este permiso, un `seq` que no viene
670
+ * del futuro).
671
+ *
672
+ * `justSealed` es para el ENROLAMIENTO: aprobar es admitir al aparato, así que el acta que
673
+ * vuelve la acaba de sellar esa misma bóveda. Exigirlo ata el acta a su llave y no solo el
674
+ * papel — el llavero que el aparato va a usar viaja ahí dentro. Al RENOVAR no se pide: la
675
+ * última acta pudo sellarla otra selladora de la cuenta.
676
+ *
677
+ * @returns {Promise<{ok:true}|{ok:false, reason:string}>}
678
+ */
679
+ export async function checkVaultReply ({ acta, cert, vault, sub, scope = null, justSealed = false } = {}) {
680
+ if (!acta) return { ok: false, reason: 'sin-acta' }
681
+ if (!isPub(vault)) return { ok: false, reason: 'sin-boveda' }
682
+ // Para QUÉ llave es el papel no es opcional: sin ella `verifyDelegation` no lo mira, y un
683
+ // papel emitido para otra llave pasaría por bueno.
684
+ if (!isPub(sub)) return { ok: false, reason: 'sin-llave-del-aparato' }
685
+ const v = await verifyActa({ acta })
686
+ if (!v.ok) return { ok: false, reason: 'acta:' + v.reason }
687
+ if (!canSeal(acta, vault)) return { ok: false, reason: 'boveda-no-sella' }
688
+ if (justSealed && !samePubkey(acta.sealedBy, vault)) return { ok: false, reason: 'acta-de-otra-selladora' }
689
+ if (!cert || !samePubkey(cert.iss, vault)) return { ok: false, reason: 'papel-de-otra-llave' }
690
+ const d = await verifyDelegation({
691
+ cert, expectedSub: sub, actaSeq: acta.seq, sealers: sealersOf(acta),
692
+ ...(scope ? { expectedScope: scope } : {})
693
+ })
694
+ if (!d.ok) return { ok: false, reason: 'papel:' + d.reason }
695
+ return { ok: true }
696
+ }
697
+
652
698
  /**
653
699
  * Aplica cambios y devuelve el acta SIGUIENTE, **sin firmar** (hay que `sealActa`).
654
700
  * `by` es quien va a sellar: si no es el sellador vigente, se rechaza (regla 1).
@@ -1218,7 +1264,7 @@ export async function canAdopt ({ candidate, current }) {
1218
1264
 
1219
1265
  export default {
1220
1266
  ACTA_V, CAPS, CAP_SCOPE, genesisActa, actaBody, actaHash, memberId, checkShape, verifySealerChain, verifySignedBy,
1221
- sealActa, verifyActa, applyChanges, makeRenounce, verifyRenounce,
1267
+ sealActa, verifyActa, checkVaultReply, applyChanges, makeRenounce, verifyRenounce,
1222
1268
  makeContinuity, verifyContinuity,
1223
1269
  cardBody, makeProfileCard, verifyProfileCard, canAdoptCard, sealersOf, canSeal,
1224
1270
  effectiveCaps, memberCan, memberCanSign, memberCanScope, memberCanReadSecrets, memberScopes, isService, capScope, isValidCn, canAdopt,
package/vault/core.js CHANGED
@@ -2575,27 +2575,29 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
2575
2575
  /**
2576
2576
  * Store DELEGADO, CIFRADO de punta a punta. Los argumentos y el resultado viajan
2577
2577
  * cifrados con la clave de contenido del perfil: el proxy transporta pero no ve nada
2578
- * de lo que guardas. Si todavía no tengo la clave (nadie me la ha envuelto), va en
2579
- * claro como antes — y se dice en el resultado en vez de fallar en silencio.
2578
+ * de lo que guardas.
2579
+ *
2580
+ * Sin la clave NO se manda nada. Antes iba en claro «como antes», y eso era dejar que
2581
+ * el proxio leyera el almacén entero justo en el aparato al que todavía no le habían
2582
+ * envuelto la clave. Ahora falla con `no-content-key`, que se arregla entrando al
2583
+ * perfil desde un aparato que ya la tenga — y se ve, en vez de viajar a la vista.
2584
+ * Por lo mismo, una respuesta sin cifrar tampoco se acepta.
2580
2585
  */
2581
2586
  async vaultStore ({ method, args }) {
2582
2587
  const v = loadVaultCert(); const device = loadVaultDevice()
2583
- if (!v?.cert || !device) throw new Error('this device is not paired with a vault')
2588
+ if (!v?.cert || !device) throw Object.assign(new Error('this device is not paired with a vault'), { code: 'not-paired' })
2584
2589
  maybeRenewVaultCert()
2585
- const mine = await myCek().catch(() => null)
2586
- let payload = { method, args }
2587
- if (mine) {
2588
- payload = { method, enc: await Content.encryptWithCek({ cek: mine.cek, gen: mine.gen, plaintext: JSON.stringify(args ?? {}) }) }
2589
- }
2590
+ const mine = await myCek()
2591
+ if (!mine) throw Object.assign(new Error('this device does not hold the profile content key yet'), { code: 'no-content-key' })
2592
+ const enc = await Content.encryptWithCek({ cek: mine.cek, gen: mine.gen, plaintext: JSON.stringify(args ?? {}) })
2590
2593
  try {
2591
- const res = await remoteStore({ master: v.master, proxy: v.proxy, device, cert: v.cert, method: payload.method, args: payload.args, enc: payload.enc, onRevoked: wipeVaultLink })
2592
- // La respuesta vuelve cifrada con la misma clave si la bóveda pudo.
2593
- if (res && typeof res === 'object' && res.__enc && mine) {
2594
- return JSON.parse(await Content.decryptWithKeyring({
2595
- envelope: res.__enc, keyring: loadActa()?.keyring, myPub: publickeyJwkStr, myEncPrivateKey: encKeypair.privateKey
2596
- }))
2594
+ const res = await remoteStore({ master: v.master, proxy: v.proxy, device, cert: v.cert, method, enc, onRevoked: wipeVaultLink })
2595
+ if (!res || typeof res !== 'object' || !res.__enc) {
2596
+ throw Object.assign(new Error('the vault replied to the store without encrypting it'), { code: 'vault-reply-unsealed' })
2597
2597
  }
2598
- return res
2598
+ return JSON.parse(await Content.decryptWithKeyring({
2599
+ envelope: res.__enc, keyring: loadActa()?.keyring, myPub: publickeyJwkStr, myEncPrivateKey: encKeypair.privateKey
2600
+ }))
2599
2601
  } catch (e) { return handleVaultError(e) }
2600
2602
  },
2601
2603
 
package/vault/remote.js CHANGED
@@ -13,8 +13,8 @@
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, verifyDelegation, verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from './capabilities.js'
17
- import { sealersOf } from './acta.js'
16
+ import { makeDeviceKey, signWithDevice, verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from './capabilities.js'
17
+ import { checkVaultReply } from './acta.js'
18
18
 
19
19
  const MSG = {
20
20
  HELLO: 'vault.hello',
@@ -218,14 +218,12 @@ export async function enrollDevice ({ qr, device, onChallenge, label = '', conti
218
218
  // (Aquí vivía el margen de reloj: el cert lo sellaba la bóveda con SU reloj y lo validaba
219
219
  // este aparato con el suyo, y 850 ms de diferencia bastaban para no poder enrolarse. Sin
220
220
  // vencimiento no hay ventana que ajustar y el problema no puede volver.)
221
- if (!res.acta) throw new Error('the vault did not send its record: cannot check who signed this cert')
222
- if (res.acta.profileId !== qr.iss) throw new Error('the record is from a profile other than the one you saw')
223
- const v = await verifyDelegation({
224
- cert: res.cert, expectedSub: dev.publickey,
225
- actaSeq: res.acta.seq, sealers: sealersOf(res.acta)
226
- })
227
- if (!v.ok) throw new Error('invalid cert: ' + v.reason)
228
- if (res.cert.sub !== dev.publickey) throw new Error('cert issued for a different device')
221
+ //
222
+ // Se juzga contra la llave de LA BÓVEDA con la que hablas, no contra el `profileId`:
223
+ // eso solo coincidía en una cuenta que nació en esa bóveda, y dejaba fuera a quien se
224
+ // emparejaba con una segunda bóveda o con una que adoptó la cuenta (`checkVaultReply`).
225
+ const chk = await checkVaultReply({ acta: res.acta, cert: res.cert, vault: qr.iss, sub: dev.publickey, justSealed: true })
226
+ if (!chk.ok) throw new Error('the vault reply does not check out: ' + chk.reason)
229
227
  return { device: dev, cert: res.cert, master: qr.iss, proxy: qr.proxy, deviceId, acta: res.acta || null }
230
228
  } finally { try { client.close() } catch (_) {} }
231
229
  }
@@ -306,7 +304,7 @@ async function vaultRpc ({ master, proxy, device, cert, acta = null, sendType, o
306
304
  cleanup(); reject(vaultError(p))
307
305
  }
308
306
  })
309
- const t = setTimeout(() => { cleanup(); reject(new Error('the vault did not reply (is it running?)')) }, timeoutMs)
307
+ const t = setTimeout(() => { cleanup(); reject(Object.assign(new Error('the vault did not reply (is it running?)'), { code: 'vault-no-reply' })) }, timeoutMs)
310
308
  const cleanup = () => { off(); clearTimeout(t); clearTimeout(graceTimer) }
311
309
  })
312
310
  client.sendByPubkey(master, { type: sendType, data: signed, signature, cert })
@@ -381,12 +379,17 @@ export async function requestDevices ({ master, proxy, device, cert, sinceSeq, o
381
379
  */
382
380
  export async function requestRenew ({ master, proxy, device, cert, onRevoked } = {}) {
383
381
  const res = await vaultRpc({ master, proxy, device, cert, onRevoked, sendType: 'vault.renew', okType: 'vault.renewed', data: { op: 'renew' } })
384
- if (!res.cert || res.cert.sub !== device.publickey) throw new Error('invalid renewed cert')
385
382
  // EL ACTA VIAJA CON EL PAPEL y hay que dejarla pasar: quien lo recibe la necesita para
386
383
  // comprobar que lo firmó una SELLADORA de este perfil. Aquí se comparaba `cert.iss` con
387
384
  // la maestra y se tiraba el acta, así que arriba no había con qué juzgar y la renovación
388
385
  // fallaba con «the vault did not send its record» — con el papel correcto en la mano.
389
- return { cert: res.cert, acta: res.acta || null }
386
+ //
387
+ // Y se juzga AQUÍ, en el pilar: antes lo hacía cada cliente por su cuenta —uno comparando
388
+ // el `profileId`, que con dos bóvedas no vale, y el navegador guardando el papel sin mirar
389
+ // nada—. `master` es la bóveda a la que se le pidió.
390
+ const chk = await checkVaultReply({ acta: res.acta, cert: res.cert, vault: master, sub: device.publickey })
391
+ if (!chk.ok) throw new Error('invalid renewed cert: ' + chk.reason)
392
+ return { cert: res.cert, acta: res.acta }
390
393
  }
391
394
 
392
395
  /**
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/proxy-client@0.20.0 (dotrino-proxy-client/src/{index,client,signature,canonical,sealing,encpub,webrtc}.js).
1
+ Copia vendorizada de @dotrino/proxy-client@0.22.0 (dotrino-proxy-client/src/{index,client,signature,canonical,sealing,encpub,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.
@@ -13,6 +13,15 @@ import { WebRTCManager, RTC_TAG, DEFAULT_ICE_SERVERS, loadNodePeerConnection, re
13
13
  * @param {string} code
14
14
  * @returns {Error & { code: string }}
15
15
  */
16
+ /**
17
+ * EL SALUDO: «este token es de esta identidad».
18
+ *
19
+ * Es una trama de CONTROL del transporte, como la señalización de WebRTC, y por eso va
20
+ * en claro y no la para `requireSealed`: lo único que lleva es una llave PÚBLICA que el
21
+ * proxio ya tiene atada a esta conexión desde `identify`. Nada del usuario viaja aquí.
22
+ */
23
+ const HELLO_TAG = '__cc_hello__'
24
+
16
25
  function errorCon (mensaje, code) {
17
26
  const e = /** @type {Error & { code: string }} */ (new Error(mensaje))
18
27
  e.code = code
@@ -33,6 +42,7 @@ function errorCon (mensaje, code) {
33
42
  * - 'channel_joined' (channel, token) : new peer joined the channel
34
43
  * - 'channel_left' (channel, token) : peer unpublished
35
44
  * - 'peer_disconnected' (token, channel?) : peer dropped (with channel if it was published there)
45
+ * - 'peer_identity' (token, publickey) : that token said whose it is (helloTo)
36
46
  * - 'reconnecting' (attempt, max)
37
47
  * - 'reconnect_failed' (attempts)
38
48
  */
@@ -79,6 +89,23 @@ export class WebSocketProxyClient {
79
89
  this._encPubs = new Map()
80
90
  this._encPubInflight = new Map()
81
91
 
92
+ /**
93
+ * QUIÉN ESTÁ AL OTRO LADO DE CADA TOKEN (token → publickey), aprendido del saludo.
94
+ *
95
+ * Un token es una dirección del proxio y no dice de quién es. Hasta que alguien lo
96
+ * diga, a ese token no se le puede sellar nada: no se sabe a qué identidad, y por lo
97
+ * tanto tampoco a qué llave de cifrado. Es el hueco por el que una sala de
98
+ * desconocidos seguía hablando en claro aunque el sellado ya existiera.
99
+ *
100
+ * Se vacía al reconectar: los tokens se reparten por conexión y los de antes ya no
101
+ * son de nadie.
102
+ */
103
+ this._tokenPubkeys = new Map()
104
+ this._helloSent = new Set()
105
+
106
+ /** Mi propia identidad en el cable, la que `identify` dejó atada a este token. */
107
+ this.myPublickey = null
108
+
82
109
  /**
83
110
  * QUIEN YA SABE LA RESPUESTA, QUE NO PREGUNTE. Los aparatos de un mismo dueño llevan su
84
111
  * llave de cifrado escrita en el ACTA (`memberEncPub` de `@dotrino/identity`), firmada
@@ -451,8 +478,14 @@ export class WebSocketProxyClient {
451
478
  async sendSealedTo (toTokens, payload, { peerPubkey, peerEncPub } = /** @type {any} */ ({})) {
452
479
  const tokens = Array.isArray(toTokens) ? toTokens : [toTokens]
453
480
  if (!peerEncPub) {
481
+ // Si la app no lo dice, lo dice el SALUDO: `helloTo` dejó apuntado de quién es
482
+ // cada token. Con más de uno no se adivina —cada identidad tiene su llave y un
483
+ // solo sobre solo lo abriría una—, así que ahí se exige decirlo.
484
+ if (!peerPubkey && tokens.length === 1) peerPubkey = this.pubkeyOfToken(tokens[0])
454
485
  if (!peerPubkey) {
455
- throw errorCon('sendSealedTo: missing peerPubkey — a token does not say whose it is', 'unsealed')
486
+ throw errorCon(
487
+ 'sendSealedTo: nobody has said whose this token is — greet it (helloTo) or pass peerPubkey',
488
+ 'no-peer-identity')
456
489
  }
457
490
  peerEncPub = await this.encPubOf(peerPubkey)
458
491
  }
@@ -461,6 +494,65 @@ export class WebSocketProxyClient {
461
494
  this.send(tokens, sobre)
462
495
  }
463
496
 
497
+ // ---------- el saludo: de quién es este token ----------
498
+
499
+ /**
500
+ * SALUDAR: decirle a uno o varios tokens quién soy.
501
+ *
502
+ * Un token es una dirección del proxio y no dice de quién es, así que sin esto no hay
503
+ * a quién sellarle: una sala de desconocidos se queda muda o —lo que pasaba— hablando
504
+ * en claro. El saludo lleva SOLO mi llave pública, la misma que el proxio ya tiene
505
+ * atada a esta conexión desde `identify`; no hay nada del usuario dentro y por eso no
506
+ * necesita sobre.
507
+ *
508
+ * Quien lo recibe contesta el suyo una vez, así que basta con que salude UNA de las
509
+ * dos puntas y la app no tiene que coreografiar nada.
510
+ *
511
+ * **No autentica, y no hace falta que lo haga.** Mentir sobre la propia identidad solo
512
+ * consigue que te sellen a una llave que no puedes abrir: el embustero se queda sin
513
+ * leer, y nadie se queda suplantado. Quién firma de verdad lo dice el reto de la app,
514
+ * o el `from_publickey` que pone el proxio cuando se enruta por pubkey.
515
+ */
516
+ helloTo (to) {
517
+ if (!this.myPublickey) {
518
+ throw errorCon('helloTo: identify first — a greeting with no identity says nothing', 'not-identified')
519
+ }
520
+ const tokens = Array.isArray(to) ? to : [to]
521
+ for (const t of tokens) {
522
+ if (!t || t === this.token) continue
523
+ this._helloSent.add(t)
524
+ this._proxySendOne(t, { t: HELLO_TAG, publickey: this.myPublickey })
525
+ }
526
+ }
527
+
528
+ /** De quién es este token, si alguien lo ha dicho. `null` es «todavía no lo sé». */
529
+ pubkeyOfToken (token) {
530
+ return this._tokenPubkeys.get(token) || null
531
+ }
532
+
533
+ /** Olvidar un token (se fue, o se quiere volver a preguntar). Sin argumento, todos. */
534
+ forgetToken (token) {
535
+ if (token == null) { this._tokenPubkeys.clear(); this._helloSent.clear(); return }
536
+ this._tokenPubkeys.delete(token)
537
+ this._helloSent.delete(token)
538
+ }
539
+
540
+ _onHello (from, msg) {
541
+ if (typeof msg.publickey !== 'string' || !msg.publickey) return
542
+ const antes = this._tokenPubkeys.get(from)
543
+ // UN TOKEN NO CAMBIA DE DUEÑO: la conexión ES la identidad, y el proxio no recicla
544
+ // tokens. Un segundo saludo con otra identidad es un intento de que le sellemos a
545
+ // otro; manda el primero y esto se dice en voz alta en vez de pisarlo.
546
+ if (antes && !samePubkey(antes, msg.publickey)) {
547
+ this._emit('error', { type: 'hello_conflict', from, code: 'hello-conflict' })
548
+ return
549
+ }
550
+ this._tokenPubkeys.set(from, msg.publickey)
551
+ // Contestar UNA vez: el saludo queda simétrico sin rebotar para siempre.
552
+ if (!this._helloSent.has(from) && this.myPublickey) this.helloTo(from)
553
+ this._emit('peer_identity', from, msg.publickey)
554
+ }
555
+
464
556
  /** Sella con lo que haya: la bóveda de la app (`sealing`) o las primitivas del pilar. */
465
557
  async _seal (payload, peerEncPub) {
466
558
  if (!peerEncPub) throw errorCon('seal: missing peerEncPub', 'unsealed')
@@ -714,6 +806,10 @@ export class WebSocketProxyClient {
714
806
  // escribirle a la PERSONA llega a cualquiera de sus dispositivos. Ver acta-de-perfil.md.
715
807
  if (acta) msg.acta = acta
716
808
  const done = this._request(msg, 'identified')
809
+ // Mi identidad en el cable, para poder decirla en el saludo sin que la app la
810
+ // repita. Se apunta al pedirlo y no al confirmarlo: si la identificación falla,
811
+ // el saludo tampoco sale (el proxio no reparte a una conexión sin identidad).
812
+ this.myPublickey = data.publickey
717
813
  if (this._rtc && typeof sign === 'function' && data.publickey) {
718
814
  done.then(() => this.enableTurn({ publicKey: data.publickey, sign })).catch(() => {})
719
815
  }
@@ -1130,6 +1226,13 @@ export class WebSocketProxyClient {
1130
1226
  this._rtc.handleIncoming(from, parsed)
1131
1227
  break
1132
1228
  }
1229
+ // El saludo se atiende AQUÍ, antes de `_deliver`: es del transporte y no sube a
1230
+ // la app, así que `requireSealed` no lo ve pasar ni tiene que hacerle una
1231
+ // excepción.
1232
+ if (parsed && parsed.t === HELLO_TAG) {
1233
+ this._onHello(from, parsed)
1234
+ break
1235
+ }
1133
1236
  this._deliver(from, parsed ?? message, {
1134
1237
  raw: message, timestamp, via: 'proxy',
1135
1238
  fromPubkey: from_publickey || null,
@@ -1139,6 +1242,9 @@ export class WebSocketProxyClient {
1139
1242
  break
1140
1243
  }
1141
1244
  case 'disconnected':
1245
+ // El token muere con la conexión y no se recicla: lo aprendido de él deja de
1246
+ // valer en el acto. Guardarlo «por si vuelve» sería sellarle a quien ya no está.
1247
+ this.forgetToken(data.token)
1142
1248
  this._emit('peer_disconnected', data.token, data.channel || null)
1143
1249
  if (this._rtc && data.token) this._rtc.closePeer(data.token)
1144
1250
  this._resolvePending(data, 'token')
@@ -3,7 +3,7 @@ export { canonicalStringify } from './canonical.js'
3
3
  export { getPublicKeyJwk, signData, verifyData, samePubkey, buildSignedChannel, setKeypairStore } from './signature.js'
4
4
  export {
5
5
  seal, open, isSealed, makeEncKeypair, importEncPrivate, exportEncPrivate,
6
- setSealingPrimitives,
6
+ setSealingPrimitives, identitySealing,
7
7
  } from './sealing.js'
8
8
  export {
9
9
  ENCPUB_V, ENCPUB_AUD, encPubBody, isEncPub,
@@ -16,6 +16,14 @@
16
16
  */
17
17
 
18
18
  const ECDH = { name: 'ECDH', namedCurve: 'P-256' }
19
+
20
+ /** Un error con `code`: quien llama decide por el código, nunca por la frase. */
21
+ function errorCon (mensaje, code) {
22
+ const e = /** @type {Error & { code: string }} */ (new Error(mensaje))
23
+ e.code = code
24
+ return e
25
+ }
26
+
19
27
  const VERSION = 1
20
28
 
21
29
  let primitives = null
@@ -75,3 +83,74 @@ export async function open (envelope, myEncPrivateKey) {
75
83
  export function isSealed (msg) {
76
84
  return !!msg && msg.v === VERSION && !!msg.sealed?.ct && !!msg.sealed?.epk
77
85
  }
86
+
87
+ /**
88
+ * EL PUENTE DE LA BÓVEDA: sellar y abrir cuando la llave privada NO está aquí.
89
+ *
90
+ * En un aparato headless la privada de cifrado es suya y basta con `myEncPrivateKey`. En
91
+ * el navegador no: la privada vive dentro del iframe de la bóveda y no sale nunca, así
92
+ * que sellar y abrir se le delegan a `@dotrino/identity` (`encrypt` / `decrypt`), que es
93
+ * la MISMA cripto —ECDH P-256 efímero + AES-GCM— y no cripto nueva.
94
+ *
95
+ * Estaba escrito en el gestor de contraseñas, que fue la primera app que selló de verdad,
96
+ * y sube aquí porque son las dos puntas del MISMO sobre: si una cambia de forma, la otra
97
+ * deja de abrirlo, y ese fallo no hace ruido —la petición sale, al otro lado «no es para
98
+ * mí», y desde fuera se ve como que nadie contestó—. Con una sola pieza no hay dos formas.
99
+ *
100
+ * Dos DIALECTOS de identidad, porque no son el mismo objeto y los dos son correctos:
101
+ * · la clase `Identity` (la que habla con el iframe): `getEncryptionPubkey()` y
102
+ * `decrypt(remitente, miToken, sobre)` que devuelve `{ plaintext }`
103
+ * · el núcleo que corre dentro de un service worker: `encryptionPubkey()` y
104
+ * `decrypt(remitente, sobre)` que devuelve la cadena
105
+ *
106
+ * `app` es la MARCA del sobre: quien recibe lo que no es suyo lo descarta por aquí. Es
107
+ * estable por app y no se cambia a la ligera — cambiarla es dejar de abrir lo de la
108
+ * versión anterior.
109
+ *
110
+ * @param {any} identity cualquiera de los dos dialectos
111
+ * @param {{ app?: string }} [opts]
112
+ * @returns {{ seal:Function, open:Function, isSealed:Function }}
113
+ */
114
+ export function identitySealing (identity, { app = 'dotrino' } = {}) {
115
+ // El dialecto se decide UNA vez, por lo que el objeto expone, y no por el resultado de
116
+ // cada llamada: así, si llega un tercero que no es ninguno de los dos, revienta aquí y
117
+ // con nombre, en vez de devolver sobres que nadie abre.
118
+ const iframe = typeof identity?.getEncryptionPubkey === 'function'
119
+ if (!iframe && typeof identity?.encryptionPubkey !== 'function') {
120
+ throw new Error('identitySealing: this identity exposes neither getEncryptionPubkey() nor encryptionPubkey()')
121
+ }
122
+ if (typeof identity?.encrypt !== 'function' || typeof identity?.decrypt !== 'function') {
123
+ throw new Error('identitySealing: this identity does not expose encrypt()/decrypt()')
124
+ }
125
+
126
+ const myEncPub = () => (iframe ? identity.getEncryptionPubkey() : identity.encryptionPubkey())
127
+ const openEnvelope = async (from, envelope) => {
128
+ const r = iframe
129
+ ? await identity.decrypt(from, null, envelope)
130
+ : await identity.decrypt(from, envelope)
131
+ // Un dialecto devuelve `{ plaintext }` y el otro la cadena. Nada más se admite: un
132
+ // `?? ''` aquí sería un sobre vacío haciéndose pasar por un mensaje.
133
+ if (typeof r === 'string') return r
134
+ if (typeof r?.plaintext === 'string') return r.plaintext
135
+ throw new Error('identitySealing: decrypt returned neither a string nor { plaintext }')
136
+ }
137
+
138
+ return {
139
+ async seal (msg, peerEncPub) {
140
+ if (!peerEncPub) throw errorCon('no encryption key for the other side', 'unsealed')
141
+ // Destinatarios como OBJETOS: `encrypt` expande cada uno a todos los aparatos de
142
+ // esa persona, y una llave suelta se le cae sin envolver nada.
143
+ const sealed = await identity.encrypt([{ encryptionPubkey: peerEncPub }], JSON.stringify(msg))
144
+ // Y SE COMPRUEBA QUE ENVOLVIÓ A ALGUIEN. `encrypt` se salta en silencio al
145
+ // destinatario cuya llave no puede importar, y devuelve un sobre con el llavero
146
+ // VACÍO: cifrado de verdad, y que no abre nadie. Eso no es un sobre, es un mensaje
147
+ // perdido con cara de enviado.
148
+ if (!sealed || !sealed.wrap || Object.keys(sealed.wrap).length === 0) {
149
+ throw errorCon('identitySealing: the vault wrapped the message for nobody', 'unsealed')
150
+ }
151
+ return { app, sealed, from: await myEncPub() }
152
+ },
153
+ async open (env) { return JSON.parse(await openEnvelope(env.from, env.sealed)) },
154
+ isSealed: (m) => !!m && m.app === app && !!m.sealed,
155
+ }
156
+ }