@dotrino/identity 0.114.1 → 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 +7 -1
- package/src/index.js +9 -1
- package/src/node.js +4 -180
- package/vault/acta.js +5 -1
- package/vault/core.js +95 -15
- package/vault/facade.js +200 -0
- package/vault/index.html +10 -0
- package/vault/machine.js +95 -0
- package/vault/remote.js +54 -9
- 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,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* advisory.js — EL AVISO A LA RED: «actualiza estos componentes».
|
|
3
|
+
*
|
|
4
|
+
* Punto 3 del dueño (2026-09-04): *«como desarrolladores vemos que una versión falla o es
|
|
5
|
+
* incompatible con otra, tenemos un método de avisar a la red que debe actualizar tales o
|
|
6
|
+
* tales componentes»*.
|
|
7
|
+
*
|
|
8
|
+
* QUIÉN LO FIRMA, y esto es lo que lo ordena todo: **los que desarrollan**. La
|
|
9
|
+
* compatibilidad es política del CÓDIGO, no de la cuenta (decidido el mismo día), así que
|
|
10
|
+
* el ancla de confianza —la pública que valida el aviso— **viaja dentro de la build**. No
|
|
11
|
+
* hay lista de confianza que se pueda cambiar a distancia: para cambiar en quién confías
|
|
12
|
+
* hay que instalar otra versión, que es exactamente lo que significa «política del código».
|
|
13
|
+
*
|
|
14
|
+
* CÓMO VIAJA: por la red que ya existe, de boca en boca. Cada pieza le pasa a la siguiente
|
|
15
|
+
* el aviso más nuevo que tenga. **No hay servidor de avisos ni consulta a Dotrino**: nadie
|
|
16
|
+
* llama a casa, nada depende de que un servicio nuestro esté encendido, y no hay un sitio
|
|
17
|
+
* donde se vea quién tiene qué versión.
|
|
18
|
+
*
|
|
19
|
+
* LO QUE ESTO ES, dicho sin adornos: un aviso firmado por nosotros puede dejar sin trabajar
|
|
20
|
+
* a una versión instalada en la máquina de otro. Es lo que se pidió y es el precio de que
|
|
21
|
+
* un fallo detectado hoy frene hoy. Lo que lo acota:
|
|
22
|
+
*
|
|
23
|
+
* · **Solo hacia adelante** (`seq`): un aviso viejo no se puede reproducir para deshacer
|
|
24
|
+
* uno nuevo.
|
|
25
|
+
* · **Un aviso nuevo REEMPLAZA la lista entera**, así que un aviso equivocado se corrige
|
|
26
|
+
* con otro aviso. Si solo pudiera sumar, una equivocación sería para siempre.
|
|
27
|
+
* · **No concede nada.** Lo que la build trae marcado como roto sigue roto aunque el
|
|
28
|
+
* aviso no lo mencione: quitar por la red lo que el código afirma sería justo el
|
|
29
|
+
* agujero (`CLAUDE.md`, «nada de repliegues»).
|
|
30
|
+
* · **Se ve.** El administrador enseña el aviso y su fecha. Un freno que no se explica
|
|
31
|
+
* es otro silencio, que es de lo que venimos huyendo.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* JSON canónico (claves ordenadas, recursivo). Es una COPIA deliberada del de
|
|
36
|
+
* `@dotrino/proxy-client`: este paquete no depende de nadie —lo importan las ~30 apps y el
|
|
37
|
+
* proxio— y la firma tiene que dar los mismos bytes en las dos puntas. Si algún día
|
|
38
|
+
* cambian, cambian los dos: hay un test que compara la salida contra la del pilar.
|
|
39
|
+
*/
|
|
40
|
+
export function canonicalStringify (value) {
|
|
41
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value)
|
|
42
|
+
if (Array.isArray(value)) return '[' + value.map(canonicalStringify).join(',') + ']'
|
|
43
|
+
const keys = Object.keys(value).sort()
|
|
44
|
+
return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalStringify(value[k])).join(',') + '}'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const enc = (s) => new TextEncoder().encode(s)
|
|
48
|
+
const b64 = (buf) => {
|
|
49
|
+
const b = new Uint8Array(buf)
|
|
50
|
+
let s = ''
|
|
51
|
+
for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i])
|
|
52
|
+
return btoa(s)
|
|
53
|
+
}
|
|
54
|
+
const fromB64 = (str) => {
|
|
55
|
+
const bin = atob(str)
|
|
56
|
+
const out = new Uint8Array(bin.length)
|
|
57
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
|
|
58
|
+
return out
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** El cuerpo que se firma: todo menos la firma. */
|
|
62
|
+
export const advisoryBody = (a) => {
|
|
63
|
+
const { sig, ...body } = a || {}
|
|
64
|
+
return body
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** ¿Tiene forma de aviso? No dice si es de fiar, solo si se puede juzgar. */
|
|
68
|
+
export function isAdvisory (a) {
|
|
69
|
+
return !!a && a.op === 'compat.advisory' && Number.isInteger(a.seq) && a.seq >= 0 &&
|
|
70
|
+
typeof a.issued === 'number' && Array.isArray(a.broken) && typeof a.sig === 'string' &&
|
|
71
|
+
a.broken.every((b) => b && typeof b.product === 'string' && Array.isArray(b.versions) &&
|
|
72
|
+
b.versions.every((v) => typeof v === 'string'))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* FIRMA un aviso. Lo corre quien publica, no quien lo recibe: aquí está para que el
|
|
77
|
+
* formato lo defina un solo sitio y no dos implementaciones que se separan.
|
|
78
|
+
*/
|
|
79
|
+
export async function signAdvisory ({ seq, broken, privateJwk, issued = Date.now() }) {
|
|
80
|
+
const body = { op: 'compat.advisory', seq, issued, broken }
|
|
81
|
+
const key = await globalThis.crypto.subtle.importKey(
|
|
82
|
+
'jwk', privateJwk, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign'])
|
|
83
|
+
const sig = await globalThis.crypto.subtle.sign(
|
|
84
|
+
{ name: 'ECDSA', hash: 'SHA-256' }, key, enc(canonicalStringify(body)))
|
|
85
|
+
return { ...body, sig: b64(sig) }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* ¿Lo firmó la llave de release que trae MI build? `publickey` es un JWK en texto, igual
|
|
90
|
+
* que en el resto del ecosistema.
|
|
91
|
+
*/
|
|
92
|
+
export async function verifyAdvisory ({ advisory, publickey }) {
|
|
93
|
+
if (!isAdvisory(advisory) || typeof publickey !== 'string') return false
|
|
94
|
+
try {
|
|
95
|
+
const key = await globalThis.crypto.subtle.importKey(
|
|
96
|
+
'jwk', JSON.parse(publickey), { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify'])
|
|
97
|
+
return await globalThis.crypto.subtle.verify(
|
|
98
|
+
{ name: 'ECDSA', hash: 'SHA-256' }, key, fromB64(advisory.sig),
|
|
99
|
+
enc(canonicalStringify(advisoryBody(advisory))))
|
|
100
|
+
} catch (_) { return false }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* ¿ME QUEDO CON EL QUE LLEGA? Solo hacia adelante y solo si lo firma quien yo digo.
|
|
105
|
+
* Devuelve el aviso vigente (el nuevo o el que ya tenía) y por qué.
|
|
106
|
+
*/
|
|
107
|
+
export async function adoptAdvisory ({ current, incoming, publickey }) {
|
|
108
|
+
if (!isAdvisory(incoming)) return { advisory: current || null, adopted: false, reason: 'no-es-un-aviso' }
|
|
109
|
+
if (current && incoming.seq <= current.seq) {
|
|
110
|
+
return { advisory: current, adopted: false, reason: 'no-es-mas-nuevo' }
|
|
111
|
+
}
|
|
112
|
+
if (!(await verifyAdvisory({ advisory: incoming, publickey }))) {
|
|
113
|
+
return { advisory: current || null, adopted: false, reason: 'firma-invalida' }
|
|
114
|
+
}
|
|
115
|
+
return { advisory: incoming, adopted: true, reason: current ? 'mas-nuevo' : 'primero' }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* LA LISTA DE ROTAS QUE VALE HOY: la del código MÁS la del aviso.
|
|
120
|
+
*
|
|
121
|
+
* Se suman, no se sustituyen: el aviso puede añadir lo que se descubrió después de esta
|
|
122
|
+
* build, pero **no puede quitar** lo que esta build ya afirma. Que la red pudiera
|
|
123
|
+
* desmarcar algo que el código da por roto sería el repliegue de manual.
|
|
124
|
+
*/
|
|
125
|
+
export function brokenNow ({ baked = [], advisory = null } = {}) {
|
|
126
|
+
return advisory && isAdvisory(advisory) ? [...baked, ...advisory.broken] : [...baked]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Lo que el administrador enseña del aviso vigente (§14: se ve, no se esconde). */
|
|
130
|
+
export function advisorySummary (advisory) {
|
|
131
|
+
if (!isAdvisory(advisory)) return null
|
|
132
|
+
return {
|
|
133
|
+
seq: advisory.seq,
|
|
134
|
+
issued: advisory.issued,
|
|
135
|
+
components: advisory.broken.map((b) => ({
|
|
136
|
+
product: b.product, versions: b.versions, why: b.why || null, fix: b.fix || null
|
|
137
|
+
}))
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export default { signAdvisory, verifyAdvisory, adoptAdvisory, brokenNow, isAdvisory, advisoryBody, advisorySummary, canonicalStringify }
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @dotrino/compat — QUÉ VERSIÓN ERES, Y SI PODEMOS TRABAJAR.
|
|
3
|
+
*
|
|
4
|
+
* EL PROBLEMA QUE VIENE A RESOLVER, dicho con los tres casos que costaron días:
|
|
5
|
+
*
|
|
6
|
+
* · El apagón del 1-2 de septiembre: endurecer la bóveda dejó mudos a `content`, a los
|
|
7
|
+
* selladores y al bot social durante un día entero. Ningún fallo era un error: eran
|
|
8
|
+
* reintentos cada 60 segundos contra alguien que ya no les entendía.
|
|
9
|
+
* · `@dotrino/env` pedía `^0.33.2` con la librería en 0.60: enrolar contestaba
|
|
10
|
+
* `invalid cert: no-acta`, que es verdad y no es la causa. La causa era la versión.
|
|
11
|
+
* · La bóveda se quedó MUDA tres días porque su respuesta pasaba el tope del proxio: el
|
|
12
|
+
* socket moría con un 1009 y no lo decía nadie.
|
|
13
|
+
*
|
|
14
|
+
* Los tres son el mismo fallo: **una incompatibilidad se manifiesta como SILENCIO**. El
|
|
15
|
+
* que llama no sabe si el otro está apagado, ocupado o hablando otro idioma, así que
|
|
16
|
+
* reintenta para siempre — y el que atiende no sabe que le están hablando.
|
|
17
|
+
*
|
|
18
|
+
* Aquí no hay red, ni disco, ni cripto: son funciones puras. Decidir es de este módulo;
|
|
19
|
+
* anunciar y refunfuñar es de quien lo usa. Y por eso no tiene dependencias — lo importan
|
|
20
|
+
* los ~30 PWA, los daemons y el proxio, y un pilar dentro de otro se cuela anidado a cada
|
|
21
|
+
* consumidor (ver `PENDIENTES.md`, «pilar anidado»).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Lo que responde `check`. El `code` es lo que se compara; el texto puede cambiar. */
|
|
25
|
+
import { satisfies } from './ranges.js'
|
|
26
|
+
|
|
27
|
+
export const OK = 'ok'
|
|
28
|
+
export const INCOMPATIBLE_PROTOCOL = 'incompatible-protocol'
|
|
29
|
+
export const BROKEN_PEER = 'broken-peer'
|
|
30
|
+
export const UNDECLARED = 'undeclared'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* ESTO INFORMA, NO BLOQUEA (dueño, 2026-09-04, corrigiendo el punto 3 del mismo día:
|
|
34
|
+
* *«quizás el aviso de incompatibilidad no debería ser bloqueante, pero sí visible»*).
|
|
35
|
+
*
|
|
36
|
+
* Y es mejor diseño, por cuatro razones que conviene tener escritas para no volver atrás:
|
|
37
|
+
*
|
|
38
|
+
* · **No hay interruptor remoto.** Un aviso que solo informa se puede firmar y repartir
|
|
39
|
+
* sin que nadie pueda dejar a otro sin bóveda desde fuera.
|
|
40
|
+
* · **No hay trampa de orden.** Si bloqueara, el día que una pieza empieza a comprobar
|
|
41
|
+
* dejaría de hablar con todas las que aún no anuncian: habría que desplegar en un orden
|
|
42
|
+
* exacto. Informando se enciende donde sea y cuando sea.
|
|
43
|
+
* · **Falla del lado seguro.** Bloquear es código nuevo decidiendo si algo funciona: un
|
|
44
|
+
* rango mal escrito o una errata en un manifiesto pasaría de aviso falso a caída real.
|
|
45
|
+
* · **Es lo que dicen los tres incidentes.** En el apagón del 1-2 de septiembre, en el
|
|
46
|
+
* `^0.33.2` y en la bóveda muda, lo que faltó fue ENTERARSE, no parar. Parar no habría
|
|
47
|
+
* arreglado ninguno.
|
|
48
|
+
*
|
|
49
|
+
* Lo que se pierde y se dice: un par realmente incompatible sigue medio funcionando. Pero
|
|
50
|
+
* medio funcionando **con un cartel que explica por qué** es otra cosa — y lo que no cuadra
|
|
51
|
+
* falla solo, con el aviso pegado al error (`annotate`).
|
|
52
|
+
*
|
|
53
|
+
* Quien consuma esto **no debe** convertir un `compatible: false` en un «no atiendo». Si
|
|
54
|
+
* algún día se decide bloquear, se decide arriba y se escribe allí, no aquí.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/** ¿Tiene forma de anuncio? No dice si es compatible, solo si se puede juzgar. */
|
|
58
|
+
export function isDeclaration (d) {
|
|
59
|
+
return !!d && typeof d.product === 'string' && d.product.length > 0 &&
|
|
60
|
+
typeof d.version === 'string' && d.version.length > 0 &&
|
|
61
|
+
Number.isInteger(d.protocol) &&
|
|
62
|
+
Array.isArray(d.speaks) && d.speaks.length > 0 && d.speaks.every(Number.isInteger)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Lo que se pone en el sobre firmado de `identify`. Corto, porque viaja en cada conexión. */
|
|
66
|
+
export function declare ({ product, version, protocol, speaks }) {
|
|
67
|
+
if (!Number.isInteger(protocol)) throw new Error('compat: protocol must be an integer')
|
|
68
|
+
const habla = speaks && speaks.length ? [...new Set(speaks)].sort((a, b) => a - b) : [protocol]
|
|
69
|
+
if (!habla.includes(protocol)) throw new Error('compat: speaks must include protocol')
|
|
70
|
+
const d = { product: String(product), version: String(version), protocol, speaks: habla }
|
|
71
|
+
if (!isDeclaration(d)) throw new Error('compat: incomplete declaration')
|
|
72
|
+
return d
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* ¿ESTE PAR ESTÁ EN LA LISTA DE ROTAS?
|
|
77
|
+
*
|
|
78
|
+
* `versions` acepta lo mismo que un rango del manifiesto (`ranges.js`): una versión
|
|
79
|
+
* exacta, un `>=`, un intervalo `a - b`, o una lista que es un O. El dueño pidió rangos
|
|
80
|
+
* para los manifiestos (2026-09-04) y aquí se usa el MISMO comparador, no otro: dos formas
|
|
81
|
+
* de decir «esta versión entra» acaban discrepando, y esa discrepancia no hace ruido.
|
|
82
|
+
*
|
|
83
|
+
* Lo que no se entiende no coincide — no marca roto y tampoco absuelve: solo no dice nada.
|
|
84
|
+
*/
|
|
85
|
+
export function isBroken (lista, { product, version }) {
|
|
86
|
+
for (const b of lista || []) {
|
|
87
|
+
if (b?.product !== product) continue
|
|
88
|
+
if (satisfies(version, b.versions)) return b
|
|
89
|
+
}
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* ¿ESTA PAREJA CUADRA?
|
|
95
|
+
*
|
|
96
|
+
* Devuelve un DICTAMEN, no un permiso: `compatible` dice si cuadran, y nadie debe usarlo
|
|
97
|
+
* para dejar de atender (ver arriba). Se enseña, se registra y se le pega a los errores.
|
|
98
|
+
*
|
|
99
|
+
* **JUZGA EL QUE ENTRA** (dueño, 2026-09-04): *«el que define si es compatible o no es la
|
|
100
|
+
* versión que entra, ya que la versión antigua de un producto no tiene idea con qué es o no
|
|
101
|
+
* compatible»*. Exacto: una build de hace tres meses no sabe nada de lo que vino después.
|
|
102
|
+
* Así que esto NO es simétrico — miro si YO te entiendo y si TÚ estás en mi lista de rotas;
|
|
103
|
+
* lo que tú creas de mí no entra en la cuenta.
|
|
104
|
+
*
|
|
105
|
+
* Y de ahí sale por qué avisar es la otra mitad: si el viejo no puede juzgar, tampoco puede
|
|
106
|
+
* enterarse solo. El que ve la incompatibilidad es el único que puede decírselo.
|
|
107
|
+
*
|
|
108
|
+
* @param {object} o.mine mi declaración
|
|
109
|
+
* @param {object} o.theirs la del otro (o null si no dijo nada)
|
|
110
|
+
* @param {Array} [o.broken] mi lista de rotas (la del código más la del aviso de la red)
|
|
111
|
+
* @returns {{compatible:boolean, code:string, reason:string, peer:object|null}}
|
|
112
|
+
*/
|
|
113
|
+
export function check ({ mine, theirs, broken = [] } = {}) {
|
|
114
|
+
if (!isDeclaration(mine)) throw new Error('compat: my own declaration is not valid')
|
|
115
|
+
|
|
116
|
+
if (!isDeclaration(theirs)) {
|
|
117
|
+
return {
|
|
118
|
+
compatible: false,
|
|
119
|
+
code: UNDECLARED,
|
|
120
|
+
peer: null,
|
|
121
|
+
reason: 'the other side does not say what it is or which version it runs'
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const roto = isBroken(broken, theirs)
|
|
126
|
+
if (roto) {
|
|
127
|
+
return {
|
|
128
|
+
compatible: false,
|
|
129
|
+
code: BROKEN_PEER,
|
|
130
|
+
peer: theirs,
|
|
131
|
+
reason: `${theirs.product} ${theirs.version} is known to be broken: ${roto.why || 'no reason recorded'}` +
|
|
132
|
+
(roto.fix ? ` — ${roto.fix}` : '')
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!mine.speaks.includes(theirs.protocol)) {
|
|
137
|
+
return {
|
|
138
|
+
compatible: false,
|
|
139
|
+
code: INCOMPATIBLE_PROTOCOL,
|
|
140
|
+
peer: theirs,
|
|
141
|
+
reason: `${mine.product} ${mine.version} speaks protocol ${mine.speaks.join(', ')} and ` +
|
|
142
|
+
`${theirs.product} ${theirs.version} speaks ${theirs.protocol}: update ${theirs.product}`
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { compatible: true, code: OK, peer: theirs, reason: '' }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* PEGA EL AVISO AL ERROR QUE YA OCURRE, y esto es lo que de verdad ahorra el día perdido.
|
|
151
|
+
*
|
|
152
|
+
* El caso real: `@dotrino/env` pedía `^0.33.2` con la librería en 0.60, y enrolar contestaba
|
|
153
|
+
* `invalid cert: no-acta`. Es verdad y no es la causa — la causa era la versión, y encontrarlo
|
|
154
|
+
* costó horas. Con esto el mismo error sale:
|
|
155
|
+
*
|
|
156
|
+
* invalid cert: no-acta — heads up: vault 0.33.2 against vaultd 0.106.2 …
|
|
157
|
+
*
|
|
158
|
+
* El mensaje original no se toca: se le añade detrás. Nadie que empareje por texto se rompe
|
|
159
|
+
* si compara por prefijo, y quien compare por `code` no se entera (ver «los errores son un
|
|
160
|
+
* contrato»).
|
|
161
|
+
*/
|
|
162
|
+
export function annotate (message, verdict) {
|
|
163
|
+
if (!verdict || verdict.compatible !== false) return message
|
|
164
|
+
return `${message} — heads up: ${verdict.reason}`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* EL AVISO QUE SE MANDA CUANDO NO SE PUEDE TRABAJAR (punto 3 del dueño: «se comunican,
|
|
169
|
+
* pero solo para alertar la incompatibilidad»).
|
|
170
|
+
*
|
|
171
|
+
* Lleva las dos declaraciones porque el que lo recibe suele ser el que NO sabe que está
|
|
172
|
+
* roto —es el viejo—, así que hay que decirle qué es él, qué somos nosotros y qué hacer.
|
|
173
|
+
* Sin esto el rechazo sería otro silencio, que es de lo que venimos huyendo.
|
|
174
|
+
*/
|
|
175
|
+
export function incompatibleNotice ({ mine, theirs, verdict }) {
|
|
176
|
+
return {
|
|
177
|
+
op: 'incompatible',
|
|
178
|
+
code: verdict.code,
|
|
179
|
+
reason: verdict.reason,
|
|
180
|
+
mine: { product: mine.product, version: mine.version, protocol: mine.protocol, speaks: mine.speaks },
|
|
181
|
+
yours: theirs && isDeclaration(theirs)
|
|
182
|
+
? { product: theirs.product, version: theirs.version, protocol: theirs.protocol }
|
|
183
|
+
: null,
|
|
184
|
+
fix: 'update the older side; nothing here will work until the two versions match'
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export default { declare, check, isBroken, isDeclaration, incompatibleNotice, annotate, OK, INCOMPATIBLE_PROTOCOL, BROKEN_PEER, UNDECLARED }
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ranges.js — RANGOS DE VERSIONES, acotados a lo que el ecosistema usa de verdad.
|
|
3
|
+
*
|
|
4
|
+
* El dueño pidió que los manifiestos digan **qué rangos son compatibles con qué**
|
|
5
|
+
* (2026-09-04), así que hace falta comparar versiones. Mi objeción de la mañana seguía en
|
|
6
|
+
* pie —un comparador de semver mal escrito es peor que no tenerlo, porque falla en silencio
|
|
7
|
+
* y del lado que no toca— y la respuesta no es no tenerlo, es acotarlo:
|
|
8
|
+
*
|
|
9
|
+
* · **`x.y.z` y nada más.** Sin prereleases, sin `+build`, sin `^` ni `~`. Todas las
|
|
10
|
+
* versiones del ecosistema tienen esa forma; no se inventa soporte para lo que no hay.
|
|
11
|
+
* · **Lo que no se entiende NO pasa.** Un rango con una forma rara devuelve `false`, no
|
|
12
|
+
* «bueno, adelante». Es la regla de siempre: si falta lo que hace falta para decidir,
|
|
13
|
+
* se para.
|
|
14
|
+
*
|
|
15
|
+
* Formas admitidas, y son todas:
|
|
16
|
+
*
|
|
17
|
+
* "0.106.2" exacta
|
|
18
|
+
* "0.106.0+" ABIERTO hacia arriba: de esa en adelante
|
|
19
|
+
* ">=0.106.0" lo mismo, en el otro idioma. También > <= <
|
|
20
|
+
* "0.100.0 - 0.106.2" intervalo, extremos incluidos
|
|
21
|
+
* "0.100.0 - *" abierto por el extremo de arriba
|
|
22
|
+
* "*" cualquiera
|
|
23
|
+
* [">=0.5.0", "0.4.2"] una lista es un O
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const PARTES = /^(\d+)\.(\d+)\.(\d+)$/
|
|
27
|
+
|
|
28
|
+
/** `[major, minor, patch]`, o `null` si no tiene la forma. */
|
|
29
|
+
export function parseVersion (v) {
|
|
30
|
+
const m = PARTES.exec(String(v || '').trim())
|
|
31
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** -1, 0 o 1. Lanza si alguna no tiene forma: comparar basura no puede dar un resultado. */
|
|
35
|
+
export function compareVersions (a, b) {
|
|
36
|
+
const x = parseVersion(a)
|
|
37
|
+
const y = parseVersion(b)
|
|
38
|
+
if (!x || !y) throw new Error(`compat: cannot compare versions "${a}" and "${b}"`)
|
|
39
|
+
for (let i = 0; i < 3; i++) {
|
|
40
|
+
if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1
|
|
41
|
+
}
|
|
42
|
+
return 0
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const OPS = [
|
|
46
|
+
['>=', (c) => c >= 0],
|
|
47
|
+
['<=', (c) => c <= 0],
|
|
48
|
+
['>', (c) => c > 0],
|
|
49
|
+
['<', (c) => c < 0]
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* ¿`version` cae dentro de `range`?
|
|
54
|
+
* @param {string} version
|
|
55
|
+
* @param {string|string[]} range
|
|
56
|
+
* @returns {boolean} `false` también cuando el rango no se entiende
|
|
57
|
+
*/
|
|
58
|
+
export function satisfies (version, range) {
|
|
59
|
+
if (Array.isArray(range)) return range.some((r) => satisfies(version, r))
|
|
60
|
+
if (typeof range !== 'string') return false
|
|
61
|
+
const r = range.trim()
|
|
62
|
+
if (!r) return false
|
|
63
|
+
if (r === '*') return true
|
|
64
|
+
if (!parseVersion(version)) return false
|
|
65
|
+
|
|
66
|
+
// ABIERTO HACIA ARRIBA: «0.106.0+» es «de esa en adelante». Es la forma que más se usa
|
|
67
|
+
// en un manifiesto —«esto y lo que venga»— y se lee mejor que `>=` cuando hay veinte
|
|
68
|
+
// líneas seguidas (dueño, 2026-09-04).
|
|
69
|
+
if (r.endsWith('+')) {
|
|
70
|
+
try { return compareVersions(version, r.slice(0, -1).trim()) >= 0 } catch (_) { return false }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const guion = r.split(' - ')
|
|
74
|
+
if (guion.length === 2) {
|
|
75
|
+
const desde = guion[0].trim()
|
|
76
|
+
const hasta = guion[1].trim()
|
|
77
|
+
try {
|
|
78
|
+
if (compareVersions(version, desde) < 0) return false
|
|
79
|
+
// El extremo de arriba también puede quedar abierto: «0.100.0 - *».
|
|
80
|
+
return hasta === '*' ? true : compareVersions(version, hasta) <= 0
|
|
81
|
+
} catch (_) { return false }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const [signo, cumple] of OPS) {
|
|
85
|
+
if (r.startsWith(signo)) {
|
|
86
|
+
try { return cumple(compareVersions(version, r.slice(signo.length).trim())) } catch (_) { return false }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try { return compareVersions(version, r) === 0 } catch (_) { return false }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export default { parseVersion, compareVersions, satisfies }
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Copia vendorizada de @dotrino/opaque@0.1.0 (dotrino-opaque/{
|
|
1
|
+
Copia vendorizada de @dotrino/opaque@0.1.0 (dotrino-opaque/{build/opaque,build/wasm-bytes,src/index}.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
|
src/index.js importa ../build/{opaque,wasm-bytes}.js, así que la copia CONSERVA
|
|
4
4
|
esas dos carpetas: aplanarla rompería el import relativo.
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
Copia vendorizada de @dotrino/passmanager@0.17.0 (dotrino-passmanager/lib/src/{crypto,fields,generate,import,index,match,model,pubkey,sealed/convert,sealed/device,sealed/entry,sealed/index,sealed/keys,sealed/recovery,sealed/store,session-cache,totp,transport/protocol,transport/proxy,transport/sealed,vault/approval,vault/errors,vault/guard,vault/interface,vault/local,vault/remote,vault/responder,vault/sealed-local,vault/sealed-responder,vault/sealed,webauthn}.js).
|
|
2
|
+
NO se edita a mano: la escribe `node vendor.mjs` y la vigila test/vendor-up-to-date.test.mjs.
|
|
3
|
+
El sobre de las contraseñas, el mismo que usan el binario y la extensión. Importa @dotrino/identity/content.
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Cifrado de las entradas de la bóveda.
|
|
2
|
+
//
|
|
3
|
+
// Una CEK (AES-GCM 256) por bóveda, con la que se cifra cada entrada por separado.
|
|
4
|
+
// Quién tiene la CEK es lo que define la confianza de cada aparato (DISENO §3): al
|
|
5
|
+
// vault y a la app nativa se les envuelve; a la extensión, no.
|
|
6
|
+
//
|
|
7
|
+
// El envoltorio de la CEK a cada miembro NO se hace aquí: eso es `wrapForMember` de
|
|
8
|
+
// @dotrino/identity/content, que ya está escrito y probado. Este módulo es solo el
|
|
9
|
+
// tramo simétrico.
|
|
10
|
+
|
|
11
|
+
const enc = new TextEncoder()
|
|
12
|
+
const dec = new TextDecoder()
|
|
13
|
+
|
|
14
|
+
/** WebCrypto del entorno (navegador, service worker o Node ≥ 20). */
|
|
15
|
+
function subtle () {
|
|
16
|
+
const c = globalThis.crypto
|
|
17
|
+
if (!c?.subtle) throw new Error('webcrypto unavailable')
|
|
18
|
+
return c.subtle
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function randomBytes (n) {
|
|
22
|
+
const b = new Uint8Array(n)
|
|
23
|
+
globalThis.crypto.getRandomValues(b)
|
|
24
|
+
return b
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function toBase64 (bytes) {
|
|
28
|
+
let s = ''
|
|
29
|
+
for (const b of bytes) s += String.fromCharCode(b)
|
|
30
|
+
return btoa(s)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function fromBase64 (b64) {
|
|
34
|
+
const s = atob(b64)
|
|
35
|
+
const out = new Uint8Array(s.length)
|
|
36
|
+
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i)
|
|
37
|
+
return out
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Genera una CEK nueva. Extraíble: hay que poder envolverla para cada aparato. */
|
|
41
|
+
export async function makeVaultKey () {
|
|
42
|
+
return subtle().generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'])
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function exportVaultKey (key) {
|
|
46
|
+
return new Uint8Array(await subtle().exportKey('raw', key))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function importVaultKey (raw) {
|
|
50
|
+
return subtle().importKey('raw', raw, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt'])
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Cifra un valor con la CEK. Devuelve `{ iv, ct }` en base64, para que una entrada
|
|
55
|
+
* cifrada siga siendo JSON serializable tal cual.
|
|
56
|
+
*
|
|
57
|
+
* `aad` ata el criptograma a su sitio: se pasa el id de la entrada y el campo, así
|
|
58
|
+
* que un criptograma movido de una entrada a otra (o de `secret` a `totp`) no abre.
|
|
59
|
+
*/
|
|
60
|
+
export async function sealValue (key, plaintext, aad) {
|
|
61
|
+
const iv = randomBytes(12)
|
|
62
|
+
const params = { name: 'AES-GCM', iv }
|
|
63
|
+
if (aad) params.additionalData = enc.encode(aad)
|
|
64
|
+
const ct = await subtle().encrypt(params, key, enc.encode(plaintext))
|
|
65
|
+
return { iv: toBase64(iv), ct: toBase64(new Uint8Array(ct)) }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function openValue (key, sealed, aad) {
|
|
69
|
+
if (!sealed || typeof sealed.iv !== 'string' || typeof sealed.ct !== 'string') {
|
|
70
|
+
throw new Error('sealed value malformed')
|
|
71
|
+
}
|
|
72
|
+
const params = { name: 'AES-GCM', iv: fromBase64(sealed.iv) }
|
|
73
|
+
if (aad) params.additionalData = enc.encode(aad)
|
|
74
|
+
const pt = await subtle().decrypt(params, key, fromBase64(sealed.ct))
|
|
75
|
+
return dec.decode(pt)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Etiqueta AAD de un campo. Un solo sitio para que sellar y abrir no se desalineen. */
|
|
79
|
+
export function fieldTag (entryId, field) {
|
|
80
|
+
return `dotrino-passmanager/v1/${entryId}/${field}`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --- Contraseña maestra ------------------------------------------------------
|
|
84
|
+
//
|
|
85
|
+
// Solo para el caso en que la bóveda vive en el propio aparato y no hay nadie más
|
|
86
|
+
// que la custodie (la extensión antes del paso 2, DISENO §8). Cuando responde el
|
|
87
|
+
// vault o el teléfono, la CEK llega envuelta y esto no se usa.
|
|
88
|
+
|
|
89
|
+
const KDF_ITERATIONS = 600_000 // recomendación OWASP para PBKDF2-SHA256
|
|
90
|
+
|
|
91
|
+
export function makeSalt () {
|
|
92
|
+
return randomBytes(16)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Deriva la CEK de una contraseña maestra.
|
|
97
|
+
*
|
|
98
|
+
* `extractable` existe por MV3: el service worker de la extensión se duerme, y un
|
|
99
|
+
* `CryptoKey` no sobrevive a `chrome.storage.session` (que serializa a JSON, no
|
|
100
|
+
* clona). Para no pedir la contraseña cada pocos minutos hay que poder exportar la
|
|
101
|
+
* clave y guardarla en memoria de sesión. Es un compromiso consciente y acotado a la
|
|
102
|
+
* extensión: donde no haga falta, se deriva no extraíble.
|
|
103
|
+
*/
|
|
104
|
+
export async function deriveKeyFromPassword (password, salt, iterations = KDF_ITERATIONS, extractable = false) {
|
|
105
|
+
const material = await subtle().importKey(
|
|
106
|
+
'raw', enc.encode(String(password)), 'PBKDF2', false, ['deriveKey'])
|
|
107
|
+
return subtle().deriveKey(
|
|
108
|
+
{ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
|
|
109
|
+
material,
|
|
110
|
+
{ name: 'AES-GCM', length: 256 },
|
|
111
|
+
extractable,
|
|
112
|
+
['encrypt', 'decrypt'],
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Verificador de contraseña: un valor sellado con la propia clave derivada. No es un
|
|
118
|
+
* hash de la contraseña — es una prueba de que la clave abre la bóveda, que es lo que
|
|
119
|
+
* de verdad importa.
|
|
120
|
+
*/
|
|
121
|
+
export async function makeVerifier (key) {
|
|
122
|
+
return sealValue(key, 'dotrino-passmanager', 'verifier')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function checkVerifier (key, verifier) {
|
|
126
|
+
try {
|
|
127
|
+
return (await openValue(key, verifier, 'verifier')) === 'dotrino-passmanager'
|
|
128
|
+
} catch {
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// --- comparar sin abrir -------------------------------------------------------
|
|
134
|
+
//
|
|
135
|
+
// Para decir «esto que escribiste ya está guardado igual» hace falta comparar con lo
|
|
136
|
+
// guardado, y abrir la entrada es una autorización (DISENO §3.3.2). La bóveda manda
|
|
137
|
+
// entonces un **resumen** de cada campo: quien pregunta hashea lo que tiene delante y
|
|
138
|
+
// compara los resúmenes.
|
|
139
|
+
//
|
|
140
|
+
// **Un solo método para todos los campos**, públicos y privados (dueño, 2026-08-29):
|
|
141
|
+
// dos formas de comparar según qué campo sea son dos comportamientos que se separan y
|
|
142
|
+
// acaban diciendo cosas distintas.
|
|
143
|
+
//
|
|
144
|
+
// El resumen va con un **nonce nuevo en cada respuesta**, así que no es una huella
|
|
145
|
+
// estable: no vale para reconocer la misma contraseña entre dos respuestas, ni para
|
|
146
|
+
// guardar una tabla y consultarla más tarde. Y el nombre del campo entra en el HMAC, así
|
|
147
|
+
// que un resumen tampoco sirve para comparar contra otro campo.
|
|
148
|
+
|
|
149
|
+
/** Un nonce nuevo, para UNA respuesta. No se guarda en ninguna parte. */
|
|
150
|
+
export function makeNonce () {
|
|
151
|
+
return toBase64(randomBytes(16))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* El hasheador de una respuesta. Se importa la llave UNA vez y se hashean todos los
|
|
156
|
+
* campos con ella: importar por campo multiplica el coste de un `find` sin ganar nada.
|
|
157
|
+
*/
|
|
158
|
+
export async function fieldHasher (nonce) {
|
|
159
|
+
const key = await subtle().importKey(
|
|
160
|
+
'raw', fromBase64(nonce), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
|
|
161
|
+
return async (name, value) => {
|
|
162
|
+
const sig = await subtle().sign('HMAC', key, enc.encode(name + ' ' + value))
|
|
163
|
+
// 128 bits: de sobra para que dos valores distintos no colisionen en la práctica, y
|
|
164
|
+
// la mitad de bytes que mandar el SHA-256 entero.
|
|
165
|
+
return toBase64(new Uint8Array(sig).slice(0, 16))
|
|
166
|
+
}
|
|
167
|
+
}
|