@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,136 @@
1
+ // LA COPIA DE RECUPERACIÓN DE UNA BÓVEDA QUE NO PUEDE LEER LO SUYO.
2
+ //
3
+ // `SealedStore` exige que toda entrada lleve la envoltura `#recovery` (`store.js`), y con
4
+ // razón: sin ella, el día que no quede ningún aparato con `passwords` esas contraseñas no
5
+ // las abre nadie — tampoco su dueño. La bóveda publica la PÚBLICA de ese par para que los
6
+ // aparatos envuelvan; la privada solo hace falta para repartir de nuevo cuando cambia
7
+ // quién puede leer.
8
+ //
9
+ // De dónde sale esa privada es lo que distingue a cada bóveda, y el dueño lo decidió el
10
+ // 2026-09-20: **de una contraseña, en las cuatro**.
11
+ //
12
+ // · el binario (`dotrino-vault`) usa la contraseña del PERFIL — ya la tenía;
13
+ // · `dotrino-passmanager serve` usa la contraseña de su bóveda — ya la tenía;
14
+ // · la pestaña (`vault.dotrino.com/vault`) y la bóveda de dentro de la extensión no
15
+ // tenían ninguna, y por eso la piden al convertir.
16
+ //
17
+ // Lo que esto NO hace, y se dice porque es la mitad del trato: la contraseña **no se puede
18
+ // recuperar**. Es la misma promesa que ya hace `passmanager serve` al crear su bóveda, y
19
+ // es lo que hace que la copia sirva de algo — una que se pudiera reponer desde el propio
20
+ // navegador no sería una copia de recuperación, sería una segunda llave del mismo llavero.
21
+ //
22
+ // Por qué una pieza y no cuatro: la regla de las tres versiones (`sealed-passwords.md`
23
+ // §2.7) dice que lo que escribe una lo abre otra. Si cada una derivara su llave a su
24
+ // manera —otro número de vueltas, otra sal, otro formato— el sobre `#recovery` de una no
25
+ // lo abriría la de al lado, y eso no falla al escribir: falla el día que hace falta.
26
+
27
+ import { deriveKeyFromPassword, makeSalt, makeVerifier, checkVerifier, sealValue, openValue, toBase64, fromBase64 } from '../crypto.js'
28
+
29
+ /** El formato de lo guardado. Sube si cambia la derivación o el sobre. */
30
+ export const RECOVERY_VERSION = 1
31
+
32
+ const subtle = () => {
33
+ const c = globalThis.crypto
34
+ if (!c?.subtle) throw new Error('webcrypto unavailable')
35
+ return c.subtle
36
+ }
37
+
38
+ const err = (code, message) => Object.assign(new Error(message), { code })
39
+
40
+ /**
41
+ * El par de recuperación. ECDH P-256, como el de los cajones del binario
42
+ * (`dotrino-vault/src/sealer.js`): es la misma curva que espera `wrapForMember`, y usar
43
+ * otra haría que el sobre de una bóveda no lo abriera la de al lado.
44
+ */
45
+ async function makePair () {
46
+ const pair = await subtle().generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey', 'deriveBits'])
47
+ return {
48
+ pub: JSON.stringify(await subtle().exportKey('jwk', pair.publicKey)),
49
+ priv: await subtle().exportKey('jwk', pair.privateKey)
50
+ }
51
+ }
52
+
53
+ /**
54
+ * CREA la copia de recuperación de esta bóveda a partir de una contraseña.
55
+ *
56
+ * Devuelve lo que hay que GUARDAR (`record`) y la pública, que es lo único que sale de
57
+ * aquí en claro. La privada se va con la función: quien la quiera, que abra con la
58
+ * contraseña.
59
+ *
60
+ * @param {object} o
61
+ * `password` la contraseña que abrirá la copia. Mínimo 12: quien tenga el archivo puede
62
+ * probar contraseñas contra el verificador sin límite y sin que nadie se entere, así
63
+ * que lo único que aguanta es su longitud (mismo criterio que los inicios de sesión).
64
+ */
65
+ export async function makeRecovery ({ password, minLength = 12 } = {}) {
66
+ if (typeof password !== 'string' || password.length < minLength) {
67
+ throw err('weak-password', `the recovery password must be at least ${minLength} characters`)
68
+ }
69
+ const salt = makeSalt()
70
+ const key = await deriveKeyFromPassword(password, salt)
71
+ const pair = await makePair()
72
+ return {
73
+ pub: pair.pub,
74
+ record: {
75
+ v: RECOVERY_VERSION,
76
+ salt: toBase64(salt),
77
+ // El verificador prueba que la contraseña ABRE esto, no que coincide con un hash:
78
+ // es lo que deja decir «esa no es» sin tener que intentar descifrar la privada.
79
+ verifier: await makeVerifier(key),
80
+ pub: pair.pub,
81
+ priv: await sealValue(key, JSON.stringify(pair.priv), 'recovery')
82
+ }
83
+ }
84
+ }
85
+
86
+ /** La pública de una copia ya creada. Es lo que la bóveda publica a los aparatos. */
87
+ export function recoveryPubOf (record) {
88
+ return record?.pub || null
89
+ }
90
+
91
+ /** ¿Hay copia de recuperación? Lo pregunta quien tiene que crearla al convertir. */
92
+ export const hasRecovery = (record) => !!record?.pub && !!record?.priv
93
+
94
+ /**
95
+ * ABRE la copia con su contraseña y devuelve la privada como `CryptoKey` de ECDH, que es
96
+ * lo que quiere `openWrap`.
97
+ *
98
+ * Se distingue «esa contraseña no es» de «esto está roto», y no es un detalle: la primera
99
+ * se arregla escribiendo bien y la segunda no se arregla de ninguna manera, así que
100
+ * confundirlas manda al usuario a intentarlo cien veces contra un archivo corrupto.
101
+ */
102
+ export async function openRecovery ({ record, password } = {}) {
103
+ if (!hasRecovery(record)) throw err('no-recovery', 'this vault has no recovery copy yet')
104
+ const key = await deriveKeyFromPassword(password, fromBase64(record.salt))
105
+ if (!(await checkVerifier(key, record.verifier))) throw err('wrong-password', 'that password does not open the recovery copy')
106
+ let jwk
107
+ try { jwk = JSON.parse(await openValue(key, record.priv, 'recovery')) }
108
+ catch (_) { throw err('bad-recovery', 'the recovery copy is there but cannot be read') }
109
+ // `key_ops` se quita: dice para qué la generó quien la creó, y aquí hace falta además
110
+ // `deriveKey`. Es lo mismo que hace `adoptLogin` al adoptar una llave de cifrado.
111
+ const { key_ops: _o, ext: _e, ...limpio } = jwk
112
+ return subtle().importKey('jwk', limpio, { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveKey', 'deriveBits'])
113
+ }
114
+
115
+ /**
116
+ * CAMBIA la contraseña de la copia sin tocar el par: los sobres que ya existen siguen
117
+ * valiendo. Hace falta la vieja, porque hay que abrirla para volver a cerrarla.
118
+ */
119
+ export async function changeRecoveryPassword ({ record, oldPassword, newPassword, minLength = 12 } = {}) {
120
+ if (typeof newPassword !== 'string' || newPassword.length < minLength) {
121
+ throw err('weak-password', `the recovery password must be at least ${minLength} characters`)
122
+ }
123
+ const vieja = await deriveKeyFromPassword(oldPassword, fromBase64(record.salt))
124
+ if (!(await checkVerifier(vieja, record.verifier))) throw err('wrong-password', 'that password does not open the recovery copy')
125
+ const priv = await openValue(vieja, record.priv, 'recovery')
126
+ const salt = makeSalt()
127
+ const key = await deriveKeyFromPassword(newPassword, salt)
128
+ return {
129
+ ...record,
130
+ salt: toBase64(salt),
131
+ verifier: await makeVerifier(key),
132
+ priv: await sealValue(key, priv, 'recovery')
133
+ }
134
+ }
135
+
136
+ export default { makeRecovery, openRecovery, recoveryPubOf, hasRecovery, changeRecoveryPassword, RECOVERY_VERSION }
@@ -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 }