@dotrino/identity 0.119.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.119.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/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/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) }
@@ -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
@@ -28,6 +28,7 @@
28
28
  */
29
29
  import { canonicalStringify } from './canonical.js'
30
30
  import { verifyData, samePubkey } from './signature.js'
31
+ import { errorWithCode } from './errors.js'
31
32
 
32
33
  export const ENCPUB_V = 1
33
34
 
@@ -54,17 +55,6 @@ export function isEncPub (v) {
54
55
  }
55
56
  }
56
57
 
57
- /**
58
- * @param {string} mensaje
59
- * @param {string} code
60
- * @returns {Error & { code: string }}
61
- */
62
- function errorCon (mensaje, code) {
63
- const e = /** @type {Error & { code: string }} */ (new Error(mensaje))
64
- e.code = code
65
- return e
66
- }
67
-
68
58
  /**
69
59
  * Firma el anuncio con lo que firma la identidad (`identity.signData`, o la llave local
70
60
  * del cliente). No genera llaves ni cifra nada: solo dice de quién es la que ya tienes.
@@ -73,15 +63,15 @@ function errorCon (mensaje, code) {
73
63
  * @returns {Promise<{data:any, signature:string}>}
74
64
  */
75
65
  export async function buildEncPubStatement ({ publickey, encPub, sign } = /** @type {any} */ ({})) {
76
- if (typeof publickey !== 'string' || !publickey) throw errorCon('buildEncPubStatement: missing publickey', 'encpub-shape')
77
- if (!isEncPub(encPub)) throw errorCon('buildEncPubStatement: encPub is not a P-256 public JWK', 'encpub-shape')
78
- if (typeof sign !== 'function') throw errorCon('buildEncPubStatement: missing sign(data)', 'encpub-shape')
66
+ if (typeof publickey !== 'string' || !publickey) throw errorWithCode('buildEncPubStatement: missing publickey', 'encpub-shape')
67
+ if (!isEncPub(encPub)) throw errorWithCode('buildEncPubStatement: encPub is not a P-256 public JWK', 'encpub-shape')
68
+ if (typeof sign !== 'function') throw errorWithCode('buildEncPubStatement: missing sign(data)', 'encpub-shape')
79
69
  const data = encPubBody({ publickey, encPub })
80
70
  const firmado = await sign(data)
81
71
  const signature = typeof firmado === 'string' ? firmado : firmado?.signature
82
72
  // «No pude firmar» no es «se cayó la red», y aquí es donde se separan: quien llama
83
73
  // decide una cosa u otra según el `code`, nunca según la frase.
84
- if (typeof signature !== 'string') throw errorCon('buildEncPubStatement: sign() returned no signature', 'no-signature')
74
+ if (typeof signature !== 'string') throw errorWithCode('buildEncPubStatement: sign() returned no signature', 'no-signature')
85
75
  return { data, signature }
86
76
  }
87
77
 
@@ -98,21 +88,21 @@ export async function buildEncPubStatement ({ publickey, encPub, sign } = /** @t
98
88
  */
99
89
  export async function readEncPubStatement (statement, { publickey } = /** @type {any} */ ({})) {
100
90
  if (typeof publickey !== 'string' || !publickey) {
101
- throw errorCon('readEncPubStatement: missing the publickey to check against', 'encpub-shape')
91
+ throw errorWithCode('readEncPubStatement: missing the publickey to check against', 'encpub-shape')
102
92
  }
103
93
  const data = statement?.data
104
94
  const signature = statement?.signature
105
- if (!data || typeof signature !== 'string') throw errorCon('encpub statement: malformed', 'encpub-unverified')
106
- if (data.v !== ENCPUB_V || data.op !== 'encpub') throw errorCon('encpub statement: not an encpub announcement', 'encpub-unverified')
107
- if (data.aud !== ENCPUB_AUD) throw errorCon('encpub statement: wrong audience', 'encpub-unverified')
95
+ if (!data || typeof signature !== 'string') throw errorWithCode('encpub statement: malformed', 'encpub-unverified')
96
+ if (data.v !== ENCPUB_V || data.op !== 'encpub') throw errorWithCode('encpub statement: not an encpub announcement', 'encpub-unverified')
97
+ if (data.aud !== ENCPUB_AUD) throw errorWithCode('encpub statement: wrong audience', 'encpub-unverified')
108
98
  // `samePubkey` y no `===`: un JWK serializado no es canónico y la misma llave escrita
109
99
  // por dos piezas distintas da dos strings distintos.
110
- if (!samePubkey(data.publickey, publickey)) throw errorCon('encpub statement: announces another identity', 'encpub-unverified')
111
- if (!isEncPub(data.encpub)) throw errorCon('encpub statement: not a P-256 public JWK', 'encpub-unverified')
100
+ if (!samePubkey(data.publickey, publickey)) throw errorWithCode('encpub statement: announces another identity', 'encpub-unverified')
101
+ if (!isEncPub(data.encpub)) throw errorWithCode('encpub statement: not a P-256 public JWK', 'encpub-unverified')
112
102
  // Se verifica con la llave que ANUNCIA el sobre, que ya se comprobó que es la misma que
113
103
  // la pedida: así el texto firmado y la llave que lo comprueba salen del mismo sitio.
114
104
  if (!(await verifyData(data.publickey, data, signature))) {
115
- throw errorCon('encpub statement: bad signature — the key is not bound to that identity', 'encpub-unverified')
105
+ throw errorWithCode('encpub statement: bad signature — the key is not bound to that identity', 'encpub-unverified')
116
106
  }
117
107
  return data.encpub
118
108
  }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Error con un `code` estable.
3
+ *
4
+ * El contrato es el CODE, no la frase: comprobar por el texto cruza procesos y
5
+ * se rompe en silencio en cuanto alguien lo reescribe o lo traduce.
6
+ *
7
+ * @param {string} message
8
+ * @param {string} code
9
+ * @returns {Error & { code: string }}
10
+ */
11
+ export function errorWithCode (message, code) {
12
+ const e = /** @type {Error & { code: string }} */ (new Error(message))
13
+ e.code = code
14
+ return e
15
+ }
@@ -15,14 +15,9 @@
15
15
  * second, older copy of a pillar inside every consumer.
16
16
  */
17
17
 
18
- const ECDH = { name: 'ECDH', namedCurve: 'P-256' }
18
+ import { errorWithCode } from './errors.js'
19
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
- }
20
+ const ECDH = { name: 'ECDH', namedCurve: 'P-256' }
26
21
 
27
22
  const VERSION = 1
28
23
 
@@ -137,7 +132,7 @@ export function identitySealing (identity, { app = 'dotrino' } = {}) {
137
132
 
138
133
  return {
139
134
  async seal (msg, peerEncPub, { publickey } = {}) {
140
- if (!peerEncPub) throw errorCon('no encryption key for the other side', 'unsealed')
135
+ if (!peerEncPub) throw errorWithCode('no encryption key for the other side', 'unsealed')
141
136
  // Destinatarios como OBJETOS, y CON SU `publickey`: `encrypt` expande cada uno a
142
137
  // todos los aparatos de esa persona por su tarjeta de perfil, y la tarjeta se busca
143
138
  // por la pubkey. Sin ella el sobre solo lo abría el aparato que anunció la llave, y
@@ -149,7 +144,7 @@ export function identitySealing (identity, { app = 'dotrino' } = {}) {
149
144
  // VACÍO: cifrado de verdad, y que no abre nadie. Eso no es un sobre, es un mensaje
150
145
  // perdido con cara de enviado.
151
146
  if (!sealed || !sealed.wrap || Object.keys(sealed.wrap).length === 0) {
152
- throw errorCon('identitySealing: the vault wrapped the message for nobody', 'unsealed')
147
+ throw errorWithCode('identitySealing: the vault wrapped the message for nobody', 'unsealed')
153
148
  }
154
149
  return { app, sealed, from: await myEncPub() }
155
150
  },
@@ -189,20 +184,20 @@ export function isBinSealed (env) {
189
184
  /** Una CEK nueva para un mensaje con bytes. */
190
185
  export async function makeMessageKey () {
191
186
  const { makeContentKey } = await crypto_()
192
- if (typeof makeContentKey !== 'function') throw errorCon('sealing bytes needs @dotrino/identity >= 0.117.0', 'no-bytes-sealing')
187
+ if (typeof makeContentKey !== 'function') throw errorWithCode('sealing bytes needs @dotrino/identity >= 0.117.0', 'no-bytes-sealing')
193
188
  return makeContentKey()
194
189
  }
195
190
 
196
191
  /** Cifra `bytes` con la CEK y devuelve los campos del sobre (`iv`, `ct`). */
197
192
  export async function encryptBytes (cek, bytes) {
198
193
  const { encryptBytesWithCek } = await crypto_()
199
- if (typeof encryptBytesWithCek !== 'function') throw errorCon('sealing bytes needs @dotrino/identity >= 0.117.0', 'no-bytes-sealing')
194
+ if (typeof encryptBytesWithCek !== 'function') throw errorWithCode('sealing bytes needs @dotrino/identity >= 0.117.0', 'no-bytes-sealing')
200
195
  return encryptBytesWithCek({ cek, bytes })
201
196
  }
202
197
 
203
198
  /** Descifra el `ct` de un sobre binario con su CEK ya abierta. */
204
199
  export async function decryptBytes (cek, env) {
205
200
  const { decryptBytesWithCek } = await crypto_()
206
- if (typeof decryptBytesWithCek !== 'function') throw errorCon('opening bytes needs @dotrino/identity >= 0.117.0', 'no-bytes-sealing')
201
+ if (typeof decryptBytesWithCek !== 'function') throw errorWithCode('opening bytes needs @dotrino/identity >= 0.117.0', 'no-bytes-sealing')
207
202
  return decryptBytesWithCek({ cek, iv: env.iv, ct: env.ct })
208
203
  }
@@ -1,3 +1,3 @@
1
- Copia vendorizada de @dotrino/roadmap@0.4.29 (dotrino-roadmap/src/{index,manifests}.js).
1
+ Copia vendorizada de @dotrino/roadmap@0.4.30 (dotrino-roadmap/src/{index,manifests}.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 registro de versiones rotas que viaja con cada versión. Importa @dotrino/compat/ranges.
@@ -8,8 +8,8 @@
8
8
  export const MANIFESTS = Object.freeze({
9
9
  "dotrino": {
10
10
  "manifest": "dotrino",
11
- "seq": 29,
12
- "issued": "2026-10-15",
11
+ "seq": 30,
12
+ "issued": "2026-10-16",
13
13
  "note": "Registro comun de compatibilidad del ecosistema. Se edita a mano en este repo y se consulta con @dotrino/roadmap. Rangos: ver @dotrino/compat/ranges (x.y.z, >= > <= <, «a - b», «*», o una lista que es un O).",
14
14
  "products": {
15
15
  "compat": {
@@ -22,18 +22,20 @@ export const MANIFESTS = Object.freeze({
22
22
  "env": {
23
23
  "repo": "imdotrino/dotrino-vault",
24
24
  "npm": "@dotrino/env",
25
- "current": "0.85.0",
25
+ "current": "0.90.1",
26
26
  "protocol": 1,
27
27
  "requires": {
28
- "vault": ">=0.70.0"
28
+ "vault": ">=0.90.1"
29
29
  }
30
30
  },
31
31
  "identity": {
32
32
  "repo": "imdotrino/dotrino-identity",
33
33
  "npm": "@dotrino/identity",
34
- "current": "0.118.0",
34
+ "current": "0.119.0",
35
35
  "protocol": 1,
36
- "requires": {}
36
+ "requires": {
37
+ "proxy-client": ">=0.13.1"
38
+ }
37
39
  },
38
40
  "lobby": {
39
41
  "repo": "imdotrino/dotrino-lobby",
@@ -107,7 +109,7 @@ export const MANIFESTS = Object.freeze({
107
109
  },
108
110
  "proxy": {
109
111
  "repo": "imdotrino/dotrino-proxy",
110
- "current": "1.6.3",
112
+ "current": "1.6.4",
111
113
  "protocol": 2,
112
114
  "requires": {
113
115
  "vault": ">=0.87.0",
@@ -215,7 +217,7 @@ export const MANIFESTS = Object.freeze({
215
217
  "roadmap": {
216
218
  "repo": "imdotrino/dotrino-roadmap",
217
219
  "npm": "@dotrino/roadmap",
218
- "current": "0.4.29",
220
+ "current": "0.4.30",
219
221
  "protocol": 1,
220
222
  "requires": {
221
223
  "compat": "0.4.0+"
@@ -251,7 +253,7 @@ export const MANIFESTS = Object.freeze({
251
253
  "vault": {
252
254
  "repo": "imdotrino/dotrino-vault",
253
255
  "npm": "@dotrino/vault",
254
- "current": "0.89.0",
256
+ "current": "0.90.1",
255
257
  "protocol": 1,
256
258
  "requires": {
257
259
  "vaultd": ">=0.105.0",
@@ -261,13 +263,13 @@ export const MANIFESTS = Object.freeze({
261
263
  "compat": ">=0.4.0",
262
264
  "roadmap": ">=0.4.26",
263
265
  "passmanager": ">=0.18.4",
264
- "store": ">=0.11.0"
266
+ "store": ">=0.12.3"
265
267
  }
266
268
  },
267
269
  "vaultd": {
268
270
  "repo": "imdotrino/dotrino-vault",
269
271
  "npm": "@dotrino/vaultd",
270
- "current": "0.161.0",
272
+ "current": "0.162.1",
271
273
  "protocol": 1,
272
274
  "requires": {
273
275
  "identity": ">=0.118.0",
@@ -318,12 +320,12 @@ export const MANIFESTS = Object.freeze({
318
320
  },
319
321
  "passmanager-extension": {
320
322
  "repo": "imdotrino/dotrino-passmanager",
321
- "current": "0.53.4",
323
+ "current": "0.54.0",
322
324
  "protocol": 1,
323
325
  "requires": {
324
326
  "identity": ">=0.118.0",
325
327
  "proxy-client": ">=0.36.1",
326
- "vault": ">=0.88.0",
328
+ "vault": ">=0.90.0",
327
329
  "proxy": "1.1.0+",
328
330
  "vaultd": ">=0.160.2"
329
331
  }
@@ -1,3 +1,3 @@
1
- Copia vendorizada de @dotrino/store/core@0.12.3 (dotrino-store/store/{core}.js).
1
+ Copia vendorizada de @dotrino/store/core@0.12.4 (dotrino-store/store/{core}.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
  Las reglas del almacén (lápidas, páginas): las mismas en el store y en la bóveda.
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/vault@0.90.0 (dotrino-vault/lib/src/{admin,b64,browser,core/approvals,core/backup,core/blocked,core/browserDisk,core/commKey,core/disk,core/libVersion,core/machines,core/notices,core/notifiable,core/passwords,core/sealKey,core/sealer,core/sealers,core/secretsStore,core/sha256,core/store,core/subacta,core/threadStore,core/transport,core/vault,enroll,index,loginClient,passwordLogins,procContext,protocol,revocation,sealed}.js).
1
+ Copia vendorizada de @dotrino/vault@0.90.2 (dotrino-vault/lib/src/{admin,b64,browser,core/approvals,core/backup,core/blocked,core/browserDisk,core/commKey,core/disk,core/libVersion,core/machines,core/notices,core/notifiable,core/passwords,core/sealKey,core/sealer,core/sealers,core/secretsStore,core/sha256,core/store,core/subacta,core/threadStore,core/transport,core/vault,enroll,index,loginClient,passwordLogins,procContext,protocol,revocation,sealed}.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
  browser.js es la bóveda de esta pestaña: el MISMO núcleo que el binario (core/), con
4
4
  el disco en IndexedDB. Importa @dotrino/{compat,roadmap,passmanager,store/core} y
@@ -5,4 +5,4 @@
5
5
  * si no coincide con la del paquete: una versión anunciada que no es la que corre es peor
6
6
  * que no anunciar ninguna.
7
7
  */
8
- export const LIB_VERSION = '0.90.0'
8
+ export const LIB_VERSION = '0.90.2'
@@ -16,30 +16,13 @@
16
16
  * P-256 + AES-GCM, llaves como JWK string.
17
17
  */
18
18
 
19
- const subtle = globalThis.crypto.subtle
20
-
21
- function b64 (buf) {
22
- const bytes = new Uint8Array(buf)
23
- let s = ''
24
- for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i])
25
- return btoa(s)
26
- }
19
+ import { wrapForMember, openWrap } from '@dotrino/identity/content'
27
20
 
28
- function fromB64 (str) {
29
- const bin = atob(str)
30
- const out = new Uint8Array(bin.length)
31
- for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
32
- return out
33
- }
21
+ // El sobre es el MISMO que el de las llaves de contenido de identity (ECDH P-256 efímero
22
+ // → AES-256-GCM, `epk` en JWK): aquí solo se le mete un JSON en vez de una CEK. Antes
23
+ // estaba escrito otra vez en este archivo, byte a byte igual.
34
24
 
35
- async function deriveAesKey (privateKey, publicJwkStr) {
36
- const pub = await subtle.importKey(
37
- 'jwk', JSON.parse(publicJwkStr),
38
- { name: 'ECDH', namedCurve: 'P-256' }, false, []
39
- )
40
- const bits = await subtle.deriveBits({ name: 'ECDH', public: pub }, privateKey, 256)
41
- return subtle.importKey('raw', bits, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'])
42
- }
25
+ const subtle = globalThis.crypto.subtle
43
26
 
44
27
  /**
45
28
  * Genera la llave ECDH EFÍMERA del solicitante (una por petición).
@@ -57,17 +40,7 @@ export async function makeEphemeralKey () {
57
40
  * @returns {Promise<{epk:string, iv:string, ct:string}>}
58
41
  */
59
42
  export async function seal ({ ek, payload }) {
60
- const eph = await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits'])
61
- const key = await deriveAesKey(eph.privateKey, ek)
62
- const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
63
- const pt = new TextEncoder().encode(JSON.stringify(payload))
64
- const ct = await subtle.encrypt({ name: 'AES-GCM', iv }, key, pt)
65
- const epkJwk = await subtle.exportKey('jwk', eph.publicKey)
66
- return {
67
- epk: JSON.stringify({ kty: epkJwk.kty, crv: epkJwk.crv, x: epkJwk.x, y: epkJwk.y }),
68
- iv: b64(iv),
69
- ct: b64(ct)
70
- }
43
+ return wrapForMember({ cek: JSON.stringify(payload), memberEncPub: ek })
71
44
  }
72
45
 
73
46
  /**
@@ -78,7 +51,5 @@ export async function openSealed ({ privateKey, enc }) {
78
51
  if (!enc || typeof enc.epk !== 'string' || typeof enc.iv !== 'string' || typeof enc.ct !== 'string') {
79
52
  throw new Error('invalid sealed envelope')
80
53
  }
81
- const key = await deriveAesKey(privateKey, enc.epk)
82
- const pt = await subtle.decrypt({ name: 'AES-GCM', iv: fromB64(enc.iv) }, key, fromB64(enc.ct))
83
- return JSON.parse(new TextDecoder().decode(pt))
54
+ return JSON.parse(await openWrap({ wrap: enc, myEncPrivateKey: privateKey }))
84
55
  }