@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
package/vault/remote.js CHANGED
@@ -281,11 +281,53 @@ function vaultError (p) {
281
281
  return e
282
282
  }
283
283
 
284
- async function vaultRpc ({ master, proxy, device, cert, acta = null, sendType, okType, data, onRevoked, timeoutMs = 15000 }) {
285
- if (!master || !proxy || !(device?.privateJwk || device?.privateKey || typeof device?.sign === 'function') || !cert) throw new Error('missing pairing data')
284
+ /**
285
+ * LA BÓVEDA DE ESTE MISMO NAVEGADOR, sin dar la vuelta por el proxio.
286
+ *
287
+ * Cuando esta pestaña o esta extensión ES la bóveda (`@dotrino/vault`, `startBrowserVault`),
288
+ * lo que el aparato le pide a su bóveda —administrar, aprobar, firmar— va directo al núcleo
289
+ * que corre aquí. Es el mostrador local del binario en el navegador: el MISMO protocolo, la
290
+ * misma firma y el mismo certificado, comprobados igual. Alcanzar el atajo no autoriza nada.
291
+ *
292
+ * `open(onReply)` → `{ send(payload), close() }`. Lo registra quien levanta la bóveda.
293
+ */
294
+ const localVaults = new Map()
295
+ export function registerLocalVault (master, open) {
296
+ if (typeof master !== 'string' || typeof open !== 'function') throw new Error('registerLocalVault: master and open are required')
297
+ localVaults.set(master, open)
298
+ return () => { if (localVaults.get(master) === open) localVaults.delete(master) }
299
+ }
300
+
301
+ /** Un cliente con la forma del del proxio que habla con la bóveda local. */
302
+ function localClient (open) {
303
+ const handlers = new Set()
304
+ const chan = open((reply) => { for (const h of [...handlers]) { try { h('local', reply) } catch (_) {} } })
305
+ return {
306
+ connect: async () => {},
307
+ identifyAs: async () => {},
308
+ on (ev, fn) {
309
+ if (ev !== 'message') return () => {}
310
+ handlers.add(fn)
311
+ return () => handlers.delete(fn)
312
+ },
313
+ sendByPubkey (_to, payload) { chan.send(payload) },
314
+ close () { handlers.clear(); chan.close() }
315
+ }
316
+ }
317
+
318
+ /** El cliente para hablar con `master`: el local si esa bóveda corre aquí, y si no el del proxio. */
319
+ async function clientFor (proxy, master) {
320
+ const open = master ? localVaults.get(master) : null
321
+ if (open) return localClient(open)
286
322
  const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
287
323
  const client = new WebSocketProxyClient({ url: proxy, enableWebRTC: false, autoReconnect: false })
288
324
  await client.connect()
325
+ return client
326
+ }
327
+
328
+ async function vaultRpc ({ master, proxy, device, cert, acta = null, sendType, okType, data, onRevoked, timeoutMs = 15000 }) {
329
+ if (!master || !proxy || !(device?.privateJwk || device?.privateKey || typeof device?.sign === 'function') || !cert) throw new Error('missing pairing data')
330
+ const client = await clientFor(proxy, master)
289
331
  try {
290
332
  try { await identifyAsDevice(client, device, { cert, acta }) } catch (_) { /* sin identify seguimos: solo perdemos la cola */ }
291
333
  const signed = { ...data, publickey: device.publickey, ts: Date.now(), ...(cleanMachine(device.machine) ? { machine: cleanMachine(device.machine) } : {}) }
@@ -336,9 +378,7 @@ async function vaultRpc ({ master, proxy, device, cert, acta = null, sendType, o
336
378
  */
337
379
  export async function checkMembership ({ master, proxy, device, onRevoked, timeoutMs = 12000 } = {}) {
338
380
  if (!master || !proxy || !(device?.privateJwk || device?.privateKey || typeof device?.sign === 'function')) throw new Error('missing device data')
339
- const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
340
- const client = new WebSocketProxyClient({ url: proxy, enableWebRTC: false, autoReconnect: false })
341
- await client.connect()
381
+ const client = await clientFor(proxy, master)
342
382
  try {
343
383
  // Identificarse hace además que el proxy entregue lo ENCOLADO (un aviso de cuando
344
384
  // estaba apagado, si todavía está dentro de las 24 h).
package/vault/vault.js CHANGED
@@ -185,9 +185,9 @@ import { useBackend as usePeerBackend } from './peerStore.js'
185
185
  core.onSyncStatus((p) => broadcast('sync', p))
186
186
  core.onVaultEvent((p) => broadcast('vault', p))
187
187
 
188
- // ---- Modo SELF: este navegador actúa como bóveda (daemon device-vault) ----
189
- // startDeviceVault convierte la identidad P en CA: atiende enrolamientos y consultas
190
- // de revocación por el proxy. Solo UN iframe por origin es el daemon activo
188
+ // ---- Modo SELF: este navegador actúa como bóveda ----
189
+ // Corre la MISMA bóveda que el binario (`startBrowserVault`): emparejar, firmar, cajones,
190
+ // aprobaciones, consola remota, inicios de sesión. Solo UN iframe por origin es el activo
191
191
  // (navigator.locks): la pestaña VISIBLE sostiene el lock; al pasar a background lo
192
192
  // libera y otra pestaña visible lo toma. Así varias apps abiertas no compiten.
193
193
  const SELF_FLAG = 'dotrino.self-vault.enabled' // persistido en localStorage (kv)
@@ -208,79 +208,47 @@ import { useBackend as usePeerBackend } from './peerStore.js'
208
208
  return /^wss?:\/\//.test(p || '') ? p : null
209
209
  } catch (_) { return null }
210
210
  })()
211
- let daemon = null // handle de startDeviceVault cuando ESTE iframe es el activo
211
+ let daemon = null // la bóveda (`startBrowserVault`) cuando ESTE iframe es el activo
212
212
  let _lockResolver = null // resolver del callback del lock (libera al resolverlo)
213
213
 
214
- // Adaptador: startDeviceVault exige identity.{me.publickey, signData, signDelegation,
215
- // listDelegations, revokeDelegation}; el core los expone vía handlers + getter me.
216
- // `admitMember`/`profileActa` son opcionales para el mostrador de enrolamiento, pero sin
217
- // ellos aprobar emitiría el cert SIN meter al dispositivo en el acta: se pasan también.
218
- const selfIdentity = {
219
- get me () { return core.me },
220
- signData: (data) => handlers.signData({ data }),
221
- signDelegation: (sub, scope, opts) => handlers.signDelegation({ sub, scope, ...(opts || {}) }),
222
- listDelegations: () => handlers.listDelegations({}),
223
- revokeDelegation: (nonce) => handlers.revokeDelegation({ nonce }),
224
- revokeDevice: (sub) => handlers.revokeDevice({ sub }),
225
- admitMember: (m) => handlers.admitMember(m),
226
- profileActa: () => handlers.profileActa({}),
227
- // Camino A (`mode: 'adopt'`): la bóveda se queda con la cuenta que trae el aparato,
228
- // y para eso tiene que poder ENTRAR en su acta. Sin esto, adoptar fallaba con un
229
- // «no es una función» en vez de con un error del protocolo.
230
- joinProfile: (acta) => handlers.joinProfile({ acta })
231
- }
214
+ // La dirección `nombre@AB12-CD34-EF56` sale del acta de la cuenta.
215
+ const selfIdentity = { profileActa: () => handlers.profileActa({}) }
232
216
 
233
217
  /**
234
- * EL ESCRITORIO DE LOS INICIOS DE SESIÓN CON CONTRASEÑA, con el almacén de este navegador.
235
- *
236
- * La bóveda no decide dónde se guarda nada: en el navegador no hay archivos, así que el
237
- * estado lo pone quien la monta —esto—. Sin escritorio la pestaña contesta
238
- * `logins-unavailable`, que es distinto de «contraseña incorrecta» y por eso se ve.
239
- *
240
- * Cuelga del PERFIL ACTIVO: los inicios de sesión son de una cuenta, no de este
241
- * navegador, y dos cuentas en el mismo equipo no comparten usuarios.
242
- *
243
- * Se carga solo aquí, y no arriba, porque arrastra el OPAQUE en WASM (~270 KB): el
244
- * iframe lo cargan las ~30 apps del ecosistema y solo esta pestaña es bóveda.
245
- *
246
- * Lo que guarda aguanta lo mismo que el disco del daemon: con una copia de este
247
- * `localStorage` se pueden probar contraseñas sin límite contra el registro de OPAQUE.
248
- * Lo único que aguanta ahí es que la contraseña sea larga (`temporary-access.md` §4).
218
+ * LOS INICIOS DE SESIÓN QUE ESTA PESTAÑA GUARDABA ANTES de correr la bóveda entera. Ahora
219
+ * viven en el almacén de la bóveda (IndexedDB cifrado, uno por perfil); lo de antes se le
220
+ * pasa una vez y se borra de aquí. Si está y no parsea, REVIENTA a propósito: tomarlo por
221
+ * vacío perdería los inicios de sesión de la cuenta sin que nadie se enterara.
249
222
  */
250
- async function makeLoginDesk () {
251
- const { createLoginDesk } = await import('@dotrino/vault/password-logins')
223
+ async function legacyLogins () {
252
224
  const { id: pid } = await handlers.currentProfile()
253
- if (!pid) throw new Error('no profile: password logins belong to an account')
225
+ if (!pid) return null
254
226
  const key = `dotrino.identity.p.${pid}.self-vault.logins`
255
- return createLoginDesk({
256
- load: () => {
257
- const raw = kv.getItem(key)
258
- // Si está y no parsea, REVIENTA aquí a propósito: devolver `null` haría nacer el
259
- // escritorio vacío, y el primer guardado se llevaría por delante todos los inicios
260
- // de sesión de esta cuenta sin que nadie se enterara.
261
- return raw ? JSON.parse(raw) : null
262
- },
263
- save: (s) => kv.setItem(key, JSON.stringify(s))
264
- })
227
+ const raw = kv.getItem(key)
228
+ if (!raw) return null
229
+ return { state: JSON.parse(raw), drop: () => kv.removeItem(key) }
265
230
  }
266
231
 
267
232
  async function startSelfDaemon () {
268
233
  if (daemon) return
269
234
  try {
270
- // Import dinámico: aísla fallos del vendor del arranque del vault (cargado por
271
- // todas las apps). El import map de index.html resuelve @dotrino/vault.
272
- const { startDeviceVault } = await import('@dotrino/vault')
273
- daemon = await startDeviceVault(selfIdentity, {
235
+ // LA MISMA BÓVEDA QUE EL BINARIO (`@dotrino/vault/browser`): el núcleo entero, con el
236
+ // disco en IndexedDB. Import dinámico: el iframe lo cargan las ~30 apps y solo esta
237
+ // pestaña es bóveda; además aísla un fallo del vendor del arranque de la identidad.
238
+ const { startBrowserVault } = await import('@dotrino/vault/browser')
239
+ daemon = await startBrowserVault({
240
+ core,
274
241
  ...(selfProxyUrl ? { proxyUrl: selfProxyUrl } : {}),
275
- logins: await makeLoginDesk()
242
+ legacyLogins,
243
+ onPendingChange: () => broadcast('selfVault', { pending: daemon ? daemon.listPending() : [] }),
244
+ log: (...a) => console.log(...a)
276
245
  })
277
- daemon.onPendingChange(() => broadcast('selfVault', { pending: daemon.listPending() }))
278
246
  broadcast('selfVault', { running: true })
279
247
  } catch (e) { daemon = null; broadcast('selfVault', { error: e?.message || String(e) }) }
280
248
  }
281
249
  function stopSelfDaemon () {
282
250
  if (!daemon) return
283
- try { daemon.close() } catch {}
251
+ try { daemon.close()?.catch?.(() => {}) } catch {}
284
252
  daemon = null
285
253
  broadcast('selfVault', { running: false })
286
254
  }
@@ -340,6 +308,9 @@ import { useBackend as usePeerBackend } from './peerStore.js'
340
308
  // agentes siempre, sin depender de qué pestaña sostenga el lock del daemon.
341
309
  listVaultDevices: async () => {
342
310
  if (kv.getItem(SELF_FLAG) !== '1') return handlers.listVaultDevices({})
311
+ // Con la bóveda encendida en ESTA pestaña, la lista sale de ella (por el camino
312
+ // local): trae lo mismo que la del binario. Sin ella encendida, se lee lo guardado.
313
+ if (daemon) return handlers.listVaultDevices({})
343
314
  const { issued, revoked } = await handlers.listDelegations({})
344
315
  const now = Date.now()
345
316
  const bySub = new Map()
@@ -491,7 +462,7 @@ import { useBackend as usePeerBackend } from './peerStore.js'
491
462
  selfVaultLoginRemove: async ({ user } = {}) => {
492
463
  const d = pidaDaemon()
493
464
  const fila = d.listLogins().find((x) => x.user === user)
494
- const r = d.removeLogin({ user })
465
+ const r = await d.removeLogin({ user })
495
466
  if (r?.ok && fila?.pub) {
496
467
  try { await handlers.revokeDevice({ sub: fila.pub }) } catch (e) {
497
468
  throw Object.assign(new Error(`the login is gone but its key is still in the record: ${e.message}`), { code: 'revoke-failed' })
@@ -0,0 +1,3 @@
1
+ Copia vendorizada de @dotrino/compat@0.4.0 (dotrino-compat/src/{advisory,index,ranges}.js).
2
+ NO se edita a mano: la escribe `node vendor.mjs` y la vigila test/vendor-up-to-date.test.mjs.
3
+ Qué versión es cada pieza y si pueden hablar (CONVENCIONES §14).
@@ -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/{src/index,build/opaque,build/wasm-bytes}.js).
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.