@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,332 @@
1
+ // Una entrada de la bóveda: cómo se sella, cómo se abre y qué se puede enseñar sin
2
+ // abrirla. Ver DISENO §5.
3
+ //
4
+ // Qué va en claro y por qué: `sites` y `title`. Los `sites` hacen falta para
5
+ // emparejar con la página SIN tener la CEK — es lo que permite que la extensión
6
+ // pregunte "¿tienes algo para salesforce.com?" sin poder abrir nada. Es una decisión
7
+ // consciente: revela EN QUÉ SITIOS tienes cuenta, que es bastante menos que la
8
+ // credencial. Todo lo demás (usuario, contraseña, TOTP, notas, llave WebAuthn) va
9
+ // cifrado.
10
+
11
+ import { sealValue, openValue, fieldTag } from './crypto.js'
12
+ import { normalizeFields, entryFieldKeys, privateKeysOf, fieldKey, KINDS } from './fields.js'
13
+
14
+ export const TYPES = ['login', 'note', 'card', 'webauthn', 'data']
15
+
16
+ // `fields` es la lista de campos libres del usuario, y va cifrada como todo lo
17
+ // demás: un correo o una cédula identifican a una persona igual que una contraseña
18
+ // da acceso. Se sella como JSON, en un solo criptograma.
19
+ //
20
+ // `name` —el nombre que el usuario le pone a la entrada— va SELLADO y no en claro como
21
+ // `title`: «la cuenta de mi mamá» dice bastante más de una persona que el dominio donde
22
+ // la usa. Quien puede abrir la entrada lo pone en la vista pública (§4.0.2), igual que
23
+ // el nombre visible de siempre.
24
+ const SEALED_FIELDS = ['name', 'username', 'secret', 'totp', 'notes', 'fields']
25
+
26
+ function uuid () {
27
+ if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
28
+ const b = new Uint8Array(16)
29
+ globalThis.crypto.getRandomValues(b)
30
+ b[6] = (b[6] & 0x0f) | 0x40
31
+ b[8] = (b[8] & 0x3f) | 0x80
32
+ const h = [...b].map(x => x.toString(16).padStart(2, '0')).join('')
33
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`
34
+ }
35
+
36
+ /** Entrada nueva en claro, lista para `sealEntry`. */
37
+ export function newEntry (fields = {}) {
38
+ const now = Date.now()
39
+ const type = TYPES.includes(fields.type) ? fields.type : 'login'
40
+ return {
41
+ id: fields.id || uuid(),
42
+ type,
43
+ title: String(fields.title || '').trim(),
44
+ // El nombre que le puso el usuario. Vacío = el que se calcula solo (`entryWho`).
45
+ name: String(fields.name || '').trim(),
46
+ sites: normalizeSites(fields.sites),
47
+ username: fields.username || '',
48
+ secret: fields.secret || '',
49
+ // Los campos libres viajan como JSON dentro de un único criptograma. Una lista
50
+ // vacía es NADA, no un criptograma vacío: si no, la entrada se anuncia con campos
51
+ // que no tiene y la UI ofrece rellenar lo que no existe.
52
+ fields: (() => {
53
+ if (!Array.isArray(fields.fields)) return fields.fields || ''
54
+ const limpios = normalizeFields(fields.fields)
55
+ return limpios.length ? JSON.stringify(limpios) : ''
56
+ })(),
57
+ totp: fields.totp || '',
58
+ notes: fields.notes || '',
59
+ webauthn: fields.webauthn || null,
60
+ createdAt: fields.createdAt || now,
61
+ updatedAt: now,
62
+ }
63
+ }
64
+
65
+ export function normalizeSites (sites) {
66
+ const out = []
67
+ for (const s of Array.isArray(sites) ? sites : [sites]) {
68
+ const v = String(s || '').trim().toLowerCase()
69
+ if (v && !out.includes(v)) out.push(v)
70
+ }
71
+ return out
72
+ }
73
+
74
+ /** Cifra los campos sensibles. Devuelve lo que se guarda de verdad. */
75
+ export async function sealEntry (key, plain) {
76
+ const e = newEntry(plain)
77
+ const out = {
78
+ id: e.id,
79
+ type: e.type,
80
+ title: e.title,
81
+ sites: e.sites,
82
+ createdAt: e.createdAt,
83
+ updatedAt: e.updatedAt,
84
+ }
85
+ for (const f of SEALED_FIELDS) {
86
+ out[f] = e[f] ? await sealValue(key, String(e[f]), fieldTag(e.id, f)) : null
87
+ }
88
+ if (e.webauthn) {
89
+ // `rpId` y `credentialId` quedan en claro: el navegador los necesita para saber
90
+ // qué credencial ofrecer antes de que nadie desbloquee nada.
91
+ out.webauthn = {
92
+ credentialId: e.webauthn.credentialId,
93
+ rpId: e.webauthn.rpId,
94
+ signCount: e.webauthn.signCount || 0,
95
+ userHandle: e.webauthn.userHandle
96
+ ? await sealValue(key, String(e.webauthn.userHandle), fieldTag(e.id, 'wa.userHandle'))
97
+ : null,
98
+ privateKey: e.webauthn.privateKey
99
+ ? await sealValue(key, String(e.webauthn.privateKey), fieldTag(e.id, 'wa.privateKey'))
100
+ : null,
101
+ }
102
+ } else {
103
+ out.webauthn = null
104
+ }
105
+ return out
106
+ }
107
+
108
+ /** Abre una entrada sellada. Exige la CEK, o sea: no lo hace la extensión. */
109
+ export async function openEntry (key, entry) {
110
+ const out = {
111
+ id: entry.id,
112
+ type: entry.type,
113
+ title: entry.title,
114
+ sites: entry.sites || [],
115
+ createdAt: entry.createdAt,
116
+ updatedAt: entry.updatedAt,
117
+ webauthn: null,
118
+ }
119
+ for (const f of SEALED_FIELDS) {
120
+ out[f] = entry[f] ? await openValue(key, entry[f], fieldTag(entry.id, f)) : ''
121
+ }
122
+ if (entry.webauthn) {
123
+ out.webauthn = {
124
+ credentialId: entry.webauthn.credentialId,
125
+ rpId: entry.webauthn.rpId,
126
+ signCount: entry.webauthn.signCount || 0,
127
+ userHandle: entry.webauthn.userHandle
128
+ ? await openValue(key, entry.webauthn.userHandle, fieldTag(entry.id, 'wa.userHandle'))
129
+ : '',
130
+ privateKey: entry.webauthn.privateKey
131
+ ? await openValue(key, entry.webauthn.privateKey, fieldTag(entry.id, 'wa.privateKey'))
132
+ : '',
133
+ }
134
+ }
135
+ return out
136
+ }
137
+
138
+ /**
139
+ * Lo que puede ver quien NO tiene la CEK: para elegir entre varias cuentas del mismo
140
+ * sitio, no para usarlas. El `hint` lo calcula quien sí puede abrir (§2).
141
+ *
142
+ * `open` es la entrada abierta, y solo la tiene quien puede abrirla. Con ella salen los
143
+ * NOMBRES de los campos que lleva —nunca sus valores—, que es lo que permite ofrecer
144
+ * «rellenar el número de socio» sin sacar nada de la bóveda.
145
+ *
146
+ * `digest` son los RESÚMENES de esos campos (`{ nonce, fields }`, ver `fieldHasher`):
147
+ * con ellos, quien pregunta puede decir si lo que tiene delante ya está guardado igual
148
+ * sin abrir nada y sin pedir una autorización. Van para todos los campos, públicos y
149
+ * privados: un solo método de comparación (dueño, 2026-08-29).
150
+ *
151
+ * `privateKeys` dice **cuáles de esos campos son privados**. No es lo mismo que el valor:
152
+ * es lo que permite pedir los públicos —y solo los públicos— sin disparar una
153
+ * autorización, que es lo que hace falta para enseñar en una lista con qué se va a
154
+ * rellenar cada casilla.
155
+ */
156
+ export function publicView (entry, hint, open, digest) {
157
+ return {
158
+ id: entry.id,
159
+ type: entry.type,
160
+ title: entry.title,
161
+ sites: entry.sites || [],
162
+ hasSecret: !!entry.secret,
163
+ hasTotp: !!entry.totp,
164
+ hasNotes: !!entry.notes,
165
+ hasFields: !!entry.fields,
166
+ hasWebauthn: !!entry.webauthn,
167
+ updatedAt: entry.updatedAt,
168
+ ...(hint ? { hint } : {}),
169
+ ...(open ? { fieldKeys: entryFieldKeys(open), privateKeys: [...privateKeysOf(open)] } : {}),
170
+ ...(digest ? { nonce: digest.nonce, fieldHashes: digest.fields } : {}),
171
+ }
172
+ }
173
+
174
+ /**
175
+ * CÓMO SE LLAMA una entrada para quien tiene que elegir entre varias.
176
+ *
177
+ * **El que le puso el usuario**, si le puso alguno. Y si no, su dato público más claro,
178
+ * **sin esconder caracteres** (dueño, 2026-08-28): el usuario; si no tiene, el correo; y
179
+ * si tampoco, el primer campo que lleve. Un `d•••g` no le dice nada a nadie — hay que
180
+ * poder reconocer la cuenta de un vistazo.
181
+ *
182
+ * **Los campos privados no salen aquí.** Para eso está la marca (§4.2): lo privado solo
183
+ * sale con confirmación, así que nunca es el nombre visible de una entrada.
184
+ */
185
+ export function entryWho (open) {
186
+ // Lo que el usuario escribió MANDA. Lo de abajo es solo el nombre que se calcula cuando
187
+ // no ha escrito ninguno: útil para no dejar filas en blanco, pero es una suposición, y
188
+ // una suposición no le gana a una decisión (dueño, 2026-08-29).
189
+ if (open?.name) return String(open.name)
190
+ if (open?.username) return String(open.username)
191
+ const campos = (() => {
192
+ if (Array.isArray(open?.fields)) return open.fields
193
+ try { return JSON.parse(open?.fields || '[]') } catch { return [] }
194
+ })()
195
+ const publicos = campos.filter(f => f && !f.private && f.value)
196
+ const correo = publicos.find(f => f.kind === 'email')
197
+ return String((correo || publicos[0])?.value || '')
198
+ }
199
+
200
+ /**
201
+ * LA ENTRADA RECORTADA a los campos que se pidieron.
202
+ *
203
+ * Pedir «el nombre» no puede devolver también la contraseña. Sin esto, `get` era todo o
204
+ * nada y cualquier relleno de un dato público sacaba de la bóveda la credencial entera —
205
+ * que además es lo que obligaba a pedir autorización para rellenar un nombre.
206
+ *
207
+ * Sin `keys` devuelve la entrada tal cual: hay quien necesita todo (una passkey al
208
+ * firmar), y eso sí se autoriza.
209
+ */
210
+ export function projectEntry (open, keys) {
211
+ if (!Array.isArray(keys)) return open
212
+ const quiere = new Set(keys)
213
+ const campos = (() => {
214
+ if (Array.isArray(open.fields)) return open.fields
215
+ try { return JSON.parse(open.fields || '[]') } catch { return [] }
216
+ })()
217
+ return {
218
+ ...open,
219
+ username: quiere.has('username') ? open.username : '',
220
+ secret: quiere.has('secret') ? open.secret : '',
221
+ totp: quiere.has('totp') ? open.totp : '',
222
+ notes: quiere.has('notes') ? open.notes : '',
223
+ webauthn: quiere.has('webauthn') ? open.webauthn : null,
224
+ fields: JSON.stringify(campos.filter(f => quiere.has(fieldKey(f)))),
225
+ }
226
+ }
227
+
228
+ /** `sandrade@dotrino.com` → `s•••e@dotrino.com`. Sigue para la CLI; la extensión ya no. */
229
+ export function maskUsername (username) {
230
+ const u = String(username || '')
231
+ if (!u) return ''
232
+ const at = u.indexOf('@')
233
+ if (at > 0) return mask(u.slice(0, at)) + u.slice(at)
234
+ return mask(u)
235
+ }
236
+
237
+ function mask (s) {
238
+ if (s.length <= 2) return s[0] + '•'
239
+ return s[0] + '•••' + s[s.length - 1]
240
+ }
241
+
242
+ /**
243
+ * LA FUSIÓN DE UN PARCHE con lo que ya había, que es más sutil de lo que parece y por eso
244
+ * vive aquí y no en cada bóveda.
245
+ *
246
+ * La diferencia que lo ordena todo: **un campo sin `value` dice «déjale el que tenga»**,
247
+ * no «bórralo». Marcar uno como privado llega así —solo la etiqueta y la marca—, y una
248
+ * bóveda que lo tomara al pie de la letra borraría su valor al marcarlo. Pasó: la bóveda
249
+ * sellada de la extensión se quedó sin campos al primer `patch`.
250
+ *
251
+ * @param {object} base la entrada ABIERTA tal como está
252
+ * @param {object} changes lo que se quiere cambiar
253
+ * @returns {object} la entrada completa, lista para volver a cerrar
254
+ */
255
+ export function mergePatch (base, changes = {}) {
256
+ const fields = normalizeFields((() => {
257
+ if (Array.isArray(base.fields)) return base.fields
258
+ try { return JSON.parse(base.fields || '[]') } catch { return [] }
259
+ })())
260
+
261
+ // Como `normalizeFields`, pero **conservando la ausencia de `value`**: ahí está la
262
+ // diferencia entre «ponle este valor» y «déjale el que tenga».
263
+ const propuestos = (Array.isArray(changes.fields) ? changes.fields : [])
264
+ .filter((f) => f && (f.label || f.value !== undefined))
265
+ .map((f) => ({
266
+ label: String(f.label || '').trim(),
267
+ kind: KINDS.includes(f.kind) ? f.kind : undefined,
268
+ ...(f.value === undefined ? {} : { value: String(f.value) }),
269
+ ...(f.private === undefined ? {} : { private: !!f.private }),
270
+ }))
271
+
272
+ for (const f of propuestos) {
273
+ const k = fieldKey(f)
274
+ // Se casa por su clave; y si no aparece, **por su etiqueta**. Un mismo campo puede
275
+ // llegar con clase una vez y sin ella otra —la página declara el `autocomplete` en un
276
+ // formulario y no en el siguiente—, y sin este respaldo eso creaba un duplicado.
277
+ const j = (() => {
278
+ const exacto = fields.findIndex((x) => fieldKey(x) === k)
279
+ if (exacto >= 0) return exacto
280
+ return f.label ? fields.findIndex((x) => x.label === f.label) : -1
281
+ })()
282
+ if (j >= 0) {
283
+ // La etiqueta que ya tenía MANDA: es la identidad de un campo libre, y cambiarla
284
+ // sería crear otro. Y lo que era privado sigue siéndolo salvo que se diga.
285
+ fields[j] = {
286
+ ...fields[j],
287
+ ...(f.value === undefined ? {} : { value: f.value }),
288
+ ...(f.kind ? { kind: f.kind } : {}),
289
+ }
290
+ if (f.private === undefined ? fields[j].private : f.private) fields[j].private = true
291
+ else delete fields[j].private
292
+ } else if (f.value !== undefined) {
293
+ // Un campo que no existe y del que no se dice el valor no es nada que crear.
294
+ fields.push({ ...f, value: f.value })
295
+ }
296
+ }
297
+
298
+ // QUITAR. Va al final, para que quitar gane si en la misma tanda también se editaba.
299
+ // Los de siempre no se pueden borrar —son parte de la forma de una entrada—, así que
300
+ // quitarlos es dejarlos vacíos, que para todo lo demás es lo mismo.
301
+ const fuera = new Set(Array.isArray(changes.removeFields) ? changes.removeFields : [])
302
+ const vaciados = {}
303
+ for (const k of ['username', 'secret', 'totp', 'notes']) if (fuera.has(k)) vaciados[k] = ''
304
+ const quedan = fields
305
+ .filter((f) => !fuera.has(fieldKey(f)))
306
+ // Un campo sin valor no es un campo: no se rellena, no se compara y no se enseña.
307
+ .filter((f) => f.value)
308
+
309
+ const sites = (() => {
310
+ // La lista puesta a mano MANDA sobre el «súmame este sitio» del guardado automático:
311
+ // una es una decisión del usuario y el otro una consecuencia de dónde estabas.
312
+ if (Array.isArray(changes.sites)) return normalizeSites(changes.sites)
313
+ const suyos = base.sites || []
314
+ if (!changes.addSite || !suyos.length || suyos.includes(changes.addSite)) return suyos
315
+ return [...suyos, changes.addSite]
316
+ })()
317
+
318
+ return {
319
+ ...base,
320
+ ...(changes.name !== undefined ? { name: changes.name } : {}),
321
+ ...(changes.title !== undefined ? { title: changes.title } : {}),
322
+ ...(changes.username !== undefined ? { username: changes.username } : {}),
323
+ ...(changes.secret !== undefined ? { secret: changes.secret } : {}),
324
+ ...(changes.totp !== undefined ? { totp: changes.totp } : {}),
325
+ ...(changes.notes !== undefined ? { notes: changes.notes } : {}),
326
+ ...vaciados,
327
+ sites,
328
+ fields: quedan,
329
+ id: base.id,
330
+ createdAt: base.createdAt,
331
+ }
332
+ }
@@ -0,0 +1,33 @@
1
+ // Comparar llaves públicas.
2
+ //
3
+ // **Nunca con `===` sobre el JSON.** Una misma llave se serializa de varias formas: el
4
+ // orden de las claves del JWK cambia según quién la escriba, y sobran campos como
5
+ // `ext` o `key_ops` que no son parte de la llave. Comparar cadenas hace que un aparato
6
+ // autorizado aparezca como desconocido, y eso se manifiesta como un «denegado» que no
7
+ // hay forma de explicar mirando los dos valores, porque *parecen* iguales.
8
+ //
9
+ // Es una lección que el ecosistema ya tenía escrita (en `@dotrino/reputation`: comparar
10
+ // con `samePubkey`, nunca con `===`) y que aquí se volvió a tropezar.
11
+
12
+ /** Los campos que IDENTIFICAN una llave EC; lo demás es adorno de la serialización. */
13
+ function canonical (jwkOrString) {
14
+ let k = jwkOrString
15
+ if (typeof k === 'string') {
16
+ try { k = JSON.parse(k) } catch { return null }
17
+ }
18
+ if (!k || typeof k !== 'object') return null
19
+ if (!k.kty || !k.crv || !k.x) return null
20
+ return `${k.kty}|${k.crv}|${k.x}|${k.y || ''}`
21
+ }
22
+
23
+ /** ¿Son la misma llave, aunque vengan escritas distinto? */
24
+ export function samePubkey (a, b) {
25
+ const ca = canonical(a)
26
+ const cb = canonical(b)
27
+ return !!ca && ca === cb
28
+ }
29
+
30
+ /** Una forma estable de la llave, para usarla como clave de un mapa o de un log. */
31
+ export function pubkeyId (jwkOrString) {
32
+ return canonical(jwkOrString)
33
+ }
@@ -0,0 +1,72 @@
1
+ // CONVERTIR UNA BÓVEDA AL FORMATO SELLADO (`docs/sealed-passwords.md` §2.7).
2
+ //
3
+ // Es un acto único y hay que hacerlo en las CUATRO bóvedas —el binario, la pestaña, la de
4
+ // dentro de la extensión y `passmanager serve`—, así que vive aquí y no en ninguna de
5
+ // ellas. Cuatro copias de esto serían cuatro formas sutilmente distintas de reescribir lo
6
+ // mismo, y la que se quedara atrás no fallaría al convertir: fallaría al leerlo otra
7
+ // bóveda, meses después.
8
+ //
9
+ // Durante la conversión —y SOLO durante la conversión— quien convierte ve los valores: hay
10
+ // que abrirlos con la llave vieja para volver a cerrarlos uno a uno. Por eso lo último que
11
+ // hace es **borrar la llave vieja**: mientras siga ahí, el agujero sigue abierto.
12
+ //
13
+ // Y por eso no se convierte «en segundo plano»: hace falta la contraseña de la copia de
14
+ // recuperación, que es lo único que puede volver a abrir esto el día que no quede ningún
15
+ // aparato. Sin ella no se empieza.
16
+
17
+ import { openEntry } from '../model.js'
18
+ import { buildSealedEntry, buildProfileKey } from './device.js'
19
+ import { profileKeys } from './keys.js'
20
+
21
+ /**
22
+ * @param {object} o
23
+ * `store` el almacén `{ get(k), set(k, v) }` con las entradas viejas
24
+ * `sealed` la `SealedStore` de esta bóveda
25
+ * `cek` la llave vieja (`CryptoKey`), o `null` si no hay nada que convertir
26
+ * `recipients` `{ recoveryPub, main, passkeys }` — a quién se le envuelve
27
+ * `author` `{ publickey, sign(body) }` — quien firma lo que se escribe
28
+ * `dropOldKey` borra la llave vieja. Se llama AL FINAL y solo si salió todo
29
+ * `legacyKey` dónde viven las entradas viejas (cada bóveda la guarda en su sitio)
30
+ * @returns {Promise<{already:boolean, entries:number}>}
31
+ */
32
+ export async function convertToSealed ({
33
+ store, sealed, cek, recipients, author, dropOldKey,
34
+ legacyKey = 'passmanager/entries/v1', log = () => {}
35
+ } = {}) {
36
+ if (!sealed) throw new Error('convertToSealed: missing the sealed store')
37
+ // `recoveryPub: null` vale: se convierte sin copia de recuperación y se añade después
38
+ // (`SealedStore.addRecovery`). Lo que no vale es no haber preguntado.
39
+ if (!recipients || recipients.recoveryPub === undefined) {
40
+ throw new Error('convertToSealed: missing the recipients')
41
+ }
42
+ // Ya convertida: no se vuelve a hacer. Repetirlo estrenaría otra llave de perfil y
43
+ // dejaría las entradas de antes con la anterior — la mitad de la bóveda ilegible.
44
+ if (await sealed.sealed()) return { already: true, entries: 0 }
45
+
46
+ const viejas = (await store.get(legacyKey)) || []
47
+
48
+ // 1. La llave del perfil, de la que salen el índice de sitios y los resúmenes.
49
+ const pk = await buildProfileKey({ recipients })
50
+ await sealed.setProfile({ envelope: pk.envelope, wraps: pk.wraps })
51
+ const keys = await profileKeys(pk.base)
52
+
53
+ // 2. Cada entrada, reescrita: un sobre por campo, la vista sellada, sus huellas.
54
+ let hechas = 0
55
+ for (const vieja of viejas) {
56
+ if (!cek) break
57
+ const abierta = await openEntry(cek, vieja)
58
+ await sealed.putSealed(await buildSealedEntry({ plain: abierta, keys, recipients, author }))
59
+ hechas++
60
+ }
61
+
62
+ // 3. Y se va lo viejo: las entradas con la llave que las abría, y la llave misma. Solo
63
+ // si TODAS pasaron: quedarse a medias y borrar la llave es perder lo que faltaba.
64
+ if (hechas === viejas.length) {
65
+ await store.set(legacyKey, [])
66
+ await dropOldKey?.()
67
+ }
68
+ log(`passwords: ${hechas} entries converted to the sealed format`)
69
+ return { already: false, entries: hechas }
70
+ }
71
+
72
+ export default { convertToSealed }
@@ -0,0 +1,211 @@
1
+ // EL LADO DEL APARATO: construir los sobres y abrirlos.
2
+ //
3
+ // La bóveda ya no puede hacer nada de esto —no tiene ninguna llave— así que lo hace quien
4
+ // pide: cifra lo que guarda, lo envuelve a cada destinatario y firma el conjunto; y al
5
+ // leer, abre su envoltura y con ella el sobre. Es lo mismo que `buildSealedVar` hace con
6
+ // una variable de un cajón (`@dotrino/vault/admin`), con una diferencia: una entrada tiene
7
+ // muchos campos y **dos juegos de destinatarios** (§2.8), no uno.
8
+ //
9
+ // QUIÉN FIRMA VA COMO FUNCIÓN, no como llave, por el mismo motivo que allí: en un navegador
10
+ // la privada del aparato vive dentro del iframe de identidad y no sale. Aquí se pide la
11
+ // firma, no la llave.
12
+
13
+ // ESTÁTICO, no `import()`: un service worker MV3 no admite el import dinámico (lo prohíbe
14
+ // la especificación), y esta pieza corre justamente ahí — dentro de la extensión del gestor.
15
+ import { makeContentKey, wrapForMember, encryptWithCek } from '@dotrino/identity/content'
16
+ import { sealEntry, openView, openFields, PASSKEY_FIELD } from './entry.js'
17
+ import { RECOVERY } from './store.js'
18
+ import { makePasswordKey, profileKeys, valueDigest } from './keys.js'
19
+
20
+ /**
21
+ * LO QUE FIRMA EL AUTOR. Un solo sitio: quien firma y quien comprueba miran exactamente lo
22
+ * mismo, o la comprobación pasa a ser decorativa.
23
+ *
24
+ * Va lo que identifica el contenido —los criptogramas, no los valores— y las huellas: si la
25
+ * bóveda cambiara un sobre por otro, o el índice de sitios de una entrada, la firma deja de
26
+ * cuadrar. Lo que NO va es la generación, que la pone el almacén después.
27
+ */
28
+ export function entryAuthorBody (entry, ts) {
29
+ return {
30
+ op: 'pm.entry',
31
+ id: entry.id,
32
+ idx: [...(entry.idx || [])].sort(),
33
+ wa: entry.wa || null,
34
+ view: entry.view.iv + '.' + entry.view.ct,
35
+ fields: Object.keys(entry.fields).sort().map((k) => k + ':' + entry.fields[k].iv + '.' + entry.fields[k].ct),
36
+ digests: entry.digests || {},
37
+ ts
38
+ }
39
+ }
40
+
41
+ /**
42
+ * LO QUE FIRMA QUIEN CAMBIA UNOS CAMPOS. Cubre exactamente lo que el parche pide hacer —
43
+ * qué sobres entran, cuáles se van y con qué vista queda— y nada más: lo que no se toca
44
+ * ya lo firmó quien lo escribió.
45
+ */
46
+ export function patchAuthorBody ({ id, view, fields = {}, drop = [], digests = {}, priv = null, idx = null, wa }, ts) {
47
+ return {
48
+ op: 'pm.patch',
49
+ id,
50
+ view: view.iv + '.' + view.ct,
51
+ fields: Object.keys(fields).sort().map((k) => k + ':' + fields[k].iv + '.' + fields[k].ct),
52
+ drop: [...drop].sort(),
53
+ digests,
54
+ priv: Array.isArray(priv) ? [...priv].sort() : null,
55
+ idx: Array.isArray(idx) ? [...idx].sort() : null,
56
+ wa: wa === undefined ? null : (wa || null),
57
+ ts
58
+ }
59
+ }
60
+
61
+ // `recoveryPub: null` es «la cuenta aún no tiene copia de recuperación» y se sella solo a
62
+ // los aparatos; `undefined` es que nadie preguntó a quién, y eso es un error.
63
+ const wrapAll = async (cek, { recoveryPub, members }) => {
64
+ if (recoveryPub === undefined) throw new Error('sealed: ask the vault for the recipients first')
65
+ const wraps = recoveryPub ? { [RECOVERY]: await wrapForMember({ cek, memberEncPub: recoveryPub }) } : {}
66
+ for (const m of members || []) wraps[m.pub] = await wrapForMember({ cek, memberEncPub: m.encPub })
67
+ return wraps
68
+ }
69
+
70
+ /**
71
+ * UNA ENTRADA LISTA PARA GUARDAR: cifrada, envuelta a quien toca y firmada.
72
+ *
73
+ * @param {object} o
74
+ * `plain` la entrada en claro
75
+ * `keys` las llaves del perfil (`profileKeys`)
76
+ * `recipients` `{ recoveryPub, main: [{pub,encPub}], passkeys: [{pub,encPub}] }`, tal
77
+ * como las contesta la bóveda: la lista sale del acta y la sabe ella.
78
+ * `author` `{ publickey, sign(body) }`
79
+ */
80
+ export async function buildSealedEntry ({ plain, keys, recipients, author } = {}) {
81
+ if (typeof author?.sign !== 'function' || typeof author?.publickey !== 'string') {
82
+ throw new Error('buildSealedEntry: author needs { publickey, sign(body) }')
83
+ }
84
+ const cek = await makeContentKey()
85
+ // La privada de la passkey va con SU llave y sus destinatarios. Se estrena solo si la
86
+ // entrada lleva una: repartir una llave que nadie va a usar es ruido en el llavero.
87
+ const llevaPasskey = !!plain?.webauthn?.privateKey
88
+ const cekPk = llevaPasskey ? await makeContentKey() : null
89
+
90
+ // `gen: 0` a propósito: la generación DE VERDAD la pone el almacén al guardar, como en
91
+ // los cajones. Aquí solo hace falta un número para que el sobre tenga forma.
92
+ const entry = await sealEntry({
93
+ plain,
94
+ main: { gen: 0, cek },
95
+ ...(cekPk ? { passkeys: { gen: 0, cek: cekPk } } : {}),
96
+ keys
97
+ })
98
+ const ts = Date.now()
99
+ const { signature } = await author.sign(entryAuthorBody(entry, ts))
100
+ return {
101
+ entry: { ...entry, author: { pub: author.publickey, sig: signature, ts } },
102
+ main: { wraps: await wrapAll(cek, { recoveryPub: recipients?.recoveryPub, members: recipients?.main }) },
103
+ ...(cekPk
104
+ ? { passkeys: { wraps: await wrapAll(cekPk, { recoveryPub: recipients?.recoveryPub, members: recipients?.passkeys }) } }
105
+ : {})
106
+ }
107
+ }
108
+
109
+ /** La llave del perfil, estrenada y envuelta. Se hace una vez, al convertir. */
110
+ export async function buildProfileKey ({ recipients } = {}) {
111
+ const base = await makePasswordKey()
112
+ const cek = await makeContentKey()
113
+ return {
114
+ base,
115
+ envelope: await encryptWithCek({ cek, gen: 0, plaintext: base }),
116
+ wraps: await wrapAll(cek, { recoveryPub: recipients?.recoveryPub, members: recipients?.main })
117
+ }
118
+ }
119
+
120
+ /**
121
+ * EL ABRIDOR de este aparato: con su envoltura saca la llave y con ella el sobre.
122
+ *
123
+ * `openSealed({ wrap, envelope })` es `identity.openSealedValue`: la privada de cifrado no
124
+ * sale del iframe (ni del núcleo, en la extensión). Aquí solo se empareja cada sobre con la
125
+ * envoltura de su generación.
126
+ */
127
+ export function makeOpener ({ wraps, openSealed }) {
128
+ if (typeof openSealed !== 'function') throw new Error('makeOpener: missing openSealed({ wrap, envelope })')
129
+ return async ({ envelope }) => {
130
+ const wrap = wraps?.[envelope?.gen]
131
+ if (!wrap) {
132
+ throw Object.assign(new Error('this device has no key for that content generation'), { code: 'no-wrap' })
133
+ }
134
+ return openSealed({ wrap, envelope })
135
+ }
136
+ }
137
+
138
+ /** La vista de una entrada, abierta con las envolturas que vinieron con ella. */
139
+ export async function openSealedView ({ view, wrap, id, updatedAt, digests, openSealed }) {
140
+ const open = makeOpener({ wraps: { [view.gen]: wrap }, openSealed })
141
+ return openView({ id, updatedAt, view, digests }, open)
142
+ }
143
+
144
+ /** Los campos que vinieron de `get`, con la forma de siempre. */
145
+ export async function openSealedEntry ({ got, openSealed, view = null }) {
146
+ const open = makeOpener({ wraps: got.wraps, openSealed })
147
+ const vista = view || await openView({ id: got.id, updatedAt: got.updatedAt, view: got.view, digests: got.digests }, open)
148
+ const abierta = await openFields({
149
+ sealed: { id: got.id, updatedAt: got.updatedAt },
150
+ envelopes: got.envelopes,
151
+ open,
152
+ view: vista
153
+ })
154
+ return { ...abierta, withheld: got.withheld || [] }
155
+ }
156
+
157
+ /**
158
+ * UN PARCHE LISTO PARA MANDAR: los campos que cambian, cifrados y envueltos, y la vista
159
+ * nueva. Lo que no viene aquí se queda en la bóveda como estaba, sin abrirse.
160
+ *
161
+ * @param {object} o
162
+ * `id`, `view` la entrada y su vista NUEVA en claro (la arma quien cambia, que ya la
163
+ * tenía abierta: la vista no es un valor)
164
+ * `values` solo lo que cambia: `{ <clave que guarda la bóveda>: { name, value } }`.
165
+ * La clave de un campo libre es una HUELLA y el `name` es su nombre de
166
+ * verdad — hace falta porque el RESUMEN se calcula sobre el nombre, no
167
+ * sobre la huella, o dejaría de casar con lo que escribió quien creó la
168
+ * entrada.
169
+ * `drop` las claves que se van (ya traducidas)
170
+ * `keys`, `recipients`, `author` como en `buildSealedEntry`
171
+ */
172
+ export async function buildSealedPatch ({ id, view, values = {}, drop = [], priv = null, idx = null, wa, keys, recipients, author } = {}) {
173
+ if (typeof author?.sign !== 'function') throw new Error('buildSealedPatch: author needs { publickey, sign(body) }')
174
+ const cek = await makeContentKey()
175
+ const hayPasskey = Object.prototype.hasOwnProperty.call(values, PASSKEY_FIELD)
176
+ const cekPk = hayPasskey ? await makeContentKey() : null
177
+
178
+ const fields = {}
179
+ const digests = {}
180
+ for (const [k, v] of Object.entries(values)) {
181
+ const { name, value } = (v && typeof v === 'object') ? v : { name: k.replace(/^f:/, ''), value: v }
182
+ const conQue = k === PASSKEY_FIELD ? cekPk : cek
183
+ fields[k] = await encryptWithCek({ cek: conQue, gen: 0, plaintext: String(value) })
184
+ // El resumen es del VALOR y del NOMBRE, con el id de la entrada dentro (§2.6). La
185
+ // privada de una passkey no se compara nunca: no se teclea, así que no hay nada que
186
+ // cotejar.
187
+ if (k !== PASSKEY_FIELD) digests[k] = await valueDigest(keys.kcmp, id, name, String(value))
188
+ }
189
+ const vista = await encryptWithCek({ cek, gen: 0, plaintext: JSON.stringify(view) })
190
+ const ts = Date.now()
191
+ const cuerpo = patchAuthorBody({ id, view: vista, fields, drop, digests, priv, idx, wa }, ts)
192
+ const { signature } = await author.sign(cuerpo)
193
+ return {
194
+ id,
195
+ view: vista,
196
+ fields,
197
+ drop,
198
+ digests,
199
+ ...(Array.isArray(priv) ? { priv } : {}),
200
+ ...(Array.isArray(idx) ? { idx } : {}),
201
+ ...(wa === undefined ? {} : { wa }),
202
+ author: { pub: author.publickey, sig: signature, ts },
203
+ main: { wraps: await wrapAll(cek, { recoveryPub: recipients?.recoveryPub, members: recipients?.main }) },
204
+ ...(cekPk
205
+ ? { passkeys: { wraps: await wrapAll(cekPk, { recoveryPub: recipients?.recoveryPub, members: recipients?.passkeys }) } }
206
+ : {})
207
+ }
208
+ }
209
+
210
+ export { PASSKEY_FIELD, profileKeys }
211
+ export default { buildSealedEntry, buildSealedPatch, buildProfileKey, openSealedEntry, openSealedView, makeOpener, entryAuthorBody, patchAuthorBody }