@dotrino/identity 0.89.1 → 0.90.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.89.1",
3
+ "version": "0.90.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/vault/acta.js CHANGED
@@ -1025,6 +1025,44 @@ export function memberScopes (acta, pub, extraRenounces = []) {
1025
1025
  /** ¿Es un servicio (tiene CN) o un dispositivo del usuario? */
1026
1026
  export const isService = (acta, pub) => !!(acta?.members || []).find((x) => x.pub === pub)?.cn
1027
1027
 
1028
+ /**
1029
+ * ¿SON LA MISMA LLAVE? Nunca `===` sobre el JWK serializado: no es canónico, así que la
1030
+ * misma llave escrita por dos piezas distintas da dos strings distintos y `===` dice que
1031
+ * no. Lo que identifica a una P-256 es el punto (`kty`, `crv`, `x`, `y`); lo demás del
1032
+ * JWK es cómo se usa, no cuál es.
1033
+ */
1034
+ export function samePubkey (a, b) {
1035
+ if (typeof a !== 'string' || typeof b !== 'string') return false
1036
+ if (a === b) return true
1037
+ try {
1038
+ const x = JSON.parse(a)
1039
+ const y = JSON.parse(b)
1040
+ return !!x && !!y && x.kty === y.kty && x.crv === y.crv && x.x === y.x && x.y === y.y
1041
+ } catch (_) {
1042
+ return false
1043
+ }
1044
+ }
1045
+
1046
+ /**
1047
+ * LA LLAVE DE CIFRADO DE UN MIEMBRO, que es lo que hace falta para sellarle algo.
1048
+ *
1049
+ * Existía el dato —cada aparato la publica al enrolarse y queda escrita en el acta— pero
1050
+ * no la función, así que cada consumidor repetía el `.find()` a mano y cada copia
1051
+ * comparaba pubkeys con `===`. Esto es esa lectura, una vez y en el pilar.
1052
+ *
1053
+ * Devuelve `null` cuando no la hay, y eso NO es un repliegue: es el dato que falta dicho
1054
+ * en voz alta. Quien llama tiene que parar ahí, no mandar en claro — un miembro sin
1055
+ * `encPub` (un acta vieja, un servicio) simplemente no puede recibir nada sellado.
1056
+ *
1057
+ * @param {any} acta
1058
+ * @param {string} pub
1059
+ * @returns {string|null}
1060
+ */
1061
+ export function memberEncPub (acta, pub) {
1062
+ const m = (acta?.members || []).find((x) => samePubkey(x?.pub, pub))
1063
+ return m?.encPub || null
1064
+ }
1065
+
1028
1066
  /** ¿Puede este miembro hacer `cap` según el acta? (con el cert se cruza aparte: cert ∩ acta). */
1029
1067
  export function memberCan (acta, pub, cap, extraRenounces = []) {
1030
1068
  return effectiveCaps(acta, pub, extraRenounces).includes(cap)
@@ -1183,5 +1221,6 @@ export default {
1183
1221
  sealActa, verifyActa, applyChanges, makeRenounce, verifyRenounce,
1184
1222
  makeContinuity, verifyContinuity,
1185
1223
  cardBody, makeProfileCard, verifyProfileCard, canAdoptCard, sealersOf, canSeal,
1186
- effectiveCaps, memberCan, memberCanSign, memberCanScope, memberCanReadSecrets, memberScopes, isService, capScope, isValidCn, canAdopt
1224
+ effectiveCaps, memberCan, memberCanSign, memberCanScope, memberCanReadSecrets, memberScopes, isService, capScope, isValidCn, canAdopt,
1225
+ samePubkey, memberEncPub
1187
1226
  }
@@ -1,4 +1,6 @@
1
- Copia vendorizada de @dotrino/proxy-client@0.19.0 (dotrino-proxy-client/src/{index,client,signature,canonical,sealing,webrtc}.js).
1
+ Copia vendorizada de @dotrino/proxy-client@0.20.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.
5
+ encpub.js (≥ 0.20) es el anuncio firmado de la llave de cifrado; no importa nada de
6
+ fuera, solo ./signature.js y ./canonical.js de esta misma copia.
@@ -1,5 +1,6 @@
1
- import { buildSignedChannel, getPublicKeyJwk, signData } from './signature.js'
1
+ import { buildSignedChannel, getPublicKeyJwk, signData, samePubkey } from './signature.js'
2
2
  import { seal, open, isSealed } from './sealing.js'
3
+ import { buildEncPubStatement, readEncPubStatement, isEncPub } from './encpub.js'
3
4
  import { WebRTCManager, RTC_TAG, DEFAULT_ICE_SERVERS, loadNodePeerConnection, resolvePeerConnection } from './webrtc.js'
4
5
 
5
6
  /**
@@ -58,6 +59,40 @@ export class WebSocketProxyClient {
58
59
  this.requireSealed = options.requireSealed === true
59
60
  this.myEncPrivateKey = options.myEncPrivateKey || null
60
61
 
62
+ /**
63
+ * MI llave de cifrado, la pública. Con ella puesta, `identify` anuncia al proxio
64
+ * —firmado— que esta identidad se abre por aquí, y cualquiera que sepa mi pubkey puede
65
+ * averiguarla y sellarme sin habernos emparejado nunca. Sin ella, los demás no tienen
66
+ * de dónde sacarla y `sendSealed` hacia mí no sale: falla en vez de ir en claro.
67
+ *
68
+ * En un aparato headless es `(await makeEncKeypair()).encPub`; en el navegador,
69
+ * `await identity.getEncryptionPubkey()` — ahí la privada vive en la bóveda y no se
70
+ * pasa, se delega en `sealing`.
71
+ */
72
+ this.myEncPub = options.myEncPub || null
73
+
74
+ /**
75
+ * Llaves de cifrado ajenas YA VERIFICADAS (pubkey → encpub). Solo entra aquí lo que
76
+ * pasó por `readEncPubStatement`, así que lo de dentro está atado a su identidad.
77
+ * No se cachean los fallos: «hoy no la tengo» no es un hecho, es un momento.
78
+ */
79
+ this._encPubs = new Map()
80
+ this._encPubInflight = new Map()
81
+
82
+ /**
83
+ * QUIEN YA SABE LA RESPUESTA, QUE NO PREGUNTE. Los aparatos de un mismo dueño llevan su
84
+ * llave de cifrado escrita en el ACTA (`memberEncPub` de `@dotrino/identity`), firmada
85
+ * por el master: eso es más fuerte que el anuncio y no hace falta ir al proxio a por
86
+ * ello. Una app con acta enchufa aquí
87
+ * `encPubResolver: (pub) => memberEncPub(acta, pub)`.
88
+ *
89
+ * Devolver `null` significa «yo no sé», y entonces se pregunta al proxio. No es un
90
+ * repliegue: si no lo sabe nadie, no se manda nada — se sigue fallando cerrado.
91
+ *
92
+ * @type {((publickey:string)=>Promise<string|null>|string|null)|null}
93
+ */
94
+ this.encPubResolver = typeof options.encPubResolver === 'function' ? options.encPubResolver : null
95
+
61
96
  /**
62
97
  * Who does the sealing. Two worlds, and only one of them holds the key:
63
98
  *
@@ -187,6 +222,8 @@ export class WebSocketProxyClient {
187
222
  // perderla.
188
223
  if (options.sealing) this.sealing = options.sealing
189
224
  if (options.myEncPrivateKey) this.myEncPrivateKey = options.myEncPrivateKey
225
+ if (options.myEncPub) this.myEncPub = options.myEncPub
226
+ if (typeof options.encPubResolver === 'function') this.encPubResolver = options.encPubResolver
190
227
 
191
228
  // Se puede ENCENDER, no apagar. Bajar la exigencia en caliente dejaría que
192
229
  // cualquier otro módulo de la app la desactivara sin querer, y no hay ningún
@@ -210,6 +247,15 @@ export class WebSocketProxyClient {
210
247
  * The payload is JSON-stringified into the envelope's `message` field.
211
248
  */
212
249
  send (to, payload) {
250
+ // `requireSealed` vale para los DOS caminos dirigidos, no solo para el de pubkey
251
+ // (CONVENCIONES §4.1 nombra `sendByPubkey` / `send`). Guardar uno y dejar el otro
252
+ // abierto es no guardar ninguno: la app manda por donde le sale y el proxio lo lee
253
+ // igual. Para sellar por token está `sendSealedTo`.
254
+ if (this.requireSealed && !this._isSealed(payload)) {
255
+ throw errorCon(
256
+ 'requireSealed: refusing to send a directed message in the clear — use sendSealedTo()',
257
+ 'unsealed')
258
+ }
213
259
  const tokens = Array.isArray(to) ? to : [to]
214
260
  const messageStr = typeof payload === 'string' ? payload : JSON.stringify(payload)
215
261
  if (!this._rtc) {
@@ -370,12 +416,150 @@ export class WebSocketProxyClient {
370
416
  * should use for anything that is not meant for the proxy's eyes.
371
417
  */
372
418
  async sendSealed (toPubkeys, payload, { peerEncPub, ...opts } = {}) {
373
- if (this.sealing) {
374
- this._sendByPubkeyRaw(toPubkeys, await this.sealing.seal(payload, peerEncPub), opts)
419
+ const list = Array.isArray(toPubkeys) ? toPubkeys : [toPubkeys]
420
+ // Con la llave puesta a mano se respeta tal cual: quien la pasa está diciendo que ya
421
+ // sabe de quién es (se emparejaron), y un sobre vale para todos los destinatarios.
422
+ if (peerEncPub) {
423
+ this._sendByPubkeyRaw(list, await this._seal(payload, peerEncPub), opts)
375
424
  return
376
425
  }
377
- if (!peerEncPub) throw Object.assign(new Error('sendSealed: missing peerEncPub'), { code: 'unsealed' })
378
- this._sendByPubkeyRaw(toPubkeys, await seal(payload, peerEncPub), opts)
426
+ // Sin ella, se averigua. UNA ENVOLTURA POR DESTINATARIO: cada uno tiene su llave, así
427
+ // que un solo sobre solo lo abriría uno.
428
+ //
429
+ // Y SE RESUELVEN TODAS ANTES DE MANDAR NADA. Enviar a los que se pudo y fallar por el
430
+ // resto deja a la app creyendo que el mensaje salió, con la mitad de la sala sin él y
431
+ // sin forma de saber cuál mitad.
432
+ const llaves = await Promise.all(list.map(async (pk) => [pk, await this.encPubOf(pk)]))
433
+ for (const [pk, encPub] of llaves) {
434
+ this._sendByPubkeyRaw([pk], await this._seal(payload, encPub), opts)
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Sellar y mandar POR TOKEN, que es como hablan entre sí los de una sala (y lo único
440
+ * que puede subir a WebRTC, §transporte: el camino más directo).
441
+ *
442
+ * `peerPubkey` no es un adorno: el token es una dirección del proxio y no dice de quién
443
+ * es. Quien puede afirmar «este token es de esta identidad» es la app —lo sabe por el
444
+ * canal, por el saludo de la sala o por la invitación—, así que lo dice ella y aquí se
445
+ * sella a esa identidad. Sin ese dato no hay a quién sellarle y no se manda nada.
446
+ *
447
+ * @param {string|string[]} toTokens
448
+ * @param {any} payload
449
+ * @param {{ peerPubkey?:string, peerEncPub?:string }} opts
450
+ */
451
+ async sendSealedTo (toTokens, payload, { peerPubkey, peerEncPub } = /** @type {any} */ ({})) {
452
+ const tokens = Array.isArray(toTokens) ? toTokens : [toTokens]
453
+ if (!peerEncPub) {
454
+ if (!peerPubkey) {
455
+ throw errorCon('sendSealedTo: missing peerPubkey — a token does not say whose it is', 'unsealed')
456
+ }
457
+ peerEncPub = await this.encPubOf(peerPubkey)
458
+ }
459
+ const sobre = await this._seal(payload, peerEncPub)
460
+ // Por `send`, no por `_sendRaw`: así sigue prefiriendo el canal directo si lo hay.
461
+ this.send(tokens, sobre)
462
+ }
463
+
464
+ /** Sella con lo que haya: la bóveda de la app (`sealing`) o las primitivas del pilar. */
465
+ async _seal (payload, peerEncPub) {
466
+ if (!peerEncPub) throw errorCon('seal: missing peerEncPub', 'unsealed')
467
+ return this.sealing ? this.sealing.seal(payload, peerEncPub) : seal(payload, peerEncPub)
468
+ }
469
+
470
+ // ---------- llaves de cifrado ajenas ----------
471
+
472
+ /**
473
+ * ANUNCIAR MI LLAVE DE CIFRADO. Una frase firmada por la misma identidad con la que me
474
+ * identifico; el proxio la guarda y se la da a quien pregunte, y quien pregunta la
475
+ * verifica contra mi pubkey. El proxio es el buzón, no la autoridad.
476
+ *
477
+ * Lo llama `identify` solo cuando el cliente tiene `myEncPub`. Se expone aparte para el
478
+ * caso de anunciar una llave nueva sin reconectar.
479
+ */
480
+ async announceEncPub ({ publickey, encPub, sign } = /** @type {any} */ ({})) {
481
+ const statement = await buildEncPubStatement({ publickey, encPub, sign })
482
+ await this._request({ type: 'encpub', ...statement }, 'encpub-announced')
483
+ // Lo mío también va a la caché: si una app se escribe a sí misma (otro aparato del
484
+ // mismo perfil no, ése tiene otra llave) no hace falta preguntar por ello.
485
+ this._encPubs.set(publickey, encPub)
486
+ return encPub
487
+ }
488
+
489
+ /**
490
+ * LA LLAVE DE CIFRADO DE UNA IDENTIDAD, verificada. Devuelve la llave o LANZA:
491
+ *
492
+ * · `no-encpub` nadie ha anunciado llave para esa identidad — se arregla
493
+ * cuando el otro lado actualice; esperar no sirve
494
+ * · `encpub-unverified` llegó una llave que NO firmó esa identidad. Es el caso que
495
+ * importa: significa que alguien intentó ponerte la suya
496
+ * · `no-encpub-support` este proxio no sabe de esto (es viejo)
497
+ *
498
+ * Nunca devuelve `null` y nunca cae a «manda igual»: si no se puede sellar, no se manda.
499
+ */
500
+ async encPubOf (publickey) {
501
+ if (typeof publickey !== 'string' || !publickey) {
502
+ throw errorCon('encPubOf: missing publickey', 'encpub-shape')
503
+ }
504
+ const cacheada = this._encPubs.get(publickey)
505
+ if (cacheada) return cacheada
506
+ // Una pregunta en vuelo por llave: una sala de ocho manda ocho mensajes a la vez y
507
+ // preguntaría ocho veces por lo mismo.
508
+ const enVuelo = this._encPubInflight.get(publickey)
509
+ if (enVuelo) return enVuelo
510
+ const promesa = this._lookupEncPub(publickey)
511
+ .finally(() => this._encPubInflight.delete(publickey))
512
+ this._encPubInflight.set(publickey, promesa)
513
+ return promesa
514
+ }
515
+
516
+ async _lookupEncPub (publickey) {
517
+ // EL CAMINO MÁS DIRECTO PRIMERO. Si la app comparte acta con el destinatario, la llave
518
+ // ya la tiene en la mano y firmada por el master: preguntársela a un servidor sería
519
+ // dar la vuelta para llegar a lo que ya está aquí.
520
+ if (this.encPubResolver) {
521
+ const propia = await this.encPubResolver(publickey)
522
+ if (propia) {
523
+ if (!isEncPub(propia)) {
524
+ throw errorCon('encPubResolver returned something that is not a P-256 public JWK', 'encpub-unverified')
525
+ }
526
+ this._encPubs.set(publickey, propia)
527
+ return propia
528
+ }
529
+ }
530
+ // SI EL PROXIO DICE QUE NO SABE DE ESTO, se dice con su propio código en vez de
531
+ // esperar diez segundos a un timeout. Un proxio que no manda `caps` es de antes de
532
+ // que esto existiera y no se sabe: se pregunta igual, y contesta la pregunta.
533
+ if (Array.isArray(this.caps) && !this.caps.includes('encpub')) {
534
+ throw errorCon(
535
+ `este proxio (${this.url}) no sirve llaves de cifrado: hace falta websocket-proxy >= 1.1.0`,
536
+ 'no-encpub-support')
537
+ }
538
+ const res = await this._request({ type: 'enc-lookup', publickeys: [publickey] }, 'enc-lookup')
539
+ // `samePubkey`, no `===`: el proxio guarda el string exacto con el que se anunció, y
540
+ // la app puede tener el mismo JWK escrito de otra forma.
541
+ const statement = (res.keys || []).find((k) => samePubkey(k?.data?.publickey, publickey))
542
+ if (!statement) {
543
+ throw errorCon('no encryption key announced for that identity — it cannot be sealed to yet', 'no-encpub')
544
+ }
545
+ // AQUÍ ES DONDE EL PROXIO DEJA DE IMPORTAR: la firma se comprueba contra la pubkey a
546
+ // la que vamos a escribir. Si cambió la llave por la suya, esto lanza.
547
+ const encPub = await readEncPubStatement(statement, { publickey })
548
+ this._encPubs.set(publickey, encPub)
549
+ return encPub
550
+ }
551
+
552
+ /** Guardar una llave que ya viene firmada por su dueño (de un canal, de una invitación). */
553
+ async learnEncPub (statement, { publickey } = /** @type {any} */ ({})) {
554
+ const encPub = await readEncPubStatement(statement, { publickey })
555
+ this._encPubs.set(publickey, encPub)
556
+ return encPub
557
+ }
558
+
559
+ /** Olvidar lo aprendido de una identidad (rotó su llave, o se quiere volver a preguntar). */
560
+ forgetEncPub (publickey) {
561
+ if (publickey == null) this._encPubs.clear()
562
+ else this._encPubs.delete(publickey)
379
563
  }
380
564
 
381
565
  _isSealed (msg) {
@@ -533,6 +717,21 @@ export class WebSocketProxyClient {
533
717
  if (this._rtc && typeof sign === 'function' && data.publickey) {
534
718
  done.then(() => this.enableTurn({ publicKey: data.publickey, sign })).catch(() => {})
535
719
  }
720
+ // ANUNCIAR LA LLAVE DE CIFRADO, y por detrás. Identificarse es lo que da dirección a
721
+ // esta identidad; anunciar su llave es lo que hace que alguien pueda sellarle sin
722
+ // haberla emparejado antes. Va después y sin bloquear, como TURN: esperar a esto
723
+ // retrasaría el primer mensaje para ganar algo que solo hace falta cuando el OTRO
724
+ // quiera escribirnos.
725
+ if (typeof sign === 'function' && data.publickey && this.myEncPub) {
726
+ done
727
+ .then(() => this.announceEncPub({ publickey: data.publickey, encPub: this.myEncPub, sign }))
728
+ .catch((e) => {
729
+ // Se dice en voz alta: sin anuncio nadie podrá sellarnos, y el síntoma llega
730
+ // días después y del otro lado («no puedo escribirte»). Callarlo es justo el
731
+ // fallo mudo que el ecosistema paga caro.
732
+ this._emit('error', { type: 'encpub_announce_failed', error: e, code: e?.code || null })
733
+ })
734
+ }
536
735
  return done
537
736
  }
538
737
 
@@ -905,6 +1104,12 @@ export class WebSocketProxyClient {
905
1104
  // de salas): se pregunta en cada nodo y se mezcla, en vez de designar a
906
1105
  // uno como árbitro. Son públicos: van en cada instancia y en /peers.
907
1106
  this.peers = Array.isArray(data.peers) ? data.peers : []
1107
+ // QUÉ SABE HACER ESTE PROXIO, dicho por él al conectar (§14: una incompatibilidad
1108
+ // que no se anuncia se vive como silencio). `caps` ausente = proxio anterior a que
1109
+ // esto existiera: no se da por hecho nada, se pregunta igual y contesta la
1110
+ // pregunta.
1111
+ this.protocol = Number.isInteger(data.protocol) ? data.protocol : null
1112
+ this.caps = Array.isArray(data.caps) ? data.caps : null
908
1113
  /** Este nodo + los que conoce, sin repetidos. */
909
1114
  this.knownNodes = [this.node, ...this.peers].filter((n, i, a) => n && a.indexOf(n) === i)
910
1115
  this.token = this.instance
@@ -963,6 +1168,8 @@ export class WebSocketProxyClient {
963
1168
  case 'turn-credentials':
964
1169
  case 'pair-code':
965
1170
  case 'pair-redeem':
1171
+ case 'encpub-announced':
1172
+ case 'enc-lookup':
966
1173
  this._resolvePending(data, type)
967
1174
  break
968
1175
  case 'error':
@@ -0,0 +1,121 @@
1
+ /**
2
+ * LA LLAVE DE CIFRADO DE UN DESCONOCIDO, Y DE QUIÉN ES.
3
+ *
4
+ * Sellar un mensaje dirigido necesita la llave de cifrado del otro lado. Hasta ahora el
5
+ * pilar la exigía (`sendSealed({ peerEncPub })`) y no ofrecía ninguna forma de
6
+ * conseguirla, así que solo podían sellar dos puntas que se hubieran emparejado antes y
7
+ * se la hubieran intercambiado a mano. Todo lo demás —una sala, una invitación, un acuse
8
+ * a alguien que está apagado— viajaba en claro, que es exactamente lo que el proxio ve.
9
+ *
10
+ * Lo que hay aquí es el ANUNCIO: una frase corta, firmada por la misma llave con la que
11
+ * uno se identifica en el cable, que dice «mi llave de cifrado es ésta».
12
+ *
13
+ * { v:1, op:'encpub', aud:'dotrino:encpub', publickey, encpub, ts } + signature
14
+ *
15
+ * LO IMPORTANTE NO ES DÓNDE SE GUARDA, ES QUIÉN LO FIRMA. El proxio hace de buzón: se lo
16
+ * queda y se lo da a quien pregunte. Pero quien pregunta **no se fía de él**: verifica la
17
+ * firma contra la pubkey a la que va a escribir, que es la misma que el proxio usa para
18
+ * enrutar y la misma que `identify` comprueba. Si el proxio cambia la llave por la suya
19
+ * para poder leer, la firma no cuadra y no sale nada — ni sellado ni en claro.
20
+ *
21
+ * De ahí sale la regla dura de este módulo: **`readEncPubStatement` no devuelve nunca
22
+ * `null`.** O devuelve una llave verificada o lanza con un `code`. Un valor por defecto
23
+ * aquí sería una llave de cifrado que no es de nadie.
24
+ *
25
+ * `aud` es un propósito y no una URL a propósito: el mismo anuncio vale en cualquier nodo
26
+ * de la malla, y con la URL del proxio delante un anuncio hecho en `proxy1` no se podría
27
+ * verificar desde `proxy2`.
28
+ */
29
+ import { canonicalStringify } from './canonical.js'
30
+ import { verifyData, samePubkey } from './signature.js'
31
+
32
+ export const ENCPUB_V = 1
33
+
34
+ /** Para quién vale este anuncio: un propósito, no un servidor. */
35
+ export const ENCPUB_AUD = 'dotrino:encpub'
36
+
37
+ /** Cuerpo canónico del anuncio. Lo que se firma, ni un campo más. */
38
+ export function encPubBody ({ publickey, encPub, ts = Date.now() }) {
39
+ return { v: ENCPUB_V, op: 'encpub', aud: ENCPUB_AUD, publickey, encpub: encPub, ts }
40
+ }
41
+
42
+ /**
43
+ * ¿Es esto una llave de cifrado P-256 y no cualquier string? Se comprueba de verdad
44
+ * —igual que hace el acta— porque una llave mal formada no falla al guardarla: falla
45
+ * mucho después, al intentar sellarle algo a alguien.
46
+ */
47
+ export function isEncPub (v) {
48
+ if (typeof v !== 'string' || !v) return false
49
+ try {
50
+ const j = JSON.parse(v)
51
+ return j?.kty === 'EC' && j?.crv === 'P-256' && typeof j?.x === 'string' && typeof j?.y === 'string'
52
+ } catch (_) {
53
+ return false
54
+ }
55
+ }
56
+
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
+ /**
69
+ * Firma el anuncio con lo que firma la identidad (`identity.signData`, o la llave local
70
+ * del cliente). No genera llaves ni cifra nada: solo dice de quién es la que ya tienes.
71
+ *
72
+ * @param {{ publickey:string, encPub:string, sign:(data:any)=>Promise<string|{signature:string}> }} args
73
+ * @returns {Promise<{data:any, signature:string}>}
74
+ */
75
+ 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')
79
+ const data = encPubBody({ publickey, encPub })
80
+ const firmado = await sign(data)
81
+ const signature = typeof firmado === 'string' ? firmado : firmado?.signature
82
+ // «No pude firmar» no es «se cayó la red», y aquí es donde se separan: quien llama
83
+ // 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')
85
+ return { data, signature }
86
+ }
87
+
88
+ /**
89
+ * Abre un anuncio ajeno y devuelve la llave de cifrado, o LANZA.
90
+ *
91
+ * `publickey` es a quién le vas a escribir, y es el ancla entera: se comprueba que el
92
+ * anuncio hable de ESA llave y que lo haya firmado ESA llave. Sin ese argumento esto
93
+ * solo diría «alguien firmó esto», que no sirve para decidir a quién le sellas.
94
+ *
95
+ * @param {{data:any, signature:string}} statement
96
+ * @param {{ publickey:string }} expected
97
+ * @returns {Promise<string>} la `encpub` verificada
98
+ */
99
+ export async function readEncPubStatement (statement, { publickey } = /** @type {any} */ ({})) {
100
+ if (typeof publickey !== 'string' || !publickey) {
101
+ throw errorCon('readEncPubStatement: missing the publickey to check against', 'encpub-shape')
102
+ }
103
+ const data = statement?.data
104
+ 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')
108
+ // `samePubkey` y no `===`: un JWK serializado no es canónico y la misma llave escrita
109
+ // 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')
112
+ // Se verifica con la llave que ANUNCIA el sobre, que ya se comprobó que es la misma que
113
+ // la pedida: así el texto firmado y la llave que lo comprueba salen del mismo sitio.
114
+ if (!(await verifyData(data.publickey, data, signature))) {
115
+ throw errorCon('encpub statement: bad signature — the key is not bound to that identity', 'encpub-unverified')
116
+ }
117
+ return data.encpub
118
+ }
119
+
120
+ /** Lo firmado, en texto canónico. Mismo orden en las dos puntas o las firmas no cuadran. */
121
+ export const encPubSigningText = (data) => canonicalStringify(data)
@@ -1,10 +1,14 @@
1
1
  export { WebSocketProxyClient } from './client.js'
2
2
  export { canonicalStringify } from './canonical.js'
3
- export { getPublicKeyJwk, signData, buildSignedChannel, setKeypairStore } from './signature.js'
3
+ export { getPublicKeyJwk, signData, verifyData, samePubkey, buildSignedChannel, setKeypairStore } from './signature.js'
4
4
  export {
5
5
  seal, open, isSealed, makeEncKeypair, importEncPrivate, exportEncPrivate,
6
6
  setSealingPrimitives,
7
7
  } from './sealing.js'
8
+ export {
9
+ ENCPUB_V, ENCPUB_AUD, encPubBody, isEncPub,
10
+ buildEncPubStatement, readEncPubStatement,
11
+ } from './encpub.js'
8
12
 
9
13
  import { WebSocketProxyClient } from './client.js'
10
14
 
@@ -194,12 +194,87 @@ export async function signData (data) {
194
194
  return bufferToBase64(new Uint8Array(signature))
195
195
  }
196
196
 
197
+ /**
198
+ * ¿SON LA MISMA LLAVE? Nunca `===` sobre el JWK serializado.
199
+ *
200
+ * Es trampa conocida del ecosistema: un JWK serializado NO es canónico, así que la misma
201
+ * llave escrita por dos piezas distintas —o guardada por una tabla vieja y otra nueva— da
202
+ * dos strings diferentes y `===` dice que no son la misma. Lo que identifica a una P-256
203
+ * es el punto: `kty`, `crv`, `x` e `y`. Lo demás del JWK (`ext`, `key_ops`, `alg`, `use`)
204
+ * es cómo se usa, no cuál es.
205
+ *
206
+ * Ojo con lo que esto NO cambia: el proxio enruta por el STRING exacto, así que la
207
+ * dirección a la que se escribe sigue siendo la que te dio la app. Esto es para DECIDIR
208
+ * si dos referencias hablan de la misma identidad, no para reemplazar una por otra.
209
+ */
210
+ export function samePubkey (a, b) {
211
+ if (typeof a !== 'string' || typeof b !== 'string') return false
212
+ if (a === b) return true
213
+ try {
214
+ const x = JSON.parse(a)
215
+ const y = JSON.parse(b)
216
+ return !!x && !!y && x.kty === y.kty && x.crv === y.crv && x.x === y.x && x.y === y.y
217
+ } catch (_) {
218
+ return false
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Verifica una firma ajena sobre el JSON canónico de `data`.
224
+ *
225
+ * POR QUÉ VIVE AQUÍ Y NO SE IMPORTA DE `@dotrino/identity`, que tiene la misma función
226
+ * (`verifyDeviceSig` en `/capabilities`): esa ruta arrastra `vault/core.js` entero —tres
227
+ * mil líneas de bóveda, con el acta, el contenido y el cliente remoto detrás— por un
228
+ * `crypto.subtle.verify` de tres líneas, y este paquete lo importan ~30 PWAs. La capa de
229
+ * firma de este cliente ya era deliberadamente sin dependencias (mira `signData` justo
230
+ * arriba: tampoco usa el pilar); esto es su operación inversa, no un esquema nuevo.
231
+ *
232
+ * Que no se separen no se deja a la buena fe: `test/encpub.test.mjs` firma con
233
+ * `@dotrino/identity` y verifica con esto, y al revés. Si el pilar cambiara de algoritmo
234
+ * o de canonicalización, esa prueba se pone roja el mismo día.
235
+ *
236
+ * Devuelve `false` ante una clave ilegible o una firma corrupta — no lanza. Es la
237
+ * respuesta correcta a «¿esto lo firmó él?» cuando lo que llega es basura.
238
+ *
239
+ * @param {string} publickeyJwkStr JWK público serializado (lo mismo que viaja en el cable)
240
+ * @param {any} data
241
+ * @param {string} signatureB64
242
+ * @returns {Promise<boolean>}
243
+ */
244
+ export async function verifyData (publickeyJwkStr, data, signatureB64) {
245
+ if (typeof publickeyJwkStr !== 'string' || typeof signatureB64 !== 'string') return false
246
+ try {
247
+ const jwk = JSON.parse(publickeyJwkStr)
248
+ const key = await crypto.subtle.importKey(
249
+ 'jwk', jwk,
250
+ { name: 'ECDSA', namedCurve: 'P-256' },
251
+ false, ['verify']
252
+ )
253
+ const bytes = new TextEncoder().encode(canonicalStringify(data))
254
+ return crypto.subtle.verify(
255
+ { name: 'ECDSA', hash: { name: 'SHA-256' } },
256
+ key,
257
+ base64ToBuffer(signatureB64),
258
+ bytes
259
+ )
260
+ } catch (e) {
261
+ return false
262
+ }
263
+ }
264
+
197
265
  function bufferToBase64 (bytes) {
198
266
  let binary = ''
199
267
  for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i])
200
268
  return btoa(binary)
201
269
  }
202
270
 
271
+ function base64ToBuffer (b64) {
272
+ const bin = atob(b64)
273
+ const out = new Uint8Array(bin.length)
274
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
275
+ return out
276
+ }
277
+
203
278
  /**
204
279
  * Build the {data, signature} envelope for a channel name.
205
280
  */