@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,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 }
@@ -0,0 +1,209 @@
1
+ // UNA ENTRADA SELLADA POR CAMPO (`sealed-passwords.md` §2.2 y §2.4).
2
+ //
3
+ // La diferencia con el formato de siempre (`../model.js`) no es criptográfica sino de
4
+ // QUIÉN PUEDE ABRIR: allí una sola llave de la bóveda cifraba todo, así que la bóveda leía
5
+ // las contraseñas —abierta o cerrada—. Aquí la bóveda guarda sobres dirigidos a los
6
+ // aparatos y **no tiene con qué abrir ninguno**.
7
+ //
8
+ // Tres consecuencias que cambian la forma del dato, y las tres están en el diseño:
9
+ //
10
+ // · **La vista pública se GUARDA sellada**, no se calcula. Antes la calculaba quien tenía
11
+ // la llave; ahora la escribe quien escribe la entrada.
12
+ // · **Cada campo va en su propio sobre.** Es lo que permite entregar solo los campos
13
+ // pedidos sin abrir la entrada, y lo que deja poner la privada de una passkey en un
14
+ // sobre con OTROS destinatarios (§2.8).
15
+ // · **Lo único en claro son huellas**: las del sitio, para poder buscar, y los resúmenes
16
+ // para comparar. Ni un nombre de dominio.
17
+ //
18
+ // Este módulo es PURO: recibe las llaves ya resueltas y no sabe de red, de almacén ni de
19
+ // quién es miembro de nada.
20
+
21
+ import { encryptWithCek, decryptWithCek } from '@dotrino/identity/content'
22
+ import { normalizeFields, fieldKey, entryFieldKeys, privateKeysOf, entryDigestValues } from '../fields.js'
23
+ import { newEntry, normalizeSites, entryWho } from '../model.js'
24
+ import { siteIndex, fieldIndex, passkeyIndex, valueDigest } from './keys.js'
25
+
26
+ /** La privada de una passkey es el único campo que va a OTROS destinatarios (§2.8). */
27
+ export const PASSKEY_FIELD = 'wa.privateKey'
28
+
29
+ /**
30
+ * Los campos de siempre. Su NOMBRE viaja en claro a propósito: es un vocabulario cerrado,
31
+ * idéntico en todas las entradas de todo el mundo, así que no dice nada de nadie — y es lo
32
+ * que deja a la bóveda saber que una contraseña es privada sin fiarse de ninguna lista.
33
+ */
34
+ export const FIXED = ['name', 'username', 'secret', 'totp', 'notes']
35
+
36
+ const sealV = async (cek, gen, value) => encryptWithCek({ cek, gen, plaintext: String(value) })
37
+
38
+ /**
39
+ * SELLA UNA ENTRADA ENTERA.
40
+ *
41
+ * @param {object} o
42
+ * `plain` la entrada en claro (la misma forma de `newEntry`)
43
+ * `main` `{ gen, cek }` la generación para todo lo normal
44
+ * `passkeys` `{ gen, cek }` la generación de la privada de la passkey. Solo hace falta
45
+ * si la entrada lleva una; si falta y la lleva, se para: guardar esa llave
46
+ * con los destinatarios de las contraseñas es justo lo que §2.8 prohíbe
47
+ * `keys` `{ kidx, kcmp }` las llaves del perfil
48
+ * @returns {Promise<object>} la entrada tal como se guarda
49
+ */
50
+ export async function sealEntry ({ plain, main, passkeys = null, keys }) {
51
+ if (!main?.cek || typeof main.gen !== 'number') throw new Error('sealEntry: missing the main generation')
52
+ if (!keys?.kidx || !keys?.kcmp) throw new Error('sealEntry: missing the profile keys')
53
+ const e = newEntry(plain)
54
+ const campos = (() => {
55
+ if (Array.isArray(e.fields)) return normalizeFields(e.fields)
56
+ try { return normalizeFields(JSON.parse(e.fields || '[]')) } catch { return [] }
57
+ })()
58
+ const abierta = { ...e, fields: JSON.stringify(campos) }
59
+
60
+ const fields = {}
61
+ for (const f of FIXED) if (e[f]) fields[f] = await sealV(main.cek, main.gen, e[f])
62
+ for (const f of campos) {
63
+ if (!f?.value) continue
64
+ // LA MARCA DE PRIVADO ES DEL SOBRE, con su valor (dueño, 2026-09-19). No es metadato
65
+ // suelto: dice qué es ese dato, y separarla dejaba el sobre incompleto —quien lo abre
66
+ // solo no sabría si es privado— y dos verdades que se pueden desincronizar. Lo que va
67
+ // en la vista y en el índice de la bóveda son COPIAS derivadas, que se rehacen al
68
+ // escribir.
69
+ //
70
+ // El precio, y se asume: cambiar la marca obliga a re-sellar el campo, o sea a tenerlo
71
+ // delante. Marcar uno público como privado no cuesta aprobación (leerlo es gratis);
72
+ // quitársela a uno privado sí la cuesta — y está bien, porque vas a exponerlo.
73
+ fields['f:' + await fieldIndex(keys.kidx, fieldKey(f))] = await sealV(main.cek, main.gen, JSON.stringify(f))
74
+ }
75
+
76
+ if (e.webauthn) {
77
+ if (e.webauthn.userHandle) fields['wa.userHandle'] = await sealV(main.cek, main.gen, e.webauthn.userHandle)
78
+ if (e.webauthn.privateKey) {
79
+ if (!passkeys?.cek || typeof passkeys.gen !== 'number') {
80
+ throw Object.assign(
81
+ new Error('this entry carries a passkey: it needs its own generation, wrapped only to devices with `passkeys`'),
82
+ { code: 'passkeys-generation-missing' })
83
+ }
84
+ fields[PASSKEY_FIELD] = await sealV(passkeys.cek, passkeys.gen, e.webauthn.privateKey)
85
+ }
86
+ }
87
+
88
+ // LA VISTA: lo que ve quien puede abrir la entrada pero no pidió ningún valor. Lleva los
89
+ // NOMBRES de los campos y cuáles son privados —nunca sus valores—, que es lo que deja
90
+ // ofrecer «rellenar el número de socio» sin sacar nada.
91
+ const view = {
92
+ type: e.type,
93
+ title: e.title,
94
+ sites: e.sites,
95
+ hint: entryWho(abierta),
96
+ fieldKeys: entryFieldKeys(abierta),
97
+ privateKeys: [...privateKeysOf(abierta)],
98
+ has: {
99
+ secret: !!e.secret, totp: !!e.totp, notes: !!e.notes,
100
+ fields: campos.length > 0, webauthn: !!e.webauthn
101
+ },
102
+ ...(e.webauthn
103
+ ? { webauthn: { rpId: e.webauthn.rpId, credentialId: e.webauthn.credentialId, signCount: e.webauthn.signCount || 0 } }
104
+ : {})
105
+ }
106
+
107
+ // LOS RESÚMENES, con la clave por huella igual que los sobres: los guarda la bóveda y
108
+ // tampoco tiene por qué saber de qué campo son. Lo que se resume sigue siendo el NOMBRE
109
+ // de verdad más el valor —es lo que compara quien escribe, que lo tiene delante.
110
+ const digests = {}
111
+ for (const f of entryDigestValues(abierta)) {
112
+ const clave = FIXED.includes(f.key) || f.key.startsWith('wa.') ? f.key : 'f:' + await fieldIndex(keys.kidx, f.key)
113
+ digests[clave] = await valueDigest(keys.kcmp, e.id, f.key, f.value)
114
+ }
115
+
116
+ return {
117
+ id: e.id,
118
+ createdAt: e.createdAt,
119
+ updatedAt: e.updatedAt,
120
+ // Sin sitios = sirve en cualquier parte, que es lo que ha significado siempre.
121
+ idx: await Promise.all(normalizeSites(e.sites).map((s) => siteIndex(keys.kidx, s))),
122
+ wa: e.webauthn
123
+ ? {
124
+ rp: await passkeyIndex(keys.kidx, 'rp', e.webauthn.rpId),
125
+ cred: await passkeyIndex(keys.kidx, 'cred', e.webauthn.credentialId)
126
+ }
127
+ : null,
128
+ view: await sealV(main.cek, main.gen, JSON.stringify(view)),
129
+ fields,
130
+ digests,
131
+ // QUÉ CLAVES SON PRIVADAS, para que la bóveda sepa cuándo pedir un dedo encima. Es un
132
+ // ÍNDICE derivado de las marcas de los sobres, no la fuente.
133
+ //
134
+ // Y va con las mismas huellas que las claves: de un campo libre, la bóveda ve
135
+ // `f:<huella>` y no «cédula». Las fijas (`secret`, `totp`, …) sí van por su nombre, que
136
+ // es un vocabulario cerrado e igual para todo el mundo — y son las que le dejan aplicar
137
+ // su propia regla sin fiarse de esta lista, que la escribe el aparato.
138
+ priv: await Promise.all([...privateKeysOf(abierta)].map(async (k) =>
139
+ (FIXED.includes(k) || k.startsWith('wa.') ? k : 'f:' + await fieldIndex(keys.kidx, k))))
140
+ }
141
+ }
142
+
143
+ /** Abre la vista de una entrada. `open({ envelope })` lo pone quien tiene la llave. */
144
+ export async function openView (sealed, open) {
145
+ const v = JSON.parse(await open({ envelope: sealed.view }))
146
+ return {
147
+ id: sealed.id,
148
+ type: v.type,
149
+ title: v.title,
150
+ sites: v.sites || [],
151
+ hint: v.hint || '',
152
+ fieldKeys: v.fieldKeys || [],
153
+ privateKeys: v.privateKeys || [],
154
+ hasSecret: !!v.has?.secret,
155
+ hasTotp: !!v.has?.totp,
156
+ hasNotes: !!v.has?.notes,
157
+ hasFields: !!v.has?.fields,
158
+ hasWebauthn: !!v.has?.webauthn,
159
+ ...(v.webauthn ? { webauthn: v.webauthn } : {}),
160
+ updatedAt: sealed.updatedAt,
161
+ digests: sealed.digests || {}
162
+ }
163
+ }
164
+
165
+ /**
166
+ * ABRE LOS CAMPOS QUE VINIERON y los devuelve con la forma de siempre, para que la interfaz
167
+ * no tenga que aprender un formato nuevo.
168
+ *
169
+ * Lo que no vino, no está: pedir el usuario y recibir la entrada entera es justo lo que se
170
+ * quitó. `withheld` dice qué se quedó fuera —porque no se pidió, o porque este aparato no
171
+ * puede abrirlo (una passkey sin el permiso)— para que la pantalla lo diga en vez de
172
+ * enseñar un hueco.
173
+ */
174
+ export async function openFields ({ sealed, envelopes, open, view = null }) {
175
+ const out = {
176
+ id: sealed?.id || view?.id,
177
+ type: view?.type || 'login',
178
+ title: view?.title || '',
179
+ sites: view?.sites || [],
180
+ name: '', username: '', secret: '', totp: '', notes: '',
181
+ fields: '[]',
182
+ webauthn: null,
183
+ createdAt: sealed?.createdAt,
184
+ updatedAt: sealed?.updatedAt || view?.updatedAt
185
+ }
186
+ const libres = []
187
+ let wa = view?.webauthn ? { ...view.webauthn, userHandle: '', privateKey: '' } : null
188
+ for (const [k, envelope] of Object.entries(envelopes || {})) {
189
+ const valor = await open({ envelope, field: k })
190
+ if (FIXED.includes(k)) { out[k] = valor; continue }
191
+ if (k.startsWith('f:')) {
192
+ // El campo viene entero: su etiqueta, su clase, su valor y su marca. La clave con la
193
+ // que la bóveda lo guarda es una huella y no hace falta para nada aquí — el nombre
194
+ // sale del propio sobre.
195
+ try { libres.push(JSON.parse(valor)) } catch (_) {}
196
+ continue
197
+ }
198
+ if (k === 'wa.userHandle') { wa = { ...(wa || {}), userHandle: valor }; continue }
199
+ if (k === PASSKEY_FIELD) { wa = { ...(wa || {}), privateKey: valor }; continue }
200
+ }
201
+ out.fields = JSON.stringify(libres)
202
+ out.webauthn = wa
203
+ return out
204
+ }
205
+
206
+ /** Qué claves de campo lleva una entrada sellada. Sin abrir nada: son los nombres. */
207
+ export const fieldNames = (sealed) => Object.keys(sealed?.fields || {})
208
+
209
+ export default { sealEntry, openView, openFields, fieldNames, PASSKEY_FIELD }
@@ -0,0 +1,20 @@
1
+ // CONTRASEÑAS SELLADAS POR APARATO — la puerta del subpath `@dotrino/passmanager/sealed`.
2
+ //
3
+ // Va por su propio subpath y NO desde el índice del paquete: esto arrastra
4
+ // `@dotrino/identity/content` (la cripto de los sobres) y no todo el que usa el gestor
5
+ // necesita cargarla. Quien sella, la pide.
6
+ //
7
+ // Las tres piezas, y quién usa cada una:
8
+ //
9
+ // · `SealedStore` — la BÓVEDA (el demonio, la pestaña, la extensión cuando hace de
10
+ // bóveda, `serve`). Guarda sobres que no puede abrir y comprueba destinatarios.
11
+ // · `buildSealedEntry` / `openSealedEntry` — el APARATO que pide. Cifra, envuelve, firma;
12
+ // y al leer, abre con su envoltura.
13
+ // · `profileKeys` y las huellas — las dos puntas: con ellas se busca por sitio y se
14
+ // compara sin abrir.
15
+ export * from './store.js'
16
+ export * from './device.js'
17
+ export * from './entry.js'
18
+ export * from './keys.js'
19
+ export * from './recovery.js'
20
+ export * from './convert.js'
@@ -0,0 +1,120 @@
1
+ // LA LLAVE DEL PERFIL PARA LAS CONTRASEÑAS, y las dos que salen de ella.
2
+ //
3
+ // `sealed-passwords.md` §2.6. Una sola llave aleatoria, sellada a los aparatos con
4
+ // `passwords` como cualquier otro valor, y de ella se DERIVAN dos:
5
+ //
6
+ // · `kidx` — el índice de sitios. La bóveda busca por huellas, no por dominios, así que
7
+ // una copia de su disco no dice en qué sitios tienes cuenta.
8
+ // · `kcmp` — comparar sin abrir. El gestor sabe si lo que acabas de escribir ya está
9
+ // guardado igual sin pedir una autorización para mirarlo.
10
+ //
11
+ // **Son dos y no una**, y el motivo es que un uso no sirva para el otro: con una sola,
12
+ // quien viera un índice de sitios podría fabricar resúmenes de valores, y al revés. Salen
13
+ // de HKDF con etiquetas distintas, que es lo que separa dos llaves de una.
14
+ //
15
+ // Lo que NO protegen, dicho como en el diseño: un aparato que tenga `kcmp` puede adivinar
16
+ // un valor muy corto (un PIN de cuatro cifras) probando resúmenes. Por eso los resúmenes no
17
+ // salen del aparato que los calcula.
18
+
19
+ const subtle = () => {
20
+ const c = globalThis.crypto
21
+ if (!c?.subtle) throw new Error('webcrypto unavailable')
22
+ return c.subtle
23
+ }
24
+
25
+ const enc = new TextEncoder()
26
+
27
+ const b64 = (bytes) => {
28
+ let s = ''
29
+ for (const b of new Uint8Array(bytes)) s += String.fromCharCode(b)
30
+ return btoa(s)
31
+ }
32
+ const unb64 = (str) => {
33
+ const bin = atob(str)
34
+ const out = new Uint8Array(bin.length)
35
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
36
+ return out
37
+ }
38
+
39
+ /** La llave base del perfil, en base64. Es lo que se sella y se reparte. */
40
+ export async function makePasswordKey () {
41
+ return b64(globalThis.crypto.getRandomValues(new Uint8Array(32)))
42
+ }
43
+
44
+ /**
45
+ * Deriva una de las dos llaves de uso. HKDF-SHA256 con `info` distinto por uso: es la
46
+ * forma estándar de que dos usos no compartan llave aunque compartan origen.
47
+ */
48
+ async function derive (base, use) {
49
+ const material = await subtle().importKey('raw', unb64(base), 'HKDF', false, ['deriveKey'])
50
+ return subtle().deriveKey(
51
+ { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: enc.encode('dotrino-passmanager/v2/' + use) },
52
+ material,
53
+ { name: 'HMAC', hash: 'SHA-256', length: 256 },
54
+ false,
55
+ ['sign']
56
+ )
57
+ }
58
+
59
+ /**
60
+ * Las dos llaves de uso, listas para firmar. Se derivan UNA vez por sesión: hacerlo en
61
+ * cada huella multiplica el coste de abrir el gestor sin ganar nada.
62
+ */
63
+ export async function profileKeys (base) {
64
+ if (typeof base !== 'string' || !base) throw new Error('profileKeys: missing the profile password key')
65
+ const [kidx, kcmp] = await Promise.all([derive(base, 'idx'), derive(base, 'cmp')])
66
+ return { kidx, kcmp, base }
67
+ }
68
+
69
+ /** 128 bits: de sobra para no colisionar, y la mitad de bytes que el SHA-256 entero. */
70
+ async function hmac (key, text) {
71
+ const sig = await subtle().sign('HMAC', key, enc.encode(text))
72
+ return b64(new Uint8Array(sig).slice(0, 16))
73
+ }
74
+
75
+ /**
76
+ * LA HUELLA DE UN SITIO. Lo que la bóveda guarda en claro de cada entrada, y con lo que
77
+ * contesta «tengo algo para esto» sin saber para qué.
78
+ *
79
+ * El sitio se normaliza aquí y en un solo sitio: si quien guarda y quien pregunta lo
80
+ * escriben distinto (`Google.com` y `google.com`), la huella no casa y la entrada no
81
+ * aparece — un fallo mudo de los caros.
82
+ */
83
+ export function siteIndex (kidx, site) {
84
+ return hmac(kidx, 'site ' + String(site || '').trim().toLowerCase())
85
+ }
86
+
87
+ /**
88
+ * LA HUELLA DEL NOMBRE DE UN CAMPO LIBRE.
89
+ *
90
+ * El nombre que el usuario le pone a un campo es SUYO —«cédula», «número de socio»— y decía
91
+ * demasiado guardado en claro: la bóveda tiene que poder emparejar la clave que le piden con
92
+ * el sobre que guarda, pero no tiene por qué saber de qué se trata. Compara huellas, como
93
+ * con los sitios.
94
+ *
95
+ * Los campos FIJOS (`username`, `secret`, `totp`, `notes`, `wa.*`) no pasan por aquí a
96
+ * propósito: son un vocabulario cerrado, iguales en todas las entradas y de todo el mundo, y
97
+ * son los que dejan a la bóveda aplicar su propia regla —la contraseña es privada siempre—
98
+ * sin fiarse de una lista que escribe el aparato.
99
+ */
100
+ export function fieldIndex (kidx, key) {
101
+ return hmac(kidx, 'field ' + String(key || ''))
102
+ }
103
+
104
+ /** La huella de una passkey: por el sitio que la pide y por el id de la credencial. */
105
+ export function passkeyIndex (kidx, kind, value) {
106
+ return hmac(kidx, 'wa.' + kind + ' ' + String(value || ''))
107
+ }
108
+
109
+ /**
110
+ * EL RESUMEN DE UN VALOR, para comparar sin abrir.
111
+ *
112
+ * El `id` de la entrada va DENTRO: así el mismo valor en dos entradas da dos resúmenes
113
+ * distintos y un resumen no delata una contraseña repetida. Y el nombre del campo también,
114
+ * para que el resumen de un usuario no se pueda comparar con el de una contraseña.
115
+ */
116
+ export function valueDigest (kcmp, id, field, value) {
117
+ return hmac(kcmp, `${id} ${field} ${value}`)
118
+ }
119
+
120
+ export default { makePasswordKey, profileKeys, siteIndex, fieldIndex, passkeyIndex, valueDigest }