@dotrino/identity 0.89.1 → 0.91.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 +1 -1
- package/src/index.d.ts +5 -0
- package/vault/acta.js +40 -1
- package/vault/core.js +17 -15
- package/vault/remote.js +1 -1
- package/vault/vendor/proxy-client/VERSION.txt +3 -1
- package/vault/vendor/proxy-client/client.js +318 -5
- package/vault/vendor/proxy-client/encpub.js +121 -0
- package/vault/vendor/proxy-client/index.js +6 -2
- package/vault/vendor/proxy-client/sealing.js +79 -0
- package/vault/vendor/proxy-client/signature.js +75 -0
package/package.json
CHANGED
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
|
@@ -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
|
}
|
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.
|
|
2579
|
-
*
|
|
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()
|
|
2586
|
-
|
|
2587
|
-
|
|
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
|
|
2592
|
-
|
|
2593
|
-
|
|
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
|
|
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
|
@@ -306,7 +306,7 @@ async function vaultRpc ({ master, proxy, device, cert, acta = null, sendType, o
|
|
|
306
306
|
cleanup(); reject(vaultError(p))
|
|
307
307
|
}
|
|
308
308
|
})
|
|
309
|
-
const t = setTimeout(() => { cleanup(); reject(new Error('the vault did not reply (is it running?)')) }, timeoutMs)
|
|
309
|
+
const t = setTimeout(() => { cleanup(); reject(Object.assign(new Error('the vault did not reply (is it running?)'), { code: 'vault-no-reply' })) }, timeoutMs)
|
|
310
310
|
const cleanup = () => { off(); clearTimeout(t); clearTimeout(graceTimer) }
|
|
311
311
|
})
|
|
312
312
|
client.sendByPubkey(master, { type: sendType, data: signed, signature, cert })
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
Copia vendorizada de @dotrino/proxy-client@0.
|
|
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.
|
|
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
|
/**
|
|
@@ -12,6 +13,15 @@ import { WebRTCManager, RTC_TAG, DEFAULT_ICE_SERVERS, loadNodePeerConnection, re
|
|
|
12
13
|
* @param {string} code
|
|
13
14
|
* @returns {Error & { code: string }}
|
|
14
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
|
+
|
|
15
25
|
function errorCon (mensaje, code) {
|
|
16
26
|
const e = /** @type {Error & { code: string }} */ (new Error(mensaje))
|
|
17
27
|
e.code = code
|
|
@@ -32,6 +42,7 @@ function errorCon (mensaje, code) {
|
|
|
32
42
|
* - 'channel_joined' (channel, token) : new peer joined the channel
|
|
33
43
|
* - 'channel_left' (channel, token) : peer unpublished
|
|
34
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)
|
|
35
46
|
* - 'reconnecting' (attempt, max)
|
|
36
47
|
* - 'reconnect_failed' (attempts)
|
|
37
48
|
*/
|
|
@@ -58,6 +69,57 @@ export class WebSocketProxyClient {
|
|
|
58
69
|
this.requireSealed = options.requireSealed === true
|
|
59
70
|
this.myEncPrivateKey = options.myEncPrivateKey || null
|
|
60
71
|
|
|
72
|
+
/**
|
|
73
|
+
* MI llave de cifrado, la pública. Con ella puesta, `identify` anuncia al proxio
|
|
74
|
+
* —firmado— que esta identidad se abre por aquí, y cualquiera que sepa mi pubkey puede
|
|
75
|
+
* averiguarla y sellarme sin habernos emparejado nunca. Sin ella, los demás no tienen
|
|
76
|
+
* de dónde sacarla y `sendSealed` hacia mí no sale: falla en vez de ir en claro.
|
|
77
|
+
*
|
|
78
|
+
* En un aparato headless es `(await makeEncKeypair()).encPub`; en el navegador,
|
|
79
|
+
* `await identity.getEncryptionPubkey()` — ahí la privada vive en la bóveda y no se
|
|
80
|
+
* pasa, se delega en `sealing`.
|
|
81
|
+
*/
|
|
82
|
+
this.myEncPub = options.myEncPub || null
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Llaves de cifrado ajenas YA VERIFICADAS (pubkey → encpub). Solo entra aquí lo que
|
|
86
|
+
* pasó por `readEncPubStatement`, así que lo de dentro está atado a su identidad.
|
|
87
|
+
* No se cachean los fallos: «hoy no la tengo» no es un hecho, es un momento.
|
|
88
|
+
*/
|
|
89
|
+
this._encPubs = new Map()
|
|
90
|
+
this._encPubInflight = new Map()
|
|
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
|
+
|
|
109
|
+
/**
|
|
110
|
+
* QUIEN YA SABE LA RESPUESTA, QUE NO PREGUNTE. Los aparatos de un mismo dueño llevan su
|
|
111
|
+
* llave de cifrado escrita en el ACTA (`memberEncPub` de `@dotrino/identity`), firmada
|
|
112
|
+
* por el master: eso es más fuerte que el anuncio y no hace falta ir al proxio a por
|
|
113
|
+
* ello. Una app con acta enchufa aquí
|
|
114
|
+
* `encPubResolver: (pub) => memberEncPub(acta, pub)`.
|
|
115
|
+
*
|
|
116
|
+
* Devolver `null` significa «yo no sé», y entonces se pregunta al proxio. No es un
|
|
117
|
+
* repliegue: si no lo sabe nadie, no se manda nada — se sigue fallando cerrado.
|
|
118
|
+
*
|
|
119
|
+
* @type {((publickey:string)=>Promise<string|null>|string|null)|null}
|
|
120
|
+
*/
|
|
121
|
+
this.encPubResolver = typeof options.encPubResolver === 'function' ? options.encPubResolver : null
|
|
122
|
+
|
|
61
123
|
/**
|
|
62
124
|
* Who does the sealing. Two worlds, and only one of them holds the key:
|
|
63
125
|
*
|
|
@@ -187,6 +249,8 @@ export class WebSocketProxyClient {
|
|
|
187
249
|
// perderla.
|
|
188
250
|
if (options.sealing) this.sealing = options.sealing
|
|
189
251
|
if (options.myEncPrivateKey) this.myEncPrivateKey = options.myEncPrivateKey
|
|
252
|
+
if (options.myEncPub) this.myEncPub = options.myEncPub
|
|
253
|
+
if (typeof options.encPubResolver === 'function') this.encPubResolver = options.encPubResolver
|
|
190
254
|
|
|
191
255
|
// Se puede ENCENDER, no apagar. Bajar la exigencia en caliente dejaría que
|
|
192
256
|
// cualquier otro módulo de la app la desactivara sin querer, y no hay ningún
|
|
@@ -210,6 +274,15 @@ export class WebSocketProxyClient {
|
|
|
210
274
|
* The payload is JSON-stringified into the envelope's `message` field.
|
|
211
275
|
*/
|
|
212
276
|
send (to, payload) {
|
|
277
|
+
// `requireSealed` vale para los DOS caminos dirigidos, no solo para el de pubkey
|
|
278
|
+
// (CONVENCIONES §4.1 nombra `sendByPubkey` / `send`). Guardar uno y dejar el otro
|
|
279
|
+
// abierto es no guardar ninguno: la app manda por donde le sale y el proxio lo lee
|
|
280
|
+
// igual. Para sellar por token está `sendSealedTo`.
|
|
281
|
+
if (this.requireSealed && !this._isSealed(payload)) {
|
|
282
|
+
throw errorCon(
|
|
283
|
+
'requireSealed: refusing to send a directed message in the clear — use sendSealedTo()',
|
|
284
|
+
'unsealed')
|
|
285
|
+
}
|
|
213
286
|
const tokens = Array.isArray(to) ? to : [to]
|
|
214
287
|
const messageStr = typeof payload === 'string' ? payload : JSON.stringify(payload)
|
|
215
288
|
if (!this._rtc) {
|
|
@@ -370,12 +443,215 @@ export class WebSocketProxyClient {
|
|
|
370
443
|
* should use for anything that is not meant for the proxy's eyes.
|
|
371
444
|
*/
|
|
372
445
|
async sendSealed (toPubkeys, payload, { peerEncPub, ...opts } = {}) {
|
|
373
|
-
|
|
374
|
-
|
|
446
|
+
const list = Array.isArray(toPubkeys) ? toPubkeys : [toPubkeys]
|
|
447
|
+
// Con la llave puesta a mano se respeta tal cual: quien la pasa está diciendo que ya
|
|
448
|
+
// sabe de quién es (se emparejaron), y un sobre vale para todos los destinatarios.
|
|
449
|
+
if (peerEncPub) {
|
|
450
|
+
this._sendByPubkeyRaw(list, await this._seal(payload, peerEncPub), opts)
|
|
375
451
|
return
|
|
376
452
|
}
|
|
377
|
-
|
|
378
|
-
|
|
453
|
+
// Sin ella, se averigua. UNA ENVOLTURA POR DESTINATARIO: cada uno tiene su llave, así
|
|
454
|
+
// que un solo sobre solo lo abriría uno.
|
|
455
|
+
//
|
|
456
|
+
// Y SE RESUELVEN TODAS ANTES DE MANDAR NADA. Enviar a los que se pudo y fallar por el
|
|
457
|
+
// resto deja a la app creyendo que el mensaje salió, con la mitad de la sala sin él y
|
|
458
|
+
// sin forma de saber cuál mitad.
|
|
459
|
+
const llaves = await Promise.all(list.map(async (pk) => [pk, await this.encPubOf(pk)]))
|
|
460
|
+
for (const [pk, encPub] of llaves) {
|
|
461
|
+
this._sendByPubkeyRaw([pk], await this._seal(payload, encPub), opts)
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Sellar y mandar POR TOKEN, que es como hablan entre sí los de una sala (y lo único
|
|
467
|
+
* que puede subir a WebRTC, §transporte: el camino más directo).
|
|
468
|
+
*
|
|
469
|
+
* `peerPubkey` no es un adorno: el token es una dirección del proxio y no dice de quién
|
|
470
|
+
* es. Quien puede afirmar «este token es de esta identidad» es la app —lo sabe por el
|
|
471
|
+
* canal, por el saludo de la sala o por la invitación—, así que lo dice ella y aquí se
|
|
472
|
+
* sella a esa identidad. Sin ese dato no hay a quién sellarle y no se manda nada.
|
|
473
|
+
*
|
|
474
|
+
* @param {string|string[]} toTokens
|
|
475
|
+
* @param {any} payload
|
|
476
|
+
* @param {{ peerPubkey?:string, peerEncPub?:string }} opts
|
|
477
|
+
*/
|
|
478
|
+
async sendSealedTo (toTokens, payload, { peerPubkey, peerEncPub } = /** @type {any} */ ({})) {
|
|
479
|
+
const tokens = Array.isArray(toTokens) ? toTokens : [toTokens]
|
|
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])
|
|
485
|
+
if (!peerPubkey) {
|
|
486
|
+
throw errorCon(
|
|
487
|
+
'sendSealedTo: nobody has said whose this token is — greet it (helloTo) or pass peerPubkey',
|
|
488
|
+
'no-peer-identity')
|
|
489
|
+
}
|
|
490
|
+
peerEncPub = await this.encPubOf(peerPubkey)
|
|
491
|
+
}
|
|
492
|
+
const sobre = await this._seal(payload, peerEncPub)
|
|
493
|
+
// Por `send`, no por `_sendRaw`: así sigue prefiriendo el canal directo si lo hay.
|
|
494
|
+
this.send(tokens, sobre)
|
|
495
|
+
}
|
|
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
|
+
|
|
556
|
+
/** Sella con lo que haya: la bóveda de la app (`sealing`) o las primitivas del pilar. */
|
|
557
|
+
async _seal (payload, peerEncPub) {
|
|
558
|
+
if (!peerEncPub) throw errorCon('seal: missing peerEncPub', 'unsealed')
|
|
559
|
+
return this.sealing ? this.sealing.seal(payload, peerEncPub) : seal(payload, peerEncPub)
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ---------- llaves de cifrado ajenas ----------
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* ANUNCIAR MI LLAVE DE CIFRADO. Una frase firmada por la misma identidad con la que me
|
|
566
|
+
* identifico; el proxio la guarda y se la da a quien pregunte, y quien pregunta la
|
|
567
|
+
* verifica contra mi pubkey. El proxio es el buzón, no la autoridad.
|
|
568
|
+
*
|
|
569
|
+
* Lo llama `identify` solo cuando el cliente tiene `myEncPub`. Se expone aparte para el
|
|
570
|
+
* caso de anunciar una llave nueva sin reconectar.
|
|
571
|
+
*/
|
|
572
|
+
async announceEncPub ({ publickey, encPub, sign } = /** @type {any} */ ({})) {
|
|
573
|
+
const statement = await buildEncPubStatement({ publickey, encPub, sign })
|
|
574
|
+
await this._request({ type: 'encpub', ...statement }, 'encpub-announced')
|
|
575
|
+
// Lo mío también va a la caché: si una app se escribe a sí misma (otro aparato del
|
|
576
|
+
// mismo perfil no, ése tiene otra llave) no hace falta preguntar por ello.
|
|
577
|
+
this._encPubs.set(publickey, encPub)
|
|
578
|
+
return encPub
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* LA LLAVE DE CIFRADO DE UNA IDENTIDAD, verificada. Devuelve la llave o LANZA:
|
|
583
|
+
*
|
|
584
|
+
* · `no-encpub` nadie ha anunciado llave para esa identidad — se arregla
|
|
585
|
+
* cuando el otro lado actualice; esperar no sirve
|
|
586
|
+
* · `encpub-unverified` llegó una llave que NO firmó esa identidad. Es el caso que
|
|
587
|
+
* importa: significa que alguien intentó ponerte la suya
|
|
588
|
+
* · `no-encpub-support` este proxio no sabe de esto (es viejo)
|
|
589
|
+
*
|
|
590
|
+
* Nunca devuelve `null` y nunca cae a «manda igual»: si no se puede sellar, no se manda.
|
|
591
|
+
*/
|
|
592
|
+
async encPubOf (publickey) {
|
|
593
|
+
if (typeof publickey !== 'string' || !publickey) {
|
|
594
|
+
throw errorCon('encPubOf: missing publickey', 'encpub-shape')
|
|
595
|
+
}
|
|
596
|
+
const cacheada = this._encPubs.get(publickey)
|
|
597
|
+
if (cacheada) return cacheada
|
|
598
|
+
// Una pregunta en vuelo por llave: una sala de ocho manda ocho mensajes a la vez y
|
|
599
|
+
// preguntaría ocho veces por lo mismo.
|
|
600
|
+
const enVuelo = this._encPubInflight.get(publickey)
|
|
601
|
+
if (enVuelo) return enVuelo
|
|
602
|
+
const promesa = this._lookupEncPub(publickey)
|
|
603
|
+
.finally(() => this._encPubInflight.delete(publickey))
|
|
604
|
+
this._encPubInflight.set(publickey, promesa)
|
|
605
|
+
return promesa
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async _lookupEncPub (publickey) {
|
|
609
|
+
// EL CAMINO MÁS DIRECTO PRIMERO. Si la app comparte acta con el destinatario, la llave
|
|
610
|
+
// ya la tiene en la mano y firmada por el master: preguntársela a un servidor sería
|
|
611
|
+
// dar la vuelta para llegar a lo que ya está aquí.
|
|
612
|
+
if (this.encPubResolver) {
|
|
613
|
+
const propia = await this.encPubResolver(publickey)
|
|
614
|
+
if (propia) {
|
|
615
|
+
if (!isEncPub(propia)) {
|
|
616
|
+
throw errorCon('encPubResolver returned something that is not a P-256 public JWK', 'encpub-unverified')
|
|
617
|
+
}
|
|
618
|
+
this._encPubs.set(publickey, propia)
|
|
619
|
+
return propia
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
// SI EL PROXIO DICE QUE NO SABE DE ESTO, se dice con su propio código en vez de
|
|
623
|
+
// esperar diez segundos a un timeout. Un proxio que no manda `caps` es de antes de
|
|
624
|
+
// que esto existiera y no se sabe: se pregunta igual, y contesta la pregunta.
|
|
625
|
+
if (Array.isArray(this.caps) && !this.caps.includes('encpub')) {
|
|
626
|
+
throw errorCon(
|
|
627
|
+
`este proxio (${this.url}) no sirve llaves de cifrado: hace falta websocket-proxy >= 1.1.0`,
|
|
628
|
+
'no-encpub-support')
|
|
629
|
+
}
|
|
630
|
+
const res = await this._request({ type: 'enc-lookup', publickeys: [publickey] }, 'enc-lookup')
|
|
631
|
+
// `samePubkey`, no `===`: el proxio guarda el string exacto con el que se anunció, y
|
|
632
|
+
// la app puede tener el mismo JWK escrito de otra forma.
|
|
633
|
+
const statement = (res.keys || []).find((k) => samePubkey(k?.data?.publickey, publickey))
|
|
634
|
+
if (!statement) {
|
|
635
|
+
throw errorCon('no encryption key announced for that identity — it cannot be sealed to yet', 'no-encpub')
|
|
636
|
+
}
|
|
637
|
+
// AQUÍ ES DONDE EL PROXIO DEJA DE IMPORTAR: la firma se comprueba contra la pubkey a
|
|
638
|
+
// la que vamos a escribir. Si cambió la llave por la suya, esto lanza.
|
|
639
|
+
const encPub = await readEncPubStatement(statement, { publickey })
|
|
640
|
+
this._encPubs.set(publickey, encPub)
|
|
641
|
+
return encPub
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Guardar una llave que ya viene firmada por su dueño (de un canal, de una invitación). */
|
|
645
|
+
async learnEncPub (statement, { publickey } = /** @type {any} */ ({})) {
|
|
646
|
+
const encPub = await readEncPubStatement(statement, { publickey })
|
|
647
|
+
this._encPubs.set(publickey, encPub)
|
|
648
|
+
return encPub
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** Olvidar lo aprendido de una identidad (rotó su llave, o se quiere volver a preguntar). */
|
|
652
|
+
forgetEncPub (publickey) {
|
|
653
|
+
if (publickey == null) this._encPubs.clear()
|
|
654
|
+
else this._encPubs.delete(publickey)
|
|
379
655
|
}
|
|
380
656
|
|
|
381
657
|
_isSealed (msg) {
|
|
@@ -530,9 +806,28 @@ export class WebSocketProxyClient {
|
|
|
530
806
|
// escribirle a la PERSONA llega a cualquiera de sus dispositivos. Ver acta-de-perfil.md.
|
|
531
807
|
if (acta) msg.acta = acta
|
|
532
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
|
|
533
813
|
if (this._rtc && typeof sign === 'function' && data.publickey) {
|
|
534
814
|
done.then(() => this.enableTurn({ publicKey: data.publickey, sign })).catch(() => {})
|
|
535
815
|
}
|
|
816
|
+
// ANUNCIAR LA LLAVE DE CIFRADO, y por detrás. Identificarse es lo que da dirección a
|
|
817
|
+
// esta identidad; anunciar su llave es lo que hace que alguien pueda sellarle sin
|
|
818
|
+
// haberla emparejado antes. Va después y sin bloquear, como TURN: esperar a esto
|
|
819
|
+
// retrasaría el primer mensaje para ganar algo que solo hace falta cuando el OTRO
|
|
820
|
+
// quiera escribirnos.
|
|
821
|
+
if (typeof sign === 'function' && data.publickey && this.myEncPub) {
|
|
822
|
+
done
|
|
823
|
+
.then(() => this.announceEncPub({ publickey: data.publickey, encPub: this.myEncPub, sign }))
|
|
824
|
+
.catch((e) => {
|
|
825
|
+
// Se dice en voz alta: sin anuncio nadie podrá sellarnos, y el síntoma llega
|
|
826
|
+
// días después y del otro lado («no puedo escribirte»). Callarlo es justo el
|
|
827
|
+
// fallo mudo que el ecosistema paga caro.
|
|
828
|
+
this._emit('error', { type: 'encpub_announce_failed', error: e, code: e?.code || null })
|
|
829
|
+
})
|
|
830
|
+
}
|
|
536
831
|
return done
|
|
537
832
|
}
|
|
538
833
|
|
|
@@ -905,6 +1200,12 @@ export class WebSocketProxyClient {
|
|
|
905
1200
|
// de salas): se pregunta en cada nodo y se mezcla, en vez de designar a
|
|
906
1201
|
// uno como árbitro. Son públicos: van en cada instancia y en /peers.
|
|
907
1202
|
this.peers = Array.isArray(data.peers) ? data.peers : []
|
|
1203
|
+
// QUÉ SABE HACER ESTE PROXIO, dicho por él al conectar (§14: una incompatibilidad
|
|
1204
|
+
// que no se anuncia se vive como silencio). `caps` ausente = proxio anterior a que
|
|
1205
|
+
// esto existiera: no se da por hecho nada, se pregunta igual y contesta la
|
|
1206
|
+
// pregunta.
|
|
1207
|
+
this.protocol = Number.isInteger(data.protocol) ? data.protocol : null
|
|
1208
|
+
this.caps = Array.isArray(data.caps) ? data.caps : null
|
|
908
1209
|
/** Este nodo + los que conoce, sin repetidos. */
|
|
909
1210
|
this.knownNodes = [this.node, ...this.peers].filter((n, i, a) => n && a.indexOf(n) === i)
|
|
910
1211
|
this.token = this.instance
|
|
@@ -925,6 +1226,13 @@ export class WebSocketProxyClient {
|
|
|
925
1226
|
this._rtc.handleIncoming(from, parsed)
|
|
926
1227
|
break
|
|
927
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
|
+
}
|
|
928
1236
|
this._deliver(from, parsed ?? message, {
|
|
929
1237
|
raw: message, timestamp, via: 'proxy',
|
|
930
1238
|
fromPubkey: from_publickey || null,
|
|
@@ -934,6 +1242,9 @@ export class WebSocketProxyClient {
|
|
|
934
1242
|
break
|
|
935
1243
|
}
|
|
936
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)
|
|
937
1248
|
this._emit('peer_disconnected', data.token, data.channel || null)
|
|
938
1249
|
if (this._rtc && data.token) this._rtc.closePeer(data.token)
|
|
939
1250
|
this._resolvePending(data, 'token')
|
|
@@ -963,6 +1274,8 @@ export class WebSocketProxyClient {
|
|
|
963
1274
|
case 'turn-credentials':
|
|
964
1275
|
case 'pair-code':
|
|
965
1276
|
case 'pair-redeem':
|
|
1277
|
+
case 'encpub-announced':
|
|
1278
|
+
case 'enc-lookup':
|
|
966
1279
|
this._resolvePending(data, type)
|
|
967
1280
|
break
|
|
968
1281
|
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
|
-
setSealingPrimitives,
|
|
6
|
+
setSealingPrimitives, identitySealing,
|
|
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
|
|
|
@@ -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
|
+
}
|
|
@@ -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
|
*/
|