@dotrino/identity 0.115.0 → 0.116.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.
Files changed (78) hide show
  1. package/package.json +4 -1
  2. package/src/node.js +4 -180
  3. package/vault/core.js +66 -7
  4. package/vault/facade.js +200 -0
  5. package/vault/index.html +10 -0
  6. package/vault/remote.js +45 -5
  7. package/vault/vault.js +29 -58
  8. package/vault/vendor/compat/VERSION.txt +3 -0
  9. package/vault/vendor/compat/advisory.js +141 -0
  10. package/vault/vendor/compat/index.js +188 -0
  11. package/vault/vendor/compat/ranges.js +93 -0
  12. package/vault/vendor/opaque/VERSION.txt +1 -1
  13. package/vault/vendor/passmanager/VERSION.txt +3 -0
  14. package/vault/vendor/passmanager/crypto.js +167 -0
  15. package/vault/vendor/passmanager/fields.js +183 -0
  16. package/vault/vendor/passmanager/generate.js +74 -0
  17. package/vault/vendor/passmanager/import.js +156 -0
  18. package/vault/vendor/passmanager/index.js +27 -0
  19. package/vault/vendor/passmanager/match.js +137 -0
  20. package/vault/vendor/passmanager/model.js +332 -0
  21. package/vault/vendor/passmanager/pubkey.js +33 -0
  22. package/vault/vendor/passmanager/sealed/convert.js +72 -0
  23. package/vault/vendor/passmanager/sealed/device.js +211 -0
  24. package/vault/vendor/passmanager/sealed/entry.js +209 -0
  25. package/vault/vendor/passmanager/sealed/index.js +20 -0
  26. package/vault/vendor/passmanager/sealed/keys.js +120 -0
  27. package/vault/vendor/passmanager/sealed/recovery.js +136 -0
  28. package/vault/vendor/passmanager/sealed/store.js +508 -0
  29. package/vault/vendor/passmanager/session-cache.js +60 -0
  30. package/vault/vendor/passmanager/totp.js +111 -0
  31. package/vault/vendor/passmanager/transport/protocol.js +83 -0
  32. package/vault/vendor/passmanager/transport/proxy.js +112 -0
  33. package/vault/vendor/passmanager/transport/sealed.js +74 -0
  34. package/vault/vendor/passmanager/vault/approval.js +63 -0
  35. package/vault/vendor/passmanager/vault/errors.js +34 -0
  36. package/vault/vendor/passmanager/vault/guard.js +114 -0
  37. package/vault/vendor/passmanager/vault/interface.js +34 -0
  38. package/vault/vendor/passmanager/vault/local.js +213 -0
  39. package/vault/vendor/passmanager/vault/remote.js +85 -0
  40. package/vault/vendor/passmanager/vault/responder.js +192 -0
  41. package/vault/vendor/passmanager/vault/sealed-local.js +199 -0
  42. package/vault/vendor/passmanager/vault/sealed-responder.js +159 -0
  43. package/vault/vendor/passmanager/vault/sealed.js +362 -0
  44. package/vault/vendor/passmanager/webauthn.js +221 -0
  45. package/vault/vendor/proxy-client/VERSION.txt +1 -1
  46. package/vault/vendor/proxy-client/client.js +36 -0
  47. package/vault/vendor/roadmap/VERSION.txt +3 -0
  48. package/vault/vendor/roadmap/index.js +108 -0
  49. package/vault/vendor/roadmap/manifests.js +797 -0
  50. package/vault/vendor/store/VERSION.txt +3 -0
  51. package/vault/vendor/store/core.js +309 -0
  52. package/vault/vendor/vault/VERSION.txt +4 -1
  53. package/vault/vendor/vault/admin.js +437 -0
  54. package/vault/vendor/vault/browser.js +167 -0
  55. package/vault/vendor/vault/core/approvals.js +204 -0
  56. package/vault/vendor/vault/core/blocked.js +68 -0
  57. package/vault/vendor/vault/core/browserDisk.js +109 -0
  58. package/vault/vendor/vault/core/commKey.js +68 -0
  59. package/vault/vendor/vault/core/disk.js +65 -0
  60. package/vault/vendor/vault/core/libVersion.js +8 -0
  61. package/vault/vendor/vault/core/machines.js +66 -0
  62. package/vault/vendor/vault/core/notices.js +71 -0
  63. package/vault/vendor/vault/core/notifiable.js +73 -0
  64. package/vault/vendor/vault/core/passwords.js +125 -0
  65. package/vault/vendor/vault/core/sealKey.js +77 -0
  66. package/vault/vendor/vault/core/sealer.js +172 -0
  67. package/vault/vendor/vault/core/sealers.js +159 -0
  68. package/vault/vendor/vault/core/secretsStore.js +1326 -0
  69. package/vault/vendor/vault/core/sha256.js +69 -0
  70. package/vault/vendor/vault/core/store.js +68 -0
  71. package/vault/vendor/vault/core/subacta.js +101 -0
  72. package/vault/vendor/vault/core/threadStore.js +273 -0
  73. package/vault/vendor/vault/core/transport.js +270 -0
  74. package/vault/vendor/vault/core/vault.js +4414 -0
  75. package/vault/vendor/vault/enroll.js +8 -4
  76. package/vault/vendor/vault/procContext.js +97 -0
  77. package/vault/vendor/vault/revocation.js +67 -0
  78. package/vault/vendor/vault/sealed.js +84 -0
@@ -0,0 +1,362 @@
1
+ // LA BÓVEDA SELLADA, VISTA DESDE EL APARATO.
2
+ //
3
+ // Cumple el MISMO contrato que `RemoteVault` (`./interface.js`: find/get/put/patch/search/
4
+ // sites/remove) para que la interfaz no tenga que aprender nada nuevo. Lo que cambia está
5
+ // debajo: al otro lado ya no hay una bóveda que abre entradas, hay una que guarda sobres.
6
+ // Así que todo lo que antes hacía ella lo hace esto, aquí:
7
+ //
8
+ // · **buscar por sitio** — se calculan las huellas de la URL que hay delante y la bóveda
9
+ // compara cadenas. El emparejamiento de verdad (subdominios, comodines) se rehace aquí
10
+ // con `match.js` sobre las vistas ya abiertas, así que la regla no cambia;
11
+ // · **abrir** — cada sobre con la envoltura de este aparato;
12
+ // · **escribir** — cifrar, envolver a los destinatarios que dice el acta y firmar;
13
+ // · **buscar por texto** — la bóveda no ve ningún texto: se piden las vistas y se busca
14
+ // aquí (§2.2). Son vistas, no valores.
15
+ //
16
+ // Lo que este aparato no puede abrir se dice en `withheld` en vez de aparecer vacío.
17
+
18
+ import { SEALED_OPS } from '../transport/protocol.js'
19
+ import { VaultError, CODES } from './errors.js'
20
+ import { hostOf, registrableDomain, findForUrl } from '../match.js'
21
+ import { profileKeys, siteIndex, fieldIndex, passkeyIndex, valueDigest } from '../sealed/keys.js'
22
+ import { buildSealedEntry, buildSealedPatch, buildProfileKey, openSealedEntry, makeOpener } from '../sealed/device.js'
23
+ import { openView, PASSKEY_FIELD } from '../sealed/entry.js'
24
+ import { fieldKey, normalizeFields } from '../fields.js'
25
+
26
+ /** Las claves de campo de la interfaz, tal como las pide `get(id, { keys })`. */
27
+ const FIXED = ['name', 'username', 'secret', 'totp', 'notes']
28
+
29
+ /**
30
+ * DE LA CLAVE QUE USA LA INTERFAZ A LA QUE GUARDA LA BÓVEDA.
31
+ *
32
+ * Las fijas viajan por su nombre —vocabulario cerrado, igual para todo el mundo— y las
33
+ * libres por HUELLA: «cédula» es del usuario y la bóveda no tiene por qué leerlo. La
34
+ * traducción vive aquí y en un solo sitio; si quien guarda y quien pide la hicieran
35
+ * distinta, el sobre no se encontraría y no habría forma de verlo.
36
+ */
37
+ const esFija = (k) => FIXED.includes(k) || k.startsWith('wa.')
38
+
39
+ export class SealedVault {
40
+ /**
41
+ * @param {object} transport `{ request(op, payload) }` — el mismo de `RemoteVault`
42
+ * @param {object} opts
43
+ * `identity` `{ publickey, sign(body), openSealed({ wrap, envelope }) }`. La privada de
44
+ * cifrado no sale de donde viva: aquí solo se pide abrir.
45
+ * `needsApproval` si lo privado exige un dedo encima al otro lado (solo informativo)
46
+ */
47
+ constructor (transport, { identity, needsApproval = true } = {}) {
48
+ if (!transport?.request) throw new Error('SealedVault: missing transport')
49
+ if (typeof identity?.sign !== 'function' || typeof identity?.openSealed !== 'function') {
50
+ throw new Error('SealedVault: identity needs { publickey, sign(body), openSealed({wrap,envelope}) }')
51
+ }
52
+ this.transport = transport
53
+ this.identity = identity
54
+ this.needsApproval = needsApproval
55
+ this._keys = null
56
+ this._recipients = null
57
+ }
58
+
59
+ get capabilities () {
60
+ return { canWrite: true, canList: false, needsApproval: this.needsApproval, sealed: true }
61
+ }
62
+
63
+ /** Lo que se cachea muere al dormirse el worker, que es justo cuando hay que releerlo. */
64
+ forget () { this._keys = null; this._recipients = null }
65
+
66
+ async #request (op, payload) {
67
+ if (!SEALED_OPS.includes(op)) throw new VaultError(CODES.NOT_ALLOWED, 'operación desconocida: ' + op)
68
+ try {
69
+ return await this.transport.request(op, payload)
70
+ } catch (e) {
71
+ if (e instanceof VaultError) throw e
72
+ throw new VaultError(e?.code || CODES.UNREACHABLE, e?.message || 'no hay bóveda al otro lado')
73
+ }
74
+ }
75
+
76
+ /**
77
+ * LAS LLAVES DEL PERFIL. Sin ellas no se puede ni buscar (el índice va con huella) ni
78
+ * comparar, así que es lo primero que pide este aparato al despertar.
79
+ */
80
+ async keys () {
81
+ if (this._keys) return this._keys
82
+ const { envelope, wrap } = await this.#request('pm2.profile', {})
83
+ const base = await this.identity.openSealed({ wrap, envelope })
84
+ this._keys = await profileKeys(base)
85
+ return this._keys
86
+ }
87
+
88
+ /** A quién hay que envolverle cada llave. La lista sale del acta y la sabe la bóveda. */
89
+ async recipients () {
90
+ if (this._recipients) return this._recipients
91
+ this._recipients = await this.#request('pm2.recipients', {})
92
+ return this._recipients
93
+ }
94
+
95
+ async #sealedKey (k) {
96
+ if (esFija(k)) return k
97
+ const { kidx } = await this.keys()
98
+ return 'f:' + await fieldIndex(kidx, k)
99
+ }
100
+
101
+ #opener (wraps) {
102
+ return makeOpener({ wraps, openSealed: (x) => this.identity.openSealed(x) })
103
+ }
104
+
105
+ async #openView (v) {
106
+ const abierta = await openView(
107
+ { id: v.id, updatedAt: v.updatedAt, view: v.view, digests: v.digests },
108
+ this.#opener({ [v.view.gen]: v.wrap })
109
+ )
110
+ // `fieldHashes` conserva el nombre que ya usa la interfaz para comparar. Dos cosas
111
+ // cambian debajo: los resúmenes están GUARDADOS (antes era un HMAC con un nonce de la
112
+ // respuesta), y su clave viaja por huella — así que aquí se traducen de vuelta a los
113
+ // nombres de verdad, que la vista sí lleva porque va sellada.
114
+ const fieldHashes = {}
115
+ for (const k of [...abierta.fieldKeys, 'notes']) {
116
+ const clave = await this.#sealedKey(k)
117
+ if (abierta.digests[clave] !== undefined) fieldHashes[k] = abierta.digests[clave]
118
+ }
119
+ return { ...abierta, fieldHashes }
120
+ }
121
+
122
+ /**
123
+ * LAS HUELLAS DE LA URL QUE HAY DELANTE. El sitio guardado puede ser el dominio y la URL
124
+ * un subdominio suyo (`empresa.com` vale en `login.empresa.com`), así que se calculan las
125
+ * huellas de CADA variante: el host, sus padres hasta el dominio registrable, y las de
126
+ * comodín. La bóveda solo compara cadenas.
127
+ */
128
+ async #hashesFor (url) {
129
+ const { kidx } = await this.keys()
130
+ const host = hostOf(url)
131
+ if (!host) return []
132
+ const base = registrableDomain(host)
133
+ const variantes = new Set([host])
134
+ let actual = host
135
+ while (actual.includes('.') && actual !== base) {
136
+ actual = actual.slice(actual.indexOf('.') + 1)
137
+ variantes.add(actual)
138
+ }
139
+ const out = []
140
+ for (const v of variantes) {
141
+ out.push(await siteIndex(kidx, v))
142
+ out.push(await siteIndex(kidx, '*.' + v))
143
+ }
144
+ return out
145
+ }
146
+
147
+ /**
148
+ * QUÉ HAY PARA ESTE SITIO. Las huellas son un filtro previo; el emparejamiento de verdad
149
+ * —el que decide si `empresa.com` vale aquí— se rehace con `match.js` sobre las vistas ya
150
+ * abiertas, para que la regla siga siendo UNA y no dos que se desincronizan.
151
+ */
152
+ async find (url) {
153
+ const hits = await this.#request('pm2.find', { idx: await this.#hashesFor(url) })
154
+ const vistas = []
155
+ for (const v of hits) vistas.push(await this.#openView(v))
156
+ return findForUrl(vistas, url).map((x) => x.entry)
157
+ }
158
+
159
+ /** La passkey que este sitio pide, o la que tiene ese id de credencial. */
160
+ async findPasskey ({ rpId = null, credentialId = null } = {}) {
161
+ const { kidx } = await this.keys()
162
+ const hits = await this.#request('pm2.passkey', {
163
+ ...(rpId ? { rp: await passkeyIndex(kidx, 'rp', rpId) } : {}),
164
+ ...(credentialId ? { cred: await passkeyIndex(kidx, 'cred', credentialId) } : {})
165
+ })
166
+ const out = []
167
+ for (const v of hits) out.push(await this.#openView(v))
168
+ return out
169
+ }
170
+
171
+ /** UNA credencial, y solo los campos pedidos. */
172
+ async get (id, opts = {}) {
173
+ const keys = Array.isArray(opts.keys) ? await Promise.all(opts.keys.map((k) => this.#sealedKey(k))) : null
174
+ const got = await this.#request('pm2.get', { id, ...(keys ? { keys } : {}) })
175
+ return openSealedEntry({ got, openSealed: (x) => this.identity.openSealed(x) })
176
+ }
177
+
178
+ async put (plain) {
179
+ const [keys, recipients] = await Promise.all([this.keys(), this.recipients()])
180
+ const sobre = await buildSealedEntry({ plain, keys, recipients, author: this.#author() })
181
+ await this.#request('pm2.put', sobre)
182
+ // La vista se devuelve ARMADA AQUÍ: quien guardó ya la tiene en claro, y pedírsela a la
183
+ // bóveda sería un viaje para que nos devuelva lo que acabamos de sellar.
184
+ return this.#viewOf(plain)
185
+ }
186
+
187
+ /**
188
+ * CAMBIAR unos campos sin leer los demás (§2.4).
189
+ *
190
+ * La vista se rehace aquí con lo que ya se sabe: los NOMBRES de los campos y cuáles son
191
+ * privados viven en ella, no en los sobres, así que marcar un teléfono como privado no
192
+ * saca el teléfono de la bóveda.
193
+ */
194
+ async patch (id, changes = {}) {
195
+ const [keys, recipients] = await Promise.all([this.keys(), this.recipients()])
196
+ const actual = await this.#viewById(id)
197
+ if (!actual) throw new VaultError(CODES.NOT_FOUND, 'no hay ninguna entrada con ese id')
198
+
199
+ const values = {}
200
+ const drop = []
201
+ for (const k of FIXED) if (typeof changes[k] === 'string') values[k] = { name: k, value: changes[k] }
202
+ if (typeof changes.title === 'string') actual.title = changes.title
203
+
204
+ const campos = normalizeFields(Array.isArray(changes.fields) ? changes.fields : [])
205
+ for (const f of campos) {
206
+ const k = fieldKey(f)
207
+ // LA MARCA VA DENTRO DEL SOBRE, así que cambiarla es reescribir el campo — y para eso
208
+ // hay que tener su valor. Quien edita lo tiene: si solo trae la marca, hay que leerlo
209
+ // primero. Leer uno PÚBLICO no cuesta aprobación; quitarle la marca a uno privado sí,
210
+ // y está bien, porque vas a exponerlo.
211
+ let valor = f.value
212
+ if (typeof valor !== 'string' || !valor) {
213
+ if (f.private === undefined) continue
214
+ const leido = await this.get(id, { keys: [k] })
215
+ valor = (JSON.parse(leido.fields || '[]').find((x) => fieldKey(x) === k) || {}).value || ''
216
+ if (!valor) continue
217
+ }
218
+ values[await this.#sealedKey(k)] = {
219
+ name: k,
220
+ value: JSON.stringify({ label: f.label, value: valor, kind: f.kind, ...(f.private ? { private: true } : {}) })
221
+ }
222
+ const privados = new Set(actual.privateKeys)
223
+ if (f.private) privados.add(k); else if (f.private === false) privados.delete(k)
224
+ actual.privateKeys = [...privados]
225
+ if (!actual.fieldKeys.includes(k)) actual.fieldKeys.push(k)
226
+ }
227
+ for (const k of (changes.removeFields || [])) {
228
+ drop.push(await this.#sealedKey(k))
229
+ actual.fieldKeys = actual.fieldKeys.filter((x) => x !== k)
230
+ actual.privateKeys = actual.privateKeys.filter((x) => x !== k)
231
+ }
232
+ for (const k of FIXED) {
233
+ if (typeof changes[k] !== 'string') continue
234
+ if (changes[k]) { if (!actual.fieldKeys.includes(k) && k !== 'name') actual.fieldKeys.push(k) } else {
235
+ drop.push(k)
236
+ actual.fieldKeys = actual.fieldKeys.filter((x) => x !== k)
237
+ }
238
+ }
239
+ // Los sitios: la lista entera manda, y `addSite` solo suma si ya tenía alguno — una
240
+ // entrada sin sitios sirve en cualquier parte y atarla al primer formulario donde se
241
+ // usó sería cambiarle el sentido.
242
+ let sites = null
243
+ if (Array.isArray(changes.sites)) sites = changes.sites
244
+ else if (changes.addSite && actual.sites.length) sites = [...new Set([...actual.sites, changes.addSite])]
245
+ if (sites) actual.sites = sites
246
+
247
+ if (typeof changes.name === 'string' || typeof changes.username === 'string') {
248
+ actual.hint = changes.name || actual.hint
249
+ }
250
+
251
+ const vista = {
252
+ type: actual.type,
253
+ title: actual.title,
254
+ sites: actual.sites,
255
+ hint: actual.hint,
256
+ fieldKeys: actual.fieldKeys,
257
+ privateKeys: actual.privateKeys,
258
+ has: {
259
+ secret: drop.includes('secret') ? false : (actual.hasSecret || !!values.secret),
260
+ totp: drop.includes('totp') ? false : (actual.hasTotp || !!values.totp),
261
+ notes: drop.includes('notes') ? false : (actual.hasNotes || !!values.notes),
262
+ fields: actual.fieldKeys.some((k) => !FIXED.includes(k)),
263
+ webauthn: actual.hasWebauthn
264
+ },
265
+ ...(actual.webauthn ? { webauthn: actual.webauthn } : {})
266
+ }
267
+ const idx = sites ? await Promise.all(sites.map((s) => siteIndex(keys.kidx, s))) : null
268
+ const parche = await buildSealedPatch({
269
+ id, view: vista, values, drop,
270
+ priv: await Promise.all(vista.privateKeys.map((k) => this.#sealedKey(k))),
271
+ ...(idx ? { idx } : {}), keys, recipients, author: this.#author()
272
+ })
273
+ await this.#request('pm2.patch', parche)
274
+ return { id, ...vista, updatedAt: Date.now() }
275
+ }
276
+
277
+ /** Buscar por texto: la bóveda no ve ninguno, así que se busca aquí sobre las vistas. */
278
+ async search (q, opts = {}) {
279
+ const termino = String(q || '').trim().toLowerCase()
280
+ if (!termino) return []
281
+ const limite = Number(opts.limit) > 0 ? Number(opts.limit) : 20
282
+ const out = []
283
+ for (const v of await this.#views()) {
284
+ const texto = [v.title, v.hint, ...(v.sites || [])].join(' ').toLowerCase()
285
+ if (texto.includes(termino)) out.push(v)
286
+ if (out.length >= limite) break
287
+ }
288
+ return out
289
+ }
290
+
291
+ /** En qué sitios hay algo. Los nombres salen de abrir las vistas, no de la bóveda. */
292
+ async sites () {
293
+ const cuenta = new Map()
294
+ for (const v of await this.#views()) {
295
+ for (const s of v.sites || []) cuenta.set(s, (cuenta.get(s) || 0) + 1)
296
+ }
297
+ return [...cuenta].map(([site, count]) => ({ site, count })).sort((a, b) => a.site.localeCompare(b.site))
298
+ }
299
+
300
+ async remove (id) {
301
+ await this.#request('pm2.remove', { id })
302
+ }
303
+
304
+ /**
305
+ * LA BÓVEDA ENTERA, ABIERTA AQUÍ. No es `list` de la bóveda: son las VISTAS, que no
306
+ * llevan ningún valor, y las abre este aparato con su llave. Es lo que sustituye a
307
+ * buscar dentro de la bóveda (§2.2), y por eso existe donde antes no podía existir.
308
+ */
309
+ async list () {
310
+ return this.#views()
311
+ }
312
+
313
+ /** El resumen de un valor, para decir si ya está guardado igual sin abrir nada (§2.6). */
314
+ async digest (id, key, value) {
315
+ const { kcmp } = await this.keys()
316
+ return valueDigest(kcmp, id, key, String(value))
317
+ }
318
+
319
+ async #views () {
320
+ const crudas = await this.#request('pm2.views', {})
321
+ const out = []
322
+ for (const v of crudas) out.push(await this.#openView(v))
323
+ return out
324
+ }
325
+
326
+ async #viewById (id) {
327
+ return (await this.#views()).find((v) => v.id === id) || null
328
+ }
329
+
330
+ #author () {
331
+ return { publickey: this.identity.publickey, sign: (body) => this.identity.sign(body) }
332
+ }
333
+
334
+ /** La vista de lo que se acaba de guardar, con la forma que espera la interfaz. */
335
+ #viewOf (plain) {
336
+ const campos = normalizeFields(Array.isArray(plain.fields) ? plain.fields : [])
337
+ return {
338
+ id: plain.id,
339
+ type: plain.type || 'login',
340
+ title: plain.title || '',
341
+ sites: plain.sites || [],
342
+ hasSecret: !!plain.secret,
343
+ hasTotp: !!plain.totp,
344
+ hasNotes: !!plain.notes,
345
+ hasFields: campos.length > 0,
346
+ hasWebauthn: !!plain.webauthn,
347
+ updatedAt: Date.now()
348
+ }
349
+ }
350
+
351
+ /** Estrenar la llave del perfil. Solo al convertir: después ya está y se pide. */
352
+ async initProfileKey () {
353
+ const recipients = await this.recipients()
354
+ const pk = await buildProfileKey({ recipients })
355
+ await this.#request('pm2.profile', { set: { envelope: pk.envelope, wraps: pk.wraps } })
356
+ this._keys = await profileKeys(pk.base)
357
+ return this._keys
358
+ }
359
+ }
360
+
361
+ export { PASSKEY_FIELD }
362
+ export default SealedVault
@@ -0,0 +1,221 @@
1
+ // Passkeys: crear una credencial WebAuthn y firmar con ella.
2
+ //
3
+ // Chrome NO expone API de proveedor de credenciales a las extensiones (Android 14+ e
4
+ // iOS 17+ sí). En el escritorio la única vía es la que usan todos: reemplazar
5
+ // `navigator.credentials.create/get` en el contexto de la página. Como la llave la
6
+ // generamos nosotros y la firma la producimos nosotros, la assertion es válida y el
7
+ // sitio no distingue.
8
+ //
9
+ // Este módulo es la parte que NO toca el DOM: qué se guarda y cómo se firma. El parche
10
+ // vive en la extensión.
11
+ //
12
+ // Referencia: WebAuthn Level 2, §6.3 (authenticatorMakeCredential) y §6.3.3
13
+ // (authenticatorGetAssertion). El formato importa hasta el bit: un `authenticatorData`
14
+ // mal armado lo rechaza el servidor sin decir por qué.
15
+
16
+ const subtle = () => globalThis.crypto.subtle
17
+ const enc = new TextEncoder()
18
+
19
+ export const AAGUID = new Uint8Array(16) // ceros: sin atestación, como todo proveedor de software
20
+
21
+ export function b64urlEncode (bytes) {
22
+ let s = ''
23
+ for (const b of new Uint8Array(bytes)) s += String.fromCharCode(b)
24
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
25
+ }
26
+
27
+ export function b64urlDecode (str) {
28
+ const b64 = String(str).replace(/-/g, '+').replace(/_/g, '/')
29
+ const bin = atob(b64)
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
+ }
34
+
35
+ function concat (...arrays) {
36
+ const total = arrays.reduce((n, a) => n + a.length, 0)
37
+ const out = new Uint8Array(total)
38
+ let i = 0
39
+ for (const a of arrays) { out.set(a, i); i += a.length }
40
+ return out
41
+ }
42
+
43
+ async function sha256 (bytes) {
44
+ return new Uint8Array(await subtle().digest('SHA-256', bytes))
45
+ }
46
+
47
+ /** Banderas del authenticatorData. UP = presencia, UV = verificación, AT = trae credencial. */
48
+ export const FLAGS = { UP: 0x01, UV: 0x04, AT: 0x40 }
49
+
50
+ /**
51
+ * `authenticatorData`: hash del rpId + banderas + contador (+ la credencial al crear).
52
+ * El orden y los tamaños son fijos; aquí no hay margen de interpretación.
53
+ */
54
+ export async function makeAuthenticatorData ({ rpId, flags, signCount, attested = null }) {
55
+ const rpIdHash = await sha256(enc.encode(rpId))
56
+ const counter = new Uint8Array(4)
57
+ new DataView(counter.buffer).setUint32(0, signCount >>> 0, false) // big-endian
58
+ const header = concat(rpIdHash, new Uint8Array([flags]), counter)
59
+ return attested ? concat(header, attested) : header
60
+ }
61
+
62
+ /** COSE_Key de una pública P-256 (kty 2, alg -7, crv 1). Es lo que el servidor guarda. */
63
+ export function coseFromJwk (jwk) {
64
+ const x = b64urlDecode(jwk.x)
65
+ const y = b64urlDecode(jwk.y)
66
+ // CBOR a mano: un mapa de 5 entradas con claves enteras. La alternativa era arrastrar
67
+ // una librería de CBOR entera para escribir 77 bytes de forma fija.
68
+ return concat(
69
+ new Uint8Array([0xa5]), // map(5)
70
+ new Uint8Array([0x01, 0x02]), // 1 (kty): 2 (EC2)
71
+ new Uint8Array([0x03, 0x26]), // 3 (alg): -7 (ES256)
72
+ new Uint8Array([0x20, 0x01]), // -1 (crv): 1 (P-256)
73
+ new Uint8Array([0x21, 0x58, 0x20]), x, // -2 (x): bytes(32)
74
+ new Uint8Array([0x22, 0x58, 0x20]), y, // -3 (y): bytes(32)
75
+ )
76
+ }
77
+
78
+ /** `attestedCredentialData`: aaguid + size + id + la pública en COSE. */
79
+ export function attestedCredentialData (credentialId, coseKey) {
80
+ const size = new Uint8Array(2)
81
+ new DataView(size.buffer).setUint16(0, credentialId.length, false)
82
+ return concat(AAGUID, size, credentialId, coseKey)
83
+ }
84
+
85
+ /**
86
+ * Crea una passkey para un sitio. Devuelve lo que se guarda en la bóveda y lo que hay
87
+ * que devolverle a la página.
88
+ */
89
+ export async function createCredential ({ rpId, userHandle, userName = '', origin, challenge }) {
90
+ const pair = await subtle().generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'])
91
+ const jwkPriv = await subtle().exportKey('jwk', pair.privateKey)
92
+ const jwkPub = await subtle().exportKey('jwk', pair.publicKey)
93
+
94
+ const credentialId = globalThis.crypto.getRandomValues(new Uint8Array(32))
95
+ const attested = attestedCredentialData(credentialId, coseFromJwk(jwkPub))
96
+ const authData = await makeAuthenticatorData({
97
+ rpId,
98
+ // UV va puesto porque el usuario abre su bóveda para esto: hay verificación real.
99
+ flags: FLAGS.UP | FLAGS.UV | FLAGS.AT,
100
+ signCount: 0,
101
+ attested,
102
+ })
103
+
104
+ const clientDataJSON = enc.encode(JSON.stringify({
105
+ type: 'webauthn.create',
106
+ challenge: typeof challenge === 'string' ? challenge : b64urlEncode(challenge),
107
+ origin,
108
+ crossOrigin: false,
109
+ }))
110
+
111
+ return {
112
+ // Lo que se guarda (la privada va cifrada por el modelo, como cualquier secreto).
113
+ entry: {
114
+ credentialId: b64urlEncode(credentialId),
115
+ rpId,
116
+ userHandle: userHandle ? b64urlEncode(userHandle) : '',
117
+ userName,
118
+ privateKey: JSON.stringify(jwkPriv),
119
+ signCount: 0,
120
+ },
121
+ // Lo que se le devuelve a la página, ya en base64url.
122
+ response: {
123
+ id: b64urlEncode(credentialId),
124
+ rawId: b64urlEncode(credentialId),
125
+ type: 'public-key',
126
+ response: {
127
+ clientDataJSON: b64urlEncode(clientDataJSON),
128
+ // `attestationObject` sin atestación: fmt "none", attStmt vacío.
129
+ attestationObject: b64urlEncode(concat(
130
+ new Uint8Array([0xa3]), // map(3)
131
+ new Uint8Array([0x63]), enc.encode('fmt'), // "fmt"
132
+ new Uint8Array([0x64]), enc.encode('none'), // "none"
133
+ new Uint8Array([0x67]), enc.encode('attStmt'), // "attStmt"
134
+ new Uint8Array([0xa0]), // {}
135
+ new Uint8Array([0x68]), enc.encode('authData'), // "authData"
136
+ new Uint8Array([0x59]), lengthBytes(authData.length), authData, // bytes(len)
137
+ )),
138
+ publicKeyAlgorithm: -7,
139
+ },
140
+ },
141
+ }
142
+ }
143
+
144
+ function lengthBytes (n) {
145
+ const b = new Uint8Array(2)
146
+ new DataView(b.buffer).setUint16(0, n, false)
147
+ return b
148
+ }
149
+
150
+ /**
151
+ * Firma un reto con una passkey guardada: `sign(authenticatorData || sha256(clientDataJSON))`.
152
+ * Devuelve también el contador nuevo, que hay que guardar.
153
+ */
154
+ export async function signAssertion ({ entry, origin, challenge }) {
155
+ const jwk = JSON.parse(entry.privateKey)
156
+ const key = await subtle().importKey('jwk', jwk, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign'])
157
+
158
+ const signCount = (entry.signCount || 0) + 1
159
+ const authData = await makeAuthenticatorData({
160
+ rpId: entry.rpId,
161
+ flags: FLAGS.UP | FLAGS.UV,
162
+ signCount,
163
+ })
164
+
165
+ const clientDataJSON = enc.encode(JSON.stringify({
166
+ type: 'webauthn.get',
167
+ challenge: typeof challenge === 'string' ? challenge : b64urlEncode(challenge),
168
+ origin,
169
+ crossOrigin: false,
170
+ }))
171
+
172
+ const signed = concat(authData, await sha256(clientDataJSON))
173
+ const raw = new Uint8Array(await subtle().sign(
174
+ { name: 'ECDSA', hash: 'SHA-256' }, key, signed))
175
+
176
+ return {
177
+ signCount,
178
+ response: {
179
+ id: entry.credentialId,
180
+ rawId: entry.credentialId,
181
+ type: 'public-key',
182
+ response: {
183
+ authenticatorData: b64urlEncode(authData),
184
+ clientDataJSON: b64urlEncode(clientDataJSON),
185
+ // WebAuthn pide la firma en DER, no en el crudo de WebCrypto.
186
+ signature: b64urlEncode(rawSignatureToDer(raw)),
187
+ userHandle: entry.userHandle || null,
188
+ },
189
+ },
190
+ }
191
+ }
192
+
193
+ /**
194
+ * WebCrypto firma ECDSA como `r || s` de 32 bytes cada uno; WebAuthn espera DER
195
+ * (SEQUENCE de dos INTEGER). Sin esta conversión el servidor rechaza la firma sin
196
+ * decir por qué, que es de los fallos más caros de diagnosticar.
197
+ */
198
+ export function rawSignatureToDer (raw) {
199
+ const r = derInteger(raw.slice(0, 32))
200
+ const s = derInteger(raw.slice(32, 64))
201
+ return concat(new Uint8Array([0x30, r.length + s.length]), r, s)
202
+ }
203
+
204
+ function derInteger (bytes) {
205
+ let i = 0
206
+ while (i < bytes.length - 1 && bytes[i] === 0) i++ // sin ceros a la izquierda
207
+ let v = bytes.slice(i)
208
+ if (v[0] & 0x80) v = concat(new Uint8Array([0]), v) // positivo: byte de relleno
209
+ return concat(new Uint8Array([0x02, v.length]), v)
210
+ }
211
+
212
+ /** ¿Sirve esta passkey para el sitio que la pide? */
213
+ export function credentialMatches (entry, rpId, allowCredentials) {
214
+ if (!entry?.webauthn) return false
215
+ if (entry.webauthn.rpId !== rpId) return false
216
+ if (!allowCredentials?.length) return true
217
+ return allowCredentials.some(c => {
218
+ const id = typeof c.id === 'string' ? c.id : b64urlEncode(c.id)
219
+ return id === entry.webauthn.credentialId
220
+ })
221
+ }
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/proxy-client@0.32.0 (dotrino-proxy-client/src/{index,client,signature,canonical,sealing,encpub,webrtc,stats,stats-log}.js).
1
+ Copia vendorizada de @dotrino/proxy-client@0.32.2 (dotrino-proxy-client/src/{canonical,client,encpub,index,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.
@@ -67,6 +67,12 @@ export class WebSocketProxyClient {
67
67
  this.autoReconnect = options.autoReconnect !== false
68
68
  this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5
69
69
  this.reconnectDelay = options.reconnectDelay ?? 3000
70
+ // UN SOCKET QUE NO ABRE NI FALLA. El WebSocket de Node (y el del navegador) no tiene
71
+ // tope para el saludo: si el TCP entra y nadie contesta el upgrade, se queda en
72
+ // CONNECTING sin disparar `open`, `error` ni `close`. Como el siguiente reintento solo
73
+ // se programa desde esos eventos, la cadena de reconexión se paraba ahí, en silencio.
74
+ // Se abandona el intento pasado este tiempo y cuenta como un fallo más.
75
+ this.connectTimeout = options.connectTimeout ?? 15000
70
76
  this.enableWebRTC = options.enableWebRTC !== false
71
77
  this.iceServers = options.iceServers || null
72
78
 
@@ -225,6 +231,8 @@ export class WebSocketProxyClient {
225
231
  connect () {
226
232
  return new Promise((resolve, reject) => {
227
233
  if (this._connected) return resolve(this.token)
234
+ // Un reintento que estaba esperando su turno sobra: este `connect()` ya abre uno.
235
+ if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null }
228
236
  this._connectResolve = resolve
229
237
  this._connectReject = reject
230
238
  this._open()
@@ -1242,11 +1250,21 @@ export class WebSocketProxyClient {
1242
1250
 
1243
1251
  _open () {
1244
1252
  registerTransport(this)
1253
+ // El intento anterior, si sigue a medio abrir, se suelta: sus eventos tardíos ya se
1254
+ // ignoran (`ws !== this.ws`), pero el socket quedaría abierto sin dueño.
1255
+ const prev = this.ws
1256
+ if (prev && prev.readyState === 0 /* CONNECTING */) {
1257
+ this.ws = null // antes de cerrarlo: su `close` ya no es de este cliente
1258
+ try { prev.close() } catch (_) {}
1259
+ }
1245
1260
  const ws = new WebSocket(this.url)
1246
1261
  this.ws = ws
1262
+ const openTimer = this.connectTimeout > 0 ? setTimeout(() => this._onConnectTimeout(ws), this.connectTimeout) : null
1263
+ openTimer?.unref?.()
1247
1264
  // `ws !== this.ws` ⇒ es un socket que ya abandonamos (p.ej. por heartbeat
1248
1265
  // muerto). Ignoramos sus eventos tardíos para no disparar reconexiones dobles.
1249
1266
  ws.addEventListener('open', () => {
1267
+ if (openTimer) clearTimeout(openTimer)
1250
1268
  if (ws !== this.ws) return
1251
1269
  this._connected = true
1252
1270
  this._reconnectAttempts = 0
@@ -1269,6 +1287,7 @@ export class WebSocketProxyClient {
1269
1287
  }
1270
1288
  })
1271
1289
  ws.addEventListener('close', (ev) => {
1290
+ if (openTimer) clearTimeout(openTimer)
1272
1291
  if (ws !== this.ws) return
1273
1292
  this._stopHeartbeat()
1274
1293
  const wasConnected = this._connected
@@ -1301,6 +1320,23 @@ export class WebSocketProxyClient {
1301
1320
  })
1302
1321
  }
1303
1322
 
1323
+ // El intento no abrió ni falló a tiempo: se suelta y se trata como un cierre fallido.
1324
+ _onConnectTimeout (ws) {
1325
+ if (ws !== this.ws || this._connected) return
1326
+ this.ws = null // a partir de acá, los eventos tardíos de `ws` se ignoran
1327
+ try { ws.close() } catch (_) {}
1328
+ const err = errorCon(`could not open the connection to ${this.url} in ${Math.round(this.connectTimeout / 1000)}s`, 'CONNECT_TIMEOUT')
1329
+ this._emit('error', { type: 'connect_timeout', error: err })
1330
+ if (this._connectReject) {
1331
+ this._connectReject(err)
1332
+ this._connectResolve = null
1333
+ this._connectReject = null
1334
+ }
1335
+ // Lo mismo que en `close`: se sigue reintentando solo si esto ya era un reintento. Un
1336
+ // `connect()` inicial que no abre se rechaza y ya.
1337
+ if (this._reconnectAttempts > 0 && this.autoReconnect) this._scheduleReconnect()
1338
+ }
1339
+
1304
1340
  // ---------- heartbeat ----------
1305
1341
 
1306
1342
  _startHeartbeat () {
@@ -0,0 +1,3 @@
1
+ Copia vendorizada de @dotrino/roadmap@0.4.13 (dotrino-roadmap/src/{index,manifests}.js).
2
+ NO se edita a mano: la escribe `node vendor.mjs` y la vigila test/vendor-up-to-date.test.mjs.
3
+ El registro de versiones rotas que viaja con cada versión. Importa @dotrino/compat/ranges.