@dotrino/identity 0.115.0 → 0.117.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 (82) hide show
  1. package/package.json +4 -1
  2. package/src/node.js +4 -180
  3. package/vault/content.js +28 -1
  4. package/vault/core.js +66 -7
  5. package/vault/facade.js +200 -0
  6. package/vault/index.html +10 -0
  7. package/vault/remote.js +45 -5
  8. package/vault/vault.js +29 -58
  9. package/vault/vendor/compat/VERSION.txt +3 -0
  10. package/vault/vendor/compat/advisory.js +141 -0
  11. package/vault/vendor/compat/index.js +188 -0
  12. package/vault/vendor/compat/ranges.js +93 -0
  13. package/vault/vendor/opaque/VERSION.txt +1 -1
  14. package/vault/vendor/passmanager/VERSION.txt +3 -0
  15. package/vault/vendor/passmanager/crypto.js +167 -0
  16. package/vault/vendor/passmanager/fields.js +183 -0
  17. package/vault/vendor/passmanager/generate.js +74 -0
  18. package/vault/vendor/passmanager/import.js +156 -0
  19. package/vault/vendor/passmanager/index.js +27 -0
  20. package/vault/vendor/passmanager/match.js +137 -0
  21. package/vault/vendor/passmanager/model.js +332 -0
  22. package/vault/vendor/passmanager/pubkey.js +33 -0
  23. package/vault/vendor/passmanager/sealed/convert.js +72 -0
  24. package/vault/vendor/passmanager/sealed/device.js +211 -0
  25. package/vault/vendor/passmanager/sealed/entry.js +209 -0
  26. package/vault/vendor/passmanager/sealed/index.js +20 -0
  27. package/vault/vendor/passmanager/sealed/keys.js +120 -0
  28. package/vault/vendor/passmanager/sealed/recovery.js +136 -0
  29. package/vault/vendor/passmanager/sealed/store.js +508 -0
  30. package/vault/vendor/passmanager/session-cache.js +60 -0
  31. package/vault/vendor/passmanager/totp.js +111 -0
  32. package/vault/vendor/passmanager/transport/protocol.js +83 -0
  33. package/vault/vendor/passmanager/transport/proxy.js +112 -0
  34. package/vault/vendor/passmanager/transport/sealed.js +74 -0
  35. package/vault/vendor/passmanager/vault/approval.js +63 -0
  36. package/vault/vendor/passmanager/vault/errors.js +34 -0
  37. package/vault/vendor/passmanager/vault/guard.js +114 -0
  38. package/vault/vendor/passmanager/vault/interface.js +34 -0
  39. package/vault/vendor/passmanager/vault/local.js +213 -0
  40. package/vault/vendor/passmanager/vault/remote.js +85 -0
  41. package/vault/vendor/passmanager/vault/responder.js +192 -0
  42. package/vault/vendor/passmanager/vault/sealed-local.js +199 -0
  43. package/vault/vendor/passmanager/vault/sealed-responder.js +159 -0
  44. package/vault/vendor/passmanager/vault/sealed.js +362 -0
  45. package/vault/vendor/passmanager/webauthn.js +221 -0
  46. package/vault/vendor/proxy-client/VERSION.txt +1 -1
  47. package/vault/vendor/proxy-client/client.js +136 -11
  48. package/vault/vendor/proxy-client/index.js +1 -0
  49. package/vault/vendor/proxy-client/parts.js +127 -0
  50. package/vault/vendor/proxy-client/webrtc.js +23 -0
  51. package/vault/vendor/roadmap/VERSION.txt +3 -0
  52. package/vault/vendor/roadmap/index.js +108 -0
  53. package/vault/vendor/roadmap/manifests.js +802 -0
  54. package/vault/vendor/store/VERSION.txt +3 -0
  55. package/vault/vendor/store/core.js +309 -0
  56. package/vault/vendor/vault/VERSION.txt +4 -1
  57. package/vault/vendor/vault/admin.js +437 -0
  58. package/vault/vendor/vault/browser.js +167 -0
  59. package/vault/vendor/vault/core/approvals.js +204 -0
  60. package/vault/vendor/vault/core/blocked.js +68 -0
  61. package/vault/vendor/vault/core/browserDisk.js +109 -0
  62. package/vault/vendor/vault/core/commKey.js +68 -0
  63. package/vault/vendor/vault/core/disk.js +65 -0
  64. package/vault/vendor/vault/core/libVersion.js +8 -0
  65. package/vault/vendor/vault/core/machines.js +66 -0
  66. package/vault/vendor/vault/core/notices.js +71 -0
  67. package/vault/vendor/vault/core/notifiable.js +73 -0
  68. package/vault/vendor/vault/core/passwords.js +125 -0
  69. package/vault/vendor/vault/core/sealKey.js +77 -0
  70. package/vault/vendor/vault/core/sealer.js +172 -0
  71. package/vault/vendor/vault/core/sealers.js +159 -0
  72. package/vault/vendor/vault/core/secretsStore.js +1326 -0
  73. package/vault/vendor/vault/core/sha256.js +69 -0
  74. package/vault/vendor/vault/core/store.js +68 -0
  75. package/vault/vendor/vault/core/subacta.js +101 -0
  76. package/vault/vendor/vault/core/threadStore.js +273 -0
  77. package/vault/vendor/vault/core/transport.js +270 -0
  78. package/vault/vendor/vault/core/vault.js +4414 -0
  79. package/vault/vendor/vault/enroll.js +8 -4
  80. package/vault/vendor/vault/procContext.js +97 -0
  81. package/vault/vendor/vault/revocation.js +67 -0
  82. package/vault/vendor/vault/sealed.js +84 -0
@@ -0,0 +1,1326 @@
1
+ /**
2
+ * Store de SECRETOS de servicios (`secrets.json`, 0600, mismo dir 0700 que la
3
+ * maestra), **cifrado en reposo** con la clave ligada a la máquina (`atrest.js`) y,
4
+ * desde v4, con los valores privados **SELLADOS A SU DESTINATARIO**.
5
+ *
6
+ * QUÉ CAMBIA EN v5 Y POR QUÉ: **ESCRIBIR NO PIDE LA FRASE**. Cifrar es una capacidad
7
+ * pública —envolver una llave solo necesita la `encPub` del que va a leer— así que
8
+ * sellar una variable nunca necesitó la contraseña. Lo que la pedía era la COPIA
9
+ * MAESTRA de v4, que guardaba las CEK cifradas con ella; para escribir había que
10
+ * abrirla, y eso obligaba a teclear la frase del perfil en el navegador. Aquí se va la
11
+ * copia maestra y en su lugar entra un **par de recuperación**: su pública se guarda en
12
+ * claro (cualquiera puede envolverle) y su privada, sellada bajo la frase (solo el
13
+ * dueño abre). Diseño completo: `docs/secretos-sellados.md` §8.
14
+ *
15
+ * De ahí salen las tres reglas de este archivo:
16
+ *
17
+ * · **Escribir** = CEK nueva → envolverla a los destinatarios → cifrar → firmar.
18
+ * Ningún secreto de la bóveda interviene.
19
+ * · **UNA GENERACIÓN POR ESCRITURA**, y no es un capricho: la bóveda no puede
20
+ * reutilizar la CEK del cajón porque no puede abrir ninguna envoltura para
21
+ * recuperarla. Las generaciones que ya no referencia ningún valor ni el histórico
22
+ * se recogen solas.
23
+ * · **Leer** (ver un valor, cambiar su visibilidad, rotar re-cifrando) sigue pidiendo
24
+ * la frase, porque leer es exactamente lo que la frase guarda.
25
+ *
26
+ * DOS CAJONES, y esa es la razón de ser de este archivo:
27
+ *
28
+ * · **Por scope** (`ns`) — un NAMESPACE de servicio (`proxy`, `geo`, `bots`…).
29
+ * Lo comparten TODOS los aparatos del perfil que sirven ese namespace: la
30
+ * llave de la API es la misma la corra quien la corra.
31
+ * · **Por aparato** (`dev`) — la llave es la del miembro (su `pub`), y solo la
32
+ * lee ESE aparato. Es donde va lo que cambia de máquina a máquina (el puerto,
33
+ * la URL pública, el nombre del nodo) sin partir el ns en uno por servidor.
34
+ *
35
+ * Al pedir el bundle se entregan MEZCLADOS y **manda el aparato**: lo suyo pisa
36
+ * lo del scope. Es el orden que espera cualquiera que haya usado un `.env`
37
+ * general y otro de la máquina — lo específico gana. **La mezcla sigue aquí** y sigue
38
+ * siendo una línea: los NOMBRES no se sellan, solo los valores, así que juntarlos no
39
+ * exige poder leerlos. Quien descifra es el agente, después.
40
+ *
41
+ * CADA VARIABLE ES PÚBLICA O PRIVADA, y eso decide UNA cosa: si su VALOR puede
42
+ * salir de esta máquina hacia la consola remota (`docs/consola-remota.md`). Al
43
+ * servicio que la lee le da igual —recibe las dos—; lo que cambia es que el valor
44
+ * de una privada no se le enseña a nadie más. Se nace PRIVADA: enseñar un secreto
45
+ * tiene que ser una decisión, no un descuido.
46
+ *
47
+ * EL HISTÓRICO. Cada escritura guarda el sobre ANTERIOR, con quién y cuándo. La bóveda
48
+ * lo escribe sin poder leerlo; auditar y revertir los hace quien puede abrir. Tiene
49
+ * tope: un histórico de secretos también es un pasivo — mantiene vivas credenciales
50
+ * viejas — así que se poda.
51
+ *
52
+ * ESTE MÓDULO NO HACE CRIPTOGRAFÍA. Recibe un `sealer` (ver `src/sealer.js`) y le
53
+ * pide sobres. Así se prueba con un sellador falso y determinista, y la forma del
54
+ * archivo se razona sin mirar una sola llave.
55
+ */
56
+ import { readJson, writeJson } from './disk.js'
57
+ import { isValidSecretsNs } from '../protocol.js'
58
+
59
+ const SCHEMA_VERSION = 5
60
+ /** v4: sellado, pero con la copia maestra bajo la frase. Se lee y se sirve; no se escribe. */
61
+ const SEALED_MASTER_VERSION = 4
62
+ const LEGACY_VERSION = 3
63
+ const MAX_VALUE_LEN = 8 * 1024
64
+ const KEY_RE = /^[A-Z0-9_]{1,64}$/
65
+
66
+ /**
67
+ * La envoltura de la copia de RECUPERACIÓN va en el llavero como una más, con este
68
+ * nombre en lugar de la pubkey de un miembro. No es un miembro y no debe parecerlo: el
69
+ * acta no lo conoce y nadie le sirve un bundle.
70
+ */
71
+ export const RECOVERY = '#recovery'
72
+
73
+ /**
74
+ * EL CAJÓN DEL PERFIL. Los datos de tu identidad se guardan como cualquier variable —
75
+ * mismo sobre, mismo llavero, mismo histórico— porque son lo mismo: un valor que solo
76
+ * pueden leer tus aparatos. Diseño: `docs/datos-del-perfil.md`.
77
+ *
78
+ * SE LLAMA `@me` A PROPÓSITO, y es la garantía entera: un nombre de cajón válido es
79
+ * `[a-z0-9-]{1,32}`, así que **ningún servicio puede llamarse `@me`**. Por lo tanto
80
+ * `memberCanReadSecrets(acta, quien, '@me')` no puede ser cierto nunca y no hay forma de
81
+ * pedir el perfil por el camino de los secretos. No es una comprobación que alguien pueda
82
+ * olvidarse de hacer: es un nombre que el validador no deja existir.
83
+ */
84
+ export const PROFILE_NS = '@me'
85
+ export const PROFILE_OWNER = 'ns:' + PROFILE_NS
86
+
87
+ /**
88
+ * El nombre de un dato del perfil. NO vale `assertVar`: aquélla exige MAYÚSCULAS porque
89
+ * describe una variable de entorno, y aquí los campos son `nickname`, `telefono`,
90
+ * `apellidos`. Mismo espíritu —un nombre corto y previsible, sin sorpresas al serializar—
91
+ * y distinto alfabeto.
92
+ */
93
+ export function assertProfileKey (key) {
94
+ if (typeof key !== 'string' || !/^[a-z][a-z0-9_]{0,31}$/.test(key)) {
95
+ throw new Error('invalid profile field (use lowercase_with_underscores, e.g. "nickname")')
96
+ }
97
+ }
98
+
99
+ /** Cuántas versiones anteriores se conservan, en total. Ver «EL HISTÓRICO» arriba. */
100
+ const MAX_HISTORY = 500
101
+
102
+ /**
103
+ * Valida un par nombre/valor. Se exporta porque quien carga varias variables de golpe
104
+ * las comprueba TODAS antes de escribir ninguna: media configuración aplicada y un
105
+ * error a la mitad es justo lo que la carga en grupo viene a evitar.
106
+ */
107
+ export function assertVar (key, value) {
108
+ if (!KEY_RE.test(String(key || ''))) throw new Error('invalid key (use UPPERCASE_WITH_UNDERSCORES, e.g. TURN_KEY_ID)')
109
+ if (typeof value !== 'string' || !value) throw new Error('value must be a non-empty string')
110
+ if (value.length > MAX_VALUE_LEN) throw new Error(`value too long (max ${MAX_VALUE_LEN})`)
111
+ }
112
+
113
+ /** Se pidió una operación que necesita LEER un valor, y no vino la llave que lo abre. */
114
+ export class NeedsPassword extends Error {
115
+ constructor (what) {
116
+ super(`this needs the profile password: ${what}`)
117
+ this.code = 'NEEDS_PASSWORD'
118
+ }
119
+ }
120
+
121
+ /**
122
+ * El archivo sigue en v4 (con copia maestra) y hay que convertirlo antes de escribir.
123
+ * Convertir exige la frase UNA vez —hay que abrir la maestra para poder re-sellar—, y a
124
+ * partir de ahí no se pide nunca más para escribir.
125
+ */
126
+ export class NeedsMigration extends Error {
127
+ constructor () {
128
+ super('the secrets file is still v4: unlock the profile once to convert it (it needs the password only this time)')
129
+ this.code = 'NEEDS_MIGRATION'
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Privada es para siempre. Una variable puede dejar de mostrarse (pública → privada), pero
135
+ * nunca al revés: lo que se marcó como secreto no se destapa ni por `visibility` ni de
136
+ * refilón con un `set --public`. Si hace falta un valor público, es OTRA variable (o se
137
+ * borra esta y se crea de nuevo, y eso queda a la vista).
138
+ */
139
+ export class PrivateStaysPrivate extends Error {
140
+ constructor (key) {
141
+ super(`${key} is private and a private variable cannot be made public: delete it and create it again as public`)
142
+ this.code = 'PRIVATE_STAYS_PRIVATE'
143
+ }
144
+ }
145
+
146
+ /** Migra un cajón `{KEY: 'valor'}` (v1/v2) a `{KEY: {v, pub}}`: todo entra como PRIVADO. */
147
+ function migrateBag (bag) {
148
+ const out = {}
149
+ for (const [k, v] of Object.entries(bag || {})) out[k] = (v && typeof v === 'object') ? v : { v: v, pub: false }
150
+ return out
151
+ }
152
+
153
+ /**
154
+ * Abre el store.
155
+ *
156
+ * @param {string} dir
157
+ * @param {{ sealer?: object, recipients?: (owner: string) => any, signer?: (body: any) => any,
158
+ * defaultKey?: () => Uint8Array }} [opts]
159
+ * `sealer`: el puerto de sobres (`src/sealer.js`). Sin él, el store solo sabe leer y
160
+ * servir lo que ya hay — que es exactamente lo que hace falta para arrancar sin nada.
161
+ * `recipients(owner)`: a quién hay que envolverle la llave de ese cajón — los servicios
162
+ * de ese namespace y los aparatos que administran. Sale del acta, y por eso lo pone
163
+ * quien llama: este módulo no conoce el acta.
164
+ * `signer(body)`: firma del sobre, `{ seq, sig }` o `null`. Es lo que dice que el sobre
165
+ * salió de esta bóveda (§8.8).
166
+ * `defaultKey`: con qué sellar la privada de recuperación cuando el perfil NO tiene
167
+ * contraseña. Vive aquí, en un solo sitio, para que dé igual por qué puerta se entre.
168
+ */
169
+ export function openSecretsStore (disk, { sealer = null, recipients = null, signer = null, defaultKey = null } = {}) {
170
+ const file = 'secrets.json'
171
+ let data = readJson(disk, file, null)
172
+
173
+ // v1 → v2: el cajón por scope se conserva y estrena el `dev`.
174
+ // v2 → v3: los valores dejan de ser strings sueltos y pasan a llevar su visibilidad.
175
+ // Lo que ya existía entra como PRIVADO: nadie marcó nunca que se pudiera ver.
176
+ if (data && data.schemaVersion > 0 && data.schemaVersion < LEGACY_VERSION) {
177
+ const ns = {}; const dev = {}
178
+ for (const [k, bag] of Object.entries(data.ns || {})) ns[k] = migrateBag(bag)
179
+ for (const [k, bag] of Object.entries(data.dev || {})) dev[k] = migrateBag(bag)
180
+ data = { schemaVersion: LEGACY_VERSION, ns, dev }
181
+ }
182
+ if (!data) data = { schemaVersion: SCHEMA_VERSION, ns: {}, dev: {}, recovery: null, history: [] }
183
+ if (!data.dev) data.dev = {}
184
+ if (!data.history) data.history = []
185
+ // 0.47.0/0.48.0 guardaron una «política» DENTRO del cajón (salía como variable): fuera.
186
+ for (const bag of Object.values(data.ns || {})) if (bag?.policy && typeof bag.policy === 'object' && !('v' in bag.policy)) delete bag.policy
187
+ delete data.policies
188
+
189
+ // v3 → v5 y v4 → v5 NO se hacen al abrir, y es deliberado: el daemon tiene que poder
190
+ // arrancar y SERVIR sin nada (un perfil bloqueado sigue atendiendo a sus agentes). Un
191
+ // archivo viejo se queda como está, sirviendo igual que siempre, hasta que alguien lo
192
+ // convierta. Hasta entonces el despliegue se deshace con un reinicio.
193
+ const isLegacy = () => data.schemaVersion === LEGACY_VERSION
194
+ const needsMigration = () => data.schemaVersion === SEALED_MASTER_VERSION
195
+
196
+ writeJson(disk, file, data) // reescribe al abrir: cifra lo que venía en claro
197
+
198
+ // Un grupo de escrituras se guarda UNA vez (ver `batch`). Fuera de un grupo, `held`
199
+ // vale 0 y cada cambio va al disco en el acto, como siempre.
200
+ let held = 0
201
+ let pending = false
202
+ const flush = () => writeJson(disk, file, data)
203
+ const save = () => { if (held) { pending = true; return } ; flush() }
204
+
205
+ const assertNs = (ns) => {
206
+ if (!isValidSecretsNs(ns)) throw new Error('invalid namespace (use [a-z0-9-]{1,32}, e.g. "proxy")')
207
+ }
208
+ const assertPub = (pub) => {
209
+ if (typeof pub !== 'string' || !pub) throw new Error('device required (the member public key)')
210
+ }
211
+ const assertKeyValue = assertVar
212
+ const needSealer = (what) => { if (!sealer) throw new NeedsPassword(what) }
213
+ /** La llave con la que se abre la privada de recuperación: la dada, o la de la máquina. */
214
+ const keyOr = (adminKey) => adminKey || defaultKey?.() || null
215
+
216
+ /** Borra la rama si se quedó vacía: un scope (o un aparato) sin variables no existe. */
217
+ const prune = (bag, k) => { if (bag[k] && Object.keys(bag[k]).length === 0) delete bag[k] }
218
+
219
+ // --- forma de un cajón -------------------------------------------------------
220
+ // v3: `bag[k]` ES el mapa de variables. v4/v5: `bag[k] = { vars, keyring }`.
221
+ // `varsOf` es el único sitio que conoce las dos, para que el resto del archivo
222
+ // hable de variables y no de versiones.
223
+ const varsOf = (bag, k) => (isLegacy() ? (bag[k] || {}) : (bag[k]?.vars || {}))
224
+ const ensureBag = (bag, k) => {
225
+ if (isLegacy()) { if (!bag[k]) bag[k] = {}; return bag[k] }
226
+ if (!bag[k]) bag[k] = { vars: {}, keyring: [] }
227
+ if (!bag[k].vars) bag[k].vars = {}
228
+ if (!bag[k].keyring) bag[k].keyring = []
229
+ return bag[k].vars
230
+ }
231
+
232
+ /** Los NOMBRES con su visibilidad: es lo que ve cualquier lista. Nunca valores. */
233
+ const names = (bag, k) => Object.entries(varsOf(bag, k)).map(([key, e]) => ({ key, public: !!e.pub }))
234
+ /** Solo las PÚBLICAS, con valor: lo único que puede salir de esta máquina en claro. */
235
+ /**
236
+ * LOS NOMBRES de las públicas. Ya no hay valores que devolver: desde 2026-09-02 una
237
+ * pública se guarda en sobre igual que una privada, y `pub` solo dice si se despacha sin
238
+ * aprobación. Para VER un valor hay que poder abrirlo, como con cualquier otro.
239
+ */
240
+ const publics = (bag, k) => Object.keys(varsOf(bag, k)).filter((key) => varsOf(bag, k)[key].pub)
241
+ /**
242
+ * Las entradas TAL CUAL van al bundle: la pública con su valor, la privada con su
243
+ * sobre y su firma. No se descifra nada aquí — de eso vive todo esto.
244
+ */
245
+ const entriesOf = (bag, k) => ({ ...varsOf(bag, k) })
246
+
247
+ /** La generación vigente de un cajón (la de `gen` mayor). */
248
+ const topGen = (bag, k) => {
249
+ const kr = bag[k]?.keyring || []
250
+ return kr.reduce((best, g) => (!best || (g.gen || 0) > (best.gen || 0) ? g : best), null)
251
+ }
252
+
253
+ /**
254
+ * LA GENERACIÓN QUE TOCA ESTRENAR. Se mira el llavero Y las entradas, no solo el llavero:
255
+ * un dato PÚBLICO del perfil lleva generación pero no sobre, así que no deja nada en el
256
+ * llavero. Contando solo el llavero, dos escrituras públicas seguidas salían con la MISMA
257
+ * generación — y la generación es lo que ordena un dato entre réplicas (`mergeBundle`):
258
+ * con empate gana el de hash menor, que puede ser el valor viejo.
259
+ */
260
+ const nextGen = (bag, k, owner) => {
261
+ let top = topGen(bag, k)?.gen || 0
262
+ for (const e of Object.values(bag[k]?.vars || {})) if ((e.gen || 0) > top) top = e.gen
263
+ for (const h of data.history) if (h.owner === owner && (h.gen || 0) > top) top = h.gen
264
+ return top + 1
265
+ }
266
+
267
+ /**
268
+ * UN CAMBIO A LA VEZ.
269
+ *
270
+ * Todo lo que toca el llavero es leer-modificar-escribir con un `await` en medio, así
271
+ * que dos operaciones en vuelo se pisan la una a la otra. No es teórico: en v4 un
272
+ * `rewrap` lanzado al aprobar un aparato volvía a sellar la copia maestra **con la
273
+ * llave vieja** justo después de cambiar la contraseña del perfil, y el vault quedaba
274
+ * abierto para cualquiera con una copia del disco, sin un solo mensaje de error.
275
+ *
276
+ * La cola es de este proceso, que es el único que escribe este archivo.
277
+ */
278
+ let cola = Promise.resolve()
279
+ const enFila = (fn) => {
280
+ const r = cola.then(fn, fn)
281
+ cola = r.then(() => {}, () => {})
282
+ return r
283
+ }
284
+
285
+ // --- histórico y recogida de generaciones ------------------------------------
286
+
287
+ /** Guarda la versión que se va a pisar. Sin valores en claro: el sobre tal cual. */
288
+ const pushHistory = (owner, key, before, by) => {
289
+ if (!before || before.pub) return // una pública no es un secreto que revertir a ciegas
290
+ data.history.push({ ts: Date.now(), owner, key, gen: before.gen, e: before.e, seal: before.seal || null, by: by || null })
291
+ if (data.history.length > MAX_HISTORY) data.history = data.history.slice(-MAX_HISTORY)
292
+ }
293
+
294
+ /**
295
+ * Tira las generaciones que ya no abre nada. Con una generación por escritura, el
296
+ * llavero crecería sin fin; lo que hay que conservar es lo que todavía referencia una
297
+ * variable viva o una entrada del histórico — ni una más, porque cada generación
298
+ * conservada es una llave que sigue por ahí.
299
+ */
300
+ const gcKeyring = (bag, k, owner) => {
301
+ if (!bag[k]) return
302
+ const vivos = new Set()
303
+ // TAMBIÉN LAS PÚBLICAS. Se saltaban porque antes no tenían generación —se guardaban en
304
+ // claro—, así que su llave se recogía por «no la usa nadie» y la variable quedaba
305
+ // ilegible. Desde 2026-09-02 van en sobre como todas y cuentan como todas.
306
+ for (const e of Object.values(bag[k].vars || {})) if (e.gen != null) vivos.add(e.gen)
307
+ for (const h of data.history) if (h.owner === owner && h.gen != null) vivos.add(h.gen)
308
+ bag[k].keyring = (bag[k].keyring || []).filter((g) => vivos.has(g.gen))
309
+ }
310
+
311
+ /**
312
+ * Abre la privada de RECUPERACIÓN. Es la única puerta de este módulo a un valor en
313
+ * claro, y por eso es la única que pide la frase.
314
+ */
315
+ const openRecovery = async (adminKey) => {
316
+ needSealer('read a private variable')
317
+ if (!data.recovery?.priv) throw new NeedsPassword('this store has no recovery key yet')
318
+ return sealer.openMaster(data.recovery.priv, keyOr(adminKey))
319
+ }
320
+
321
+ /**
322
+ * El par de recuperación, creándolo si es la primera vez. Se sella con lo que haya:
323
+ * con la frase si quien llama la trajo, y si no con la llave de la máquina — que es
324
+ * la protección de siempre y no una nueva promesa. Cuando el perfil estrene
325
+ * contraseña, `rekeyRecovery` lo vuelve a cerrar con ella.
326
+ */
327
+ const ensureRecovery = async (adminKey) => {
328
+ if (data.recovery?.pub) return data.recovery
329
+ needSealer('create the recovery key')
330
+ const pair = await sealer.makeRecoveryPair()
331
+ data.recovery = { pub: pair.pub, priv: await sealer.sealMaster(pair.priv, keyOr(adminKey)) }
332
+ return data.recovery
333
+ }
334
+
335
+ /** Los destinatarios de un cajón: los que dice el acta, más la copia de recuperación. */
336
+ /**
337
+ * @param {boolean} isPublic Una PÚBLICA se envuelve además para quien administra, aunque
338
+ * el cajón tenga dueño (dueño, 2026-09-02). Puede variar por variable porque cada
339
+ * escritura estrena su propia CEK: el llavero guarda una generación por valor.
340
+ */
341
+ const wrapAll = async (cek, owner, isPublic = false) => {
342
+ const members = (await recipients?.(owner, { public: isPublic })) || []
343
+ const { wraps, sinLlave } = await sealer.wrapFor(cek, members)
344
+ wraps[RECOVERY] = await sealer.wrapForKey(cek, data.recovery.pub)
345
+ return { wraps, sinLlave }
346
+ }
347
+
348
+ return {
349
+ /** `true` mientras el archivo siga en v3 (sin sellar). */
350
+ isLegacy,
351
+ /** `true` si sigue en v4 (sellado, pero con copia maestra): hay que convertirlo. */
352
+ needsMigration,
353
+ schemaVersion: () => data.schemaVersion,
354
+ /** ¿La privada de recuperación está sellada solo con la llave de esta máquina? */
355
+ recoveryPub: () => data.recovery?.pub || null,
356
+ /**
357
+ * CREA LA LLAVE DE RECUPERACIÓN SI NO EXISTE. Se llama al ABRIR la bóveda, que es el
358
+ * único momento en que se puede: sellar su privada necesita la maestra.
359
+ *
360
+ * Nacía perezosamente, al escribir el primer secreto. En un perfil recién hecho eso
361
+ * significaba que la bóveda no tenía a quién envolver, y como crearla exige estar
362
+ * abierta, escribir el PERFIL con la bóveda cerrada era imposible — que es justo lo
363
+ * que el perfil en sobres viene a permitir. Lo encontró el escenario del navegador:
364
+ * «the vault did not say who to seal the profile for».
365
+ *
366
+ * Idempotente: si ya está, no toca nada.
367
+ */
368
+ async ensureRecovery (adminKey = null) { return enFila(async () => (await ensureRecovery(adminKey)).pub) },
369
+
370
+ /**
371
+ * MUCHAS ESCRITURAS, UN GUARDADO. Cargar la configuración de un servicio son veinte
372
+ * variables pero un solo cambio: con un `save()` por variable, el archivo entero se
373
+ * reescribía y se volvía a cifrar veinte veces.
374
+ */
375
+ async batch (fn) {
376
+ held++
377
+ try { return await fn() } finally {
378
+ held--
379
+ if (!held && pending) { pending = false; flush() }
380
+ }
381
+ },
382
+
383
+ /**
384
+ * El bundle que se le entrega a un servicio: lo del scope con lo del aparato
385
+ * ENCIMA. Públicas y privadas por igual — la visibilidad no es un permiso de
386
+ * lectura del servicio, es si el valor puede salir de esta máquina.
387
+ *
388
+ * Devuelve las entradas SIN abrir, más las envolturas **de este miembro y solo las
389
+ * suyas**: el resto del llavero son las llaves de sus compañeros y no le hacen falta.
390
+ * Van TODAS sus generaciones, no solo la última, porque desde v5 cada variable puede
391
+ * venir de una escritura distinta. En v3 devuelve los valores en claro, como siempre.
392
+ */
393
+ /**
394
+ * @param {string} ns
395
+ * @param {string|null} devicePub
396
+ * @param {{publicOnly?: boolean}} [opts] `publicOnly`: SOLO las públicas — pero CON sus
397
+ * envolturas, porque desde 2026-09-02 van en sobre igual que las privadas. Lo que las
398
+ * distingue es solo que se despachan sin aprobación (ver `handleSecrets`); aquí se
399
+ * devolvía el paquete sin llavero, y entonces no había con qué abrirlas.
400
+ */
401
+ bundleFor (ns, devicePub = null, { publicOnly = false } = {}) {
402
+ assertNs(ns)
403
+ const todo = { ...entriesOf(data.ns, ns), ...(devicePub ? entriesOf(data.dev, devicePub) : {}) }
404
+ // EL FILTRO LO HACE LA BÓVEDA, no quien pide. Si mandara todo y el cliente eligiera,
405
+ // pedir «solo públicas» sería la forma de saltarse la aprobación y llevarse las
406
+ // privadas igual — o sea justo lo contrario de lo que se quiere.
407
+ const entries = publicOnly
408
+ ? Object.fromEntries(Object.entries(todo).filter(([, e]) => e.pub))
409
+ : todo
410
+ if (isLegacy()) return { legacy: true, entries }
411
+
412
+ const misWraps = (bag, k) => (bag[k]?.keyring || [])
413
+ .filter((g) => g.wraps?.[devicePub])
414
+ .map((g) => ({ gen: g.gen, wrap: g.wraps[devicePub] }))
415
+ const ns1 = misWraps(data.ns, ns)
416
+ const dev1 = devicePub ? misWraps(data.dev, devicePub) : []
417
+ return {
418
+ entries,
419
+ // Compat con el bundle de v4, que llevaba UNA envoltura por cajón: se manda la
420
+ // vigente ahí y la lista entera aparte. Quien sepa leer `wraps` usa la lista.
421
+ ns: ns1[ns1.length - 1] || null,
422
+ dev: dev1[dev1.length - 1] || null,
423
+ wraps: { ns: ns1, dev: dev1 }
424
+ }
425
+ },
426
+
427
+ /**
428
+ * VER UN VALOR. Es lo único que pide la frase, y es lo que la frase significa desde
429
+ * v5 (§8.3). Devuelve `null` si esa variable no existe.
430
+ */
431
+ async reveal (owner, key, adminKey = null) {
432
+ const [kind, k] = splitOwner(owner)
433
+ const bag = kind === 'ns' ? data.ns : data.dev
434
+ const e = varsOf(bag, k)[key]
435
+ if (!e) return null
436
+ // UNA PÚBLICA SE ABRE IGUAL QUE UNA PRIVADA (dueño, 2026-09-02). Aquí había un atajo
437
+ // —`if (e.pub) return e.v`— porque una pública se guardaba en claro. Ya no: la marca
438
+ // solo dice si se despacha sin aprobación, y para VER un valor hay que poder abrirlo.
439
+ if (isLegacy()) return e.v
440
+ const priv = await openRecovery(adminKey)
441
+ const cek = await this._cekOf(bag, k, e.gen, priv)
442
+ return sealer.openValue(cek, e.e)
443
+ },
444
+
445
+
446
+ /**
447
+ * Las variables privadas de un cajón que ESE miembro **no puede abrir**: las que no
448
+ * tienen envoltura para su llave en la generación vigente.
449
+ *
450
+ * Es la deuda del §8.7 vista desde el aparato en vez de desde el cajón. Un aparato
451
+ * que entra después de escrita una variable no tiene envoltura de ella —y no la
452
+ * puede tener, porque envolver exige abrir la CEK y eso pide la frase—, así que se
453
+ * queda sin poder leerla. Eso es correcto y no se relaja; lo que no puede pasar es
454
+ * que no se vea, que era el modo de fallo de verdad: un servicio en el acta,
455
+ * aparentemente bien, que arranca sin configuración.
456
+ *
457
+ * @param {string} owner `ns:<scope>` o `dev:<pub>`
458
+ * @param {string} memberPub la llave de FIRMA del miembro (así se indexan las envolturas)
459
+ * @param {{ only?: (isPublic: boolean) => boolean }} [opts] qué variables le TOCAN, según
460
+ * su visibilidad: a quien administra se le envuelven las públicas de un cajón con dueño
461
+ * y no las privadas, así que la falta de una privada no es deuda suya.
462
+ * @returns {string[]} nombres de variables, vacío si puede con todas
463
+ */
464
+ missingFor (owner, memberPub, { only = null } = {}) {
465
+ if (isLegacy()) return []
466
+ const [kind, k] = splitOwner(owner)
467
+ const bag = kind === 'ns' ? data.ns : data.dev
468
+ const out = []
469
+ for (const [key, e] of Object.entries(varsOf(bag, k))) {
470
+ // Lo PÚBLICO DEL PERFIL no lleva sobre: va en claro y firmado, porque quien lo lee
471
+ // desde fuera no tiene ninguna llave tuya. No hay envoltura que pueda faltarle a
472
+ // nadie, y contarla como deuda era un aviso que ningún reparto podía apagar.
473
+ if (e.cls === 'public') continue
474
+ if (only && !only(!!e.pub)) continue
475
+ // También las públicas: desde 2026-09-02 van en sobre, así que a quien no tenga su
476
+ // envoltura le falta igual que con una privada.
477
+ const g = (bag[k]?.keyring || []).find((x) => x.gen === e.gen)
478
+ if (!g?.wraps?.[memberPub]) out.push(key)
479
+ }
480
+ return out
481
+ },
482
+
483
+ /**
484
+ * Las generaciones de un cajón que a `memberPub` le faltan, con LA ENVOLTURA DE
485
+ * QUIEN PREGUNTA para cada una. Es lo que necesita un aparato que administra para
486
+ * completar a otro sin la frase: abre cada una con su llave y la vuelve a envolver
487
+ * (`rewrapFor` de `@dotrino/identity`).
488
+ *
489
+ * Devuelve solo las generaciones que están EN USO (alguna variable las apunta): las
490
+ * viejas no hacen falta y re-envolverlas sería repartir llaves que ya no abren nada.
491
+ *
492
+ * @returns {Array<{ gen: number, mine: any }>} vacío si quien pregunta tampoco puede
493
+ */
494
+ wrapsToShare (owner, memberPub, myPub) {
495
+ if (isLegacy()) return []
496
+ const [kind, k] = splitOwner(owner)
497
+ const bag = kind === 'ns' ? data.ns : data.dev
498
+ const inUse = new Set(Object.values(varsOf(bag, k)).map((e) => e.gen))
499
+ const out = []
500
+ for (const g of bag[k]?.keyring || []) {
501
+ if (!inUse.has(g.gen) || g.wraps?.[memberPub]) continue
502
+ if (g.wraps?.[myPub]) out.push({ gen: g.gen, mine: g.wraps[myPub] })
503
+ }
504
+ return out
505
+ },
506
+
507
+ /**
508
+ * Guarda una envoltura que hizo otro. La bóveda NO la puede comprobar —abrirla
509
+ * exigiría la frase, que es justo lo que este camino evita—, así que lo que sí
510
+ * comprueba es lo que puede: que la generación exista y que ese miembro no tuviera
511
+ * ya una. Una envoltura mala deja al servicio sin leer, que se ve; no da acceso a
512
+ * nadie.
513
+ */
514
+ putWrap (owner, gen, memberPub, wrap) {
515
+ const [kind, k] = splitOwner(owner)
516
+ const bag = kind === 'ns' ? data.ns : data.dev
517
+ const g = (bag[k]?.keyring || []).find((x) => x.gen === gen)
518
+ if (!g) throw new Error(`putWrap: no existe la generación ${gen} de ${owner}`)
519
+ if (!wrap?.epk || !wrap?.iv || !wrap?.ct) throw new Error('putWrap: envoltura mal formada')
520
+ // SOLO AÑADE, NUNCA PISA. Quien re-envuelve es un servicio, y un servicio no
521
+ // administra: si pudiera reemplazar una envoltura existente podría dejar sin leer
522
+ // a otro miembro —o a quien administra— con una envoltura basura, y eso es
523
+ // denegación de servicio disfrazada de reparto. Reemplazar es cosa de la bóveda,
524
+ // por el camino de escribir (que estrena generación entera).
525
+ if (g.wraps?.[memberPub]) throw new Error(`putWrap: ${memberPub.slice(0, 12)}… ya tiene envoltura de la generación ${gen}`)
526
+ g.wraps = { ...(g.wraps || {}), [memberPub]: wrap }
527
+ save()
528
+ return true
529
+ },
530
+
531
+
532
+ /** @private La CEK de una generación, abierta con la privada de recuperación. */
533
+ async _cekOf (bag, k, gen, priv) {
534
+ const g = (bag[k]?.keyring || []).find((x) => x.gen === gen) || topGen(bag, k)
535
+ const w = g?.wraps?.[RECOVERY]
536
+ if (!w) throw new NeedsPassword(`there is no recovery copy of the key for generation ${gen}`)
537
+ return sealer.openWrapWith(priv, w)
538
+ },
539
+
540
+ /**
541
+ * Igual que `bundleFor` pero YA ABIERTO. Solo existe para las pruebas y para
542
+ * diagnosticar: pide la frase. Nadie del camino de servir lo llama.
543
+ */
544
+ async openBundle (ns, devicePub = null, adminKey = null) {
545
+ const b = this.bundleFor(ns, devicePub)
546
+ if (b.legacy) return Object.fromEntries(Object.entries(b.entries).map(([k, e]) => [k, e.v]))
547
+ const priv = await openRecovery(adminKey)
548
+ const out = {}
549
+ for (const [key, e] of Object.entries(b.entries)) {
550
+ if (e.pub) { out[key] = e.v; continue }
551
+ const [kind, k] = splitOwner(e.owner)
552
+ const bag = kind === 'ns' ? data.ns : data.dev
553
+ out[key] = await sealer.openValue(await this._cekOf(bag, k, e.gen, priv), e.e)
554
+ }
555
+ return out
556
+ },
557
+
558
+ // --- escritura: NO pide la frase (§8.1) --------------------------------------
559
+ /**
560
+ * Escribe en un cajón. `isPublic` es OPCIONAL a propósito: sin decir nada se
561
+ * conserva lo que la variable ya era (rotar un valor no debe cambiar quién puede
562
+ * verlo por olvido) y una variable nueva nace PRIVADA.
563
+ *
564
+ * `by` es quién la escribió (la pubkey del aparato), y va al histórico.
565
+ */
566
+ async set (ns, key, value, isPublic, { by = null } = {}) {
567
+ assertNs(ns)
568
+ return this._put(data.ns, ns, `ns:${ns}`, key, value, isPublic, by)
569
+ },
570
+ async setDevice (pub, key, value, isPublic, { by = null } = {}) {
571
+ assertPub(pub)
572
+ return this._put(data.dev, pub, `dev:${pub}`, key, value, isPublic, by)
573
+ },
574
+ /** @private */
575
+ async _put (...a) { return enFila(() => this._putRaw(...a)) },
576
+ /** @private El cuerpo, ya en fila (ver `enFila`). */
577
+ async _putRaw (bag, k, owner, key, value, isPublic, by) {
578
+ assertKeyValue(key, value)
579
+ const vars = ensureBag(bag, k)
580
+ const before = vars[key]
581
+ const pub = isPublic === undefined ? !!before?.pub : !!isPublic
582
+ if (before && !before.pub && pub) throw new PrivateStaysPrivate(key)
583
+
584
+ // v3: se guarda como siempre. Convertir a sobres es un gesto aparte y explícito
585
+ // (`migrate`), no algo que ocurra de refilón al escribir.
586
+ if (isLegacy()) { vars[key] = { v: value, pub }; save(); return }
587
+ if (needsMigration()) throw new NeedsMigration()
588
+
589
+ // PÚBLICA Y PRIVADA SE GUARDAN IGUAL (dueño, 2026-09-02: «las variables públicas
590
+ // igual, codificadas en sobres… la única diferencia es si se despachan o no, son
591
+ // políticas, dales el mismo tratamiento de seguridad»).
592
+ //
593
+ // Antes una pública se guardaba EN CLARO, y eso mezclaba dos cosas que no tienen que
594
+ // ver: quién puede recibirla (política) y cómo se guarda (seguridad). El precio era
595
+ // un valor legible en el disco y un camino aparte en cada sitio que las tocaba.
596
+ // Ahora `pub` es solo la marca de despacho: se lleva sin aprobación (`--public`).
597
+ needSealer('write a variable')
598
+ await ensureRecovery(null)
599
+
600
+ // CEK NUEVA, siempre: no se puede reutilizar la de antes sin poder abrirla.
601
+ const cek = await sealer.newKey()
602
+ const gen = nextGen(bag, k, owner)
603
+ const { wraps, sinLlave } = await wrapAll(cek, owner, pub)
604
+ const e = await sealer.encrypt(cek, value, gen)
605
+ // La FIRMA dice que este sobre salió de esta bóveda, y con qué acta (§8.8). Si no
606
+ // hay con qué firmar, el sobre sale sin firma: guardar es más importante.
607
+ const seal = signer ? await signer({ owner, key, gen, iv: e.iv, ct: e.ct }) : null
608
+
609
+ pushHistory(owner, key, before, by)
610
+ vars[key] = { pub, owner, gen, e, seal, at: Date.now(), by: by || null }
611
+ bag[k].keyring = [...(bag[k].keyring || []), { gen, createdAt: Date.now(), wraps }]
612
+ gcKeyring(bag, k, owner)
613
+ save()
614
+ return { gen, sinLlave }
615
+ },
616
+
617
+ /**
618
+ * GUARDAR UN SOBRE YA HECHO, SIN ABRIRLO (dueño, 2026-09-01).
619
+ *
620
+ * «La bóveda cerrada no puede ver el valor; debe confiar en la firma del admin y en el
621
+ * contenido de esos sobres, y es la razón por la que al abrir la bóveda rehace los
622
+ * sobres: por si alguno tiene alguna incoherencia.»
623
+ *
624
+ * Antes, escribir hacía un rodeo absurdo: la consola sellaba el valor AL PERFIL, la
625
+ * bóveda lo ABRÍA para sacarlo en claro y `_putRaw` lo volvía a cerrar. Ese descifrado
626
+ * era el único motivo por el que la llave de cifrado del perfil tenía que estar
627
+ * accesible con la bóveda cerrada — o sea, la razón por la que una copia del disco
628
+ * abría todos los sobres dirigidos al perfil.
629
+ *
630
+ * Aquí no se abre nada: llega el valor ya cifrado con una CEK nueva y esa CEK ya
631
+ * envuelta para cada destinatario. Lo único que se comprueba es lo que se puede
632
+ * comprobar sin la llave — la FORMA, y sobre todo que venga la envoltura de
633
+ * recuperación: sin ella el cajón nace ilegible para siempre, y eso no se arregla
634
+ * después ni abriendo la bóveda.
635
+ *
636
+ * @param {string} owner `ns:<scope>` o `dev:<pub>`
637
+ * @param {{e:{iv:string,ct:string,gen?:number}, wraps:Object}} sobre
638
+ */
639
+ async putSealed (owner, key, sobre, { by = null, public: isPublic = false } = {}) {
640
+ return enFila(() => this._putSealedRaw(owner, key, sobre, by, !!isPublic))
641
+ },
642
+ /** @private */
643
+ async _putSealedRaw (owner, key, sobre, by, isPublic) {
644
+ if (isLegacy()) throw new Error('putSealed: this store is still v3 (run `migrate` first)')
645
+ if (needsMigration()) throw new NeedsMigration()
646
+ const [kind, k] = splitOwner(owner)
647
+ const bag = kind === 'ns' ? data.ns : data.dev
648
+ const e = sobre?.e; const wraps = sobre?.wraps
649
+ if (!e || typeof e.iv !== 'string' || typeof e.ct !== 'string') {
650
+ throw new Error('putSealed: the envelope must carry `iv` and `ct`')
651
+ }
652
+ if (!wraps || typeof wraps !== 'object' || !wraps[RECOVERY]) {
653
+ // SIN LA ENVOLTURA DE RECUPERACIÓN EL CAJÓN NACE MUERTO. Es la que abre la frase, y
654
+ // la única que puede rehacer las demás: si falta, ni el dueño con su contraseña
655
+ // podría reenvolverlo para nadie. Se rechaza antes de tocar el disco.
656
+ throw new Error('putSealed: the wraps must include the recovery one (#recovery), or the drawer could never be re-wrapped')
657
+ }
658
+ const vars = ensureBag(bag, k)
659
+ const before = vars[key]
660
+ // Una PRIVADA no se vuelve pública: cambiar eso es cambiar quién la recibe sin
661
+ // aprobación, y no puede colarse en una escritura normal.
662
+ if (before && !before.pub && isPublic) throw new PrivateStaysPrivate(key)
663
+
664
+ // LA GENERACIÓN LA PONE EL ALMACÉN, no quien escribe: es la que ordena el llavero, y
665
+ // dejar que la eligiera el que llega permitiría pisar una anterior.
666
+ const gen = nextGen(bag, k, owner)
667
+ // La FIRMA sigue siendo de la bóveda —dice que este sobre salió de aquí y con qué
668
+ // acta (§8.8)— y se hace con la llave de SELLADO, que funciona con el perfil cerrado.
669
+ const seal = signer ? await signer({ owner, key, gen, iv: e.iv, ct: e.ct }) : null
670
+
671
+ pushHistory(owner, key, before, by)
672
+ vars[key] = { pub: isPublic, owner, gen, e: { ...e, gen }, seal, at: Date.now(), by: by || null }
673
+ bag[k].keyring = [...(bag[k].keyring || []), { gen, createdAt: Date.now(), wraps }]
674
+ gcKeyring(bag, k, owner)
675
+ save()
676
+ return { gen }
677
+ },
678
+
679
+ /**
680
+ * LA ENTRADA TAL CUAL (sobre, generación, visibilidad) — sin abrir nada.
681
+ *
682
+ * Es lo que hace falta para MANDARLE a alguien una variable que sí puede leer: el sobre
683
+ * viaja cerrado y lo abre en su casa, con su envoltura. Aquí no se descifra.
684
+ */
685
+ entryOf (owner, key) {
686
+ const [kind, k] = splitOwner(owner)
687
+ const bag = kind === 'ns' ? data.ns : data.dev
688
+ return varsOf(bag, k)[key] || null
689
+ },
690
+ /** La envoltura de ESA generación para ESE miembro, o `null` si no la tiene. */
691
+ wrapOf (owner, gen, memberPub) {
692
+ const [kind, k] = splitOwner(owner)
693
+ const bag = kind === 'ns' ? data.ns : data.dev
694
+ return (bag[k]?.keyring || []).find((g) => g.gen === gen)?.wraps?.[memberPub] || null
695
+ },
696
+
697
+ /**
698
+ * ¿EXISTE YA ESTA VARIABLE? Sin abrirla ni decir qué vale.
699
+ *
700
+ * Hace falta para la regla de «un servicio solo rellena lo que falta, no pisa lo que
701
+ * hay» (dueño, 2026-09-01): quien decide eso necesita saber si había algo, y nada más.
702
+ */
703
+ has (owner, key) {
704
+ const [kind, k] = splitOwner(owner)
705
+ const bag = kind === 'ns' ? data.ns : data.dev
706
+ return key in varsOf(bag, k)
707
+ },
708
+
709
+ /**
710
+ * PARA QUIÉN hay que envolver, con su llave pública de cifrado, más la de recuperación.
711
+ * Es lo que necesita quien va a fabricar el sobre — y se lo dice la BÓVEDA, para que no
712
+ * haya dos sitios respondiendo a «quién puede abrir este cajón» (la lista la sabe el
713
+ * acta, y de ahí sale `recipients`).
714
+ */
715
+ async recipientsFor (owner, { public: isPublic = false } = {}) {
716
+ await ensureRecovery(null)
717
+ const members = (await recipients?.(owner, { public: isPublic })) || []
718
+ return {
719
+ recoveryPub: data.recovery.pub,
720
+ members: members.filter((m) => m.encPub).map((m) => ({ pub: m.pub, encPub: m.encPub }))
721
+ }
722
+ },
723
+
724
+ async delete (ns, key) {
725
+ assertNs(ns)
726
+ return this._drop(data.ns, ns, `ns:${ns}`, key)
727
+ },
728
+ async deleteDevice (pub, key) {
729
+ assertPub(pub)
730
+ return this._drop(data.dev, pub, `dev:${pub}`, key)
731
+ },
732
+ /**
733
+ * @private Borrar NO exige la frase: quitar algo no pide poder leerlo.
734
+ *
735
+ * Y borrar SE LLEVA SU HISTÓRICO. Si no, borrar una variable dejaría sus versiones
736
+ * anteriores guardadas —cifradas, pero recuperables— y «borré esa credencial» sería
737
+ * mentira. El histórico existe para revertir mientras la variable vive; cuando se va,
738
+ * se va entera.
739
+ */
740
+ _drop (bag, k, owner, key) {
741
+ const vars = varsOf(bag, k)
742
+ const existed = key in vars
743
+ if (!existed) return false
744
+ delete vars[key]
745
+ data.history = data.history.filter((h) => !(h.owner === owner && h.key === key))
746
+ if (!isLegacy()) gcKeyring(bag, k, owner)
747
+ if (Object.keys(vars).length === 0) prune(bag, k)
748
+ save()
749
+ return true
750
+ },
751
+
752
+ /**
753
+ * Cambiar la visibilidad solo va en UNA dirección: de pública a privada (sellar el
754
+ * valor, que es escribir y no pide nada). De privada a pública NO existe
755
+ * (`PrivateStaysPrivate`): destapar un secreto no es una casilla, es borrarlo y
756
+ * crear otro.
757
+ */
758
+ async setVisibility (ns, key, isPublic, adminKey = null) {
759
+ assertNs(ns)
760
+ return this._setVis(data.ns, ns, `ns:${ns}`, key, isPublic, adminKey)
761
+ },
762
+ async setDeviceVisibility (pub, key, isPublic, adminKey = null) {
763
+ assertPub(pub)
764
+ return this._setVis(data.dev, pub, `dev:${pub}`, key, isPublic, adminKey)
765
+ },
766
+ /** @private */
767
+ async _setVis (...a) { return enFila(() => this._setVisRaw(...a)) },
768
+ /** @private El cuerpo, ya en fila. */
769
+ async _setVisRaw (bag, k, owner, key, isPublic, adminKey) {
770
+ const vars = varsOf(bag, k)
771
+ const e = vars[key]
772
+ if (!e) return false
773
+ const want = !!isPublic
774
+ if (!!e.pub === want) return true
775
+ // DE PRIVADA A PÚBLICA NO EXISTE, y no es una limitación técnica: hacer despachable
776
+ // sin aprobación algo que se guardó como secreto no puede ser una casilla. Se crea
777
+ // otra variable a propósito.
778
+ if (!e.pub) throw new PrivateStaysPrivate(key)
779
+ if (isLegacy()) { e.pub = false; save(); return true }
780
+ if (needsMigration()) throw new NeedsMigration()
781
+
782
+ // DE PÚBLICA A PRIVADA ES CAMBIAR LA MARCA, Y NADA MÁS (dueño, 2026-09-02).
783
+ //
784
+ // Antes había que reescribir la variable entera —generación nueva, sobre nuevo—
785
+ // porque una pública se guardaba EN CLARO y taparla significaba cifrarla por primera
786
+ // vez. Ya no: las dos se guardan igual desde el principio, así que aquí solo cambia
787
+ // a quién se le despacha sin aprobación. Reescribir ahora sería trabajo (y una
788
+ // generación) para no cambiar nada de lo guardado.
789
+ e.pub = false
790
+ save()
791
+ return true
792
+ },
793
+
794
+ // --- lecturas que NO abren nada ---------------------------------------------
795
+ /** Nombres y visibilidad (ns → [{key, public}]), sin valores: para `secret list`. */
796
+ list () {
797
+ const out = {}
798
+ for (const ns of Object.keys(data.ns)) out[ns] = names(data.ns, ns)
799
+ return out
800
+ },
801
+ /** Nombres y visibilidad (pub → [{key, public}]), sin valores. */
802
+ listDevices () {
803
+ const out = {}
804
+ for (const pub of Object.keys(data.dev)) out[pub] = names(data.dev, pub)
805
+ return out
806
+ },
807
+ /**
808
+ * QUIÉN puede abrir la generación vigente de un cajón (sus llaves de firma, más
809
+ * `#recovery`). Es diagnóstico, no un secreto: saber a cuántos se les envolvió no
810
+ * ayuda a abrir nada, y en cambio es lo único que responde de verdad a «¿quién
811
+ * puede leer esto?» — que es la pregunta que uno se hace mirando un cajón.
812
+ */
813
+ recipientsIn (owner) {
814
+ const [kind, k] = splitOwner(owner)
815
+ const bag = kind === 'ns' ? data.ns : data.dev
816
+ return Object.keys(topGen(bag, k)?.wraps || {})
817
+ },
818
+
819
+ /** Los NOMBRES de las públicas de un scope. Ya no hay valor que devolver: van en sobre. */
820
+ publicOf (ns) {
821
+ assertNs(ns)
822
+ return publics(data.ns, ns)
823
+ },
824
+ /** Los NOMBRES de las públicas de un aparato. */
825
+ publicOfDevice (pub) {
826
+ assertPub(pub)
827
+ return publics(data.dev, pub)
828
+ },
829
+
830
+ // --- histórico: qué había antes, y cómo se vuelve ---------------------------
831
+ /**
832
+ * Las versiones anteriores, de la más nueva a la más vieja. SIN valores: son sobres.
833
+ * Quien pueda abrirlos los abre con `revealHistory`; quien no, ve que existieron.
834
+ */
835
+ history (owner = null, key = null) {
836
+ return data.history
837
+ .filter((h) => (!owner || h.owner === owner) && (!key || h.key === key))
838
+ .map((h) => ({ ts: h.ts, owner: h.owner, key: h.key, gen: h.gen, by: h.by || null, signed: !!h.seal }))
839
+ .reverse()
840
+ },
841
+
842
+ /** El valor de una versión anterior. Pide la frase, como cualquier lectura. */
843
+ async revealHistory (owner, key, ts, adminKey = null) {
844
+ const h = data.history.find((x) => x.owner === owner && x.key === key && x.ts === ts)
845
+ if (!h) return null
846
+ const [kind, k] = splitOwner(owner)
847
+ const bag = kind === 'ns' ? data.ns : data.dev
848
+ const priv = await openRecovery(adminKey)
849
+ return sealer.openValue(await this._cekOf(bag, k, h.gen, priv), h.e)
850
+ },
851
+
852
+ /**
853
+ * REVERTIR: coge una versión anterior y la vuelve a guardar. No es un modo especial
854
+ * del store —es abrir y escribir—, así que hereda las dos reglas: abrir pide la
855
+ * frase (o la hace quien puede leer, desde su aparato) y escribir no pide nada.
856
+ */
857
+ async revert (owner, key, ts, { adminKey = null, by = null } = {}) {
858
+ const value = await this.revealHistory(owner, key, ts, adminKey)
859
+ if (value == null) return false
860
+ const [kind, k] = splitOwner(owner)
861
+ const bag = kind === 'ns' ? data.ns : data.dev
862
+ await this._put(bag, k, owner, key, value, false, by)
863
+ return true
864
+ },
865
+
866
+ // --- llavero: quién puede abrir cada cajón ----------------------------------
867
+ /**
868
+ * Re-envuelve la llave de lo YA GUARDADO a los miembros dados. Hace falta cuando
869
+ * entra un aparato a un cajón que ya tiene variables: lo que se escriba desde ahora
870
+ * ya se le envuelve solo, pero lo de antes está cerrado con llaves que él no tiene.
871
+ *
872
+ * Y por eso ESTO sí pide la frase: heredar lo viejo obliga a abrirlo.
873
+ */
874
+ async rewrap (...a) { return enFila(() => this._rewrap(...a)) },
875
+ /** Los cajones que existen, para poder recorrerlos todos (`ns:…` y `dev:…`). */
876
+ owners () {
877
+ return [...Object.keys(data.ns).map((k) => `ns:${k}`), ...Object.keys(data.dev).map((k) => `dev:${k}`)]
878
+ },
879
+ // ---------------- EL PERFIL (`docs/datos-del-perfil.md`) ----------------
880
+ //
881
+ // Escribir un dato PRIVADO no necesita nada de aquí: es `putSealed(PROFILE_OWNER, …)`,
882
+ // que ya guarda un sobre hecho sin abrirlo y funciona con la bóveda cerrada. Lo que
883
+ // falta es leerlo, y el camino de lo PÚBLICO, que no se parece a ninguna variable.
884
+
885
+ /**
886
+ * Los sobres del perfil TALLADOS a un aparato: sus entradas y las envolturas suyas.
887
+ * Igual que `bundleFor`, sin `assertNs` — `@me` no es un nombre de cajón válido, y esa
888
+ * es justamente su garantía.
889
+ *
890
+ * El PAQUETE LO ARMA EL APARATO (dueño, 2026-09-03): aquí solo se entrega lo que hay.
891
+ */
892
+ profileBundleFor (devicePub) {
893
+ const bag = data.ns
894
+ const k = PROFILE_NS
895
+ if (isLegacy() || !bag[k]) return { entries: {}, wraps: [] }
896
+ const wraps = (bag[k].keyring || [])
897
+ .filter((g) => g.wraps?.[devicePub])
898
+ .map((g) => ({ gen: g.gen, wrap: g.wraps[devicePub] }))
899
+ return { entries: { ...varsOf(bag, k) }, wraps }
900
+ },
901
+
902
+ /**
903
+ * LO PÚBLICO DE VERDAD: en claro, y firmado. Es lo que recibe quien pregunta desde
904
+ * fuera y no tiene ninguna llave tuya — no hay a quién sellárselo (dueño, 2026-09-03).
905
+ *
906
+ * Va firmado igual que lo demás: un dato público sin firma es un dato que cualquiera
907
+ * puede inventar en tu nombre, que es peor que no tenerlo.
908
+ */
909
+ profileView () {
910
+ // EL PERFIL TAL COMO LO TIENE LA BÓVEDA, para enseñárselo al dueño (`dotrino-vault me`,
911
+ // la TUI). Sale del cajón `@me`, que es donde escribe el aparato dato a dato. Lo
912
+ // público va en claro; de lo privado solo el NOMBRE del dato: viaja sellado y la
913
+ // bóveda no lo abre para enseñarlo (§8.1), igual que una variable privada.
914
+ const vars = data.ns[PROFILE_NS]?.vars || {}
915
+ const pub = {}
916
+ const priv = []
917
+ let at = 0
918
+ for (const [key, e] of Object.entries(vars)) {
919
+ if (!e) continue
920
+ at = Math.max(at, e.at || 0)
921
+ if (e.cls === 'public' && typeof e.pubv === 'string') pub[key] = e.pubv
922
+ else priv.push(key)
923
+ }
924
+ return { public: pub, private: priv.sort(), updatedAt: at || null }
925
+ },
926
+
927
+ profilePublic () {
928
+ const bag = data.ns[PROFILE_NS]
929
+ if (!bag?.vars) return {}
930
+ const out = {}
931
+ for (const [key, e] of Object.entries(bag.vars)) {
932
+ if (e?.cls !== 'public' || typeof e.pubv !== 'string') continue
933
+ out[key] = { v: e.pubv, gen: e.gen, seal: e.seal || null, at: e.at || null, by: e.by || null }
934
+ }
935
+ return out
936
+ },
937
+
938
+ /**
939
+ * Guarda un dato PÚBLICO del perfil: en claro y con su firma. No lleva sobre ni
940
+ * envoltura porque no se le sella a nadie.
941
+ *
942
+ * `pubv` y no `v`: un `v` suelto dentro de un paquete de variables es un error duro
943
+ * desde el 2026-09-02 (`plaintext-var`), y con razón. Aquí es lo correcto, así que se
944
+ * escribe con otro nombre — el paquete del perfil lo arma otro código y esa invariante
945
+ * de los secretos se queda intacta.
946
+ */
947
+ async profilePutPublic (key, value, { by = null } = {}) {
948
+ return enFila(async () => {
949
+ if (isLegacy()) throw new Error('profile: this store is still v3 (run `migrate` first)')
950
+ if (needsMigration()) throw new NeedsMigration()
951
+ assertProfileKey(key)
952
+ if (typeof value !== 'string' || value.length > 200000) {
953
+ throw new Error('a public profile field must be a string of at most 200 kB')
954
+ }
955
+ const vars = ensureBag(data.ns, PROFILE_NS)
956
+ const before = vars[key]
957
+ const gen = nextGen(data.ns, PROFILE_NS, PROFILE_OWNER)
958
+ const seal = signer ? await signer({ owner: PROFILE_OWNER, key, gen, iv: '', ct: value }) : null
959
+ pushHistory(PROFILE_OWNER, key, before, by)
960
+ vars[key] = { cls: 'public', pub: true, owner: PROFILE_OWNER, gen, pubv: value, seal, at: Date.now(), by: by || null }
961
+ save()
962
+ return { gen }
963
+ })
964
+ },
965
+
966
+ /** @private */
967
+ /**
968
+ * @param {Array|((isPublic:boolean)=>Promise<Array>)} members A quién envolver. Puede ser
969
+ * una FUNCIÓN de la visibilidad: desde 2026-09-02 una pública se envuelve además para
970
+ * quien administra, y eso se decide por variable — cada escritura estrena su CEK, así
971
+ * que el llavero tiene una generación por valor. Con una lista fija se aplicaría la
972
+ * misma a todas y se le daría a la consola la envoltura de una privada con dueño.
973
+ */
974
+ async _rewrap (owner, members, adminKey = null, { exact = false } = {}) {
975
+ needSealer('re-wrap the key of a drawer')
976
+ if (needsMigration()) throw new NeedsMigration()
977
+ const [kind, k] = splitOwner(owner)
978
+ const bag = kind === 'ns' ? data.ns : data.dev
979
+ if (!bag[k]) return { wrapped: 0, sinLlave: [] }
980
+ const priv = await openRecovery(adminKey)
981
+ let wrapped = 0
982
+ let sealed = 0
983
+ const sinLlave = new Set()
984
+ const vars = varsOf(bag, k)
985
+ const paraVisibilidad = async (esPublica) => (typeof members === 'function' ? members(esPublica) : members)
986
+
987
+ // UN VALOR QUE SE QUEDÓ EN CLARO SE SELLA AQUÍ, que es el único sitio donde se puede.
988
+ //
989
+ // Una pública escrita antes del 2026-09-02 se guardó legible y SIN sobre, así que no
990
+ // tiene generación ni entrada en el llavero. Y el llavero es lo único que este
991
+ // método reenvuelve: por muchas veces que se abra la bóveda, a esa variable no se le
992
+ // crea una envoltura nunca. Sus aparatos la reclaman para siempre («no key yet») y la
993
+ // consola invita a abrir la bóveda, que es justo lo que no lo arregla. Peor: desde
994
+ // 2026-09-02 el agente rechaza el cajón ENTERO por ella, así que una variable de
995
+ // prueba deja sin leer el token que está al lado.
996
+ //
997
+ // Con la bóveda abierta el valor está delante, así que se cifra como si se acabara
998
+ // de escribir. No es un repliegue ni una migración a medias: es la conversión que
999
+ // faltaba, y ocurre en el único momento en que hay con qué hacerla.
1000
+ for (const [key, e] of Object.entries(vars)) {
1001
+ if (typeof e.v !== 'string' || e.e) continue
1002
+ const cek = await sealer.newKey()
1003
+ const gen = nextGen(bag, k, owner)
1004
+ const r = await sealer.wrapFor(cek, await paraVisibilidad(!!e.pub))
1005
+ r.wraps[RECOVERY] = await sealer.wrapForKey(cek, data.recovery.pub)
1006
+ const sobre = await sealer.encrypt(cek, e.v, gen)
1007
+ const seal = signer ? await signer({ owner, key, gen, iv: sobre.iv, ct: sobre.ct }) : null
1008
+ // `pub` y `at` se conservan: no es un valor nuevo, es el mismo mejor guardado.
1009
+ vars[key] = { pub: !!e.pub, owner, gen, e: sobre, seal, at: e.at || Date.now(), by: e.by || null }
1010
+ bag[k].keyring = [...(bag[k].keyring || []), { gen, createdAt: Date.now(), wraps: r.wraps }]
1011
+ for (const x of r.sinLlave) sinLlave.add(x)
1012
+ sealed++
1013
+ }
1014
+
1015
+ // De qué variable es cada generación: es lo que dice si esa CEK es de una pública.
1016
+ // Se calcula DESPUÉS de sellar, para que las generaciones recién creadas entren.
1017
+ const visibilidad = new Map()
1018
+ for (const e of Object.values(vars)) if (e.gen != null) visibilidad.set(e.gen, !!e.pub)
1019
+ const paraGen = async (gen) => paraVisibilidad(visibilidad.get(gen) === true)
1020
+ for (const g of bag[k].keyring || []) {
1021
+ const w = g.wraps?.[RECOVERY]
1022
+ if (!w) continue
1023
+ const cek = await sealer.openWrapWith(priv, w)
1024
+ const r = await sealer.wrapFor(cek, await paraGen(g.gen))
1025
+ // `exact`: el llavero queda con lo que dice el acta y NADA más. Es lo que hace
1026
+ // falta para rehacerlo al abrir la bóveda:
1027
+ // · una envoltura basura que metió alguien se reemplaza por la buena;
1028
+ // · una que sobra —de quien administraba antes de que este cajón tuviera
1029
+ // dueño, o de un miembro inventado— se cae.
1030
+ // Sin `exact` se fusiona, que es lo correcto cuando solo se está repartiendo a
1031
+ // unos pocos y no se quiere tocar al resto.
1032
+ //
1033
+ // OJO con lo que esto NO es: quitarle la envoltura a alguien no le quita lo que
1034
+ // ya leyó ni la llave que se haya guardado. Cortar de verdad es `rotate`.
1035
+ g.wraps = exact ? { ...r.wraps, [RECOVERY]: w } : { ...g.wraps, ...r.wraps }
1036
+ for (const s of r.sinLlave) sinLlave.add(s)
1037
+ wrapped += Object.keys(r.wraps).length
1038
+ }
1039
+ save()
1040
+ return { wrapped, sealed, sinLlave: [...sinLlave] }
1041
+ },
1042
+
1043
+ /**
1044
+ * RENOMBRAR UNA VARIABLE. Cambia el nombre y NADA MÁS: el mismo sobre, la misma
1045
+ * generación, las mismas envolturas. El valor no se toca ni se mira.
1046
+ *
1047
+ * Lo único que hay que rehacer es la FIRMA, porque el nombre va dentro de lo firmado
1048
+ * (§8.8: `{owner, key, gen, iv, ct}`). Sin eso el sobre quedaría diciendo que se llama
1049
+ * de otra manera y el agente lo rechazaría al abrirlo — un fallo que aparecería lejos
1050
+ * de aquí y sin pista.
1051
+ *
1052
+ * Y firmar de nuevo NO pide la frase: la llave de sellado existe justo para esto, no
1053
+ * abre nada. Así que renombrar funciona con la bóveda cerrada, como escribir.
1054
+ *
1055
+ * El HISTÓRICO se muda con ella: es la misma variable, y dejarlo bajo el nombre viejo
1056
+ * lo volvería inalcanzable — se vería como un hueco, no como una decisión.
1057
+ */
1058
+ async rename (owner, from, to) { return enFila(() => this._renameRaw(owner, from, to)) },
1059
+ /** @private */
1060
+ async _renameRaw (owner, from, to) {
1061
+ if (needsMigration()) throw new NeedsMigration()
1062
+ assertKeyValue(to, 'x')
1063
+ const [kind, k] = splitOwner(owner)
1064
+ const bag = kind === 'ns' ? data.ns : data.dev
1065
+ const vars = varsOf(bag, k)
1066
+ const e = vars[from]
1067
+ if (!e) throw new Error(`there is no variable called "${from}" in ${owner}`)
1068
+ if (from === to) return { renamed: false }
1069
+ if (vars[to]) throw new Error(`"${to}" already exists in ${owner}: pick another name or remove it first`)
1070
+
1071
+ // La firma lleva el nombre dentro, así que se rehace. Si esta bóveda no puede firmar,
1072
+ // se para: dejar el sobre con la firma vieja es dejarlo roto para quien lo abra.
1073
+ if (e.e) {
1074
+ if (!signer) throw new Error('this vault cannot sign right now: renaming would leave the envelope signed with the old name')
1075
+ e.seal = await signer({ owner, key: to, gen: e.gen, iv: e.e.iv, ct: e.e.ct })
1076
+ }
1077
+ delete vars[from]
1078
+ vars[to] = e
1079
+ for (const h of data.history) if (h.owner === owner && h.key === from) h.key = to
1080
+ save()
1081
+ return { renamed: true }
1082
+ },
1083
+
1084
+ /**
1085
+ * ROTA de verdad: vuelve a cifrar las variables privadas del cajón con llaves nuevas
1086
+ * y solo para los miembros dados. Es lo que corta el acceso de quien salió —
1087
+ * quitarle la envoltura no basta, porque si guardó la llave sigue abriendo lo que ya
1088
+ * estaba cifrado con ella.
1089
+ *
1090
+ * No devuelve lo que el expulsado ya leyó. Eso no se puede deshacer y no se promete.
1091
+ */
1092
+ async rotate (...a) { return enFila(() => this._rotate(...a)) },
1093
+ /** @private */
1094
+ async _rotate (owner, members, adminKey = null) {
1095
+ needSealer('rotate the key of a drawer')
1096
+ if (needsMigration()) throw new NeedsMigration()
1097
+ const [kind, k] = splitOwner(owner)
1098
+ const bag = kind === 'ns' ? data.ns : data.dev
1099
+ if (!bag[k]) return { rotated: 0, sinLlave: [] }
1100
+ const priv = await openRecovery(adminKey)
1101
+
1102
+ const vars = bag[k].vars || {}
1103
+ const claras = {}
1104
+ for (const [key, e] of Object.entries(vars)) {
1105
+ if (!e.pub) claras[key] = await sealer.openValue(await this._cekOf(bag, k, e.gen, priv), e.e)
1106
+ }
1107
+ // Se tira el llavero entero: las generaciones viejas son justamente lo que el que
1108
+ // se fue podría abrir. Con ellas se va el histórico de este cajón, que estaba
1109
+ // cifrado con ellas — rotar es renunciar a poder revertir lo de antes.
1110
+ bag[k].keyring = []
1111
+ data.history = data.history.filter((h) => h.owner !== owner)
1112
+ const sinLlave = new Set()
1113
+ let gen = 0
1114
+ for (const [key, value] of Object.entries(claras)) {
1115
+ const cek = await sealer.newKey()
1116
+ gen += 1
1117
+ const { wraps, sinLlave: faltan } = await sealer.wrapFor(cek, members)
1118
+ wraps[RECOVERY] = await sealer.wrapForKey(cek, data.recovery.pub)
1119
+ for (const s of faltan) sinLlave.add(s)
1120
+ const e = await sealer.encrypt(cek, value, gen)
1121
+ const seal = signer ? await signer({ owner, key, gen, iv: e.iv, ct: e.ct }) : null
1122
+ vars[key] = { pub: false, owner, gen, e, seal, at: Date.now(), by: null }
1123
+ bag[k].keyring.push({ gen, createdAt: Date.now(), wraps })
1124
+ }
1125
+ save()
1126
+ return { rotated: Object.keys(claras).length, sinLlave: [...sinLlave], gen, cambio: true }
1127
+ },
1128
+
1129
+ /**
1130
+ * Se va un aparato, se van sus variables. Lo llama el vault al quitar un
1131
+ * miembro: dejarlas sería guardar la configuración de una llave que ya no
1132
+ * entra, y reaparecería sola si mañana se enrola otro aparato con esa llave.
1133
+ *
1134
+ * NO exige la frase: es la mitad del interruptor de emergencia. Su cajón `dev`
1135
+ * estaba sellado solo a él, así que borrarlo es inmediato y completo. Lo que sí queda
1136
+ * pendiente es rotar los `ns` que compartía (ver `rotate`).
1137
+ */
1138
+ forgetDevice (pub) {
1139
+ assertPub(pub)
1140
+ const keys = Object.keys(varsOf(data.dev, pub))
1141
+ if (keys.length || data.dev[pub]) { delete data.dev[pub]; save() }
1142
+ data.history = data.history.filter((h) => h.owner !== `dev:${pub}`)
1143
+ return keys.length
1144
+ },
1145
+
1146
+ /**
1147
+ * Quita a un miembro del llavero de un cajón, sin abrir nada. Es lo que se puede
1148
+ * hacer SIN la frase cuando alguien sale: deja de poder abrir lo que se guarde en
1149
+ * adelante y lo que aún no había abierto. Lo que ya guardó no se arregla así — para
1150
+ * eso está `rotate`, que sí pide la frase.
1151
+ */
1152
+ unwrap (owner, pub) {
1153
+ if (isLegacy() || needsMigration()) return 0
1154
+ const [kind, k] = splitOwner(owner)
1155
+ const bag = kind === 'ns' ? data.ns : data.dev
1156
+ if (!bag[k]) return 0
1157
+ let n = 0
1158
+ for (const g of bag[k].keyring || []) {
1159
+ if (g.wraps?.[pub]) { delete g.wraps[pub]; n++ }
1160
+ }
1161
+ if (n) save()
1162
+ return n
1163
+ },
1164
+
1165
+ /**
1166
+ * Vuelve a cerrar la privada de RECUPERACIÓN con otra llave. Es lo que hay que hacer
1167
+ * al poner, cambiar o quitar la contraseña del perfil: los sobres de las variables no
1168
+ * se tocan —siguen sellados a cada destinatario—, lo único que cambia es con qué se
1169
+ * abre la copia del dueño.
1170
+ *
1171
+ * Sin esto, cambiar la contraseña dejaría los secretos ILEGIBLES para él: la copia
1172
+ * seguiría sellada con la llave vieja y ya nadie tendría cómo abrirla. Es barato (un
1173
+ * solo sobre) y es obligatorio.
1174
+ *
1175
+ * `null` en cualquiera de las dos significa «la del perfil sin contraseña».
1176
+ */
1177
+ async rekeyRecovery (...a) { return enFila(() => this._rekeyRecovery(...a)) },
1178
+ /**
1179
+ * ¿ESTA llave abre la copia de recuperación? Lanza si no.
1180
+ *
1181
+ * Sirve para saber con cuál está cerrada sin tocar nada — que es lo que hace falta para
1182
+ * decidir si hay que migrarla. Preguntarlo intentando reenvolver un cajón mezcla dos
1183
+ * cosas y deja el fallo diciendo «wrong password» sobre el cajón, que no es donde está.
1184
+ */
1185
+ /**
1186
+ * ABRE UNA ENVOLTURA DE RECUPERACIÓN y devuelve lo que lleva dentro.
1187
+ *
1188
+ * Es para OTROS almacenes de este mismo perfil —hoy la bóveda de contraseñas—, que
1189
+ * comparten ESTA copia de recuperación en vez de estrenar una segunda. Una segunda
1190
+ * sería otra llave que custodiar, otra que rotar y otra que se queda atrás.
1191
+ *
1192
+ * Sigue siendo la única puerta a un valor en claro, y sigue pidiendo la frase.
1193
+ */
1194
+ async openRecoveryWrap (wrap, adminKey = null) {
1195
+ const priv = await openRecovery(adminKey)
1196
+ return sealer.openWrapWith(priv, wrap)
1197
+ },
1198
+
1199
+ async recoveryOpensWith (key) {
1200
+ needSealer('check the recovery key')
1201
+ if (!data.recovery?.priv) throw new NeedsPassword('this store has no recovery key yet')
1202
+ await sealer.openMaster(data.recovery.priv, keyOr(key))
1203
+ return true
1204
+ },
1205
+ /** @private */
1206
+ async _rekeyRecovery (oldKey, newKey) {
1207
+ if (isLegacy()) return { rekeyed: false, reason: 'v3' }
1208
+ if (!data.recovery?.priv) return { rekeyed: false, reason: 'no-recovery' }
1209
+ needSealer('change the profile password')
1210
+ const priv = await sealer.openMaster(data.recovery.priv, keyOr(oldKey))
1211
+ data.recovery.priv = await sealer.sealMaster(priv, keyOr(newKey))
1212
+ save()
1213
+ return { rekeyed: true }
1214
+ },
1215
+
1216
+ // --- conversión a v5 ---------------------------------------------------------
1217
+ /**
1218
+ * Lleva el archivo a v5, venga de donde venga:
1219
+ *
1220
+ * · **desde v3** (valores en claro): no hace falta la frase para nada. Se sella
1221
+ * cada valor a sus destinatarios y se estrena el par de recuperación.
1222
+ * · **desde v4** (sellado con copia maestra): la frase hace falta UNA vez, para
1223
+ * abrir esa copia. A partir de ahí, escribir no la pide nunca más.
1224
+ *
1225
+ * Verificar antes de reemplazar, igual que `migrateFile()` de `atrest.js`: se
1226
+ * construye la forma nueva, se vuelve a abrir **valor por valor** y se compara con
1227
+ * el original, y solo entonces se escribe. Si algo no cuadra no se toca nada y se
1228
+ * lanza: media migración es peor que ninguna.
1229
+ */
1230
+ async migrate (...a) { return enFila(() => this._migrate(...a)) },
1231
+ /** @private */
1232
+ async _migrate (membersOf, adminKey = null) {
1233
+ if (data.schemaVersion === SCHEMA_VERSION) return { migrated: false, reason: 'already-v5' }
1234
+ needSealer('seal the store')
1235
+ const desde = data.schemaVersion
1236
+
1237
+ // De v4: las CEK viejas viven en la copia maestra, y para abrirla hace falta la
1238
+ // frase. Es la única vez que se pide.
1239
+ const master = desde === SEALED_MASTER_VERSION ? await sealer.openMaster(data.master, keyOr(adminKey)) : null
1240
+ const claro = async (owner, e) => {
1241
+ if (e.pub) return e.v
1242
+ if (desde === LEGACY_VERSION) return e.v
1243
+ return sealer.decrypt(master, e.e, e.owner || owner)
1244
+ }
1245
+
1246
+ const pair = await sealer.makeRecoveryPair()
1247
+ const next = {
1248
+ schemaVersion: SCHEMA_VERSION,
1249
+ ns: {},
1250
+ dev: {},
1251
+ recovery: { pub: pair.pub, priv: await sealer.sealMaster(pair.priv, keyOr(adminKey)) },
1252
+ // El histórico empieza aquí: de lo de antes no se guardó ninguna versión previa.
1253
+ history: []
1254
+ }
1255
+ const antes = {}
1256
+ const sinLlave = {}
1257
+
1258
+ for (const [kind, src, dst] of [['ns', data.ns, next.ns], ['dev', data.dev, next.dev]]) {
1259
+ for (const [k, bag] of Object.entries(src)) {
1260
+ const owner = `${kind}:${k}`
1261
+ const entradas = desde === LEGACY_VERSION ? (bag || {}) : (bag?.vars || {})
1262
+ const vars = {}
1263
+ const keyring = []
1264
+ // UNA SOLA generación para todo el cajón, y no es una excepción a «una por
1265
+ // escritura»: convertir es UN acto, y aquí la bóveda tiene delante todos los
1266
+ // valores en claro, así que puede sellarlos con la misma llave sin tener que
1267
+ // recuperar nada. Además deja el cajón como lo espera un agente que todavía no
1268
+ // se ha actualizado —una envoltura por cajón—, y eso es lo que permite convertir
1269
+ // sin apagar a nadie. Lo que se escriba DESPUÉS ya estrena generación.
1270
+ const gen = 1
1271
+ let cek = null
1272
+ for (const [key, e] of Object.entries(entradas)) {
1273
+ const value = await claro(owner, e)
1274
+ antes[`${owner}\u0000${key}`] = value
1275
+ // LA PÚBLICA TAMBIÉN SE SELLA (dueño, 2026-09-02). Aquí se dejaba en claro,
1276
+ // así que un archivo migrado se quedaba con valores legibles en el disco —
1277
+ // exactamente lo que la conversión venía a quitar. La marca se conserva: dice
1278
+ // a quién se le despacha sin aprobación, no cómo se guarda.
1279
+ if (!cek) {
1280
+ cek = await sealer.newKey()
1281
+ const { wraps, sinLlave: faltan } = await sealer.wrapFor(cek, (await membersOf(owner)) || [])
1282
+ wraps[RECOVERY] = await sealer.wrapForKey(cek, next.recovery.pub)
1283
+ if (faltan.length) sinLlave[owner] = faltan
1284
+ keyring.push({ gen, createdAt: Date.now(), wraps })
1285
+ }
1286
+ const sobre = await sealer.encrypt(cek, value, gen)
1287
+ const seal = signer ? await signer({ owner, key, gen, iv: sobre.iv, ct: sobre.ct }) : null
1288
+ vars[key] = { pub: !!e.pub, owner, gen, e: sobre, seal, at: Date.now(), by: null }
1289
+ }
1290
+ dst[k] = { vars, keyring }
1291
+ }
1292
+ }
1293
+
1294
+ // Releer lo escrito y comparar contra el original, antes de reemplazar nada.
1295
+ const priv = await sealer.openMaster(next.recovery.priv, keyOr(adminKey))
1296
+ for (const [kind, dst] of [['ns', next.ns], ['dev', next.dev]]) {
1297
+ for (const [k, bag] of Object.entries(dst)) {
1298
+ const owner = `${kind}:${k}`
1299
+ for (const [key, e] of Object.entries(bag.vars)) {
1300
+ // Se abre TODO, también las públicas: ahora van selladas igual, así que la
1301
+ // comprobación de que la migración no cambió ningún valor vale para las dos.
1302
+ const g = bag.keyring.find((x) => x.gen === e.gen)
1303
+ const abierto = await sealer.openValue(await sealer.openWrapWith(priv, g.wraps[RECOVERY]), e.e)
1304
+ if (abierto !== antes[`${owner}\u0000${key}`]) {
1305
+ throw new Error(`secrets: the migration check failed on ${owner}/${key}; nothing was touched`)
1306
+ }
1307
+ }
1308
+ }
1309
+ }
1310
+
1311
+ // Copia de lo anterior antes de pisarlo. Con nodos en producción, deshacer tiene
1312
+ // que ser un `mv`, no una restauración. La borra el operador a mano.
1313
+ writeJson(disk, `${file}.v${desde}.bak`, data)
1314
+ data = next
1315
+ flush()
1316
+ return { migrated: true, from: desde, sinLlave }
1317
+ }
1318
+ }
1319
+ }
1320
+
1321
+ /** `ns:proxy` → `['ns','proxy']`. El `dev:` lleva dentro un JWK con dos puntos. */
1322
+ function splitOwner (owner) {
1323
+ const i = String(owner).indexOf(':')
1324
+ if (i < 0) throw new Error('invalid drawer: expected "ns:<name>" or "dev:<pubkey>"')
1325
+ return [owner.slice(0, i), owner.slice(i + 1)]
1326
+ }