@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.
- package/package.json +4 -1
- package/src/node.js +4 -180
- package/vault/content.js +28 -1
- package/vault/core.js +66 -7
- package/vault/facade.js +200 -0
- package/vault/index.html +10 -0
- package/vault/remote.js +45 -5
- 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 +136 -11
- package/vault/vendor/proxy-client/index.js +1 -0
- package/vault/vendor/proxy-client/parts.js +127 -0
- package/vault/vendor/proxy-client/webrtc.js +23 -0
- package/vault/vendor/roadmap/VERSION.txt +3 -0
- package/vault/vendor/roadmap/index.js +108 -0
- package/vault/vendor/roadmap/manifests.js +802 -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,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'
|