@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.
- package/package.json +7 -1
- package/src/index.js +9 -1
- package/src/node.js +4 -180
- package/vault/acta.js +5 -1
- package/vault/core.js +95 -15
- package/vault/facade.js +200 -0
- package/vault/index.html +10 -0
- package/vault/machine.js +95 -0
- package/vault/remote.js +54 -9
- package/vault/vault.js +29 -58
- package/vault/vendor/compat/VERSION.txt +3 -0
- package/vault/vendor/compat/advisory.js +141 -0
- package/vault/vendor/compat/index.js +188 -0
- package/vault/vendor/compat/ranges.js +93 -0
- package/vault/vendor/opaque/VERSION.txt +1 -1
- package/vault/vendor/passmanager/VERSION.txt +3 -0
- package/vault/vendor/passmanager/crypto.js +167 -0
- package/vault/vendor/passmanager/fields.js +183 -0
- package/vault/vendor/passmanager/generate.js +74 -0
- package/vault/vendor/passmanager/import.js +156 -0
- package/vault/vendor/passmanager/index.js +27 -0
- package/vault/vendor/passmanager/match.js +137 -0
- package/vault/vendor/passmanager/model.js +332 -0
- package/vault/vendor/passmanager/pubkey.js +33 -0
- package/vault/vendor/passmanager/sealed/convert.js +72 -0
- package/vault/vendor/passmanager/sealed/device.js +211 -0
- package/vault/vendor/passmanager/sealed/entry.js +209 -0
- package/vault/vendor/passmanager/sealed/index.js +20 -0
- package/vault/vendor/passmanager/sealed/keys.js +120 -0
- package/vault/vendor/passmanager/sealed/recovery.js +136 -0
- package/vault/vendor/passmanager/sealed/store.js +508 -0
- package/vault/vendor/passmanager/session-cache.js +60 -0
- package/vault/vendor/passmanager/totp.js +111 -0
- package/vault/vendor/passmanager/transport/protocol.js +83 -0
- package/vault/vendor/passmanager/transport/proxy.js +112 -0
- package/vault/vendor/passmanager/transport/sealed.js +74 -0
- package/vault/vendor/passmanager/vault/approval.js +63 -0
- package/vault/vendor/passmanager/vault/errors.js +34 -0
- package/vault/vendor/passmanager/vault/guard.js +114 -0
- package/vault/vendor/passmanager/vault/interface.js +34 -0
- package/vault/vendor/passmanager/vault/local.js +213 -0
- package/vault/vendor/passmanager/vault/remote.js +85 -0
- package/vault/vendor/passmanager/vault/responder.js +192 -0
- package/vault/vendor/passmanager/vault/sealed-local.js +199 -0
- package/vault/vendor/passmanager/vault/sealed-responder.js +159 -0
- package/vault/vendor/passmanager/vault/sealed.js +362 -0
- package/vault/vendor/passmanager/webauthn.js +221 -0
- package/vault/vendor/proxy-client/VERSION.txt +1 -1
- package/vault/vendor/proxy-client/client.js +36 -0
- package/vault/vendor/roadmap/VERSION.txt +3 -0
- package/vault/vendor/roadmap/index.js +108 -0
- package/vault/vendor/roadmap/manifests.js +797 -0
- package/vault/vendor/store/VERSION.txt +3 -0
- package/vault/vendor/store/core.js +309 -0
- package/vault/vendor/vault/VERSION.txt +4 -1
- package/vault/vendor/vault/admin.js +437 -0
- package/vault/vendor/vault/browser.js +167 -0
- package/vault/vendor/vault/core/approvals.js +204 -0
- package/vault/vendor/vault/core/blocked.js +68 -0
- package/vault/vendor/vault/core/browserDisk.js +109 -0
- package/vault/vendor/vault/core/commKey.js +68 -0
- package/vault/vendor/vault/core/disk.js +65 -0
- package/vault/vendor/vault/core/libVersion.js +8 -0
- package/vault/vendor/vault/core/machines.js +66 -0
- package/vault/vendor/vault/core/notices.js +71 -0
- package/vault/vendor/vault/core/notifiable.js +73 -0
- package/vault/vendor/vault/core/passwords.js +125 -0
- package/vault/vendor/vault/core/sealKey.js +77 -0
- package/vault/vendor/vault/core/sealer.js +172 -0
- package/vault/vendor/vault/core/sealers.js +159 -0
- package/vault/vendor/vault/core/secretsStore.js +1326 -0
- package/vault/vendor/vault/core/sha256.js +69 -0
- package/vault/vendor/vault/core/store.js +68 -0
- package/vault/vendor/vault/core/subacta.js +101 -0
- package/vault/vendor/vault/core/threadStore.js +273 -0
- package/vault/vendor/vault/core/transport.js +270 -0
- package/vault/vendor/vault/core/vault.js +4414 -0
- package/vault/vendor/vault/enroll.js +8 -4
- package/vault/vendor/vault/procContext.js +97 -0
- package/vault/vendor/vault/revocation.js +67 -0
- package/vault/vendor/vault/sealed.js +84 -0
|
@@ -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 }
|
|
@@ -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 }
|