@dotrino/identity 0.115.0 → 0.116.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/package.json +4 -1
  2. package/src/node.js +4 -180
  3. package/vault/core.js +66 -7
  4. package/vault/facade.js +200 -0
  5. package/vault/index.html +10 -0
  6. package/vault/remote.js +45 -5
  7. package/vault/vault.js +29 -58
  8. package/vault/vendor/compat/VERSION.txt +3 -0
  9. package/vault/vendor/compat/advisory.js +141 -0
  10. package/vault/vendor/compat/index.js +188 -0
  11. package/vault/vendor/compat/ranges.js +93 -0
  12. package/vault/vendor/opaque/VERSION.txt +1 -1
  13. package/vault/vendor/passmanager/VERSION.txt +3 -0
  14. package/vault/vendor/passmanager/crypto.js +167 -0
  15. package/vault/vendor/passmanager/fields.js +183 -0
  16. package/vault/vendor/passmanager/generate.js +74 -0
  17. package/vault/vendor/passmanager/import.js +156 -0
  18. package/vault/vendor/passmanager/index.js +27 -0
  19. package/vault/vendor/passmanager/match.js +137 -0
  20. package/vault/vendor/passmanager/model.js +332 -0
  21. package/vault/vendor/passmanager/pubkey.js +33 -0
  22. package/vault/vendor/passmanager/sealed/convert.js +72 -0
  23. package/vault/vendor/passmanager/sealed/device.js +211 -0
  24. package/vault/vendor/passmanager/sealed/entry.js +209 -0
  25. package/vault/vendor/passmanager/sealed/index.js +20 -0
  26. package/vault/vendor/passmanager/sealed/keys.js +120 -0
  27. package/vault/vendor/passmanager/sealed/recovery.js +136 -0
  28. package/vault/vendor/passmanager/sealed/store.js +508 -0
  29. package/vault/vendor/passmanager/session-cache.js +60 -0
  30. package/vault/vendor/passmanager/totp.js +111 -0
  31. package/vault/vendor/passmanager/transport/protocol.js +83 -0
  32. package/vault/vendor/passmanager/transport/proxy.js +112 -0
  33. package/vault/vendor/passmanager/transport/sealed.js +74 -0
  34. package/vault/vendor/passmanager/vault/approval.js +63 -0
  35. package/vault/vendor/passmanager/vault/errors.js +34 -0
  36. package/vault/vendor/passmanager/vault/guard.js +114 -0
  37. package/vault/vendor/passmanager/vault/interface.js +34 -0
  38. package/vault/vendor/passmanager/vault/local.js +213 -0
  39. package/vault/vendor/passmanager/vault/remote.js +85 -0
  40. package/vault/vendor/passmanager/vault/responder.js +192 -0
  41. package/vault/vendor/passmanager/vault/sealed-local.js +199 -0
  42. package/vault/vendor/passmanager/vault/sealed-responder.js +159 -0
  43. package/vault/vendor/passmanager/vault/sealed.js +362 -0
  44. package/vault/vendor/passmanager/webauthn.js +221 -0
  45. package/vault/vendor/proxy-client/VERSION.txt +1 -1
  46. package/vault/vendor/proxy-client/client.js +36 -0
  47. package/vault/vendor/roadmap/VERSION.txt +3 -0
  48. package/vault/vendor/roadmap/index.js +108 -0
  49. package/vault/vendor/roadmap/manifests.js +797 -0
  50. package/vault/vendor/store/VERSION.txt +3 -0
  51. package/vault/vendor/store/core.js +309 -0
  52. package/vault/vendor/vault/VERSION.txt +4 -1
  53. package/vault/vendor/vault/admin.js +437 -0
  54. package/vault/vendor/vault/browser.js +167 -0
  55. package/vault/vendor/vault/core/approvals.js +204 -0
  56. package/vault/vendor/vault/core/blocked.js +68 -0
  57. package/vault/vendor/vault/core/browserDisk.js +109 -0
  58. package/vault/vendor/vault/core/commKey.js +68 -0
  59. package/vault/vendor/vault/core/disk.js +65 -0
  60. package/vault/vendor/vault/core/libVersion.js +8 -0
  61. package/vault/vendor/vault/core/machines.js +66 -0
  62. package/vault/vendor/vault/core/notices.js +71 -0
  63. package/vault/vendor/vault/core/notifiable.js +73 -0
  64. package/vault/vendor/vault/core/passwords.js +125 -0
  65. package/vault/vendor/vault/core/sealKey.js +77 -0
  66. package/vault/vendor/vault/core/sealer.js +172 -0
  67. package/vault/vendor/vault/core/sealers.js +159 -0
  68. package/vault/vendor/vault/core/secretsStore.js +1326 -0
  69. package/vault/vendor/vault/core/sha256.js +69 -0
  70. package/vault/vendor/vault/core/store.js +68 -0
  71. package/vault/vendor/vault/core/subacta.js +101 -0
  72. package/vault/vendor/vault/core/threadStore.js +273 -0
  73. package/vault/vendor/vault/core/transport.js +270 -0
  74. package/vault/vendor/vault/core/vault.js +4414 -0
  75. package/vault/vendor/vault/enroll.js +8 -4
  76. package/vault/vendor/vault/procContext.js +97 -0
  77. package/vault/vendor/vault/revocation.js +67 -0
  78. package/vault/vendor/vault/sealed.js +84 -0
@@ -0,0 +1,73 @@
1
+ /**
2
+ * QUÉ APROBADORES PUEDEN RECIBIR UN AVISO: lo dice cada uno, y caduca.
3
+ *
4
+ * Un aparato con `approve` que no recibe avisos es un aprobador solo en el papel: el pedido
5
+ * no le suena y solo lo ve si abre la app por su cuenta.
6
+ *
7
+ * Así que el aprobador lo declara: al pedir su lista de pedidos (`op: 'approvals'`, que ya
8
+ * manda firmado cada vez que se abre) dice `notify: true` si tiene los avisos encendidos.
9
+ * Aquí se apunta CUÁNDO lo dijo por última vez, y vale `NOTIFIABLE_TTL_MS`: una app que se
10
+ * desinstala deja de renovarlo.
11
+ *
12
+ * NO DECIDE NADA: SE ENSEÑA (dueño, 2026-10-08). Un día decidió si la bóveda pedía permiso
13
+ * para actualizarse —sin nadie que lo hubiera dicho, se actualizaba sola— y se quitó: era
14
+ * una condición más, y el silencio de un aprobador abría la puerta. Quién aprueba lo dice
15
+ * el acta y nada más; los avisos salen a todos los que tienen `approve`, lo hayan dicho o
16
+ * no. Esto sirve para VER a cuál no le está sonando el pedido (`members`, `status`, la
17
+ * consola).
18
+ *
19
+ * Y NO SE LE PREGUNTA AL PROXIO, que es quien guarda las suscripciones: es la pieza de la
20
+ * que el diseño no se fía, y creerle sería dejarle decidir cuándo no hace falta permiso.
21
+ *
22
+ * Cifrado en reposo como lo demás. Un archivo que existe y no se puede leer NO es «nadie
23
+ * puede recibir avisos»: se lanza.
24
+ */
25
+
26
+ export const NOTIFIABLE_FILE = 'notifiable.json'
27
+ /** Cuánto vale un «puedo recibir avisos» sin renovarse. */
28
+ export const NOTIFIABLE_TTL_MS = 30 * 24 * 60 * 60 * 1000
29
+
30
+ const fail = (code, message) => Object.assign(new Error(message), { code })
31
+
32
+ /**
33
+ * @param {{ disk: object, now?: () => number }} opts
34
+ */
35
+ export function openNotifiable ({ disk, now = Date.now }) {
36
+ const file = NOTIFIABLE_FILE
37
+ const atRest = disk.atRest
38
+ /** pub → ms de la última vez que dijo que sí */
39
+ const items = load()
40
+
41
+ function load () {
42
+ let raw
43
+ try { raw = disk.read(file) } catch (e) {
44
+ throw fail('notifiable-unreadable', `cannot read ${NOTIFIABLE_FILE}: ${e.message}`)
45
+ }
46
+ if (raw == null) return new Map()
47
+ let d
48
+ try { d = JSON.parse(atRest.decrypt(raw)) } catch (e) { throw fail('notifiable-unreadable', `${NOTIFIABLE_FILE} cannot be opened: ${e.message}`) }
49
+ if (d?.v !== 1 || !d.items || typeof d.items !== 'object') throw fail('notifiable-unreadable', `${NOTIFIABLE_FILE} has an unknown shape`)
50
+ return new Map(Object.entries(d.items))
51
+ }
52
+
53
+ function save () {
54
+ disk.write(file, atRest.encrypt(JSON.stringify({ v: 1, items: Object.fromEntries(items) })))
55
+ }
56
+
57
+ return {
58
+ /**
59
+ * Lo que acaba de decir un aprobador. `true` lo apunta (o lo renueva); `false` lo quita
60
+ * ya, sin esperar a que caduque. Cualquier otra cosa no dice nada y no cambia nada: un
61
+ * cliente viejo, que no manda el campo, no borra lo que dijo uno nuevo.
62
+ */
63
+ declare (pub, canNotify) {
64
+ if (typeof pub !== 'string' || !pub) throw fail('bad-pub', 'declare: a device key is required')
65
+ if (canNotify === true) { items.set(pub, now()); save() } else if (canNotify === false && items.delete(pub)) save()
66
+ },
67
+ /** Cuándo lo dijo por última vez, o `null` si nunca o si ya caducó. */
68
+ since (pub) {
69
+ const t = items.get(pub)
70
+ return typeof t === 'number' && now() - t < NOTIFIABLE_TTL_MS ? t : null
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * LA BÓVEDA DE CONTRASEÑAS del dueño, atendida por el vault — Y QUE EL VAULT NO PUEDE LEER.
3
+ *
4
+ * Hasta 0.122 esto tenía una llave propia (`passwordsKey()`) guardada **dentro del mismo
5
+ * archivo que las entradas**, cifrada con la llave de la máquina. O sea: el demonio con el
6
+ * perfil CERRADO seguía descifrando contraseñas, y cualquiera con una copia del disco las
7
+ * leía todas. Era el mismo esquema que tuvo la maestra hasta que se selló.
8
+ *
9
+ * Ahora la bóveda es un cartero también para las contraseñas
10
+ * (`dotrino-passmanager/docs/sealed-passwords.md`): guarda sobres dirigidos a los aparatos
11
+ * y envolturas que no puede abrir. Lo que sí hace, y es lo que sostiene el modelo:
12
+ *
13
+ * · dice A QUIÉN hay que envolver cada llave —sale del acta, y **la bóveda nunca está en
14
+ * esa lista**: ni su maestra, ni su llave de comunicación;
15
+ * · comprueba que quien escribe sea un aparato del acta con `passwords`, por su firma;
16
+ * · filtra por destinatario al entregar, y decide si hace falta un dedo encima.
17
+ *
18
+ * Lo que SIGUE igual: el protocolo es de a una credencial, `list` no existe en remoto, y la
19
+ * aprobación es la misma del resto del vault (`caps +aprueba`).
20
+ */
21
+
22
+ import { SealedStore, SealedResponder } from '@dotrino/passmanager'
23
+ import { verifyDeviceSig } from '@dotrino/identity/capabilities'
24
+
25
+ export const PASSWORDS_CAP = 'passwords'
26
+ export const PASSKEYS_CAP = 'passkeys'
27
+
28
+ /**
29
+ * @param {object} opts
30
+ * `client` cliente del proxio del vault, ya identificado
31
+ * `store` `{ get(k), set(k,v) }` donde viven los sobres
32
+ * `members(kind)` → `[{ pub, encPub }]` a quién envolverle cada llave (`main`/`passkeys`),
33
+ * SIN la bóveda. Lo pone `vault.js`, que es quien lee el acta.
34
+ * `recoveryPub()` la pública de la copia de recuperación (la que abre la frase)
35
+ * `canWrite(pubkey)` ¿ese aparato puede ESCRIBIR contraseñas? (acta: `passwords`)
36
+ * `isAllowed(pubkey)` ¿puede pedir? — lo decide el acta, no esto
37
+ * `encPubOf(pubkey)` su llave de cifrado, para sellarle la respuesta
38
+ * `needsApproval(pubkey)` (async) si ese aparato tiene que pedir aprobación (`unattended`)
39
+ * `approve({ pubkey, op })` pide el visto bueno (el teléfono) y espera
40
+ * `audit(op, info)` la bitácora del vault
41
+ */
42
+ export function createPasswordDesk (opts = {}) {
43
+ const {
44
+ client, store,
45
+ members = () => [],
46
+ recoveryPub = () => null,
47
+ canWrite = () => false,
48
+ isAllowed = () => false,
49
+ encPubOf = () => null,
50
+ needsApproval = () => true,
51
+ approve = async () => false,
52
+ audit = () => {},
53
+ log = () => {},
54
+ } = opts
55
+
56
+ const sealed = new SealedStore(store, {
57
+ recipients: (kind) => members(kind),
58
+ /**
59
+ * ¿LA FIRMÓ QUIEN DICE, Y PUEDE ESCRIBIR? Es lo único que la bóveda puede comprobar
60
+ * sobre una entrada cuyo contenido no ve. Las dos mitades importan: la firma ata el
61
+ * sobre a una llave, y el acta dice si esa llave tiene algo que hacer aquí.
62
+ */
63
+ verifyAuthor: async ({ body, author }) => {
64
+ if (!canWrite(author?.pub)) return false
65
+ return verifyDeviceSig({ publickey: author.pub, data: body, signature: author.sig })
66
+ },
67
+ })
68
+
69
+ const responder = new SealedResponder({
70
+ client,
71
+ store: sealed,
72
+ recipients: async () => ({
73
+ recoveryPub: recoveryPub(),
74
+ main: await members('main'),
75
+ passkeys: await members('passkeys'),
76
+ }),
77
+ isAllowed,
78
+ encPubOf,
79
+ // Dos condiciones, y las dos tienen que darse: que ese APARATO esté marcado para
80
+ // aprobar, y que lo que pide sea PRIVADO. Rellenar un nombre no es sacar un secreto, y
81
+ // pedir permiso para todo enseña a decir que sí sin mirar.
82
+ needsApproval: async (pubkey) => await needsApproval(pubkey),
83
+ approve,
84
+ onRequest: (r) => {
85
+ // La bitácora NO lleva qué credencial se pidió: eso es contenido del usuario.
86
+ audit('passwords', { op: r.op, outcome: r.outcome, device: (r.from || '').slice(0, 24) })
87
+ if (r.outcome !== 'ok') log(`[vault] passwords: ${r.op} ${r.outcome}`)
88
+ },
89
+ })
90
+
91
+ return {
92
+ start () { responder.start(); return this },
93
+ stop () { responder.stop() },
94
+ /**
95
+ * EL CANDADO DEL PERFIL YA NO PINTA NADA AQUÍ, y eso es la noticia: esta bóveda no
96
+ * tiene ninguna llave que cerrar. Se conserva el método porque quien lo llama no tiene
97
+ * por qué saberlo, y porque decirlo en la bitácora ayuda a entender el cambio.
98
+ */
99
+ lock () { audit('passwords.lock', { sealed: true }) },
100
+ unlock () {},
101
+ /** Para la consola local y las pruebas. Aquí tampoco se abre nada. */
102
+ sealed,
103
+ responder,
104
+ }
105
+ }
106
+
107
+ /**
108
+ * CONVERTIR LO QUE HAY (§2.7). Es un acto único y es **segundo trabajo de la maestra**: se
109
+ * hace al ABRIR la bóveda, que es cuando la frase está a mano y hay con qué reescribirlo
110
+ * todo.
111
+ *
112
+ * El trabajo está en `@dotrino/passmanager/sealed` y no aquí, porque lo hacen las CUATRO
113
+ * bóvedas: esta, la pestaña, la de dentro de la extensión y `passmanager serve`. Cuatro
114
+ * copias serían cuatro formas sutilmente distintas de reescribir lo mismo, y la que se
115
+ * quedara atrás no fallaría al convertir — fallaría al leerlo otra bóveda, meses después.
116
+ *
117
+ * Lo que sí es de aquí: `dropOldKey`, porque dónde vive la llave vieja lo sabe cada una.
118
+ */
119
+ export async function convertPasswords ({ store, sealed, cek, recipients, author, dropOldKey, log = () => {} } = {}) {
120
+ const { convertToSealed } = await import('@dotrino/passmanager/sealed')
121
+ return convertToSealed({
122
+ store, sealed, cek, recipients, author, dropOldKey,
123
+ log: (m) => log('[vault] ' + m)
124
+ })
125
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * La LLAVE DE SELLADO de esta bóveda: con ella FIRMA los sobres de los secretos, para
3
+ * que se sepa que salieron de aquí. Diseño: `docs/secretos-sellados.md` §8.8 y §8.9.
4
+ *
5
+ * Tres cosas que la hacen distinta de todo lo demás que hay en este directorio:
6
+ *
7
+ * · **Firmar no es leer.** Esta llave no abre ningún valor. Por eso puede vivir
8
+ * cifrada en reposo con la llave de la máquina y usarse **sin la frase** — que es
9
+ * justo lo que hace posible administrar sin contraseña.
10
+ * · **Su autoridad la da el ACTA**, que la nombra (`sealPub`) y que sella únicamente
11
+ * la maestra. Ella no se autoriza a sí misma.
12
+ * · **Rota con el acta.** Cada acta nueva puede estrenar una, así que aquí se guarda
13
+ * un puñado: la vigente para firmar, y las anteriores porque un sobre se firmó con
14
+ * la que mandaba entonces y hay que poder seguir firmando… no, seguir
15
+ * **identificando** cuál era. Verificar se hace con la pública que dice el acta.
16
+ *
17
+ * No guarda ninguna correspondencia con `seq`: quién mandaba y cuándo lo dice el acta
18
+ * (`sealKeys` con su tramo). Aquí solo están las privadas, indexadas por su pública.
19
+ */
20
+ import { readJson, writeJson } from './disk.js'
21
+ import { signWithDevice } from '@dotrino/identity/capabilities'
22
+
23
+ /**
24
+ * Cuántas llaves anteriores se conservan. Firmar solo usa la vigente; las viejas se
25
+ * guardan por si hay que re-firmar algo sellado con ellas (recoger el histórico), y
26
+ * porque tirarlas no ahorra nada: son cuatro líneas de JSON.
27
+ */
28
+ const MAX_KEYS = 8
29
+
30
+ /** Abre (o estrena) el llavero de sellado del perfil. */
31
+ export function openSealKeys (disk) {
32
+ const file = 'sealkeys.json'
33
+ const data = readJson(disk, file, null) || { v: 1, keys: [] }
34
+ if (!Array.isArray(data.keys)) data.keys = []
35
+
36
+ const save = () => writeJson(disk, file, data)
37
+ const find = (pub) => data.keys.find((k) => k.pub === pub) || null
38
+
39
+ return {
40
+ /** La pública de la llave vigente (la última estrenada), o `null` si no hay ninguna. */
41
+ current: () => data.keys[data.keys.length - 1]?.pub || null,
42
+
43
+ /** ¿Tenemos la privada de esta pública? Es lo que decide si podemos firmar por ella. */
44
+ has: (pub) => !!find(pub),
45
+
46
+ /**
47
+ * Estrena una llave y devuelve su PÚBLICA, para que el acta la nombre. La privada
48
+ * se queda aquí; no sale de esta máquina ni viaja a ninguna parte.
49
+ *
50
+ * Es lo que se le pasa a `identity.setSealKeyProvider`, así que se llama una vez
51
+ * por acta sellada.
52
+ */
53
+ async mint () {
54
+ const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'])
55
+ const pub = JSON.stringify(await crypto.subtle.exportKey('jwk', pair.publicKey))
56
+ const priv = await crypto.subtle.exportKey('jwk', pair.privateKey)
57
+ data.keys.push({ pub, priv, createdAt: Date.now() })
58
+ if (data.keys.length > MAX_KEYS) data.keys = data.keys.slice(-MAX_KEYS)
59
+ save()
60
+ return pub
61
+ },
62
+
63
+ /**
64
+ * Firma un cuerpo con la llave que el acta nombra. Devuelve `null` si esa llave no es
65
+ * nuestra —el disco se restauró, o el acta la puso otro master—: entonces el sobre
66
+ * sale SIN firma, que es peor que firmado pero mucho mejor que no poder guardar.
67
+ */
68
+ async sign (sealPub, body) {
69
+ const k = sealPub ? find(sealPub) : null
70
+ if (!k) return null
71
+ const { signature } = await signWithDevice({ privateJwk: k.priv, publickey: k.pub, data: body })
72
+ return signature
73
+ }
74
+ }
75
+ }
76
+
77
+ export default { openSealKeys }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * El SELLADOR: lo único de los secretos que toca criptografía.
3
+ *
4
+ * `secretsStore.js` guarda la forma y las reglas y no sabe cifrar; le inyecta este
5
+ * puerto y le pide sobres. Así el store se prueba con un sellador falso y este archivo
6
+ * se lee entero de una sentada.
7
+ *
8
+ * QUÉ HACE. Cada cajón (un namespace, o el cajón propio de un aparato) tiene una CEK
9
+ * AES-256-GCM. Las variables privadas se cifran con ella. La CEK viaja de dos formas,
10
+ * y esa dualidad es todo el diseño:
11
+ *
12
+ * · **hacia los miembros** — envuelta a la llave de cifrado (`encPub`) de cada
13
+ * aparato que deba leer ese cajón, con ECDH efímero: quien abre solo necesita su
14
+ * propia privada. Es lo que va en el llavero del archivo y lo que viaja al agente.
15
+ * · **hacia quien administra** — en la COPIA MAESTRA, cifrada con la llave derivada
16
+ * de la contraseña del perfil. Es lo que permite sellar una variable nueva o
17
+ * re-envolver la CEK a un aparato que entra.
18
+ *
19
+ * Y hay una tercera forma que NO existe a propósito: **la CEK nunca se envuelve a la
20
+ * llave de esta bóveda**. Si se hiciera, el daemon podría abrir todo por su cuenta y
21
+ * esto no valdría nada — que es exactamente la situación de la que venimos.
22
+ *
23
+ * La contraseña no está en el disco, así que una copia del disco no abre las privadas.
24
+ * Lo que sí abre es todo lo demás; los límites honestos están en
25
+ * `docs/secretos-sellados.md` §3 y conviene leerlos antes de confiar de más.
26
+ */
27
+ import {
28
+ makeContentKey, makeGeneration, encryptWithCek, decryptWithCek, wrapForMember, openWrap
29
+ } from '@dotrino/identity/content'
30
+
31
+ const b64 = (b) => { let s = ''; for (const x of new Uint8Array(b)) s += String.fromCharCode(x); return btoa(s) }
32
+ const un64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0))
33
+ /** AES-256-GCM con WebCrypto: el mismo en Node y en el navegador. */
34
+ const aesKey = (raw, uses) => crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, uses)
35
+
36
+ /** La copia maestra va cifrada con AES-256-GCM bajo la llave derivada de la contraseña. */
37
+ const MASTER_V = 1
38
+
39
+ /** Se pidió abrir la copia maestra sin llave, o con la equivocada. */
40
+ export class WrongPassword extends Error {
41
+ constructor (msg = 'wrong password') {
42
+ super(msg)
43
+ this.code = 'WRONG_PASSWORD'
44
+ }
45
+ }
46
+
47
+ function assertKey (adminKey) {
48
+ if (!(adminKey instanceof Uint8Array) || adminKey.length !== 32) {
49
+ throw new WrongPassword('the derived key is missing or malformed (32 bytes expected)')
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Crea el sellador. No guarda estado ni recuerda la contraseña: cada operación recibe
55
+ * la llave derivada y la suelta. Que no se cachee es la mitad del valor de todo esto —
56
+ * una llave que se queda en memoria para siempre deja el disco protegido y la máquina
57
+ * igual de expuesta que antes.
58
+ */
59
+ export function makeSealer () {
60
+ return {
61
+ /**
62
+ * Abre la copia maestra: `{ "ns:<ns>": cek, "dev:<pub>": cek }`. Un `blob` vacío
63
+ * devuelve un mapa vacío — es el primer arranque, no un error.
64
+ */
65
+ async openMaster (blob, adminKey) {
66
+ assertKey(adminKey)
67
+ if (!blob) return {}
68
+ if (blob.v !== MASTER_V) throw new Error(`master copy: unknown format v${blob.v}`)
69
+ try {
70
+ // WebCrypto quiere la etiqueta pegada al final del cifrado; en disco van aparte.
71
+ const ct = un64(blob.ct); const tag = un64(blob.tag)
72
+ const both = new Uint8Array(ct.length + tag.length); both.set(ct); both.set(tag, ct.length)
73
+ const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: un64(blob.iv) }, await aesKey(adminKey, ['decrypt']), both)
74
+ return JSON.parse(new TextDecoder().decode(plain))
75
+ } catch (_) {
76
+ // El tag de AES-GCM ES el verificador: si no cuadra, la contraseña no era.
77
+ // Por eso no hace falta guardar aparte un verificador de la contraseña, que
78
+ // sería un camino más barato para atacarla desde una copia del disco.
79
+ throw new WrongPassword()
80
+ }
81
+ },
82
+
83
+ async sealMaster (obj, adminKey) {
84
+ assertKey(adminKey)
85
+ const iv = crypto.getRandomValues(new Uint8Array(12))
86
+ const both = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, await aesKey(adminKey, ['encrypt']), new TextEncoder().encode(JSON.stringify(obj))))
87
+ return { v: MASTER_V, iv: b64(iv), ct: b64(both.subarray(0, both.length - 16)), tag: b64(both.subarray(both.length - 16)) }
88
+ },
89
+
90
+ /** La CEK de un cajón, creándola si es la primera variable que entra ahí. */
91
+ async cekFor (master, owner) {
92
+ if (!master[owner]) master[owner] = await makeContentKey()
93
+ return master[owner]
94
+ },
95
+
96
+ /** Una CEK NUEVA para el cajón, tirando la anterior. Rotar es esto. */
97
+ async newCek (master, owner) {
98
+ master[owner] = await makeContentKey()
99
+ return master[owner]
100
+ },
101
+
102
+ /** Cifra un valor con la CEK del cajón. */
103
+ async encrypt (cek, plaintext, gen = 0) {
104
+ const { iv, ct } = await encryptWithCek({ cek, gen, plaintext })
105
+ return { iv, ct }
106
+ },
107
+
108
+ /** Descifra con la CEK que corresponda al cajón. Solo lo usa quien administra. */
109
+ async decrypt (master, envelope, owner) {
110
+ const cek = master[owner]
111
+ if (!cek) throw new Error(`master copy: no key for drawer ${owner}`)
112
+ return decryptWithCek({ cek, envelope })
113
+ },
114
+
115
+ /**
116
+ * Una CEK NUEVA, sin llavero de por medio. Es lo que usa cada escritura desde v5: la
117
+ * bóveda no puede reutilizar la de antes porque **no puede abrir ninguna envoltura
118
+ * para recuperarla** — sellar es una capacidad pública, abrir no. Una generación por
119
+ * escritura es la consecuencia, no un capricho (§8.7).
120
+ */
121
+ async newKey () {
122
+ return makeContentKey()
123
+ },
124
+
125
+ /**
126
+ * El par de RECUPERACIÓN: su pública se guarda en claro —para poder envolverle sin
127
+ * saber nada— y su privada va sellada bajo la frase. Es la copia que se abre el día
128
+ * que no queda ningún aparato, y la que abre la TUI cuando el dueño pide ver un valor.
129
+ */
130
+ async makeRecoveryPair () {
131
+ const pair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey', 'deriveBits'])
132
+ return {
133
+ pub: JSON.stringify(await crypto.subtle.exportKey('jwk', pair.publicKey)),
134
+ priv: await crypto.subtle.exportKey('jwk', pair.privateKey)
135
+ }
136
+ },
137
+
138
+ /** Envuelve una CEK a UNA pública suelta (la de recuperación, que no es un miembro). */
139
+ async wrapForKey (cek, encPub) {
140
+ return wrapForMember({ cek, memberEncPub: encPub })
141
+ },
142
+
143
+ /**
144
+ * Abre una envoltura con una privada ECDH **en JWK**. Lo hace quien PUEDE leer.
145
+ *
146
+ * La privada llega como JWK porque así se guarda (sellada bajo la frase, en un JSON),
147
+ * y `openWrap` quiere una `CryptoKey`: importarla es el puente, y va aquí porque este
148
+ * es el único archivo que toca criptografía.
149
+ */
150
+ async openWrapWith (privJwk, wrap) {
151
+ const key = await crypto.subtle.importKey('jwk', privJwk, { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits'])
152
+ return openWrap({ wrap, myEncPrivateKey: key })
153
+ },
154
+
155
+ /** Descifra un sobre con la CEK ya abierta. */
156
+ async openValue (cek, envelope) {
157
+ return decryptWithCek({ cek, envelope })
158
+ },
159
+
160
+ /**
161
+ * Envuelve la CEK a cada miembro. Los que no tengan llave de cifrado salen en
162
+ * `sinLlave` en vez de reventar: quien administra tiene que poder VERLO, porque el
163
+ * síntoma sería un servicio arrancando sin configuración y sin decir por qué.
164
+ */
165
+ async wrapFor (cek, members) {
166
+ const { generation, sinLlave } = await makeGeneration({ members, cek })
167
+ return { wraps: generation.wraps, sinLlave }
168
+ }
169
+ }
170
+ }
171
+
172
+ export default { makeSealer, WrongPassword }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * sealers.js — depositar la cadena de selladores en el registro público.
3
+ *
4
+ * POR QUÉ ESTO EXISTE. Un acta que alguien guardó el año pasado no puede responder la única
5
+ * pregunta que importa al leer una firma vieja: *la llave que firmó esto, ¿sigue pudiendo
6
+ * sellar?* Si a una bóveda le quitaron el permiso, quien tenga el acta vieja no se entera
7
+ * nunca — y seguiría creyéndole. El registro es el sitio donde mirar.
8
+ *
9
+ * QUÉ SE MANDA: los ESLABONES (`sealerLinkOf`), nunca las actas. Y un eslabón no es un acta
10
+ * recortada: es un documento propio de ocho campos, firmado aparte y metido DENTRO del acta
11
+ * antes de firmarla (identity >= 0.72). Costó una vuelta descubrirlo — la primera cadena que
12
+ * llegó al registro se fue con los `label` y los `cn` de cada aparato, porque «un eslabón»
13
+ * era un acta entera. Un acta lleva el inventario de tu casa: no se publica, ni recortada.
14
+ *
15
+ * POR QUÉ SIN SELLAR, y no es un descuido (§4.1 pide sellar los mensajes dirigidos): lo que
16
+ * viaja aquí va a acabar en un repo PÚBLICO, que es su propósito. Es el mismo caso que los
17
+ * canales `publish`/`list` del proxio, exentos por la misma razón: no hay nada que ocultar.
18
+ * Y no filtra de más — quién lo manda ya lo sabe el proxio por el `identify`. Sellarlo,
19
+ * además, obligaría a conocer la llave de cifrado del testigo, y depositar tiene que poder
20
+ * hacerlo cualquiera sin pedirle nada a nadie.
21
+ *
22
+ * Va por `sendByPubkey` y no por `send`: al testigo se le conoce por su pubkey, y así lo
23
+ * depositado entra en la cola de 24 h del proxio si estuviera caído.
24
+ *
25
+ * CUÁNDO: al arrancar y después de cada acta. Publicar de más no cuesta nada (el registro
26
+ * contesta «ya estaba») y así no hay que llevar la cuenta de qué se depositó — un estado
27
+ * que se desincroniza en cuanto se restaura un disco.
28
+ */
29
+ import * as Acta from '@dotrino/identity/acta'
30
+ import { verifyDelegation } from '@dotrino/identity/capabilities'
31
+
32
+ export const OP = 'sealers.publish'
33
+
34
+ /**
35
+ * LA IDENTIDAD DEL TESTIGO DE DOTRINO — no su aparato (dueño, 2026-08-31: «¿no me puedes
36
+ * quemar un device en el código?»).
37
+ *
38
+ * Tenía razón y era el mismo fallo que ya costó caro con el `nodeId` del proxio: la pubkey
39
+ * de un APARATO muere en cuanto lo revocas o lo re-enrolas, y todo lo que la tuviera
40
+ * apuntada se queda hablando con una dirección muerta. Lo que no cambia nunca es la
41
+ * IDENTIDAD: el `profileId` es el nombre de la cuenta y es para siempre.
42
+ *
43
+ * Así que aquí va la identidad, y el aparato se DESCUBRE: el testigo se anuncia en el canal
44
+ * con su cert, y se acepta al que traiga uno emitido por esta identidad. Re-enrolarlo da un
45
+ * cert nuevo de la misma identidad y todo sigue solo; revocarlo lo saca del acta y su
46
+ * anuncio deja de valer.
47
+ *
48
+ * Y nadie configura nada: esto es una dirección pública, como `wss://proxy.dotrino.com`.
49
+ */
50
+ export const DOTRINO_REGISTRY_ID = "{\"key_ops\":[\"verify\"],\"ext\":true,\"kty\":\"EC\",\"x\":\"McyhKieRw_Jcl3TY9EvLovL-ALzGoWqMOrfnFjT7XpA\",\"y\":\"V7tfpDWN37tPnhXnIFUlMaTepcTzzxPWTj_72PkpKPw\",\"crv\":\"P-256\"}"
51
+
52
+ /** El canal donde el testigo se anuncia (ver `@dotrino/sealers`). */
53
+ export const CANAL = 'dotrino.sealers'
54
+
55
+ /** Se le pregunta a cada anunciado quién es; contesta con su cert (ver `@dotrino/sealers`). */
56
+ export const WHOIS = 'sealers.whois'
57
+
58
+ export function startSealersPublisher ({ identity, client, log = console.log, registryId = DOTRINO_REGISTRY_ID, canal = CANAL } = {}) {
59
+ if (!registryId) return () => {}
60
+
61
+ /** Una respuesta a `sealers.whois`, o null si no contesta a tiempo. */
62
+ function preguntarQuienEs (token, timeoutMs = 8000) {
63
+ return new Promise((resolve) => {
64
+ const fin = (v) => { try { off() } catch (_) {} ; clearTimeout(t); resolve(v) }
65
+ const off = client.on('message', (_f, p) => { if (p?.op === WHOIS + '.result') fin(p) })
66
+ const t = setTimeout(() => fin(null), timeoutMs)
67
+ t.unref?.()
68
+ try { client.send(token, { op: WHOIS }) } catch (_) { fin(null) }
69
+ })
70
+ }
71
+
72
+ /**
73
+ * ¿A QUIÉN LE MANDO ESTO? Al que esté anunciado en el canal y demuestre ser de la
74
+ * IDENTIDAD que llevamos en el código.
75
+ *
76
+ * El canal dice DÓNDE mirar y nada más: `list` del proxio devuelve tokens sueltos — el
77
+ * `extraData` del `publish` no vuelve, comprobado contra el servidor. Así que a cada uno
78
+ * se le pregunta quién es (`sealers.whois`) y contesta con su cert. Que el cert venga del
79
+ * propio testigo y no del anuncio es además mejor: llega fresco.
80
+ *
81
+ * Sin comprobar el cert, cualquiera se anuncia ahí y se queda con los depósitos. No
82
+ * filtraría nada —el eslabón es público— pero se los tragaría EN SILENCIO, que es peor
83
+ * que un error.
84
+ */
85
+ async function testigo () {
86
+ let tokens
87
+ try { tokens = await client.list(canal) } catch (_) { return null }
88
+ for (const token of tokens || []) {
89
+ const r = await preguntarQuienEs(token)
90
+ const cert = r?.cert
91
+ if (!cert?.sub || cert.iss !== registryId) continue
92
+ // AQUÍ SOMOS EXTRAÑOS: no tenemos el acta del registro, así que no podemos preguntarle
93
+ // quién sella. Lo que sí tenemos es su `profileId` quemado en el código —inmutable, a
94
+ // diferencia de la dirección de un aparato— y eso es lo que se ancla: aceptamos un
95
+ // papel firmado POR ESA LLAVE, para el acta que él diga.
96
+ //
97
+ // Es exactamente la misma fuerza que antes (`cert.iss === registryId` + firma válida),
98
+ // dicho con la forma nueva. Y tiene un límite que conviene decir: si el registro
99
+ // llegara a tener una SEGUNDA selladora, sus papeles no entrarían por aquí hasta que
100
+ // presente su cadena de eslabones — que es, con gracia, justo lo que este registro
101
+ // publica.
102
+ const v = await verifyDelegation({
103
+ cert, expectedSub: cert.sub, actaSeq: cert.seq, sealers: [registryId]
104
+ }).catch(() => ({ ok: false }))
105
+ if (v?.ok) return cert.sub
106
+ }
107
+ return null
108
+ }
109
+
110
+ let ultima = null
111
+
112
+ let avisadoDeOtroTestigo = false
113
+
114
+ async function publicar (motivo) {
115
+ let actas
116
+ try { actas = await identity.sealerChain?.() } catch (_) { return }
117
+ if (!Array.isArray(actas)) return
118
+
119
+ // SI LA CUENTA DECLARÓ SU PROPIO TESTIGO, no se deposita en el de Dotrino. No es que
120
+ // hacerlo filtre algo —el eslabón es público— sino que el `chainUrl` del génesis es la
121
+ // forma que tiene una cuenta de decir dónde vive su cadena, y decidir por ella sería
122
+ // exactamente lo que ese campo existe para impedir. Se dice una vez y se calla.
123
+ const genesis = actas[0]
124
+ if (genesis?.chainUrl && registryId === DOTRINO_REGISTRY_ID) {
125
+ if (!avisadoDeOtroTestigo) {
126
+ avisadoDeOtroTestigo = true
127
+ log(`[sealers] this account names its own registry (${genesis.chainUrl}): not depositing into Dotrino's`)
128
+ }
129
+ return
130
+ }
131
+ // De cada acta sale su eslabón, y las que no cambiaron el sellador no dan ninguno.
132
+ const chain = actas.map((a) => Acta.sealerLinkOf(a)).filter(Boolean)
133
+ // Una cuenta de una sola bóveda no tiene nada que refrescar: su conjunto de selladores
134
+ // no puede cambiar. El registro la rechazaría, así que ni se molesta en mandarla.
135
+ // Una cuenta anterior a identity 0.72 tampoco tiene eslabones, y es el mismo caso: no
136
+ // aparece en el registro hasta que selle un acta nueva.
137
+ if (chain.length < 2) return
138
+
139
+ const cabeza = chain[chain.length - 1]?.seq
140
+ if (cabeza === ultima) return
141
+ try {
142
+ const a = await testigo()
143
+ if (!a) { log('[sealers] no witness announced in ' + canal + ': the chain stays for the next time'); return }
144
+ await client.sendByPubkey(a, { op: OP, chain })
145
+ ultima = cabeza
146
+ log(`[sealers] chain up to #${cabeza} deposited (${motivo})`)
147
+ } catch (e) {
148
+ // Que no se pueda depositar no rompe nada de lo que la bóveda hace: el registro es
149
+ // una comodidad para terceros, no una dependencia. Se reintenta en la próxima acta.
150
+ log('[sealers] could not deposit the chain:', e.message)
151
+ }
152
+ }
153
+
154
+ publicar('startup').catch(() => {})
155
+ const off = identity.onVault?.((e) => { if (e?.phase === 'acta') publicar(`record #${e.seq}`).catch(() => {}) })
156
+ return () => { try { off?.() } catch (_) {} }
157
+ }
158
+
159
+ export default { startSealersPublisher, OP, DOTRINO_REGISTRY_ID, CANAL }