@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,34 @@
1
+ /**
2
+ * El contrato que consumen la extensión, la consola web y la app nativa. Quién esté
3
+ * detrás (el vault del PC, el teléfono, o la caché local) es intercambiable — esa es
4
+ * la razón de que exista esta interfaz y no se lea el almacén directamente
5
+ * (DISENO §6.1).
6
+ *
7
+ * find(url) → [publicView] qué hay para este sitio. SIN secretos: sirve para
8
+ * elegir entre varias cuentas, no para usarlas.
9
+ * get(id, {keys}) → entrada abierta UNA credencial, y solo los campos pedidos.
10
+ * Pedir algo PRIVADO exige aprobación; lo público, no.
11
+ * put(entry) → publicView crear o actualizar. Exige una bóveda de verdad.
12
+ * patch(id, changes) → publicView cambiar unos campos SIN leer la entrada antes: la
13
+ * fusión la hace quien tiene el dato. No exige aprobación
14
+ * —no sale nada— y no puede perder lo que no se tocó.
15
+ * search(q) → [publicView] buscar por texto en TODA la bóveda, con tope. Exige un
16
+ * término que escribe una persona; no es `list`.
17
+ * remove(id) → void quitarla. La borra quien la tiene, no quien la pide.
18
+ * list() → [publicView] la bóveda entera. Exige la CEK.
19
+ *
20
+ * `list` no está para la extensión: si pudiera listarlo todo, el "pide de a una"
21
+ * (DISENO §2) sería decorativo.
22
+ *
23
+ * Toda implementación expone además:
24
+ * capabilities → { canWrite, canList, needsApproval }
25
+ */
26
+ export const VaultSource = {
27
+ async find (_url) { throw new Error('not implemented') },
28
+ async get (_id, _opts) { throw new Error('not implemented') },
29
+ async put (_entry) { throw new Error('not implemented') },
30
+ async patch (_id, _changes) { throw new Error('not implemented') },
31
+ async search (_q, _opts) { throw new Error('not implemented') },
32
+ async remove (_id) { throw new Error('not implemented') },
33
+ async list () { throw new Error('not implemented') },
34
+ }
@@ -0,0 +1,213 @@
1
+ // Bóveda local: la que corre donde SÍ está la CEK — el vault del PC, la app nativa,
2
+ // y la caché opt-in de solo lectura de la extensión (DISENO §3).
3
+ //
4
+ // El almacén se inyecta para no atar la librería a nada: en la app es
5
+ // `@dotrino/store`, en la extensión `chrome.storage.local`, en las pruebas un Map.
6
+ // Solo tiene que cumplir `{ get(k), set(k, v) }`.
7
+
8
+ import { openEntry, sealEntry, mergePatch, publicView, entryWho, normalizeSites } from '../model.js'
9
+ import { entryDigestValues, fieldKey, normalizeFields, KINDS } from '../fields.js'
10
+ import { makeNonce, fieldHasher } from '../crypto.js'
11
+ import { findForUrl } from '../match.js'
12
+ import { VaultError, CODES } from './errors.js'
13
+
14
+ const KEY = 'passmanager/entries/v1'
15
+
16
+ export class LocalVault {
17
+ /**
18
+ * @param {object} store almacén `{ get(k), set(k, v) }`
19
+ * @param {object} opts `{ readOnly }` — la caché de la extensión va en `true`
20
+ */
21
+ constructor (store, opts = {}) {
22
+ this.store = store
23
+ this.readOnly = !!opts.readOnly
24
+ this.key = null
25
+ }
26
+
27
+ get capabilities () {
28
+ return { canWrite: !this.readOnly, canList: true, needsApproval: false }
29
+ }
30
+
31
+ /** Entrega la CEK. Sin esto, todo lo demás falla con `locked`. */
32
+ unlock (cek) {
33
+ this.key = cek
34
+ }
35
+
36
+ lock () {
37
+ this.key = null
38
+ }
39
+
40
+ #requireKey () {
41
+ if (!this.key) throw new VaultError(CODES.LOCKED, 'la bóveda está cerrada')
42
+ }
43
+
44
+ async #all () {
45
+ return (await this.store.get(KEY)) || []
46
+ }
47
+
48
+ /**
49
+ * Los RESÚMENES de los campos de una entrada, para que quien pregunta pueda comparar
50
+ * sin abrir nada (§4.0.2). Un nonce por respuesta, no por entrada: así dos entradas de
51
+ * la misma respuesta se pueden comparar contra lo mismo, y entre dos respuestas no hay
52
+ * nada que correlacionar.
53
+ */
54
+ async #digest (nonce, hash, open) {
55
+ const fields = {}
56
+ for (const f of entryDigestValues(open)) fields[f.key] = await hash(f.key, f.value)
57
+ return { nonce, fields }
58
+ }
59
+
60
+ async find (url) {
61
+ this.#requireKey()
62
+ const hits = findForUrl(await this.#all(), url)
63
+ const nonce = makeNonce()
64
+ const hash = await fieldHasher(nonce)
65
+ // El nombre visible obliga a abrir la entrada: por eso lo calcula quien tiene la CEK,
66
+ // y no quien pregunta. Lo mismo los resúmenes.
67
+ return Promise.all(hits.map(async ({ entry }) => {
68
+ const open = await openEntry(this.key, entry)
69
+ return publicView(entry, entryWho(open), open, await this.#digest(nonce, hash, open))
70
+ }))
71
+ }
72
+
73
+ async get (id) {
74
+ this.#requireKey()
75
+ const entry = (await this.#all()).find(e => e.id === id)
76
+ if (!entry) throw new VaultError(CODES.NOT_FOUND, 'no hay ninguna entrada con ese id')
77
+ return openEntry(this.key, entry)
78
+ }
79
+
80
+ async put (plain) {
81
+ this.#requireKey()
82
+ if (this.readOnly) throw new VaultError(CODES.READ_ONLY, 'esta copia solo puede leer')
83
+ const sealed = await sealEntry(this.key, plain)
84
+ const all = await this.#all()
85
+ const i = all.findIndex(e => e.id === sealed.id)
86
+ if (i >= 0) {
87
+ sealed.createdAt = all[i].createdAt
88
+ all[i] = sealed
89
+ } else {
90
+ all.push(sealed)
91
+ }
92
+ await this.store.set(KEY, all)
93
+ return publicView(sealed)
94
+ }
95
+
96
+ /**
97
+ * CAMBIAR unos campos de una entrada, dejando el resto como estaba.
98
+ *
99
+ * Existe para que quien guarda **no tenga que leerla antes**. Antes el aparato hacía
100
+ * `get` + fusionar + `put`, y eso tenía dos costes que no se veían: sacaba de la bóveda
101
+ * la entrada entera —contraseña incluida— para escribir un teléfono, y si esa lectura
102
+ * fallaba, el `put` que venía detrás escribía la entrada **sin lo que no pudo leer**.
103
+ * Eso no es un fallo raro: es perder datos por una autorización denegada.
104
+ *
105
+ * Aquí la fusión ocurre donde vive el dato. Lo que no venga en `changes` no se toca, y
106
+ * si la entrada no existe no se crea nada — para crear está `put`.
107
+ *
108
+ * `changes`:
109
+ * `name`, `title`, `username`, `secret`, `totp`, `notes` cada uno, si viene, reemplaza
110
+ * `fields` lista `{ label, value, kind, private }`; se casan por su clave (§4.2),
111
+ * los que no aparecen se quedan. **`value` sin poner no toca el valor**:
112
+ * sirve para cambiar solo la marca de privado, que es lo único que se puede
113
+ * hacer desde el gestor con un campo cuyo valor no se tiene delante
114
+ * `removeFields` claves que se van. Un campo libre desaparece; uno de los de siempre
115
+ * (`username`, `secret`, `totp`, `notes`) se queda vacío, que es lo mismo
116
+ * `sites` la lista ENTERA, tal cual queda. Es lo que manda el gestor, que la edita
117
+ * a mano; vacía significa «sirve en cualquier sitio», que es lo que la
118
+ * ausencia de sitios ha significado siempre. Si viene, `addSite` se ignora
119
+ * `addSite` suma este sitio **solo si la entrada ya tenía alguno**: una entrada sin
120
+ * sitios sirve en cualquier parte, y atarla al primer formulario donde se
121
+ * usó sería quitarle eso sin que nadie lo pidiera
122
+ */
123
+ async patch (id, changes = {}) {
124
+ this.#requireKey()
125
+ if (this.readOnly) throw new VaultError(CODES.READ_ONLY, 'esta copia solo puede leer')
126
+ const all = await this.#all()
127
+ const i = all.findIndex(e => e.id === id)
128
+ if (i < 0) throw new VaultError(CODES.NOT_FOUND, 'no hay ninguna entrada con ese id')
129
+
130
+ const base = await openEntry(this.key, all[i])
131
+ // La fusión vive en `model.js` y la comparten esta bóveda y la sellada: no es un
132
+ // `{...a, ...b}` —un campo sin `value` dice «déjale el que tenga»— y dos copias de esa
133
+ // sutileza son una que se queda atrás.
134
+ const sealed = await sealEntry(this.key, mergePatch(base, changes))
135
+ all[i] = sealed
136
+ await this.store.set(KEY, all)
137
+ return publicView(sealed)
138
+ }
139
+
140
+ /**
141
+ * EN QUÉ SITIOS HAY ALGO GUARDADO, y cuánto en cada uno.
142
+ *
143
+ * Es lo que le permite al gestor abrir con la lista de dominios en vez de con un
144
+ * buscador en blanco (dueño, 2026-08-29). **No es `list`**, y la diferencia no es de
145
+ * grado: de aquí no sale ni un id, ni un nombre, ni qué campos lleva nadie, ni un
146
+ * resumen con el que comparar. Sale el dominio, que **ya viaja en claro** —`sites` no
147
+ * se cifra, porque hace falta para emparejar con la página sin tener la CEK (§5)— y
148
+ * cuántas entradas hay en él.
149
+ *
150
+ * Lo que sí concede, y conviene decirlo: hoy hay que preguntar sitio por sitio para
151
+ * saber dónde tienes cuenta, y esto lo contesta de una vez. Es la razón de que el
152
+ * dueño lo decidiera a propósito y no se colara como un detalle.
153
+ *
154
+ * `site: ''` son las entradas SIN sitio, que sirven en cualquier parte.
155
+ */
156
+ async sites () {
157
+ this.#requireKey()
158
+ const cuenta = new Map()
159
+ for (const e of await this.#all()) {
160
+ const suyos = e.sites?.length ? e.sites : ['']
161
+ for (const s of suyos) cuenta.set(s, (cuenta.get(s) || 0) + 1)
162
+ }
163
+ return [...cuenta.entries()]
164
+ .map(([site, count]) => ({ site, count }))
165
+ .sort((a, b) => (a.site === '' ? 1 : b.site === '' ? -1 : a.site.localeCompare(b.site)))
166
+ }
167
+
168
+ /**
169
+ * BUSCAR por texto en toda la bóveda. Devuelve lo público, y como mucho `limit`.
170
+ *
171
+ * No es `list` con otro nombre, y la diferencia importa (DISENO §2): `list` lo puede
172
+ * pedir el código en cualquier momento y devuelve todo; esto exige un término que
173
+ * **escribe una persona** y devuelve un puñado. Existe porque a veces la cuenta que
174
+ * sirve está guardada en otro dominio —el subdominio cambió y la clave es la misma— y
175
+ * sin buscarla no hay forma de llegar a ella.
176
+ *
177
+ * Se busca en lo que identifica a la entrada: su título, sus sitios y su nombre
178
+ * visible. **Nunca en los valores de los campos**: buscar «1700» y que aparezca tu
179
+ * documento es exactamente lo que no puede pasar.
180
+ */
181
+ async search (q, { limit = 20 } = {}) {
182
+ this.#requireKey()
183
+ const texto = String(q || '').trim().toLowerCase()
184
+ if (texto.length < 2) return []
185
+ const nonce = makeNonce()
186
+ const hash = await fieldHasher(nonce)
187
+ const out = []
188
+ for (const entry of await this.#all()) {
189
+ if (out.length >= limit) break
190
+ const open = await openEntry(this.key, entry)
191
+ const quien = entryWho(open)
192
+ const heno = [entry.title, quien, ...(entry.sites || [])].filter(Boolean).join(' ').toLowerCase()
193
+ if (heno.includes(texto)) {
194
+ out.push(publicView(entry, quien, open, await this.#digest(nonce, hash, open)))
195
+ }
196
+ }
197
+ return out
198
+ }
199
+
200
+ async remove (id) {
201
+ this.#requireKey()
202
+ if (this.readOnly) throw new VaultError(CODES.READ_ONLY, 'esta copia solo puede leer')
203
+ const all = await this.#all()
204
+ const rest = all.filter(e => e.id !== id)
205
+ if (rest.length === all.length) throw new VaultError(CODES.NOT_FOUND, 'no hay ninguna entrada con ese id')
206
+ await this.store.set(KEY, rest)
207
+ }
208
+
209
+ async list () {
210
+ this.#requireKey()
211
+ return (await this.#all()).map(e => publicView(e))
212
+ }
213
+ }
@@ -0,0 +1,85 @@
1
+ // Bóveda remota: la que usa la extensión de Chrome por defecto (DISENO §2).
2
+ //
3
+ // No tiene la CEK y no guarda nada. Pregunta por un dominio, recibe metadatos, y
4
+ // pide UNA credencial cuando el usuario elige. Quién responde al otro lado del
5
+ // transporte —el vault del PC o el teléfono— le da igual.
6
+
7
+ import { VaultError, CODES } from './errors.js'
8
+
9
+ export class RemoteVault {
10
+ /**
11
+ * @param {object} transport `{ request(op, payload) }` — proxy, WebSocket o
12
+ * `chrome.runtime` según dónde corra. Devuelve la respuesta ya deserializada.
13
+ */
14
+ constructor (transport, opts = {}) {
15
+ this.transport = transport
16
+ this.needsApproval = opts.needsApproval !== false
17
+ }
18
+
19
+ get capabilities () {
20
+ return { canWrite: true, canList: false, needsApproval: this.needsApproval }
21
+ }
22
+
23
+ async find (url) {
24
+ return this.#request('find', { url })
25
+ }
26
+
27
+ /**
28
+ * UNA credencial, y **solo los campos que se piden**.
29
+ *
30
+ * `keys` no es una optimización: es lo que hace que rellenar un nombre no saque de la
31
+ * bóveda la contraseña. Al otro lado, pedir algo privado puede exigir la huella; pedir
32
+ * lo público, no (DISENO §4.2).
33
+ */
34
+ async get (id, opts = {}) {
35
+ return this.#request('get', { id, ...(Array.isArray(opts.keys) ? { keys: opts.keys } : {}) })
36
+ }
37
+
38
+ async put (entry) {
39
+ return this.#request('put', { entry })
40
+ }
41
+
42
+ /**
43
+ * CAMBIAR unos campos sin leer la entrada antes. Lo demás se queda como estaba, y la
44
+ * fusión la hace quien tiene el dato — así guardar un teléfono no saca una contraseña.
45
+ */
46
+ async patch (id, changes) {
47
+ return this.#request('patch', { id, changes })
48
+ }
49
+
50
+ /**
51
+ * No se puede, y no es una limitación técnica: si la extensión pudiera listar la
52
+ * bóveda entera, el "pide de a una" no significaría nada (DISENO §2).
53
+ */
54
+ /** Buscar por texto en toda la bóveda. Lo público, y con tope (ver `LocalVault`). */
55
+ async search (q, opts = {}) {
56
+ return this.#request('search', { q, limit: opts.limit })
57
+ }
58
+
59
+ /**
60
+ * En qué sitios hay algo guardado, y cuántas entradas en cada uno. Ni un id ni un
61
+ * nombre: el dominio, que ya viaja en claro para poder emparejar (§5).
62
+ */
63
+ async sites () {
64
+ return this.#request('sites', {})
65
+ }
66
+
67
+ /** Quitar una entrada. La borra quien la tiene, no quien la pide. */
68
+ async remove (id) {
69
+ return this.#request('remove', { id })
70
+ }
71
+
72
+ async list () {
73
+ throw new VaultError(CODES.NO_KEY, 'una bóveda remota no lista: pide de a una')
74
+ }
75
+
76
+ async #request (op, payload) {
77
+ try {
78
+ return await this.transport.request(op, payload)
79
+ } catch (e) {
80
+ // El transporte puede fallar de muchas formas; hacia arriba solo hay códigos.
81
+ if (e instanceof VaultError) throw e
82
+ throw new VaultError(e?.code || CODES.UNREACHABLE, e?.message || 'no hay bóveda al otro lado')
83
+ }
84
+ }
85
+ }
@@ -0,0 +1,192 @@
1
+ // El lado que RESPONDE: la bóveda de verdad (el vault del PC, la app nativa).
2
+ //
3
+ // Es donde vive la política, y por eso está aquí y no repartida por cada aparato:
4
+ // quién puede pedir, qué puede pedir, qué exige aprobación y qué queda anotado. Un
5
+ // aparato que pide no decide nada.
6
+
7
+ import { isRequest, reply, replyError, REMOTE_OPS, ADMIN_OPS } from '../transport/protocol.js'
8
+ import { ApprovalGate } from './approval.js'
9
+ import { privateKeysOf } from '../fields.js'
10
+ import { projectEntry } from '../model.js'
11
+ import { CODES } from './errors.js'
12
+
13
+ export class VaultResponder {
14
+ /**
15
+ * @param {object} opts
16
+ * `client` cliente de @dotrino/proxy-client conectado e identificado
17
+ * `vault` una bóveda con la CEK (LocalVault desbloqueada)
18
+ * `isAllowed(pubkey) -> boolean` qué aparatos pueden pedir. Por defecto NADIE:
19
+ * una bóveda que responde a cualquiera no es una bóveda.
20
+ * `needsApproval(op, payload, pubkey) -> boolean|Promise` qué exige un dedo encima.
21
+ * Por defecto: **solo `get`, y solo si lo pedido incluye algo privado**
22
+ * (dueño, 2026-08-29). Rellenar un nombre no es sacar un secreto.
23
+ * `approve({ op, payload, pubkey }) -> Promise<boolean>` cómo se pide ese dedo
24
+ * `onRequest(record)` la bitácora
25
+ * `admin` `{ devices(), unlink(pubkey) }` — opcional. Sin esto, un aparato no
26
+ * puede administrar nada, que es el valor por defecto correcto.
27
+ *
28
+ * **La aprobación es del APARATO, no de cada credencial.** Se pide una vez y vale
29
+ * mientras esta bóveda siga encendida; apagarla la retira. Es el mismo modelo que
30
+ * `pair --approval` del vault: una por arranque, sin ventana de tiempo que vigilar.
31
+ *
32
+ * No se ata a la conexión a propósito: un service worker se duerme cada poco y
33
+ * reconecta constantemente, así que «por conexión» sería pedir el dedo todo el rato.
34
+ * Quien manda es la bóveda, que es la que el usuario apaga cuando quiere cortar.
35
+ */
36
+ constructor ({ client, vault, isAllowed, needsApproval, approve, onRequest, encPubOf, admin } = {}) {
37
+ this.admin = admin || null
38
+ this.client = client
39
+ this.vault = vault
40
+ // La pública de cifrado de cada aparato, para sellarle la respuesta. Abrir lo que
41
+ // llega es cosa del cliente del pilar (`requireSealed` + `myEncPrivateKey`).
42
+ this.encPubOf = encPubOf || (() => null)
43
+ this.isAllowed = isAllowed || (() => false)
44
+ this.needsApproval = needsApproval || ((op, payload) => this.wantsPrivate(op, payload))
45
+ this.approve = approve || (async () => false)
46
+ this.onRequest = onRequest || (() => {})
47
+ this._handler = null
48
+ // La puerta es la MISMA que la de la bóveda de dentro de la extensión
49
+ // (`GuardedVault`): dedupe de simultáneas, el sí recordado, el no nunca. Aquí se
50
+ // recuerda por APARATO, que es lo que dice §2.0, y vive en memoria — apagar la
51
+ // bóveda retira lo aprobado, que es el diseño.
52
+ this.gate = new ApprovalGate({
53
+ ask: ({ op, payload, pubkey }) => this.approve({ op, payload, pubkey }),
54
+ scope: ({ pubkey }) => pubkey,
55
+ })
56
+ }
57
+
58
+ /** Retira la aprobación de un aparato sin apagar la bóveda. */
59
+ revokeApproval (pubkey) {
60
+ this.gate.revoke(pubkey || null)
61
+ }
62
+
63
+ isApproved (pubkey) {
64
+ return this.gate.granted(pubkey)
65
+ }
66
+
67
+ /**
68
+ * ¿Lo que se pide incluye algo PRIVADO? Es la pregunta que decide si hace falta un dedo
69
+ * encima, y solo la puede contestar quien tiene la llave: hay que abrir la entrada para
70
+ * saber qué campos marcó el usuario (§4.2).
71
+ *
72
+ * Es **pública** para que una bóveda con su propia política la componga en vez de
73
+ * reescribirla: el vault del ecosistema, por ejemplo, aprueba por aparato *y* quiere
74
+ * este criterio encima (`needsApproval(pubkey) && responder.wantsPrivate(op, payload)`).
75
+ * Sin esto, cada bóveda acabaría con su propia idea de qué es privado.
76
+ *
77
+ * Si no se puede abrir, se contesta que **sí**: ante la duda, se pregunta.
78
+ */
79
+ async wantsPrivate (op, payload) {
80
+ if (op !== 'get') return false
81
+ try {
82
+ const open = await this.vault.get(payload.id)
83
+ const privadas = privateKeysOf(open)
84
+ if (!Array.isArray(payload.keys)) return privadas.size > 0
85
+ return payload.keys.some(k => privadas.has(k))
86
+ } catch { return true }
87
+ }
88
+
89
+ start () {
90
+ if (this._handler) return
91
+ this._handler = (from, payload, meta) => {
92
+ this.#handle(payload, meta?.fromPubkey || from, meta).catch(() => {})
93
+ }
94
+ this.client.on('message', this._handler)
95
+ }
96
+
97
+ stop () {
98
+ if (this._handler && this.client.off) this.client.off('message', this._handler)
99
+ this._handler = null
100
+ }
101
+
102
+ async #send (pubkey, msg) {
103
+ try {
104
+ // La respuesta lleva la credencial: se sella al aparato que la pidió, o no sale.
105
+ const encPub = this.encPubOf(pubkey)
106
+ if (!encPub) return
107
+ await this.client.sendSealed([pubkey], msg, { peerEncPub: encPub })
108
+ } catch { /* el que pidió reintentará */ }
109
+ }
110
+
111
+ async #handle (msg, fromPubkey, meta) {
112
+ if (!isRequest(msg)) return
113
+
114
+ // **Nada sin cifrar entra.** El cliente del pilar abre los sobres y marca
115
+ // `meta.sealed`; aquí se exige esa marca. Sin ella bastaría con mandar la petición
116
+ // en claro para saltarse el sellado entero, y el proxio vería qué se pide.
117
+ if (meta?.sealed !== true) {
118
+ this.onRequest({ op: msg.op, from: fromPubkey, ts: Date.now(), outcome: 'unsealed' })
119
+ return
120
+ }
121
+
122
+ const record = { op: msg.op, from: fromPubkey, ts: Date.now() }
123
+
124
+ // 1. ¿Es un aparato que conozco? Si no, ni se le contesta qué falló.
125
+ if (!fromPubkey || !this.isAllowed(fromPubkey)) {
126
+ this.onRequest({ ...record, outcome: 'denied' })
127
+ return this.#send(fromPubkey, replyError(msg.rid, CODES.DENIED, 'aparato no autorizado'))
128
+ }
129
+
130
+ // 2. `list` no está en la lista, y esa ausencia es el diseño: si un aparato
131
+ // pudiera pedir la bóveda entera, el "de a una" no significaría nada.
132
+ const esAdmin = ADMIN_OPS.includes(msg.op)
133
+ if (!REMOTE_OPS.includes(msg.op) && !esAdmin) {
134
+ this.onRequest({ ...record, outcome: 'denied' })
135
+ return this.#send(fromPubkey, replyError(msg.rid, CODES.DENIED, 'operación no permitida en remoto'))
136
+ }
137
+ if (esAdmin && !this.admin) {
138
+ this.onRequest({ ...record, outcome: 'denied' })
139
+ return this.#send(fromPubkey, replyError(msg.rid, CODES.DENIED, 'esta bóveda no se administra en remoto'))
140
+ }
141
+
142
+ try {
143
+ const payload = msg.payload || {}
144
+
145
+ // Administrar SIEMPRE se aprueba, y aparte: retirar un aparato desde otro
146
+ // aparato es tan delicado como entregar una contraseña, y en el otro sentido.
147
+ // Que el aparato ya esté aprobado para pedir credenciales no le da esto.
148
+ if (esAdmin) {
149
+ const ok = await this.approve({ op: msg.op, payload, pubkey: fromPubkey, admin: true })
150
+ if (!ok) {
151
+ this.onRequest({ ...record, outcome: 'refused' })
152
+ return this.#send(fromPubkey, replyError(msg.rid, CODES.NOT_APPROVED, 'no aprobado'))
153
+ }
154
+ const result = msg.op === 'devices'
155
+ ? await this.admin.devices()
156
+ : await this.admin.unlink(payload.pubkey)
157
+ this.onRequest({ ...record, outcome: 'served' })
158
+ return this.#send(fromPubkey, reply(msg.rid, result ?? null))
159
+ }
160
+
161
+ if (await this.needsApproval(msg.op, payload, fromPubkey)) {
162
+ const ok = await this.gate.allow({ op: msg.op, payload, pubkey: fromPubkey })
163
+ if (!ok) {
164
+ this.onRequest({ ...record, outcome: 'refused' })
165
+ return this.#send(fromPubkey, replyError(msg.rid, CODES.NOT_APPROVED, 'no aprobado'))
166
+ }
167
+ }
168
+
169
+ let result
170
+ switch (msg.op) {
171
+ case 'find': result = await this.vault.find(payload.url); break
172
+ // Solo lo pedido sale por el cable: sin este recorte, rellenar un nombre mandaba
173
+ // la entrada entera —contraseña incluida— al aparato que preguntó.
174
+ case 'get':
175
+ result = projectEntry(await this.vault.get(payload.id),
176
+ Array.isArray(payload.keys) ? payload.keys : null)
177
+ break
178
+ case 'put': result = await this.vault.put(payload.entry); break
179
+ case 'patch': result = await this.vault.patch(payload.id, payload.changes); break
180
+ case 'sites': result = await this.vault.sites(); break
181
+ case 'search': result = await this.vault.search(payload.q, { limit: payload.limit }); break
182
+ case 'remove': result = await this.vault.remove(payload.id); break
183
+ }
184
+
185
+ this.onRequest({ ...record, outcome: 'served', id: payload.id })
186
+ await this.#send(fromPubkey, reply(msg.rid, result ?? null))
187
+ } catch (e) {
188
+ this.onRequest({ ...record, outcome: 'error', code: e?.code })
189
+ await this.#send(fromPubkey, replyError(msg.rid, e?.code || 'error', e?.message))
190
+ }
191
+ }
192
+ }