@dotrino/identity 0.115.0 → 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 (78) hide show
  1. package/package.json +4 -1
  2. package/src/node.js +4 -180
  3. package/vault/core.js +66 -7
  4. package/vault/facade.js +200 -0
  5. package/vault/index.html +10 -0
  6. package/vault/remote.js +45 -5
  7. package/vault/vault.js +29 -58
  8. package/vault/vendor/compat/VERSION.txt +3 -0
  9. package/vault/vendor/compat/advisory.js +141 -0
  10. package/vault/vendor/compat/index.js +188 -0
  11. package/vault/vendor/compat/ranges.js +93 -0
  12. package/vault/vendor/opaque/VERSION.txt +1 -1
  13. package/vault/vendor/passmanager/VERSION.txt +3 -0
  14. package/vault/vendor/passmanager/crypto.js +167 -0
  15. package/vault/vendor/passmanager/fields.js +183 -0
  16. package/vault/vendor/passmanager/generate.js +74 -0
  17. package/vault/vendor/passmanager/import.js +156 -0
  18. package/vault/vendor/passmanager/index.js +27 -0
  19. package/vault/vendor/passmanager/match.js +137 -0
  20. package/vault/vendor/passmanager/model.js +332 -0
  21. package/vault/vendor/passmanager/pubkey.js +33 -0
  22. package/vault/vendor/passmanager/sealed/convert.js +72 -0
  23. package/vault/vendor/passmanager/sealed/device.js +211 -0
  24. package/vault/vendor/passmanager/sealed/entry.js +209 -0
  25. package/vault/vendor/passmanager/sealed/index.js +20 -0
  26. package/vault/vendor/passmanager/sealed/keys.js +120 -0
  27. package/vault/vendor/passmanager/sealed/recovery.js +136 -0
  28. package/vault/vendor/passmanager/sealed/store.js +508 -0
  29. package/vault/vendor/passmanager/session-cache.js +60 -0
  30. package/vault/vendor/passmanager/totp.js +111 -0
  31. package/vault/vendor/passmanager/transport/protocol.js +83 -0
  32. package/vault/vendor/passmanager/transport/proxy.js +112 -0
  33. package/vault/vendor/passmanager/transport/sealed.js +74 -0
  34. package/vault/vendor/passmanager/vault/approval.js +63 -0
  35. package/vault/vendor/passmanager/vault/errors.js +34 -0
  36. package/vault/vendor/passmanager/vault/guard.js +114 -0
  37. package/vault/vendor/passmanager/vault/interface.js +34 -0
  38. package/vault/vendor/passmanager/vault/local.js +213 -0
  39. package/vault/vendor/passmanager/vault/remote.js +85 -0
  40. package/vault/vendor/passmanager/vault/responder.js +192 -0
  41. package/vault/vendor/passmanager/vault/sealed-local.js +199 -0
  42. package/vault/vendor/passmanager/vault/sealed-responder.js +159 -0
  43. package/vault/vendor/passmanager/vault/sealed.js +362 -0
  44. package/vault/vendor/passmanager/webauthn.js +221 -0
  45. package/vault/vendor/proxy-client/VERSION.txt +1 -1
  46. package/vault/vendor/proxy-client/client.js +36 -0
  47. package/vault/vendor/roadmap/VERSION.txt +3 -0
  48. package/vault/vendor/roadmap/index.js +108 -0
  49. package/vault/vendor/roadmap/manifests.js +797 -0
  50. package/vault/vendor/store/VERSION.txt +3 -0
  51. package/vault/vendor/store/core.js +309 -0
  52. package/vault/vendor/vault/VERSION.txt +4 -1
  53. package/vault/vendor/vault/admin.js +437 -0
  54. package/vault/vendor/vault/browser.js +167 -0
  55. package/vault/vendor/vault/core/approvals.js +204 -0
  56. package/vault/vendor/vault/core/blocked.js +68 -0
  57. package/vault/vendor/vault/core/browserDisk.js +109 -0
  58. package/vault/vendor/vault/core/commKey.js +68 -0
  59. package/vault/vendor/vault/core/disk.js +65 -0
  60. package/vault/vendor/vault/core/libVersion.js +8 -0
  61. package/vault/vendor/vault/core/machines.js +66 -0
  62. package/vault/vendor/vault/core/notices.js +71 -0
  63. package/vault/vendor/vault/core/notifiable.js +73 -0
  64. package/vault/vendor/vault/core/passwords.js +125 -0
  65. package/vault/vendor/vault/core/sealKey.js +77 -0
  66. package/vault/vendor/vault/core/sealer.js +172 -0
  67. package/vault/vendor/vault/core/sealers.js +159 -0
  68. package/vault/vendor/vault/core/secretsStore.js +1326 -0
  69. package/vault/vendor/vault/core/sha256.js +69 -0
  70. package/vault/vendor/vault/core/store.js +68 -0
  71. package/vault/vendor/vault/core/subacta.js +101 -0
  72. package/vault/vendor/vault/core/threadStore.js +273 -0
  73. package/vault/vendor/vault/core/transport.js +270 -0
  74. package/vault/vendor/vault/core/vault.js +4414 -0
  75. package/vault/vendor/vault/enroll.js +8 -4
  76. package/vault/vendor/vault/procContext.js +97 -0
  77. package/vault/vendor/vault/revocation.js +67 -0
  78. package/vault/vendor/vault/sealed.js +84 -0
@@ -0,0 +1,167 @@
1
+ // Cifrado de las entradas de la bóveda.
2
+ //
3
+ // Una CEK (AES-GCM 256) por bóveda, con la que se cifra cada entrada por separado.
4
+ // Quién tiene la CEK es lo que define la confianza de cada aparato (DISENO §3): al
5
+ // vault y a la app nativa se les envuelve; a la extensión, no.
6
+ //
7
+ // El envoltorio de la CEK a cada miembro NO se hace aquí: eso es `wrapForMember` de
8
+ // @dotrino/identity/content, que ya está escrito y probado. Este módulo es solo el
9
+ // tramo simétrico.
10
+
11
+ const enc = new TextEncoder()
12
+ const dec = new TextDecoder()
13
+
14
+ /** WebCrypto del entorno (navegador, service worker o Node ≥ 20). */
15
+ function subtle () {
16
+ const c = globalThis.crypto
17
+ if (!c?.subtle) throw new Error('webcrypto unavailable')
18
+ return c.subtle
19
+ }
20
+
21
+ function randomBytes (n) {
22
+ const b = new Uint8Array(n)
23
+ globalThis.crypto.getRandomValues(b)
24
+ return b
25
+ }
26
+
27
+ export function toBase64 (bytes) {
28
+ let s = ''
29
+ for (const b of bytes) s += String.fromCharCode(b)
30
+ return btoa(s)
31
+ }
32
+
33
+ export function fromBase64 (b64) {
34
+ const s = atob(b64)
35
+ const out = new Uint8Array(s.length)
36
+ for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i)
37
+ return out
38
+ }
39
+
40
+ /** Genera una CEK nueva. Extraíble: hay que poder envolverla para cada aparato. */
41
+ export async function makeVaultKey () {
42
+ return subtle().generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'])
43
+ }
44
+
45
+ export async function exportVaultKey (key) {
46
+ return new Uint8Array(await subtle().exportKey('raw', key))
47
+ }
48
+
49
+ export async function importVaultKey (raw) {
50
+ return subtle().importKey('raw', raw, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt'])
51
+ }
52
+
53
+ /**
54
+ * Cifra un valor con la CEK. Devuelve `{ iv, ct }` en base64, para que una entrada
55
+ * cifrada siga siendo JSON serializable tal cual.
56
+ *
57
+ * `aad` ata el criptograma a su sitio: se pasa el id de la entrada y el campo, así
58
+ * que un criptograma movido de una entrada a otra (o de `secret` a `totp`) no abre.
59
+ */
60
+ export async function sealValue (key, plaintext, aad) {
61
+ const iv = randomBytes(12)
62
+ const params = { name: 'AES-GCM', iv }
63
+ if (aad) params.additionalData = enc.encode(aad)
64
+ const ct = await subtle().encrypt(params, key, enc.encode(plaintext))
65
+ return { iv: toBase64(iv), ct: toBase64(new Uint8Array(ct)) }
66
+ }
67
+
68
+ export async function openValue (key, sealed, aad) {
69
+ if (!sealed || typeof sealed.iv !== 'string' || typeof sealed.ct !== 'string') {
70
+ throw new Error('sealed value malformed')
71
+ }
72
+ const params = { name: 'AES-GCM', iv: fromBase64(sealed.iv) }
73
+ if (aad) params.additionalData = enc.encode(aad)
74
+ const pt = await subtle().decrypt(params, key, fromBase64(sealed.ct))
75
+ return dec.decode(pt)
76
+ }
77
+
78
+ /** Etiqueta AAD de un campo. Un solo sitio para que sellar y abrir no se desalineen. */
79
+ export function fieldTag (entryId, field) {
80
+ return `dotrino-passmanager/v1/${entryId}/${field}`
81
+ }
82
+
83
+ // --- Contraseña maestra ------------------------------------------------------
84
+ //
85
+ // Solo para el caso en que la bóveda vive en el propio aparato y no hay nadie más
86
+ // que la custodie (la extensión antes del paso 2, DISENO §8). Cuando responde el
87
+ // vault o el teléfono, la CEK llega envuelta y esto no se usa.
88
+
89
+ const KDF_ITERATIONS = 600_000 // recomendación OWASP para PBKDF2-SHA256
90
+
91
+ export function makeSalt () {
92
+ return randomBytes(16)
93
+ }
94
+
95
+ /**
96
+ * Deriva la CEK de una contraseña maestra.
97
+ *
98
+ * `extractable` existe por MV3: el service worker de la extensión se duerme, y un
99
+ * `CryptoKey` no sobrevive a `chrome.storage.session` (que serializa a JSON, no
100
+ * clona). Para no pedir la contraseña cada pocos minutos hay que poder exportar la
101
+ * clave y guardarla en memoria de sesión. Es un compromiso consciente y acotado a la
102
+ * extensión: donde no haga falta, se deriva no extraíble.
103
+ */
104
+ export async function deriveKeyFromPassword (password, salt, iterations = KDF_ITERATIONS, extractable = false) {
105
+ const material = await subtle().importKey(
106
+ 'raw', enc.encode(String(password)), 'PBKDF2', false, ['deriveKey'])
107
+ return subtle().deriveKey(
108
+ { name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
109
+ material,
110
+ { name: 'AES-GCM', length: 256 },
111
+ extractable,
112
+ ['encrypt', 'decrypt'],
113
+ )
114
+ }
115
+
116
+ /**
117
+ * Verificador de contraseña: un valor sellado con la propia clave derivada. No es un
118
+ * hash de la contraseña — es una prueba de que la clave abre la bóveda, que es lo que
119
+ * de verdad importa.
120
+ */
121
+ export async function makeVerifier (key) {
122
+ return sealValue(key, 'dotrino-passmanager', 'verifier')
123
+ }
124
+
125
+ export async function checkVerifier (key, verifier) {
126
+ try {
127
+ return (await openValue(key, verifier, 'verifier')) === 'dotrino-passmanager'
128
+ } catch {
129
+ return false
130
+ }
131
+ }
132
+
133
+ // --- comparar sin abrir -------------------------------------------------------
134
+ //
135
+ // Para decir «esto que escribiste ya está guardado igual» hace falta comparar con lo
136
+ // guardado, y abrir la entrada es una autorización (DISENO §3.3.2). La bóveda manda
137
+ // entonces un **resumen** de cada campo: quien pregunta hashea lo que tiene delante y
138
+ // compara los resúmenes.
139
+ //
140
+ // **Un solo método para todos los campos**, públicos y privados (dueño, 2026-08-29):
141
+ // dos formas de comparar según qué campo sea son dos comportamientos que se separan y
142
+ // acaban diciendo cosas distintas.
143
+ //
144
+ // El resumen va con un **nonce nuevo en cada respuesta**, así que no es una huella
145
+ // estable: no vale para reconocer la misma contraseña entre dos respuestas, ni para
146
+ // guardar una tabla y consultarla más tarde. Y el nombre del campo entra en el HMAC, así
147
+ // que un resumen tampoco sirve para comparar contra otro campo.
148
+
149
+ /** Un nonce nuevo, para UNA respuesta. No se guarda en ninguna parte. */
150
+ export function makeNonce () {
151
+ return toBase64(randomBytes(16))
152
+ }
153
+
154
+ /**
155
+ * El hasheador de una respuesta. Se importa la llave UNA vez y se hashean todos los
156
+ * campos con ella: importar por campo multiplica el coste de un `find` sin ganar nada.
157
+ */
158
+ export async function fieldHasher (nonce) {
159
+ const key = await subtle().importKey(
160
+ 'raw', fromBase64(nonce), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
161
+ return async (name, value) => {
162
+ const sig = await subtle().sign('HMAC', key, enc.encode(name + ' ' + value))
163
+ // 128 bits: de sobra para que dos valores distintos no colisionen en la práctica, y
164
+ // la mitad de bytes que mandar el SHA-256 entero.
165
+ return toBase64(new Uint8Array(sig).slice(0, 16))
166
+ }
167
+ }
@@ -0,0 +1,183 @@
1
+ // Campos sueltos para rellenar formularios: correo, teléfono, dirección, cédula, lo
2
+ // que sea. Son del usuario y no de un sitio concreto.
3
+ //
4
+ // Dos ideas, y las dos son deliberadamente simples:
5
+ //
6
+ // 1. **Los campos son libres.** Una lista de `{ label, value, kind }`, no un formulario
7
+ // fijo. Cada quien guarda lo que necesita rellenar, y puede tener varios juegos —
8
+ // la dirección de casa y la del trabajo son dos entradas, no dos campos.
9
+ //
10
+ // 2. **Cross-domain es no tener sitios**, no un tipo aparte. Una entrada sin `sites`
11
+ // sirve en cualquier parte; con `sites`, solo ahí. Eso vale para cualquier tipo de
12
+ // entrada, y así no hay dos reglas de emparejamiento que mantener.
13
+ //
14
+ // `kind` es opcional y solo sirve para el relleno automático: dice QUÉ ES el dato
15
+ // (un correo, un teléfono), para poder ponerlo en el hueco que le corresponde. Sin
16
+ // `kind`, el campo se guarda y se copia igual — solo no se rellena solo.
17
+
18
+ /** Clases de dato que el relleno automático sabe colocar. */
19
+ export const KINDS = [
20
+ 'email', 'tel', 'given-name', 'family-name', 'full-name',
21
+ 'street-address', 'city', 'region', 'postal-code', 'country',
22
+ 'organization', 'birthday', 'id-number', 'other',
23
+ ]
24
+
25
+ /** Tokens de `autocomplete` del estándar HTML. Cuando el sitio los declara, mandan. */
26
+ export const AUTOCOMPLETE_BY_KIND = {
27
+ email: ['email'],
28
+ tel: ['tel', 'tel-national', 'tel-local'],
29
+ 'given-name': ['given-name'],
30
+ 'family-name': ['family-name'],
31
+ 'full-name': ['name'],
32
+ 'street-address': ['street-address', 'address-line1'],
33
+ city: ['address-level2'],
34
+ region: ['address-level1'],
35
+ 'postal-code': ['postal-code'],
36
+ country: ['country', 'country-name'],
37
+ organization: ['organization'],
38
+ birthday: ['bday'],
39
+ }
40
+
41
+ /** Pistas por nombre/id/etiqueta, para los sitios que no declaran `autocomplete`. */
42
+ export const HINTS_BY_KIND = {
43
+ email: ['email', 'correo', 'e-mail', 'mail'],
44
+ tel: ['phone', 'telefono', 'teléfono', 'celular', 'movil', 'móvil', 'mobile', 'tel'],
45
+ 'given-name': ['firstname', 'first-name', 'first_name', 'givenname', 'nombre', 'nombres', 'fname'],
46
+ 'family-name': ['lastname', 'last-name', 'last_name', 'surname', 'apellido', 'apellidos', 'lname'],
47
+ 'full-name': ['fullname', 'full-name', 'nombrecompleto'],
48
+ 'street-address': ['address', 'direccion', 'dirección', 'street', 'calle', 'domicilio'],
49
+ city: ['city', 'ciudad', 'localidad'],
50
+ region: ['state', 'province', 'provincia', 'region', 'estado'],
51
+ 'postal-code': ['zip', 'zipcode', 'postal', 'postcode'],
52
+ country: ['country', 'pais', 'país'],
53
+ organization: ['company', 'empresa', 'organization', 'organizacion'],
54
+ 'id-number': ['cedula', 'cédula', 'dni', 'nif', 'rut', 'curp', 'documento'],
55
+ }
56
+
57
+ /**
58
+ * Normaliza la lista de campos de una entrada.
59
+ *
60
+ * `private` marca lo que **solo sale de la bóveda con confirmación** (dueño,
61
+ * 2026-08-28). Es la distinción que ya existía entre lo público y lo privado de un
62
+ * registro, ahora dicha campo a campo: el teléfono que rellenas en veinte sitios no
63
+ * pide lo mismo que el número de tu documento.
64
+ */
65
+ export function normalizeFields (fields) {
66
+ if (!Array.isArray(fields)) return []
67
+ return fields
68
+ .filter(f => f && (f.label || f.value))
69
+ .map(f => ({
70
+ label: String(f.label || '').trim(),
71
+ value: String(f.value ?? ''),
72
+ kind: KINDS.includes(f.kind) ? f.kind : undefined,
73
+ ...(f.private ? { private: true } : {}),
74
+ }))
75
+ }
76
+
77
+ /** ¿Esta entrada sirve en cualquier sitio? Sin sitios, sí. */
78
+ export function isCrossDomain (entry) {
79
+ return !entry?.sites?.length
80
+ }
81
+
82
+ /** El primer campo de esa clase. */
83
+ export function fieldOfKind (entry, kind) {
84
+ return (entry?.fields || []).find(f => f.kind === kind) || null
85
+ }
86
+
87
+ /** Un valor por etiqueta, para cuando se sabe qué se busca. */
88
+ export function fieldValue (entry, label) {
89
+ const f = (entry?.fields || []).find(x => x.label === label)
90
+ return f ? f.value : ''
91
+ }
92
+
93
+ /**
94
+ * CÓMO SE LLAMA un campo para decir si dos son «el mismo».
95
+ *
96
+ * La clase si se reconoce, y si no la etiqueta que le puso el sitio. Vive aquí porque la
97
+ * usan las dos puntas —la página, al mirar un formulario, y la bóveda, al decir qué
98
+ * lleva una entrada— y dos ideas distintas de qué es el mismo campo serían un campo
99
+ * duplicado en cada guardado. Su gemela para el DOM está en `extension/src/detect.js`,
100
+ * que corre donde no llega esta librería; `offers.test.mjs` comprueba que dicen lo mismo.
101
+ */
102
+ export function fieldKey (f = {}) {
103
+ if (f.kind) return f.kind
104
+ const l = String(f.label || '').trim()
105
+ return l ? `label:${l}` : 'other'
106
+ }
107
+
108
+ /**
109
+ * LO QUE NO SALE SIN AUTORIZACIÓN, pase lo que pase.
110
+ *
111
+ * La contraseña, el código de dos pasos, las notas y la llave de una passkey son privados
112
+ * por lo que son, no por una marca: nadie los guarda para enseñarlos. El resto de campos
113
+ * lo decide su marca `private` (§4.2), y el usuario es quien la pone al guardar.
114
+ *
115
+ * El **usuario** no está aquí a propósito: es el nombre visible de la entrada (§5), ya
116
+ * viaja en la vista pública, y pedir permiso para escribirlo en el formulario donde lo
117
+ * acabas de teclear no protege nada.
118
+ */
119
+ export const ALWAYS_PRIVATE = ['secret', 'totp', 'notes', 'webauthn']
120
+
121
+ /** Las claves privadas de una entrada abierta: las de arriba, más las marcadas. */
122
+ export function privateKeysOf (open = {}) {
123
+ const out = new Set()
124
+ for (const k of ALWAYS_PRIVATE) if (open[k]) out.add(k)
125
+ const campos = (() => {
126
+ if (Array.isArray(open.fields)) return open.fields
127
+ try { return JSON.parse(open.fields || '[]') } catch { return [] }
128
+ })()
129
+ for (const f of campos) if (f?.private && f?.value) out.add(fieldKey(f))
130
+ return out
131
+ }
132
+
133
+ /**
134
+ * TODOS los campos de una entrada abierta, como pares `{ key, value }`.
135
+ *
136
+ * Es la lista canónica de «qué lleva dentro»: el usuario, la contraseña, el código de dos
137
+ * pasos y cada campo libre, **sin distinguir público de privado**. Esa distinción decide
138
+ * qué se ENSEÑA (§4.2), no qué existe, y mezclar las dos preguntas fue lo que hizo falta
139
+ * separar el 2026-08-29.
140
+ */
141
+ export function entryFieldValues (open = {}) {
142
+ const out = []
143
+ const add = (key, value) => {
144
+ if (!value) return
145
+ if (out.some(f => f.key === key)) return
146
+ out.push({ key, value: String(value) })
147
+ }
148
+ add('username', open.username)
149
+ add('secret', open.secret)
150
+ add('totp', open.totp)
151
+ const campos = (() => {
152
+ if (Array.isArray(open.fields)) return open.fields
153
+ try { return JSON.parse(open.fields || '[]') } catch { return [] }
154
+ })()
155
+ for (const f of campos) if (f?.value) add(fieldKey(f), f.value)
156
+ return out
157
+ }
158
+
159
+ /**
160
+ * LO MISMO, MÁS LAS NOTAS. Solo para los resúmenes (§4.0.2).
161
+ *
162
+ * Las notas no están en `entryFieldValues` a propósito: eso es «qué se puede rellenar en
163
+ * un formulario», y una nota no se rellena en ninguna parte. Pero sí se **edita** en el
164
+ * gestor, y para decir ahí si lo escrito cambia algo hace falta su resumen como el de
165
+ * cualquier otro campo. Un método de comparación, no dos (dueño, 2026-08-29).
166
+ */
167
+ export function entryDigestValues (open = {}) {
168
+ const out = entryFieldValues(open)
169
+ if (open.notes && !out.some(f => f.key === 'notes')) out.push({ key: 'notes', value: String(open.notes) })
170
+ return out
171
+ }
172
+
173
+ /**
174
+ * QUÉ CAMPOS lleva una entrada abierta, por su nombre y sin un solo valor.
175
+ *
176
+ * Es lo que permite ofrecer «rellenar el número de socio» sin abrir nada: lo calcula
177
+ * quien tiene la CEK y viaja en la vista pública (§4.0.2). Los NOMBRES no son los
178
+ * valores — de la misma familia que `sites`, que ya va en claro para poder emparejar con
179
+ * la página, y bastante menos que ellos.
180
+ */
181
+ export function entryFieldKeys (open = {}) {
182
+ return entryFieldValues(open).map(f => f.key)
183
+ }
@@ -0,0 +1,74 @@
1
+ // Generador de contraseñas.
2
+ //
3
+ // Un gestor que no genera contraseñas obliga a inventárselas, y ahí es donde la gente
4
+ // repite la de siempre. Es parte del producto, no un extra.
5
+ //
6
+ // Toda la aleatoriedad sale de `crypto.getRandomValues`, y la elección de carácter se
7
+ // hace SIN sesgo: `% alphabet.length` reparte de más los primeros caracteres cuando el
8
+ // alphabet no divide a 256, y en un generador de contraseñas eso es exactamente lo que
9
+ // no se quiere. Se descartan los valores que caen fuera del último bloque completo.
10
+
11
+ const MINUS = 'abcdefghijkmnopqrstuvwxyz' // sin la ele
12
+ const MAYUS = 'ABCDEFGHJKLMNPQRSTUVWXYZ' // sin la I ni la O
13
+ const DIGITOS = '23456789' // sin el cero ni el uno
14
+ const SIMBOLOS = '!#$%&*+-=?@_'
15
+ const AMBIGUOS = 'lIO01'
16
+
17
+ /** Un entero en [0, max) sin sesgo. */
18
+ function randomBelow (max) {
19
+ const limite = Math.floor(256 / max) * max
20
+ const b = new Uint8Array(1)
21
+ for (;;) {
22
+ globalThis.crypto.getRandomValues(b)
23
+ if (b[0] < limite) return b[0] % max
24
+ }
25
+ }
26
+
27
+ function pick (alphabet) {
28
+ return alphabet[randomBelow(alphabet.length)]
29
+ }
30
+
31
+ /** Baraja en su sitio (Fisher-Yates), para que los obligatorios no queden al principio. */
32
+ function shuffle (arr) {
33
+ for (let i = arr.length - 1; i > 0; i--) {
34
+ const j = randomBelow(i + 1)
35
+ ;[arr[i], arr[j]] = [arr[j], arr[i]]
36
+ }
37
+ return arr
38
+ }
39
+
40
+ /**
41
+ * @param {object} opts
42
+ * `length` size (por defecto 20)
43
+ * `upper`/`digits`/`symbols` incluir mayúsculas, números, símbolos
44
+ * `ambiguous` incluir los caracteres que se confunden al leer (l, I, O, 0, 1)
45
+ */
46
+ export function generatePassword ({
47
+ length = 20, upper = true, digits = true, symbols = true, ambiguous = false,
48
+ } = {}) {
49
+ const groups = [ambiguous ? MINUS + 'l' : MINUS]
50
+ if (upper) groups.push(ambiguous ? MAYUS + 'IO' : MAYUS)
51
+ if (digits) groups.push(ambiguous ? DIGITOS + '01' : DIGITOS)
52
+ if (symbols) groups.push(SIMBOLOS)
53
+
54
+ const alphabet = groups.join('')
55
+ const min = Math.max(groups.length, 4)
56
+ const size = Math.max(min, Math.min(256, Math.floor(length) || 20))
57
+
58
+ // Uno de cada grupo pedido, para que «con números» signifique que HAY un número y no
59
+ // que podría haberlo. El resto, del alphabet entero.
60
+ const chars = groups.map(g => pick(g))
61
+ while (chars.length < size) chars.push(pick(alphabet))
62
+ return shuffle(chars).join('')
63
+ }
64
+
65
+ /** Frase de varias palabras, más fácil de dictar o teclear a mano. */
66
+ export function generatePassphrase (words, { count = 4, separator = '-' } = {}) {
67
+ if (!Array.isArray(words) || words.length < 16) {
68
+ throw new Error('generatePassphrase: hace falta una lista de palabras')
69
+ }
70
+ return Array.from({ length: Math.max(2, count) }, () => words[randomBelow(words.length)])
71
+ .join(separator)
72
+ }
73
+
74
+ export { AMBIGUOS }
@@ -0,0 +1,156 @@
1
+ // Importar desde otros gestores. Sin esto nadie migra, por bueno que sea el resto
2
+ // (DISENO §8, paso 1).
3
+ //
4
+ // Devuelve entradas EN CLARO, listas para `sealEntry`. Esta función no cifra ni
5
+ // guarda: quien importa es quien tiene la CEK, y es él quien decide.
6
+
7
+ import { normalizeSites } from './model.js'
8
+ import { hostOf } from './match.js'
9
+
10
+ /** CSV con comillas, comas y saltos de línea dentro de campo (RFC 4180). */
11
+ export function parseCsv (text) {
12
+ const rows = []
13
+ let row = []
14
+ let field = ''
15
+ let quoted = false
16
+ const s = String(text || '').replace(/^/, '')
17
+
18
+ for (let i = 0; i < s.length; i++) {
19
+ const c = s[i]
20
+ if (quoted) {
21
+ if (c === '"') {
22
+ if (s[i + 1] === '"') { field += '"'; i++ } else { quoted = false }
23
+ } else field += c
24
+ continue
25
+ }
26
+ if (c === '"') { quoted = true; continue }
27
+ if (c === ',') { row.push(field); field = ''; continue }
28
+ if (c === '\r') continue
29
+ if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; continue }
30
+ field += c
31
+ }
32
+ if (field !== '' || row.length) { row.push(field); rows.push(row) }
33
+ return rows.filter(r => r.some(v => v !== ''))
34
+ }
35
+
36
+ function csvObjects (text) {
37
+ const rows = parseCsv(text)
38
+ if (!rows.length) return []
39
+ const head = rows[0].map(h => h.trim().toLowerCase())
40
+ return rows.slice(1).map(r => {
41
+ const o = {}
42
+ head.forEach((h, i) => { o[h] = r[i] ?? '' })
43
+ return o
44
+ })
45
+ }
46
+
47
+ function sitesFrom (...urls) {
48
+ const out = []
49
+ for (const u of urls.flat()) {
50
+ const h = hostOf(u)
51
+ if (h) out.push(h)
52
+ }
53
+ return normalizeSites(out)
54
+ }
55
+
56
+ function entryFrom ({ title, sites, username, secret, totp, notes }) {
57
+ return {
58
+ type: 'login',
59
+ title: String(title || '').trim() || (sites[0] || 'sin nombre'),
60
+ sites,
61
+ username: String(username || ''),
62
+ secret: String(secret || ''),
63
+ totp: String(totp || ''),
64
+ notes: String(notes || ''),
65
+ }
66
+ }
67
+
68
+ /** Chrome / Edge: `name,url,username,password,note`. */
69
+ export function fromChromeCsv (text) {
70
+ return csvObjects(text)
71
+ .map(o => entryFrom({
72
+ title: o.name,
73
+ sites: sitesFrom(o.url),
74
+ username: o.username,
75
+ secret: o.password,
76
+ notes: o.note || o.notes,
77
+ }))
78
+ .filter(e => e.secret || e.username)
79
+ }
80
+
81
+ /** 1Password: `Title,Url,Username,Password,OTPAuth,Favorite,Archived,Tags,Notes`. */
82
+ export function from1PasswordCsv (text) {
83
+ return csvObjects(text)
84
+ .map(o => entryFrom({
85
+ title: o.title,
86
+ sites: sitesFrom(o.url, o.urls),
87
+ username: o.username,
88
+ secret: o.password,
89
+ totp: o.otpauth,
90
+ notes: o.notes,
91
+ }))
92
+ .filter(e => e.secret || e.username)
93
+ }
94
+
95
+ /** Bitwarden CSV (`login_uri`, `login_username`, `login_password`, `login_totp`). */
96
+ export function fromBitwardenCsv (text) {
97
+ return csvObjects(text)
98
+ .filter(o => !o.type || o.type === 'login')
99
+ .map(o => entryFrom({
100
+ title: o.name,
101
+ sites: sitesFrom((o.login_uri || '').split(',')),
102
+ username: o.login_username,
103
+ secret: o.login_password,
104
+ totp: o.login_totp,
105
+ notes: o.notes,
106
+ }))
107
+ .filter(e => e.secret || e.username)
108
+ }
109
+
110
+ /** Bitwarden JSON (export sin cifrar). */
111
+ export function fromBitwardenJson (json) {
112
+ const data = typeof json === 'string' ? JSON.parse(json) : json
113
+ if (data?.encrypted) {
114
+ const e = new Error('el export de Bitwarden está cifrado: expórtalo sin cifrar')
115
+ e.code = 'encrypted-export'
116
+ throw e
117
+ }
118
+ return (data?.items || [])
119
+ .filter(it => it.type === 1 && it.login)
120
+ .map(it => entryFrom({
121
+ title: it.name,
122
+ sites: sitesFrom((it.login.uris || []).map(u => u.uri)),
123
+ username: it.login.username,
124
+ secret: it.login.password,
125
+ totp: it.login.totp,
126
+ notes: it.notes,
127
+ }))
128
+ }
129
+
130
+ /**
131
+ * Detecta el formato por su cabecera y lo importa. Es lo que consume la UI: el
132
+ * usuario suelta el archivo y no tiene por qué saber qué formato es.
133
+ *
134
+ * `1pux` (el export nativo de 1Password) NO entra: es un zip. Se descomprime en la
135
+ * UI y se le pasa aquí el JSON de dentro.
136
+ */
137
+ export function importAuto (text) {
138
+ const t = String(text || '').trim()
139
+ if (!t) return { format: 'vacío', entries: [] }
140
+
141
+ if (t.startsWith('{')) return { format: 'bitwarden-json', entries: fromBitwardenJson(t) }
142
+
143
+ const head = (parseCsv(t)[0] || []).map(h => h.trim().toLowerCase())
144
+ if (head.includes('login_password') || head.includes('login_username')) {
145
+ return { format: 'bitwarden-csv', entries: fromBitwardenCsv(t) }
146
+ }
147
+ if (head.includes('otpauth') || (head.includes('title') && head.includes('url'))) {
148
+ return { format: '1password-csv', entries: from1PasswordCsv(t) }
149
+ }
150
+ if (head.includes('name') && head.includes('url') && head.includes('password')) {
151
+ return { format: 'chrome-csv', entries: fromChromeCsv(t) }
152
+ }
153
+ const e = new Error('no reconozco el formato del archivo')
154
+ e.code = 'unknown-format'
155
+ throw e
156
+ }
@@ -0,0 +1,27 @@
1
+ export * from './crypto.js'
2
+ export * from './model.js'
3
+ export * from './match.js'
4
+ export * from './fields.js'
5
+ export * from './generate.js'
6
+ // Los importadores son parte de la API: sin esto una app tenía que ir al subpath, y
7
+ // eso se descubre cuando ya está escrito el import.
8
+ export * from './import.js'
9
+ export * from './pubkey.js'
10
+ export * from './totp.js'
11
+ export { LocalVault } from './vault/local.js'
12
+ // Las contraseñas SELLADAS por aparato viven en `./sealed/` y se importan por su subpath
13
+ // (`@dotrino/passmanager/sealed`), que es lo que arrastra la cripto de los sobres. Aquí solo
14
+ // las dos puntas del protocolo, que es lo que cablea cada bóveda y cada aparato.
15
+ export { SealedVault } from './vault/sealed.js'
16
+ export { SealedResponder } from './vault/sealed-responder.js'
17
+ export { SealedLocalVault } from './vault/sealed-local.js'
18
+ export { SealedStore, SealedError } from './sealed/store.js'
19
+ export { RemoteVault } from './vault/remote.js'
20
+ export { VaultResponder } from './vault/responder.js'
21
+ export { GuardedVault } from './vault/guard.js'
22
+ export { ApprovalGate } from './vault/approval.js'
23
+ export { SessionCache } from './session-cache.js'
24
+ export { ProxyTransport } from './transport/proxy.js'
25
+ export * from './transport/protocol.js'
26
+ export * from './transport/sealed.js'
27
+ export { VaultError, CODES } from './vault/errors.js'