@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.
- package/package.json +4 -1
- package/src/node.js +4 -180
- package/vault/core.js +66 -7
- package/vault/facade.js +200 -0
- package/vault/index.html +10 -0
- package/vault/remote.js +45 -5
- package/vault/vault.js +29 -58
- package/vault/vendor/compat/VERSION.txt +3 -0
- package/vault/vendor/compat/advisory.js +141 -0
- package/vault/vendor/compat/index.js +188 -0
- package/vault/vendor/compat/ranges.js +93 -0
- package/vault/vendor/opaque/VERSION.txt +1 -1
- package/vault/vendor/passmanager/VERSION.txt +3 -0
- package/vault/vendor/passmanager/crypto.js +167 -0
- package/vault/vendor/passmanager/fields.js +183 -0
- package/vault/vendor/passmanager/generate.js +74 -0
- package/vault/vendor/passmanager/import.js +156 -0
- package/vault/vendor/passmanager/index.js +27 -0
- package/vault/vendor/passmanager/match.js +137 -0
- package/vault/vendor/passmanager/model.js +332 -0
- package/vault/vendor/passmanager/pubkey.js +33 -0
- package/vault/vendor/passmanager/sealed/convert.js +72 -0
- package/vault/vendor/passmanager/sealed/device.js +211 -0
- package/vault/vendor/passmanager/sealed/entry.js +209 -0
- package/vault/vendor/passmanager/sealed/index.js +20 -0
- package/vault/vendor/passmanager/sealed/keys.js +120 -0
- package/vault/vendor/passmanager/sealed/recovery.js +136 -0
- package/vault/vendor/passmanager/sealed/store.js +508 -0
- package/vault/vendor/passmanager/session-cache.js +60 -0
- package/vault/vendor/passmanager/totp.js +111 -0
- package/vault/vendor/passmanager/transport/protocol.js +83 -0
- package/vault/vendor/passmanager/transport/proxy.js +112 -0
- package/vault/vendor/passmanager/transport/sealed.js +74 -0
- package/vault/vendor/passmanager/vault/approval.js +63 -0
- package/vault/vendor/passmanager/vault/errors.js +34 -0
- package/vault/vendor/passmanager/vault/guard.js +114 -0
- package/vault/vendor/passmanager/vault/interface.js +34 -0
- package/vault/vendor/passmanager/vault/local.js +213 -0
- package/vault/vendor/passmanager/vault/remote.js +85 -0
- package/vault/vendor/passmanager/vault/responder.js +192 -0
- package/vault/vendor/passmanager/vault/sealed-local.js +199 -0
- package/vault/vendor/passmanager/vault/sealed-responder.js +159 -0
- package/vault/vendor/passmanager/vault/sealed.js +362 -0
- package/vault/vendor/passmanager/webauthn.js +221 -0
- package/vault/vendor/proxy-client/VERSION.txt +1 -1
- package/vault/vendor/proxy-client/client.js +36 -0
- package/vault/vendor/roadmap/VERSION.txt +3 -0
- package/vault/vendor/roadmap/index.js +108 -0
- package/vault/vendor/roadmap/manifests.js +797 -0
- package/vault/vendor/store/VERSION.txt +3 -0
- package/vault/vendor/store/core.js +309 -0
- package/vault/vendor/vault/VERSION.txt +4 -1
- package/vault/vendor/vault/admin.js +437 -0
- package/vault/vendor/vault/browser.js +167 -0
- package/vault/vendor/vault/core/approvals.js +204 -0
- package/vault/vendor/vault/core/blocked.js +68 -0
- package/vault/vendor/vault/core/browserDisk.js +109 -0
- package/vault/vendor/vault/core/commKey.js +68 -0
- package/vault/vendor/vault/core/disk.js +65 -0
- package/vault/vendor/vault/core/libVersion.js +8 -0
- package/vault/vendor/vault/core/machines.js +66 -0
- package/vault/vendor/vault/core/notices.js +71 -0
- package/vault/vendor/vault/core/notifiable.js +73 -0
- package/vault/vendor/vault/core/passwords.js +125 -0
- package/vault/vendor/vault/core/sealKey.js +77 -0
- package/vault/vendor/vault/core/sealer.js +172 -0
- package/vault/vendor/vault/core/sealers.js +159 -0
- package/vault/vendor/vault/core/secretsStore.js +1326 -0
- package/vault/vendor/vault/core/sha256.js +69 -0
- package/vault/vendor/vault/core/store.js +68 -0
- package/vault/vendor/vault/core/subacta.js +101 -0
- package/vault/vendor/vault/core/threadStore.js +273 -0
- package/vault/vendor/vault/core/transport.js +270 -0
- package/vault/vendor/vault/core/vault.js +4414 -0
- package/vault/vendor/vault/enroll.js +8 -4
- package/vault/vendor/vault/procContext.js +97 -0
- package/vault/vendor/vault/revocation.js +67 -0
- 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.
|
|
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.
|