@dotrino/identity 0.114.1 → 0.116.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/package.json +7 -1
  2. package/src/index.js +9 -1
  3. package/src/node.js +4 -180
  4. package/vault/acta.js +5 -1
  5. package/vault/core.js +95 -15
  6. package/vault/facade.js +200 -0
  7. package/vault/index.html +10 -0
  8. package/vault/machine.js +95 -0
  9. package/vault/remote.js +54 -9
  10. package/vault/vault.js +29 -58
  11. package/vault/vendor/compat/VERSION.txt +3 -0
  12. package/vault/vendor/compat/advisory.js +141 -0
  13. package/vault/vendor/compat/index.js +188 -0
  14. package/vault/vendor/compat/ranges.js +93 -0
  15. package/vault/vendor/opaque/VERSION.txt +1 -1
  16. package/vault/vendor/passmanager/VERSION.txt +3 -0
  17. package/vault/vendor/passmanager/crypto.js +167 -0
  18. package/vault/vendor/passmanager/fields.js +183 -0
  19. package/vault/vendor/passmanager/generate.js +74 -0
  20. package/vault/vendor/passmanager/import.js +156 -0
  21. package/vault/vendor/passmanager/index.js +27 -0
  22. package/vault/vendor/passmanager/match.js +137 -0
  23. package/vault/vendor/passmanager/model.js +332 -0
  24. package/vault/vendor/passmanager/pubkey.js +33 -0
  25. package/vault/vendor/passmanager/sealed/convert.js +72 -0
  26. package/vault/vendor/passmanager/sealed/device.js +211 -0
  27. package/vault/vendor/passmanager/sealed/entry.js +209 -0
  28. package/vault/vendor/passmanager/sealed/index.js +20 -0
  29. package/vault/vendor/passmanager/sealed/keys.js +120 -0
  30. package/vault/vendor/passmanager/sealed/recovery.js +136 -0
  31. package/vault/vendor/passmanager/sealed/store.js +508 -0
  32. package/vault/vendor/passmanager/session-cache.js +60 -0
  33. package/vault/vendor/passmanager/totp.js +111 -0
  34. package/vault/vendor/passmanager/transport/protocol.js +83 -0
  35. package/vault/vendor/passmanager/transport/proxy.js +112 -0
  36. package/vault/vendor/passmanager/transport/sealed.js +74 -0
  37. package/vault/vendor/passmanager/vault/approval.js +63 -0
  38. package/vault/vendor/passmanager/vault/errors.js +34 -0
  39. package/vault/vendor/passmanager/vault/guard.js +114 -0
  40. package/vault/vendor/passmanager/vault/interface.js +34 -0
  41. package/vault/vendor/passmanager/vault/local.js +213 -0
  42. package/vault/vendor/passmanager/vault/remote.js +85 -0
  43. package/vault/vendor/passmanager/vault/responder.js +192 -0
  44. package/vault/vendor/passmanager/vault/sealed-local.js +199 -0
  45. package/vault/vendor/passmanager/vault/sealed-responder.js +159 -0
  46. package/vault/vendor/passmanager/vault/sealed.js +362 -0
  47. package/vault/vendor/passmanager/webauthn.js +221 -0
  48. package/vault/vendor/proxy-client/VERSION.txt +1 -1
  49. package/vault/vendor/proxy-client/client.js +36 -0
  50. package/vault/vendor/roadmap/VERSION.txt +3 -0
  51. package/vault/vendor/roadmap/index.js +108 -0
  52. package/vault/vendor/roadmap/manifests.js +797 -0
  53. package/vault/vendor/store/VERSION.txt +3 -0
  54. package/vault/vendor/store/core.js +309 -0
  55. package/vault/vendor/vault/VERSION.txt +4 -1
  56. package/vault/vendor/vault/admin.js +437 -0
  57. package/vault/vendor/vault/browser.js +167 -0
  58. package/vault/vendor/vault/core/approvals.js +204 -0
  59. package/vault/vendor/vault/core/blocked.js +68 -0
  60. package/vault/vendor/vault/core/browserDisk.js +109 -0
  61. package/vault/vendor/vault/core/commKey.js +68 -0
  62. package/vault/vendor/vault/core/disk.js +65 -0
  63. package/vault/vendor/vault/core/libVersion.js +8 -0
  64. package/vault/vendor/vault/core/machines.js +66 -0
  65. package/vault/vendor/vault/core/notices.js +71 -0
  66. package/vault/vendor/vault/core/notifiable.js +73 -0
  67. package/vault/vendor/vault/core/passwords.js +125 -0
  68. package/vault/vendor/vault/core/sealKey.js +77 -0
  69. package/vault/vendor/vault/core/sealer.js +172 -0
  70. package/vault/vendor/vault/core/sealers.js +159 -0
  71. package/vault/vendor/vault/core/secretsStore.js +1326 -0
  72. package/vault/vendor/vault/core/sha256.js +69 -0
  73. package/vault/vendor/vault/core/store.js +68 -0
  74. package/vault/vendor/vault/core/subacta.js +101 -0
  75. package/vault/vendor/vault/core/threadStore.js +273 -0
  76. package/vault/vendor/vault/core/transport.js +270 -0
  77. package/vault/vendor/vault/core/vault.js +4414 -0
  78. package/vault/vendor/vault/enroll.js +8 -4
  79. package/vault/vendor/vault/procContext.js +97 -0
  80. package/vault/vendor/vault/revocation.js +67 -0
  81. package/vault/vendor/vault/sealed.js +84 -0
@@ -0,0 +1,508 @@
1
+ // LA BÓVEDA DE CONTRASEÑAS QUE NO PUEDE LEERLAS (`sealed-passwords.md`).
2
+ //
3
+ // Guarda sobres dirigidos a los aparatos y las envolturas de sus llaves, y **no tiene
4
+ // ninguna llave propia con la que abrir nada** — ni abierta ni cerrada. Es el mismo modelo
5
+ // que los cajones de secretos del vault (`dotrino-vault/src/secretsStore.js`), y se parece
6
+ // a propósito: quien lea uno entiende el otro.
7
+ //
8
+ // Lo que sí hace, porque no necesita la llave para hacerlo:
9
+ //
10
+ // · **comprobar los destinatarios**. Es LA comprobación que sostiene el modelo: un
11
+ // aparato no puede guardar una entrada que solo él pueda abrir, ni dejar fuera la copia
12
+ // de recuperación, ni meter la privada de una passkey en el sobre de las contraseñas;
13
+ // · **filtrar por destinatario** al entregar: a cada aparato, lo suyo;
14
+ // · **numerar las generaciones** y barrer las que ya no usa nadie.
15
+ //
16
+ // El almacén se inyecta (`{ get(k), set(k, v) }`), como en `../vault/local.js`: en el
17
+ // demonio es un archivo, en la pestaña IndexedDB, en las pruebas un Map. Una sola pieza
18
+ // para las tres bóvedas y el `serve`, que es lo que la regla de las tres versiones exige.
19
+
20
+ import { wrapForMember } from '@dotrino/identity/content'
21
+ import { PASSKEY_FIELD } from './entry.js'
22
+ import { entryAuthorBody, patchAuthorBody } from './device.js'
23
+
24
+ export const KEY = 'passmanager/sealed/v2'
25
+ /** La envoltura que abre la frase del perfil. Sin ella, una entrada nace ilegible. */
26
+ export const RECOVERY = '#recovery'
27
+
28
+ const isEnv = (e) => !!e && typeof e.iv === 'string' && typeof e.ct === 'string'
29
+ const sorted = (a) => [...a].sort()
30
+ const same = (a, b) => a.length === b.length && sorted(a).every((x, i) => x === sorted(b)[i])
31
+
32
+ export class SealedError extends Error {
33
+ constructor (code, message) { super(message); this.code = code; this.name = 'SealedError' }
34
+ }
35
+
36
+ export class SealedStore {
37
+ /**
38
+ * @param {object} store `{ get(k), set(k, v) }`
39
+ * @param {object} opts
40
+ * `recipients(kind)` → `[{ pub, encPub }]` quién DEBE tener envoltura. `kind` es
41
+ * `'main'` (los aparatos con `passwords`) o `'passkeys'` (los que además tienen
42
+ * `passkeys`). Lo pone quien conoce el acta: esta pieza no la lee.
43
+ * `verifyAuthor({ body, author })` → ¿la firmó quien dice, y puede escribir? Lo
44
+ * contesta quien tiene el acta. **No es opcional**: sin ella, cualquiera que llegue
45
+ * al almacén podría dejar una entrada a nombre de otro, y la bóveda no puede mirar
46
+ * dentro para notarlo.
47
+ * `recovery()` → ¿la cuenta YA tiene copia de recuperación? Si la tiene, toda envoltura
48
+ * lleva la suya (`#recovery`). Si todavía no, la bóveda funciona igual, sellada solo a
49
+ * los aparatos del acta, y la copia se añade después con `addRecovery`. Por defecto
50
+ * contesta que sí: lo estricto es el valor por omisión, no el permisivo.
51
+ * `now()` el reloj, para poder probar sin esperar.
52
+ */
53
+ constructor (store, { recipients, verifyAuthor, recovery = async () => true, now = () => Date.now() } = {}) {
54
+ if (!store) throw new Error('SealedStore: missing store')
55
+ if (typeof recipients !== 'function') throw new Error('SealedStore: missing recipients(kind)')
56
+ if (typeof verifyAuthor !== 'function') throw new Error('SealedStore: missing verifyAuthor({ body, author })')
57
+ this.store = store
58
+ this.recipients = recipients
59
+ this.verifyAuthor = verifyAuthor
60
+ this.recovery = recovery
61
+ this.now = now
62
+ }
63
+
64
+ async #state () {
65
+ const s = await this.store.get(KEY)
66
+ if (s && typeof s === 'object') return s
67
+ return { v: 2, keyring: [], profile: null, entries: [] }
68
+ }
69
+
70
+ async #save (s) { await this.store.set(KEY, s) }
71
+
72
+ /** ¿Hay algo guardado en el formato sellado? Lo pregunta quien tiene que convertir. */
73
+ async sealed () {
74
+ const s = await this.#state()
75
+ return !!s.profile
76
+ }
77
+
78
+ #gen (s, gen) { return (s.keyring || []).find((g) => g.gen === gen) || null }
79
+
80
+ /** La envoltura de ESTE aparato para esa generación, o `null` si no le tocaba. */
81
+ #wrapFor (s, gen, pub) { return this.#gen(s, gen)?.wraps?.[pub] || null }
82
+
83
+ /**
84
+ * ¿SON EXACTAMENTE LOS DESTINATARIOS QUE MANDA EL ACTA? Ni de más ni de menos, más la
85
+ * copia de recuperación.
86
+ *
87
+ * Ni de menos, porque un aparato podría guardar algo que los demás no pueden abrir —y la
88
+ * pérdida no se nota hasta que hace falta—. Ni de más, porque entonces cualquiera podría
89
+ * darle acceso a una llave que el acta no reconoce. Y la de recuperación siempre que la
90
+ * cuenta tenga copia: sin ella, ni el dueño con su frase puede volver a repartir esa
91
+ * entrada. Una cuenta que aún no la creó guarda igual, y la pantalla lo avisa.
92
+ */
93
+ async #checkWraps (kind, wraps) {
94
+ if (!wraps || typeof wraps !== 'object') throw new SealedError('wraps-missing', 'the envelope wraps are missing')
95
+ if (!wraps[RECOVERY] && await this.recovery()) {
96
+ throw new SealedError('recovery-missing',
97
+ 'the wraps must include the recovery one (#recovery), or this entry could never be handed out again')
98
+ }
99
+ const deben = (await this.recipients(kind)).filter((m) => m?.encPub).map((m) => m.pub)
100
+ // NADIE A QUIEN ENVOLVÉRSELA NO ES «CERO DESTINATARIOS», ES UN ERROR. Guardar una
101
+ // passkey que ningún aparato puede abrir la deja viva solo para la frase del perfil: no
102
+ // se pierde, pero no sirve, y el usuario se entera el día que la necesita. Se dice
103
+ // ahora, con un código que explica qué falta.
104
+ if (kind === 'passkeys' && !deben.length) {
105
+ throw new SealedError('no-passkeys-device',
106
+ 'no device in the account record can open passkeys: grant `passkeys` to one (`dotrino-vault caps <ID> +passkeys`) before saving it')
107
+ }
108
+ const hay = Object.keys(wraps).filter((k) => k !== RECOVERY)
109
+ if (!same(deben, hay)) {
110
+ throw new SealedError('wrong-recipients',
111
+ `the wraps do not match the account record: it expects [${sorted(deben).join(', ')}] and got [${sorted(hay).join(', ')}]`)
112
+ }
113
+ }
114
+
115
+ /** La forma de una entrada sellada, antes de tocar el disco. */
116
+ #checkEntry (entry, tienePasskeys) {
117
+ if (!entry || typeof entry.id !== 'string' || !entry.id) throw new SealedError('bad-entry', 'the entry has no id')
118
+ if (!isEnv(entry.view)) throw new SealedError('bad-entry', 'the entry has no sealed public view')
119
+ if (!entry.fields || typeof entry.fields !== 'object') throw new SealedError('bad-entry', 'the entry has no fields')
120
+ for (const [k, e] of Object.entries(entry.fields)) {
121
+ if (!isEnv(e)) throw new SealedError('bad-entry', `field «${k}» is not a sealed envelope`)
122
+ }
123
+ if (!Array.isArray(entry.idx)) throw new SealedError('bad-entry', 'the entry has no site index')
124
+ const llevaPasskey = !!entry.fields[PASSKEY_FIELD]
125
+ // Una passkey en el sobre de las contraseñas la abriría un aparato sin el permiso, que
126
+ // es exactamente lo que §2.8 separa. Y al revés: una generación de passkeys sin passkey
127
+ // reparte una llave que nadie va a usar.
128
+ if (llevaPasskey && !tienePasskeys) {
129
+ throw new SealedError('passkeys-generation-missing',
130
+ 'this entry carries a passkey: it needs its own wraps, only for devices with `passkeys`')
131
+ }
132
+ if (!llevaPasskey && tienePasskeys) {
133
+ throw new SealedError('passkeys-generation-unused', 'there is no passkey in this entry to wrap')
134
+ }
135
+ }
136
+
137
+ /**
138
+ * GUARDAR. Llega todo cerrado: la bóveda no ve ni un valor, tampoco al escribir.
139
+ *
140
+ * La GENERACIÓN la pone el almacén y no quien escribe —igual que en los cajones—: es lo
141
+ * que ordena el llavero, y dejar elegirla permitiría pisar una anterior.
142
+ *
143
+ * @param {object} o
144
+ * `entry` la entrada de `sealEntry`, con los sobres SIN `gen`
145
+ * `main` `{ wraps }` de la llave con la que se cerró todo menos la passkey
146
+ * `passkeys` `{ wraps }` de la llave de la passkey, si la entrada lleva una
147
+ * `by` el aparato que escribe (para la bitácora; quién PUEDE lo decide quien llama)
148
+ */
149
+ async putSealed ({ entry, main, passkeys = null, by = null } = {}) {
150
+ this.#checkEntry(entry, !!passkeys)
151
+ await this.#checkWraps('main', main?.wraps)
152
+ if (passkeys) await this.#checkWraps('passkeys', passkeys.wraps)
153
+ // LA FIRMA DEL AUTOR, sobre lo que de verdad se guarda. Es lo que ata cada entrada a un
154
+ // aparato del acta: la bóveda no ve los valores, así que esto es lo único que puede
155
+ // comprobar sobre su procedencia.
156
+ if (!entry.author?.pub || !entry.author?.sig) throw new SealedError('unsigned', 'the entry is not signed by its author')
157
+ const ok = await this.verifyAuthor({ body: entryAuthorBody(entry, entry.author.ts), author: entry.author })
158
+ if (!ok) throw new SealedError('bad-author', 'that entry was not signed by a device allowed to write passwords')
159
+
160
+ const s = await this.#state()
161
+ const t = this.now()
162
+ const siguiente = () => (s.keyring.reduce((max, g) => Math.max(max, g.gen || 0), 0) + 1)
163
+
164
+ const genMain = siguiente()
165
+ s.keyring.push({ gen: genMain, kind: 'main', createdAt: t, wraps: main.wraps })
166
+ let genPk = null
167
+ if (passkeys) {
168
+ genPk = siguiente()
169
+ s.keyring.push({ gen: genPk, kind: 'passkeys', createdAt: t, wraps: passkeys.wraps })
170
+ }
171
+
172
+ const fields = {}
173
+ for (const [k, e] of Object.entries(entry.fields)) {
174
+ fields[k] = { ...e, gen: k === PASSKEY_FIELD ? genPk : genMain }
175
+ }
176
+ const guardada = {
177
+ id: entry.id,
178
+ createdAt: entry.createdAt || t,
179
+ updatedAt: t,
180
+ by: by || entry.author.pub,
181
+ author: entry.author,
182
+ idx: [...entry.idx],
183
+ wa: entry.wa || null,
184
+ view: { ...entry.view, gen: genMain },
185
+ fields,
186
+ digests: { ...(entry.digests || {}) },
187
+ priv: [...(entry.priv || [])]
188
+ }
189
+ const i = s.entries.findIndex((e) => e.id === guardada.id)
190
+ if (i >= 0) { guardada.createdAt = s.entries[i].createdAt || guardada.createdAt; s.entries[i] = guardada }
191
+ else s.entries.push(guardada)
192
+
193
+ this.#gc(s)
194
+ await this.#save(s)
195
+ return { id: guardada.id, gen: genMain, updatedAt: guardada.updatedAt }
196
+ }
197
+
198
+ /**
199
+ * CAMBIAR UNOS CAMPOS SIN LEER LA ENTRADA (§2.4).
200
+ *
201
+ * Es la razón de que exista `patch` y no se haya caído con el formato nuevo: antes, para
202
+ * escribir un teléfono había que sacar la entrada ENTERA de la bóveda —contraseña
203
+ * incluida—, y si esa lectura fallaba, lo que se escribía detrás perdía lo que no se pudo
204
+ * leer. Perder datos por una autorización denegada no es un fallo raro.
205
+ *
206
+ * Aquí los campos que no vienen **se quedan con su sobre y su generación**: ni se abren
207
+ * ni se reescriben. Lo único que hay que rehacer siempre es la VISTA, porque dice qué
208
+ * campos lleva la entrada — y eso lo sabe quien la cambia, sin abrir ningún valor.
209
+ *
210
+ * @param {object} o
211
+ * `id` la entrada
212
+ * `view` la vista nueva (sobre), obligatoria: cambia con cualquier campo
213
+ * `fields` solo los campos que cambian `{ clave: sobre }`
214
+ * `drop` las claves que se van
215
+ * `digests` los resúmenes de lo que cambió (los de `drop` se quitan)
216
+ * `idx`/`wa` si cambian los sitios o la passkey
217
+ * `main` / `passkeys` `{ wraps }` de las llaves nuevas
218
+ */
219
+ async patchSealed ({ id, view, fields = {}, drop = [], digests = {}, priv = null, idx = null, wa, main, passkeys = null, author = null, by = null } = {}) {
220
+ if (!isEnv(view)) throw new SealedError('bad-entry', 'a patch has to bring the public view again')
221
+ for (const [k, e] of Object.entries(fields)) {
222
+ if (!isEnv(e)) throw new SealedError('bad-entry', `field «${k}» is not a sealed envelope`)
223
+ }
224
+ await this.#checkWraps('main', main?.wraps)
225
+ if (passkeys) await this.#checkWraps('passkeys', passkeys.wraps)
226
+ if (fields[PASSKEY_FIELD] && !passkeys) {
227
+ throw new SealedError('passkeys-generation-missing',
228
+ 'that patch carries a passkey: it needs its own wraps, only for devices with `passkeys`')
229
+ }
230
+
231
+ const s = await this.#state()
232
+ const e = s.entries.find((x) => x.id === id)
233
+ if (!e) throw new SealedError('not-found', 'there is no entry with that id')
234
+
235
+ if (!author?.pub || !author?.sig) throw new SealedError('unsigned', 'the patch is not signed by its author')
236
+ const body = patchAuthorBody({ id, view, fields, drop, digests, priv, idx, wa }, author.ts)
237
+ if (!(await this.verifyAuthor({ body, author }))) {
238
+ throw new SealedError('bad-author', 'that patch was not signed by a device allowed to write passwords')
239
+ }
240
+
241
+ const t = this.now()
242
+ const siguiente = () => (s.keyring.reduce((max, g) => Math.max(max, g.gen || 0), 0) + 1)
243
+ const genMain = siguiente()
244
+ s.keyring.push({ gen: genMain, kind: 'main', createdAt: t, wraps: main.wraps })
245
+ let genPk = null
246
+ if (passkeys) {
247
+ genPk = siguiente()
248
+ s.keyring.push({ gen: genPk, kind: 'passkeys', createdAt: t, wraps: passkeys.wraps })
249
+ }
250
+
251
+ for (const k of drop) { delete e.fields[k]; delete e.digests[k] }
252
+ for (const [k, env] of Object.entries(fields)) {
253
+ e.fields[k] = { ...env, gen: k === PASSKEY_FIELD ? genPk : genMain }
254
+ }
255
+ for (const [k, d] of Object.entries(digests)) e.digests[k] = d
256
+ e.view = { ...view, gen: genMain }
257
+ if (Array.isArray(priv)) e.priv = [...priv]
258
+ if (Array.isArray(idx)) e.idx = [...idx]
259
+ if (wa !== undefined) e.wa = wa
260
+ e.updatedAt = t
261
+ e.by = by || author.pub
262
+ e.author = author
263
+
264
+ this.#gc(s)
265
+ await this.#save(s)
266
+ return { id: e.id, gen: genMain, updatedAt: e.updatedAt }
267
+ }
268
+
269
+ /**
270
+ * LAS VISTAS que este aparato puede abrir. Es lo que sustituye a `list`, `search` y
271
+ * `sites`: la bóveda ya no puede buscar por texto —no ve ninguno— así que entrega las
272
+ * vistas selladas y busca quien las abre (§2.2).
273
+ *
274
+ * **Vistas, no valores**: de aquí no sale ninguna contraseña. Y solo las que este aparato
275
+ * puede abrir: a quien lleva una selección de entradas no se le dice ni que existen las
276
+ * demás (§2.6).
277
+ */
278
+ async views ({ pub } = {}) {
279
+ const s = await this.#state()
280
+ const out = []
281
+ for (const e of s.entries) {
282
+ const wrap = this.#wrapFor(s, e.view.gen, pub)
283
+ if (!wrap) continue
284
+ out.push({ id: e.id, updatedAt: e.updatedAt, idx: e.idx, wa: e.wa, view: e.view, wrap, digests: e.digests, author: e.author })
285
+ }
286
+ return out
287
+ }
288
+
289
+ /**
290
+ * QUÉ HAY PARA ESTE SITIO. `idx` son las huellas que calculó el aparato con la llave del
291
+ * perfil: la bóveda compara cadenas, sin saber de qué sitio se habla.
292
+ *
293
+ * `anySite` trae además las entradas SIN sitios, que son las que sirven en cualquier
294
+ * parte — es lo que ha significado siempre no tener sitios.
295
+ */
296
+ async find ({ pub, idx = [], anySite = true } = {}) {
297
+ const busca = new Set(idx)
298
+ return (await this.views({ pub })).filter((v) =>
299
+ v.idx.some((h) => busca.has(h)) || (anySite && v.idx.length === 0))
300
+ }
301
+
302
+ /** Lo mismo para una passkey: por el sitio que la pide o por el id de la credencial. */
303
+ async findPasskey ({ pub, rp = null, cred = null } = {}) {
304
+ return (await this.views({ pub })).filter((v) =>
305
+ v.wa && ((rp && v.wa.rp === rp) || (cred && v.wa.cred === cred)))
306
+ }
307
+
308
+ /**
309
+ * LOS SOBRES DE LOS CAMPOS PEDIDOS, con la envoltura de quien pregunta.
310
+ *
311
+ * Lo que este aparato no puede abrir no se manda y **se dice** (`withheld`): una passkey
312
+ * para un aparato sin el permiso se queda aquí, y la pantalla lo explica en vez de
313
+ * enseñar un hueco.
314
+ */
315
+ async get (id, { pub, keys = null } = {}) {
316
+ const s = await this.#state()
317
+ const e = s.entries.find((x) => x.id === id)
318
+ if (!e) throw new SealedError('not-found', 'there is no entry with that id')
319
+ const quiere = Array.isArray(keys) ? new Set(keys) : null
320
+ const envelopes = {}
321
+ const wraps = {}
322
+ const withheld = []
323
+ const vista = this.#wrapFor(s, e.view.gen, pub)
324
+ if (!vista) throw new SealedError('not-yours', 'this device has no key for that entry')
325
+ for (const [k, env] of Object.entries(e.fields)) {
326
+ if (quiere && !quiere.has(k)) continue
327
+ const w = this.#wrapFor(s, env.gen, pub)
328
+ if (!w) { withheld.push(k); continue }
329
+ envelopes[k] = env
330
+ wraps[env.gen] = w
331
+ }
332
+ wraps[e.view.gen] = vista
333
+ return { id: e.id, view: e.view, envelopes, wraps, withheld, updatedAt: e.updatedAt, digests: e.digests }
334
+ }
335
+
336
+ /**
337
+ * QUÉ CLAVES DE ESA ENTRADA SON PRIVADAS. Es lo único de la vista que la bóveda ve, y lo
338
+ * ve porque la aprobación es suya: sin esto no podría distinguir «rellena mi nombre» de
339
+ * «dame la contraseña» (ver la nota en `entry.js`).
340
+ */
341
+ async privateKeysOf (id) {
342
+ const s = await this.#state()
343
+ return s.entries.find((e) => e.id === id)?.priv || []
344
+ }
345
+
346
+ async remove (id) {
347
+ const s = await this.#state()
348
+ const antes = s.entries.length
349
+ s.entries = s.entries.filter((e) => e.id !== id)
350
+ if (s.entries.length === antes) return { ok: false }
351
+ this.#gc(s)
352
+ await this.#save(s)
353
+ return { ok: true }
354
+ }
355
+
356
+ /**
357
+ * LA LLAVE DEL PERFIL (de la que salen el índice y los resúmenes), sellada como un valor
358
+ * más. Se escribe una vez y se vuelve a repartir cuando cambia quién puede leer.
359
+ */
360
+ async profile ({ pub } = {}) {
361
+ const s = await this.#state()
362
+ if (!s.profile) throw new SealedError('not-sealed', 'this vault has no sealed password key yet: open it to convert')
363
+ const wrap = this.#wrapFor(s, s.profile.gen, pub)
364
+ if (!wrap) throw new SealedError('not-yours', 'this device has no key for the profile password key')
365
+ return { envelope: s.profile, wrap }
366
+ }
367
+
368
+ async setProfile ({ envelope, wraps } = {}) {
369
+ if (!isEnv(envelope)) throw new SealedError('bad-entry', 'the profile key is not a sealed envelope')
370
+ await this.#checkWraps('main', wraps)
371
+ const s = await this.#state()
372
+ const gen = s.keyring.reduce((max, g) => Math.max(max, g.gen || 0), 0) + 1
373
+ s.keyring.push({ gen, kind: 'main', createdAt: this.now(), wraps })
374
+ s.profile = { ...envelope, gen }
375
+ this.#gc(s)
376
+ await this.#save(s)
377
+ return { gen }
378
+ }
379
+
380
+ /**
381
+ * LAS GENERACIONES QUE YA NO ABRE NADA se van. Cada escritura estrena una, así que sin
382
+ * este barrido el llavero crece para siempre (§2.4).
383
+ */
384
+ #gc (s) {
385
+ const vivas = new Set()
386
+ if (s.profile?.gen) vivas.add(s.profile.gen)
387
+ for (const e of s.entries) {
388
+ vivas.add(e.view.gen)
389
+ for (const env of Object.values(e.fields)) vivas.add(env.gen)
390
+ }
391
+ s.keyring = s.keyring.filter((g) => vivas.has(g.gen))
392
+ }
393
+
394
+ /**
395
+ * PAGAR LA DEUDA: reenvolver cada generación para exactamente quien dice el acta AHORA.
396
+ *
397
+ * Lo hace quien puede abrir la copia de RECUPERACIÓN, o sea la bóveda con la frase del
398
+ * perfil delante — al abrirla, que es cuando la tiene (§2.5). Sin esto, un aparato que
399
+ * entra después de escrita una entrada no puede abrirla nunca: nadie más tiene con qué
400
+ * envolvérsela.
401
+ *
402
+ * Y de paso se va lo que sobra: la envoltura de quien ya no está en la lista. Ni se
403
+ * descifra ni se reescribe ningún valor — solo cambian las envolturas de las llaves.
404
+ *
405
+ * **O lo hace un MIEMBRO que ya tiene su envoltura** (`holder`). Es el caso de la bóveda de
406
+ * dentro de la extensión: ella misma está entre los destinatarios de todo, así que puede
407
+ * completar a un aparato que entró después sin pedirle a nadie la frase. No regala nada:
408
+ * quien hace esto ya podía leerlo, y a quien se lo entrega lo reconoce el acta. La llave
409
+ * tampoco sale de él — `rewrapFor` es de `@dotrino/identity`, que abre y vuelve a
410
+ * envolver sin devolverla.
411
+ *
412
+ * @param {object} o hace falta uno de los dos, y si están los dos manda `holder`
413
+ * @param {(wrap:object) => Promise<string>} [o.openRecovery] abre la envoltura `#recovery`
414
+ * @param {{ pub:string, rewrapFor:({wrap, encPub}) => Promise<object> }} [o.holder]
415
+ * un miembro con envoltura propia: su pública y cómo re-envuelve la llave que ya abre
416
+ */
417
+ async rewrapAll ({ openRecovery, holder } = {}) {
418
+ const conHolder = !!holder?.pub && typeof holder.rewrapFor === 'function'
419
+ if (typeof openRecovery !== 'function' && !conHolder) {
420
+ throw new Error('rewrapAll: missing openRecovery(wrap) or holder { pub, rewrapFor }')
421
+ }
422
+ const s = await this.#state()
423
+ const out = { generations: 0, wrapped: 0, dropped: 0, failed: [] }
424
+ for (const g of s.keyring) {
425
+ const kind = g.kind || 'main'
426
+ const deben = (await this.recipients(kind)).filter((m) => m?.encPub)
427
+ const antes = Object.keys(g.wraps).filter((k) => k !== RECOVERY)
428
+ const faltan = deben.filter((m) => !g.wraps[m.pub])
429
+ const sobran = antes.filter((p) => !deben.some((m) => m.pub === p))
430
+ if (!faltan.length && !sobran.length) continue
431
+ try {
432
+ if (faltan.length) {
433
+ const suya = conHolder ? g.wraps[holder.pub] : null
434
+ if (suya) {
435
+ for (const m of faltan) { g.wraps[m.pub] = await holder.rewrapFor({ wrap: suya, encPub: m.encPub }); out.wrapped++ }
436
+ } else if (typeof openRecovery === 'function') {
437
+ const cek = await openRecovery(g.wraps[RECOVERY])
438
+ for (const m of faltan) { g.wraps[m.pub] = await wrapForMember({ cek, memberEncPub: m.encPub }); out.wrapped++ }
439
+ } else {
440
+ // Ni envoltura propia en esta generación ni la frase: no hay con qué, y se dice.
441
+ throw new SealedError('no-opener', 'this generation is not wrapped for the holder and no recovery was given')
442
+ }
443
+ }
444
+ for (const p of sobran) { delete g.wraps[p]; out.dropped++ }
445
+ out.generations++
446
+ } catch (e) {
447
+ out.failed.push({ gen: g.gen, error: e?.message || String(e) })
448
+ }
449
+ }
450
+ if (out.wrapped || out.dropped) await this.#save(s)
451
+ return out
452
+ }
453
+
454
+ /**
455
+ * LA COPIA DE RECUPERACIÓN LLEGA DESPUÉS. Lo guardado antes de crearla solo lo abren los
456
+ * aparatos del acta; esto le añade a cada generación la envoltura `#recovery`, con la
457
+ * llave de un miembro que ya la abre (`holder`, como en `rewrapAll`). La llave no sale
458
+ * de él: `rewrapFor` abre y vuelve a envolver sin devolverla.
459
+ *
460
+ * Lo que no se pudo envolver se devuelve en `failed`, no se calla.
461
+ */
462
+ async addRecovery ({ recoveryPub, holder } = {}) {
463
+ if (!recoveryPub) throw new Error('addRecovery: missing recoveryPub')
464
+ if (!holder?.pub || typeof holder.rewrapFor !== 'function') throw new Error('addRecovery: missing holder { pub, rewrapFor }')
465
+ const s = await this.#state()
466
+ const out = { wrapped: 0, failed: [] }
467
+ for (const g of s.keyring) {
468
+ if (g.wraps?.[RECOVERY]) continue
469
+ const suya = g.wraps?.[holder.pub]
470
+ if (!suya) { out.failed.push({ gen: g.gen, error: 'this generation is not wrapped for the holder' }); continue }
471
+ try {
472
+ g.wraps[RECOVERY] = await holder.rewrapFor({ wrap: suya, encPub: recoveryPub })
473
+ out.wrapped++
474
+ } catch (e) {
475
+ out.failed.push({ gen: g.gen, error: e?.message || String(e) })
476
+ }
477
+ }
478
+ if (out.wrapped) await this.#save(s)
479
+ return out
480
+ }
481
+
482
+ /**
483
+ * QUIÉN SE QUEDÓ SIN ENVOLTURA, para que la consola lo diga en vez de que un aparato se
484
+ * encuentre con que no puede abrir nada (§2.5). No hace falta ninguna llave: se mira
485
+ * quién debería estar en cada generación y quién está.
486
+ */
487
+ async incompleteMembers () {
488
+ const s = await this.#state()
489
+ const faltan = new Map()
490
+ for (const kind of ['main', 'passkeys']) {
491
+ const deben = (await this.recipients(kind)).filter((m) => m?.encPub).map((m) => m.pub)
492
+ for (const g of s.keyring.filter((x) => (x.kind || 'main') === kind)) {
493
+ for (const pub of deben) {
494
+ if (!g.wraps?.[pub]) faltan.set(pub, (faltan.get(pub) || 0) + 1)
495
+ }
496
+ }
497
+ }
498
+ return [...faltan].map(([pub, generations]) => ({ pub, generations }))
499
+ }
500
+
501
+ /** Cuántas entradas y cuántas generaciones. Diagnóstico: no abre nada. */
502
+ async stats () {
503
+ const s = await this.#state()
504
+ return { entries: s.entries.length, generations: s.keyring.length, sealed: !!s.profile }
505
+ }
506
+ }
507
+
508
+ export default { SealedStore, SealedError, KEY, RECOVERY }
@@ -0,0 +1,60 @@
1
+ // Recuerdo de lo YA entregado, durante esta sesión del navegador.
2
+ //
3
+ // No es la caché que se descartó (DISENO §3.1): aquella era una copia de la bóveda
4
+ // entera, y para abrirla hacía falta la llave. Esto guarda solo las credenciales que
5
+ // la bóveda ya entregó, en memoria de sesión, y muere al cerrar el navegador.
6
+ //
7
+ // Para qué: entrar tres veces al mismo sitio en una tarde no debería ser tres
8
+ // aprobaciones en el teléfono. Lo que nunca pediste, nunca estuvo aquí.
9
+
10
+ const DEFAULT_TTL = 5 * 60 * 1000
11
+
12
+ export class SessionCache {
13
+ /**
14
+ * @param {object} store almacén de SESIÓN `{ get(k), set(k, v) }` — en la
15
+ * extensión, `chrome.storage.session`: memoria, nunca disco.
16
+ * @param {object} opts `{ ttlMs, now }`
17
+ */
18
+ constructor (store, { ttlMs = DEFAULT_TTL, now = () => Date.now() } = {}) {
19
+ this.store = store
20
+ this.ttlMs = ttlMs
21
+ this.now = now
22
+ this.key = 'passmanager/session/v1'
23
+ }
24
+
25
+ async #all () {
26
+ return (await this.store.get(this.key)) || {}
27
+ }
28
+
29
+ /** Devuelve la entrada si sigue vigente. Una caducada se tira al pasar por ella. */
30
+ async get (id) {
31
+ const all = await this.#all()
32
+ const hit = all[id]
33
+ if (!hit) return null
34
+ if (this.now() - hit.ts > this.ttlMs) {
35
+ delete all[id]
36
+ await this.store.set(this.key, all)
37
+ return null
38
+ }
39
+ return hit.entry
40
+ }
41
+
42
+ /** Guarda lo entregado. */
43
+ async put (id, entry) {
44
+ if (!entry) return false
45
+ const all = await this.#all()
46
+ all[id] = { entry, ts: this.now() }
47
+ await this.store.set(this.key, all)
48
+ return true
49
+ }
50
+
51
+ async forget (id) {
52
+ const all = await this.#all()
53
+ if (id) delete all[id]
54
+ await this.store.set(this.key, id ? all : {})
55
+ }
56
+
57
+ async size () {
58
+ return Object.keys(await this.#all()).length
59
+ }
60
+ }
@@ -0,0 +1,111 @@
1
+ // Códigos de dos pasos (TOTP, RFC 6238 / HOTP, RFC 4226).
2
+ //
3
+ // Se guardan como URI `otpauth://`, que es lo que exportan e importan todos los
4
+ // demás y lo que lleva un QR. El secreto viaja cifrado con la CEK como cualquier
5
+ // otro campo (ver crypto.js); aquí solo se calcula el código.
6
+
7
+ const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
8
+
9
+ export function base32Decode (input) {
10
+ const s = String(input || '').toUpperCase().replace(/=+$/, '').replace(/\s+/g, '')
11
+ let bits = 0
12
+ let value = 0
13
+ const out = []
14
+ for (const ch of s) {
15
+ const idx = B32.indexOf(ch)
16
+ if (idx === -1) throw new Error('base32: carácter inválido')
17
+ value = (value << 5) | idx
18
+ bits += 5
19
+ if (bits >= 8) {
20
+ bits -= 8
21
+ out.push((value >>> bits) & 0xff)
22
+ }
23
+ }
24
+ return new Uint8Array(out)
25
+ }
26
+
27
+ /**
28
+ * Bitwarden (y algún otro) exporta el TOTP como el secreto base32 pelado, sin URI.
29
+ * Se acepta y se envuelve, para que el resto del código vea siempre un `otpauth://`.
30
+ */
31
+ export function normalizeOtpauth (input, label = 'dotrino') {
32
+ const s = String(input || '').trim()
33
+ if (!s) return ''
34
+ if (s.toLowerCase().startsWith('otpauth://')) return s
35
+ return `otpauth://totp/${encodeURIComponent(label)}?secret=${encodeURIComponent(s.replace(/\s+/g, ''))}`
36
+ }
37
+
38
+ /** Parsea `otpauth://totp/Emisor:cuenta?secret=...&digits=6&period=30&algorithm=SHA1`. */
39
+ export function parseOtpauth (uri) {
40
+ const u = new URL(normalizeOtpauth(uri))
41
+ if (u.protocol !== 'otpauth:') throw new Error('otpauth: esquema inválido')
42
+ const type = u.hostname.toLowerCase()
43
+ if (type !== 'totp' && type !== 'hotp') throw new Error('otpauth: tipo no soportado')
44
+ const q = u.searchParams
45
+ const secret = q.get('secret')
46
+ if (!secret) throw new Error('otpauth: falta secret')
47
+
48
+ const label = decodeURIComponent(u.pathname.replace(/^\//, ''))
49
+ const [maybeIssuer, account] = label.includes(':') ? label.split(/:(.+)/) : [null, label]
50
+
51
+ return {
52
+ type,
53
+ secret,
54
+ issuer: q.get('issuer') || maybeIssuer || '',
55
+ account: (account || '').trim(),
56
+ digits: clampDigits(q.get('digits')),
57
+ period: clampPeriod(q.get('period')),
58
+ algorithm: (q.get('algorithm') || 'SHA1').toUpperCase(),
59
+ counter: Number(q.get('counter') || 0),
60
+ }
61
+ }
62
+
63
+ function clampDigits (v) {
64
+ const n = Number(v || 6)
65
+ return Number.isInteger(n) && n >= 6 && n <= 10 ? n : 6
66
+ }
67
+
68
+ function clampPeriod (v) {
69
+ const n = Number(v || 30)
70
+ return Number.isInteger(n) && n > 0 && n <= 300 ? n : 30
71
+ }
72
+
73
+ const HASHES = { SHA1: 'SHA-1', SHA256: 'SHA-256', SHA512: 'SHA-512' }
74
+
75
+ async function hotp (secretBytes, counter, digits, algorithm) {
76
+ const hash = HASHES[algorithm]
77
+ if (!hash) throw new Error('otp: algoritmo no soportado: ' + algorithm)
78
+
79
+ const buf = new ArrayBuffer(8)
80
+ const view = new DataView(buf)
81
+ view.setBigUint64(0, BigInt(counter), false)
82
+
83
+ const key = await globalThis.crypto.subtle.importKey(
84
+ 'raw', secretBytes, { name: 'HMAC', hash }, false, ['sign'])
85
+ const mac = new Uint8Array(await globalThis.crypto.subtle.sign('HMAC', key, buf))
86
+
87
+ const offset = mac[mac.length - 1] & 0x0f
88
+ const code = ((mac[offset] & 0x7f) << 24) | (mac[offset + 1] << 16) |
89
+ (mac[offset + 2] << 8) | mac[offset + 3]
90
+ return String(code % 10 ** digits).padStart(digits, '0')
91
+ }
92
+
93
+ /**
94
+ * Código actual y cuántos segundos le quedan.
95
+ * `now` en milisegundos, para poder probarlo con vectores fijos.
96
+ */
97
+ export async function totpNow (uri, now = Date.now()) {
98
+ const cfg = parseOtpauth(uri)
99
+ const secret = base32Decode(cfg.secret)
100
+ const seconds = Math.floor(now / 1000)
101
+
102
+ if (cfg.type === 'hotp') {
103
+ return { code: await hotp(secret, cfg.counter, cfg.digits, cfg.algorithm), expiresIn: null, cfg }
104
+ }
105
+ const counter = Math.floor(seconds / cfg.period)
106
+ return {
107
+ code: await hotp(secret, counter, cfg.digits, cfg.algorithm),
108
+ expiresIn: cfg.period - (seconds % cfg.period),
109
+ cfg,
110
+ }
111
+ }