@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,137 @@
|
|
|
1
|
+
// Emparejamiento de una entrada con el sitio que el usuario tiene delante.
|
|
2
|
+
//
|
|
3
|
+
// Es la pieza de la que depende la seguridad del autocompletado: emparejar de más
|
|
4
|
+
// entrega una credencial al sitio equivocado. Por eso todo se compara por ETIQUETAS
|
|
5
|
+
// de dominio, nunca por sufijo de cadena — `evil-salesforce.com` termina en
|
|
6
|
+
// `salesforce.com` como texto y no tiene nada que ver con él.
|
|
7
|
+
|
|
8
|
+
// Sufijos públicos de dos etiquetas más comunes. NO es la Public Suffix List entera
|
|
9
|
+
// (son miles de reglas y varios cientos de KB); es la aproximación que cubre el caso
|
|
10
|
+
// real. Deuda anotada: cargar la PSL de verdad cuando el autocompletado esté maduro.
|
|
11
|
+
// Mientras tanto un sufijo desconocido solo hace el emparejamiento MÁS estricto, que
|
|
12
|
+
// es el lado seguro por el que fallar.
|
|
13
|
+
const TWO_LABEL_SUFFIXES = new Set([
|
|
14
|
+
'co.uk', 'org.uk', 'ac.uk', 'gov.uk', 'me.uk', 'net.uk', 'sch.uk',
|
|
15
|
+
'com.au', 'net.au', 'org.au', 'edu.au', 'gov.au', 'id.au',
|
|
16
|
+
'com.br', 'net.br', 'org.br', 'gov.br',
|
|
17
|
+
'com.ec', 'net.ec', 'org.ec', 'gob.ec', 'edu.ec', 'fin.ec', 'med.ec',
|
|
18
|
+
'com.ar', 'net.ar', 'org.ar', 'gob.ar', 'edu.ar',
|
|
19
|
+
'com.mx', 'net.mx', 'org.mx', 'gob.mx', 'edu.mx',
|
|
20
|
+
'com.co', 'net.co', 'org.co', 'gov.co', 'edu.co',
|
|
21
|
+
'co.jp', 'ne.jp', 'or.jp', 'ac.jp', 'go.jp',
|
|
22
|
+
'com.cn', 'net.cn', 'org.cn', 'gov.cn', 'edu.cn',
|
|
23
|
+
'co.nz', 'net.nz', 'org.nz', 'govt.nz',
|
|
24
|
+
'co.za', 'org.za', 'net.za',
|
|
25
|
+
'com.tr', 'com.tw', 'com.hk', 'com.sg', 'com.my', 'com.pe', 'com.uy',
|
|
26
|
+
'com.ve', 'com.py', 'com.bo', 'com.do', 'com.gt', 'com.pa', 'com.pl',
|
|
27
|
+
'com.es', 'com.pt', 'com.ua', 'com.ru', 'com.vn', 'com.ph',
|
|
28
|
+
])
|
|
29
|
+
|
|
30
|
+
/** Extrae el host de una URL o de algo que ya es un host. Devuelve '' si no vale. */
|
|
31
|
+
export function hostOf (input) {
|
|
32
|
+
if (typeof input !== 'string') return ''
|
|
33
|
+
let s = input.trim().toLowerCase()
|
|
34
|
+
if (!s) return ''
|
|
35
|
+
if (!s.includes('://')) s = 'https://' + s
|
|
36
|
+
let u
|
|
37
|
+
try { u = new URL(s) } catch { return '' }
|
|
38
|
+
// Solo esquemas web: un `javascript:` o un `file:` no tienen sitio con el que emparejar.
|
|
39
|
+
if (u.protocol !== 'https:' && u.protocol !== 'http:') return ''
|
|
40
|
+
return u.hostname.replace(/\.$/, '')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Dominio registrable (eTLD+1 aproximado). Para una IP o un host raro, el host tal cual. */
|
|
44
|
+
export function registrableDomain (host) {
|
|
45
|
+
if (!host) return ''
|
|
46
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(host) || host.includes(':')) return host
|
|
47
|
+
const parts = host.split('.')
|
|
48
|
+
if (parts.length <= 2) return host
|
|
49
|
+
const lastTwo = parts.slice(-2).join('.')
|
|
50
|
+
if (TWO_LABEL_SUFFIXES.has(lastTwo)) return parts.slice(-3).join('.')
|
|
51
|
+
return lastTwo
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** ¿`host` es igual a `base` o un subdominio suyo? Compara por etiquetas. */
|
|
55
|
+
export function isSameOrSubdomainOf (host, base) {
|
|
56
|
+
if (!host || !base) return false
|
|
57
|
+
if (host === base) return true
|
|
58
|
+
return host.endsWith('.' + base)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const MATCH = {
|
|
62
|
+
EXACT: 3,
|
|
63
|
+
SUBDOMAIN: 2,
|
|
64
|
+
/** Sin sitios: sirve en cualquier parte, y por eso va la última. */
|
|
65
|
+
ANY: 0.5,
|
|
66
|
+
NONE: 0,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// `DOMAIN` (mismo dominio registrable) EXISTIÓ y se quitó el 2026-08-29, porque hacía
|
|
70
|
+
// justo lo que un gestor de contraseñas no puede hacer: con `pass.dotrino.com` guardado,
|
|
71
|
+
// `r.dotrino.com` recibía sus credenciales — dos servicios distintos que solo comparten
|
|
72
|
+
// el dominio de arriba (lo vio el dueño: *«en r.dotrino.com me salen records de
|
|
73
|
+
// pass.dotrino.com»*).
|
|
74
|
+
//
|
|
75
|
+
// Se puso para el caso «el subdominio cambió y la clave es la misma», pero para eso no
|
|
76
|
+
// hacía falta: guardar el dominio a secas (`empresa.com`) YA cubre todos sus subdominios
|
|
77
|
+
// por la vía de SUBDOMAIN, y traerse la cuenta de otro dominio ya tiene su camino
|
|
78
|
+
// explícito —buscarla (§4.1)—, que además lo decide una persona en vez de la máquina.
|
|
79
|
+
//
|
|
80
|
+
// Lo único que aportaba era el sentido contrario: un subdominio GUARDADO emparejando con
|
|
81
|
+
// sus hermanos y con el dominio padre. Y ese sentido es exactamente la fuga.
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Compara UN patrón de `sites` con el host actual.
|
|
85
|
+
* Patrones admitidos: `ejemplo.com`, `*.ejemplo.com`, o una URL entera.
|
|
86
|
+
*
|
|
87
|
+
* Un patrón cubre **el host y lo que cuelga de él**, nunca hacia arriba ni hacia los
|
|
88
|
+
* lados: `empresa.com` vale en `login.empresa.com`, pero `login.empresa.com` NO vale en
|
|
89
|
+
* `otra.empresa.com`. Por eso el comodín casi nunca hace falta — un dominio a secas ya
|
|
90
|
+
* trae sus subdominios— y por eso `*.x` y `x` cubren hoy lo mismo.
|
|
91
|
+
*/
|
|
92
|
+
export function matchSite (pattern, currentHost) {
|
|
93
|
+
if (!currentHost) return MATCH.NONE
|
|
94
|
+
let p = String(pattern || '').trim().toLowerCase()
|
|
95
|
+
if (!p) return MATCH.NONE
|
|
96
|
+
|
|
97
|
+
let wildcard = false
|
|
98
|
+
if (p.startsWith('*.')) { wildcard = true; p = p.slice(2) }
|
|
99
|
+
|
|
100
|
+
const base = hostOf(p)
|
|
101
|
+
if (!base) return MATCH.NONE
|
|
102
|
+
|
|
103
|
+
if (wildcard) return isSameOrSubdomainOf(currentHost, base) ? MATCH.SUBDOMAIN : MATCH.NONE
|
|
104
|
+
if (currentHost === base) return MATCH.EXACT
|
|
105
|
+
if (isSameOrSubdomainOf(currentHost, base)) return MATCH.SUBDOMAIN
|
|
106
|
+
// Y nada más. Un hermano NO empareja: ver la nota de `MATCH`.
|
|
107
|
+
return MATCH.NONE
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Mejor nivel de emparejamiento de una entrada con la URL actual.
|
|
112
|
+
*
|
|
113
|
+
* **Una entrada SIN sitios sirve en cualquier parte.** Es como se dice «esto no es de
|
|
114
|
+
* un dominio»: el correo o la cédula valen en todas partes, la contraseña del banco
|
|
115
|
+
* no. No hace falta un tipo aparte para eso, y así hay una sola regla que mantener.
|
|
116
|
+
* Empareja siempre por debajo de lo que sí es de este sitio.
|
|
117
|
+
*/
|
|
118
|
+
export function matchEntry (entry, url) {
|
|
119
|
+
const host = hostOf(url)
|
|
120
|
+
if (!host) return MATCH.NONE
|
|
121
|
+
if (!entry?.sites?.length) return MATCH.ANY
|
|
122
|
+
let best = MATCH.NONE
|
|
123
|
+
for (const s of entry?.sites || []) {
|
|
124
|
+
const m = matchSite(s, host)
|
|
125
|
+
if (m > best) best = m
|
|
126
|
+
if (best === MATCH.EXACT) break
|
|
127
|
+
}
|
|
128
|
+
return best
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Entradas que sirven para esta URL, de mejor a peor emparejamiento. */
|
|
132
|
+
export function findForUrl (entries, url) {
|
|
133
|
+
return (entries || [])
|
|
134
|
+
.map(e => ({ entry: e, match: matchEntry(e, url) }))
|
|
135
|
+
.filter(x => x.match > MATCH.NONE)
|
|
136
|
+
.sort((a, b) => b.match - a.match || (b.entry.updatedAt || 0) - (a.entry.updatedAt || 0))
|
|
137
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// Una entrada de la bóveda: cómo se sella, cómo se abre y qué se puede enseñar sin
|
|
2
|
+
// abrirla. Ver DISENO §5.
|
|
3
|
+
//
|
|
4
|
+
// Qué va en claro y por qué: `sites` y `title`. Los `sites` hacen falta para
|
|
5
|
+
// emparejar con la página SIN tener la CEK — es lo que permite que la extensión
|
|
6
|
+
// pregunte "¿tienes algo para salesforce.com?" sin poder abrir nada. Es una decisión
|
|
7
|
+
// consciente: revela EN QUÉ SITIOS tienes cuenta, que es bastante menos que la
|
|
8
|
+
// credencial. Todo lo demás (usuario, contraseña, TOTP, notas, llave WebAuthn) va
|
|
9
|
+
// cifrado.
|
|
10
|
+
|
|
11
|
+
import { sealValue, openValue, fieldTag } from './crypto.js'
|
|
12
|
+
import { normalizeFields, entryFieldKeys, privateKeysOf, fieldKey, KINDS } from './fields.js'
|
|
13
|
+
|
|
14
|
+
export const TYPES = ['login', 'note', 'card', 'webauthn', 'data']
|
|
15
|
+
|
|
16
|
+
// `fields` es la lista de campos libres del usuario, y va cifrada como todo lo
|
|
17
|
+
// demás: un correo o una cédula identifican a una persona igual que una contraseña
|
|
18
|
+
// da acceso. Se sella como JSON, en un solo criptograma.
|
|
19
|
+
//
|
|
20
|
+
// `name` —el nombre que el usuario le pone a la entrada— va SELLADO y no en claro como
|
|
21
|
+
// `title`: «la cuenta de mi mamá» dice bastante más de una persona que el dominio donde
|
|
22
|
+
// la usa. Quien puede abrir la entrada lo pone en la vista pública (§4.0.2), igual que
|
|
23
|
+
// el nombre visible de siempre.
|
|
24
|
+
const SEALED_FIELDS = ['name', 'username', 'secret', 'totp', 'notes', 'fields']
|
|
25
|
+
|
|
26
|
+
function uuid () {
|
|
27
|
+
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
|
|
28
|
+
const b = new Uint8Array(16)
|
|
29
|
+
globalThis.crypto.getRandomValues(b)
|
|
30
|
+
b[6] = (b[6] & 0x0f) | 0x40
|
|
31
|
+
b[8] = (b[8] & 0x3f) | 0x80
|
|
32
|
+
const h = [...b].map(x => x.toString(16).padStart(2, '0')).join('')
|
|
33
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Entrada nueva en claro, lista para `sealEntry`. */
|
|
37
|
+
export function newEntry (fields = {}) {
|
|
38
|
+
const now = Date.now()
|
|
39
|
+
const type = TYPES.includes(fields.type) ? fields.type : 'login'
|
|
40
|
+
return {
|
|
41
|
+
id: fields.id || uuid(),
|
|
42
|
+
type,
|
|
43
|
+
title: String(fields.title || '').trim(),
|
|
44
|
+
// El nombre que le puso el usuario. Vacío = el que se calcula solo (`entryWho`).
|
|
45
|
+
name: String(fields.name || '').trim(),
|
|
46
|
+
sites: normalizeSites(fields.sites),
|
|
47
|
+
username: fields.username || '',
|
|
48
|
+
secret: fields.secret || '',
|
|
49
|
+
// Los campos libres viajan como JSON dentro de un único criptograma. Una lista
|
|
50
|
+
// vacía es NADA, no un criptograma vacío: si no, la entrada se anuncia con campos
|
|
51
|
+
// que no tiene y la UI ofrece rellenar lo que no existe.
|
|
52
|
+
fields: (() => {
|
|
53
|
+
if (!Array.isArray(fields.fields)) return fields.fields || ''
|
|
54
|
+
const limpios = normalizeFields(fields.fields)
|
|
55
|
+
return limpios.length ? JSON.stringify(limpios) : ''
|
|
56
|
+
})(),
|
|
57
|
+
totp: fields.totp || '',
|
|
58
|
+
notes: fields.notes || '',
|
|
59
|
+
webauthn: fields.webauthn || null,
|
|
60
|
+
createdAt: fields.createdAt || now,
|
|
61
|
+
updatedAt: now,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function normalizeSites (sites) {
|
|
66
|
+
const out = []
|
|
67
|
+
for (const s of Array.isArray(sites) ? sites : [sites]) {
|
|
68
|
+
const v = String(s || '').trim().toLowerCase()
|
|
69
|
+
if (v && !out.includes(v)) out.push(v)
|
|
70
|
+
}
|
|
71
|
+
return out
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Cifra los campos sensibles. Devuelve lo que se guarda de verdad. */
|
|
75
|
+
export async function sealEntry (key, plain) {
|
|
76
|
+
const e = newEntry(plain)
|
|
77
|
+
const out = {
|
|
78
|
+
id: e.id,
|
|
79
|
+
type: e.type,
|
|
80
|
+
title: e.title,
|
|
81
|
+
sites: e.sites,
|
|
82
|
+
createdAt: e.createdAt,
|
|
83
|
+
updatedAt: e.updatedAt,
|
|
84
|
+
}
|
|
85
|
+
for (const f of SEALED_FIELDS) {
|
|
86
|
+
out[f] = e[f] ? await sealValue(key, String(e[f]), fieldTag(e.id, f)) : null
|
|
87
|
+
}
|
|
88
|
+
if (e.webauthn) {
|
|
89
|
+
// `rpId` y `credentialId` quedan en claro: el navegador los necesita para saber
|
|
90
|
+
// qué credencial ofrecer antes de que nadie desbloquee nada.
|
|
91
|
+
out.webauthn = {
|
|
92
|
+
credentialId: e.webauthn.credentialId,
|
|
93
|
+
rpId: e.webauthn.rpId,
|
|
94
|
+
signCount: e.webauthn.signCount || 0,
|
|
95
|
+
userHandle: e.webauthn.userHandle
|
|
96
|
+
? await sealValue(key, String(e.webauthn.userHandle), fieldTag(e.id, 'wa.userHandle'))
|
|
97
|
+
: null,
|
|
98
|
+
privateKey: e.webauthn.privateKey
|
|
99
|
+
? await sealValue(key, String(e.webauthn.privateKey), fieldTag(e.id, 'wa.privateKey'))
|
|
100
|
+
: null,
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
out.webauthn = null
|
|
104
|
+
}
|
|
105
|
+
return out
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Abre una entrada sellada. Exige la CEK, o sea: no lo hace la extensión. */
|
|
109
|
+
export async function openEntry (key, entry) {
|
|
110
|
+
const out = {
|
|
111
|
+
id: entry.id,
|
|
112
|
+
type: entry.type,
|
|
113
|
+
title: entry.title,
|
|
114
|
+
sites: entry.sites || [],
|
|
115
|
+
createdAt: entry.createdAt,
|
|
116
|
+
updatedAt: entry.updatedAt,
|
|
117
|
+
webauthn: null,
|
|
118
|
+
}
|
|
119
|
+
for (const f of SEALED_FIELDS) {
|
|
120
|
+
out[f] = entry[f] ? await openValue(key, entry[f], fieldTag(entry.id, f)) : ''
|
|
121
|
+
}
|
|
122
|
+
if (entry.webauthn) {
|
|
123
|
+
out.webauthn = {
|
|
124
|
+
credentialId: entry.webauthn.credentialId,
|
|
125
|
+
rpId: entry.webauthn.rpId,
|
|
126
|
+
signCount: entry.webauthn.signCount || 0,
|
|
127
|
+
userHandle: entry.webauthn.userHandle
|
|
128
|
+
? await openValue(key, entry.webauthn.userHandle, fieldTag(entry.id, 'wa.userHandle'))
|
|
129
|
+
: '',
|
|
130
|
+
privateKey: entry.webauthn.privateKey
|
|
131
|
+
? await openValue(key, entry.webauthn.privateKey, fieldTag(entry.id, 'wa.privateKey'))
|
|
132
|
+
: '',
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return out
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Lo que puede ver quien NO tiene la CEK: para elegir entre varias cuentas del mismo
|
|
140
|
+
* sitio, no para usarlas. El `hint` lo calcula quien sí puede abrir (§2).
|
|
141
|
+
*
|
|
142
|
+
* `open` es la entrada abierta, y solo la tiene quien puede abrirla. Con ella salen los
|
|
143
|
+
* NOMBRES de los campos que lleva —nunca sus valores—, que es lo que permite ofrecer
|
|
144
|
+
* «rellenar el número de socio» sin sacar nada de la bóveda.
|
|
145
|
+
*
|
|
146
|
+
* `digest` son los RESÚMENES de esos campos (`{ nonce, fields }`, ver `fieldHasher`):
|
|
147
|
+
* con ellos, quien pregunta puede decir si lo que tiene delante ya está guardado igual
|
|
148
|
+
* sin abrir nada y sin pedir una autorización. Van para todos los campos, públicos y
|
|
149
|
+
* privados: un solo método de comparación (dueño, 2026-08-29).
|
|
150
|
+
*
|
|
151
|
+
* `privateKeys` dice **cuáles de esos campos son privados**. No es lo mismo que el valor:
|
|
152
|
+
* es lo que permite pedir los públicos —y solo los públicos— sin disparar una
|
|
153
|
+
* autorización, que es lo que hace falta para enseñar en una lista con qué se va a
|
|
154
|
+
* rellenar cada casilla.
|
|
155
|
+
*/
|
|
156
|
+
export function publicView (entry, hint, open, digest) {
|
|
157
|
+
return {
|
|
158
|
+
id: entry.id,
|
|
159
|
+
type: entry.type,
|
|
160
|
+
title: entry.title,
|
|
161
|
+
sites: entry.sites || [],
|
|
162
|
+
hasSecret: !!entry.secret,
|
|
163
|
+
hasTotp: !!entry.totp,
|
|
164
|
+
hasNotes: !!entry.notes,
|
|
165
|
+
hasFields: !!entry.fields,
|
|
166
|
+
hasWebauthn: !!entry.webauthn,
|
|
167
|
+
updatedAt: entry.updatedAt,
|
|
168
|
+
...(hint ? { hint } : {}),
|
|
169
|
+
...(open ? { fieldKeys: entryFieldKeys(open), privateKeys: [...privateKeysOf(open)] } : {}),
|
|
170
|
+
...(digest ? { nonce: digest.nonce, fieldHashes: digest.fields } : {}),
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* CÓMO SE LLAMA una entrada para quien tiene que elegir entre varias.
|
|
176
|
+
*
|
|
177
|
+
* **El que le puso el usuario**, si le puso alguno. Y si no, su dato público más claro,
|
|
178
|
+
* **sin esconder caracteres** (dueño, 2026-08-28): el usuario; si no tiene, el correo; y
|
|
179
|
+
* si tampoco, el primer campo que lleve. Un `d•••g` no le dice nada a nadie — hay que
|
|
180
|
+
* poder reconocer la cuenta de un vistazo.
|
|
181
|
+
*
|
|
182
|
+
* **Los campos privados no salen aquí.** Para eso está la marca (§4.2): lo privado solo
|
|
183
|
+
* sale con confirmación, así que nunca es el nombre visible de una entrada.
|
|
184
|
+
*/
|
|
185
|
+
export function entryWho (open) {
|
|
186
|
+
// Lo que el usuario escribió MANDA. Lo de abajo es solo el nombre que se calcula cuando
|
|
187
|
+
// no ha escrito ninguno: útil para no dejar filas en blanco, pero es una suposición, y
|
|
188
|
+
// una suposición no le gana a una decisión (dueño, 2026-08-29).
|
|
189
|
+
if (open?.name) return String(open.name)
|
|
190
|
+
if (open?.username) return String(open.username)
|
|
191
|
+
const campos = (() => {
|
|
192
|
+
if (Array.isArray(open?.fields)) return open.fields
|
|
193
|
+
try { return JSON.parse(open?.fields || '[]') } catch { return [] }
|
|
194
|
+
})()
|
|
195
|
+
const publicos = campos.filter(f => f && !f.private && f.value)
|
|
196
|
+
const correo = publicos.find(f => f.kind === 'email')
|
|
197
|
+
return String((correo || publicos[0])?.value || '')
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* LA ENTRADA RECORTADA a los campos que se pidieron.
|
|
202
|
+
*
|
|
203
|
+
* Pedir «el nombre» no puede devolver también la contraseña. Sin esto, `get` era todo o
|
|
204
|
+
* nada y cualquier relleno de un dato público sacaba de la bóveda la credencial entera —
|
|
205
|
+
* que además es lo que obligaba a pedir autorización para rellenar un nombre.
|
|
206
|
+
*
|
|
207
|
+
* Sin `keys` devuelve la entrada tal cual: hay quien necesita todo (una passkey al
|
|
208
|
+
* firmar), y eso sí se autoriza.
|
|
209
|
+
*/
|
|
210
|
+
export function projectEntry (open, keys) {
|
|
211
|
+
if (!Array.isArray(keys)) return open
|
|
212
|
+
const quiere = new Set(keys)
|
|
213
|
+
const campos = (() => {
|
|
214
|
+
if (Array.isArray(open.fields)) return open.fields
|
|
215
|
+
try { return JSON.parse(open.fields || '[]') } catch { return [] }
|
|
216
|
+
})()
|
|
217
|
+
return {
|
|
218
|
+
...open,
|
|
219
|
+
username: quiere.has('username') ? open.username : '',
|
|
220
|
+
secret: quiere.has('secret') ? open.secret : '',
|
|
221
|
+
totp: quiere.has('totp') ? open.totp : '',
|
|
222
|
+
notes: quiere.has('notes') ? open.notes : '',
|
|
223
|
+
webauthn: quiere.has('webauthn') ? open.webauthn : null,
|
|
224
|
+
fields: JSON.stringify(campos.filter(f => quiere.has(fieldKey(f)))),
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** `sandrade@dotrino.com` → `s•••e@dotrino.com`. Sigue para la CLI; la extensión ya no. */
|
|
229
|
+
export function maskUsername (username) {
|
|
230
|
+
const u = String(username || '')
|
|
231
|
+
if (!u) return ''
|
|
232
|
+
const at = u.indexOf('@')
|
|
233
|
+
if (at > 0) return mask(u.slice(0, at)) + u.slice(at)
|
|
234
|
+
return mask(u)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function mask (s) {
|
|
238
|
+
if (s.length <= 2) return s[0] + '•'
|
|
239
|
+
return s[0] + '•••' + s[s.length - 1]
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* LA FUSIÓN DE UN PARCHE con lo que ya había, que es más sutil de lo que parece y por eso
|
|
244
|
+
* vive aquí y no en cada bóveda.
|
|
245
|
+
*
|
|
246
|
+
* La diferencia que lo ordena todo: **un campo sin `value` dice «déjale el que tenga»**,
|
|
247
|
+
* no «bórralo». Marcar uno como privado llega así —solo la etiqueta y la marca—, y una
|
|
248
|
+
* bóveda que lo tomara al pie de la letra borraría su valor al marcarlo. Pasó: la bóveda
|
|
249
|
+
* sellada de la extensión se quedó sin campos al primer `patch`.
|
|
250
|
+
*
|
|
251
|
+
* @param {object} base la entrada ABIERTA tal como está
|
|
252
|
+
* @param {object} changes lo que se quiere cambiar
|
|
253
|
+
* @returns {object} la entrada completa, lista para volver a cerrar
|
|
254
|
+
*/
|
|
255
|
+
export function mergePatch (base, changes = {}) {
|
|
256
|
+
const fields = normalizeFields((() => {
|
|
257
|
+
if (Array.isArray(base.fields)) return base.fields
|
|
258
|
+
try { return JSON.parse(base.fields || '[]') } catch { return [] }
|
|
259
|
+
})())
|
|
260
|
+
|
|
261
|
+
// Como `normalizeFields`, pero **conservando la ausencia de `value`**: ahí está la
|
|
262
|
+
// diferencia entre «ponle este valor» y «déjale el que tenga».
|
|
263
|
+
const propuestos = (Array.isArray(changes.fields) ? changes.fields : [])
|
|
264
|
+
.filter((f) => f && (f.label || f.value !== undefined))
|
|
265
|
+
.map((f) => ({
|
|
266
|
+
label: String(f.label || '').trim(),
|
|
267
|
+
kind: KINDS.includes(f.kind) ? f.kind : undefined,
|
|
268
|
+
...(f.value === undefined ? {} : { value: String(f.value) }),
|
|
269
|
+
...(f.private === undefined ? {} : { private: !!f.private }),
|
|
270
|
+
}))
|
|
271
|
+
|
|
272
|
+
for (const f of propuestos) {
|
|
273
|
+
const k = fieldKey(f)
|
|
274
|
+
// Se casa por su clave; y si no aparece, **por su etiqueta**. Un mismo campo puede
|
|
275
|
+
// llegar con clase una vez y sin ella otra —la página declara el `autocomplete` en un
|
|
276
|
+
// formulario y no en el siguiente—, y sin este respaldo eso creaba un duplicado.
|
|
277
|
+
const j = (() => {
|
|
278
|
+
const exacto = fields.findIndex((x) => fieldKey(x) === k)
|
|
279
|
+
if (exacto >= 0) return exacto
|
|
280
|
+
return f.label ? fields.findIndex((x) => x.label === f.label) : -1
|
|
281
|
+
})()
|
|
282
|
+
if (j >= 0) {
|
|
283
|
+
// La etiqueta que ya tenía MANDA: es la identidad de un campo libre, y cambiarla
|
|
284
|
+
// sería crear otro. Y lo que era privado sigue siéndolo salvo que se diga.
|
|
285
|
+
fields[j] = {
|
|
286
|
+
...fields[j],
|
|
287
|
+
...(f.value === undefined ? {} : { value: f.value }),
|
|
288
|
+
...(f.kind ? { kind: f.kind } : {}),
|
|
289
|
+
}
|
|
290
|
+
if (f.private === undefined ? fields[j].private : f.private) fields[j].private = true
|
|
291
|
+
else delete fields[j].private
|
|
292
|
+
} else if (f.value !== undefined) {
|
|
293
|
+
// Un campo que no existe y del que no se dice el valor no es nada que crear.
|
|
294
|
+
fields.push({ ...f, value: f.value })
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// QUITAR. Va al final, para que quitar gane si en la misma tanda también se editaba.
|
|
299
|
+
// Los de siempre no se pueden borrar —son parte de la forma de una entrada—, así que
|
|
300
|
+
// quitarlos es dejarlos vacíos, que para todo lo demás es lo mismo.
|
|
301
|
+
const fuera = new Set(Array.isArray(changes.removeFields) ? changes.removeFields : [])
|
|
302
|
+
const vaciados = {}
|
|
303
|
+
for (const k of ['username', 'secret', 'totp', 'notes']) if (fuera.has(k)) vaciados[k] = ''
|
|
304
|
+
const quedan = fields
|
|
305
|
+
.filter((f) => !fuera.has(fieldKey(f)))
|
|
306
|
+
// Un campo sin valor no es un campo: no se rellena, no se compara y no se enseña.
|
|
307
|
+
.filter((f) => f.value)
|
|
308
|
+
|
|
309
|
+
const sites = (() => {
|
|
310
|
+
// La lista puesta a mano MANDA sobre el «súmame este sitio» del guardado automático:
|
|
311
|
+
// una es una decisión del usuario y el otro una consecuencia de dónde estabas.
|
|
312
|
+
if (Array.isArray(changes.sites)) return normalizeSites(changes.sites)
|
|
313
|
+
const suyos = base.sites || []
|
|
314
|
+
if (!changes.addSite || !suyos.length || suyos.includes(changes.addSite)) return suyos
|
|
315
|
+
return [...suyos, changes.addSite]
|
|
316
|
+
})()
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
...base,
|
|
320
|
+
...(changes.name !== undefined ? { name: changes.name } : {}),
|
|
321
|
+
...(changes.title !== undefined ? { title: changes.title } : {}),
|
|
322
|
+
...(changes.username !== undefined ? { username: changes.username } : {}),
|
|
323
|
+
...(changes.secret !== undefined ? { secret: changes.secret } : {}),
|
|
324
|
+
...(changes.totp !== undefined ? { totp: changes.totp } : {}),
|
|
325
|
+
...(changes.notes !== undefined ? { notes: changes.notes } : {}),
|
|
326
|
+
...vaciados,
|
|
327
|
+
sites,
|
|
328
|
+
fields: quedan,
|
|
329
|
+
id: base.id,
|
|
330
|
+
createdAt: base.createdAt,
|
|
331
|
+
}
|
|
332
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Comparar llaves públicas.
|
|
2
|
+
//
|
|
3
|
+
// **Nunca con `===` sobre el JSON.** Una misma llave se serializa de varias formas: el
|
|
4
|
+
// orden de las claves del JWK cambia según quién la escriba, y sobran campos como
|
|
5
|
+
// `ext` o `key_ops` que no son parte de la llave. Comparar cadenas hace que un aparato
|
|
6
|
+
// autorizado aparezca como desconocido, y eso se manifiesta como un «denegado» que no
|
|
7
|
+
// hay forma de explicar mirando los dos valores, porque *parecen* iguales.
|
|
8
|
+
//
|
|
9
|
+
// Es una lección que el ecosistema ya tenía escrita (en `@dotrino/reputation`: comparar
|
|
10
|
+
// con `samePubkey`, nunca con `===`) y que aquí se volvió a tropezar.
|
|
11
|
+
|
|
12
|
+
/** Los campos que IDENTIFICAN una llave EC; lo demás es adorno de la serialización. */
|
|
13
|
+
function canonical (jwkOrString) {
|
|
14
|
+
let k = jwkOrString
|
|
15
|
+
if (typeof k === 'string') {
|
|
16
|
+
try { k = JSON.parse(k) } catch { return null }
|
|
17
|
+
}
|
|
18
|
+
if (!k || typeof k !== 'object') return null
|
|
19
|
+
if (!k.kty || !k.crv || !k.x) return null
|
|
20
|
+
return `${k.kty}|${k.crv}|${k.x}|${k.y || ''}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** ¿Son la misma llave, aunque vengan escritas distinto? */
|
|
24
|
+
export function samePubkey (a, b) {
|
|
25
|
+
const ca = canonical(a)
|
|
26
|
+
const cb = canonical(b)
|
|
27
|
+
return !!ca && ca === cb
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Una forma estable de la llave, para usarla como clave de un mapa o de un log. */
|
|
31
|
+
export function pubkeyId (jwkOrString) {
|
|
32
|
+
return canonical(jwkOrString)
|
|
33
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// CONVERTIR UNA BÓVEDA AL FORMATO SELLADO (`docs/sealed-passwords.md` §2.7).
|
|
2
|
+
//
|
|
3
|
+
// Es un acto único y hay que hacerlo en las CUATRO bóvedas —el binario, la pestaña, la de
|
|
4
|
+
// dentro de la extensión y `passmanager serve`—, así que vive aquí y no en ninguna de
|
|
5
|
+
// ellas. Cuatro copias de esto serían cuatro formas sutilmente distintas de reescribir lo
|
|
6
|
+
// mismo, y la que se quedara atrás no fallaría al convertir: fallaría al leerlo otra
|
|
7
|
+
// bóveda, meses después.
|
|
8
|
+
//
|
|
9
|
+
// Durante la conversión —y SOLO durante la conversión— quien convierte ve los valores: hay
|
|
10
|
+
// que abrirlos con la llave vieja para volver a cerrarlos uno a uno. Por eso lo último que
|
|
11
|
+
// hace es **borrar la llave vieja**: mientras siga ahí, el agujero sigue abierto.
|
|
12
|
+
//
|
|
13
|
+
// Y por eso no se convierte «en segundo plano»: hace falta la contraseña de la copia de
|
|
14
|
+
// recuperación, que es lo único que puede volver a abrir esto el día que no quede ningún
|
|
15
|
+
// aparato. Sin ella no se empieza.
|
|
16
|
+
|
|
17
|
+
import { openEntry } from '../model.js'
|
|
18
|
+
import { buildSealedEntry, buildProfileKey } from './device.js'
|
|
19
|
+
import { profileKeys } from './keys.js'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {object} o
|
|
23
|
+
* `store` el almacén `{ get(k), set(k, v) }` con las entradas viejas
|
|
24
|
+
* `sealed` la `SealedStore` de esta bóveda
|
|
25
|
+
* `cek` la llave vieja (`CryptoKey`), o `null` si no hay nada que convertir
|
|
26
|
+
* `recipients` `{ recoveryPub, main, passkeys }` — a quién se le envuelve
|
|
27
|
+
* `author` `{ publickey, sign(body) }` — quien firma lo que se escribe
|
|
28
|
+
* `dropOldKey` borra la llave vieja. Se llama AL FINAL y solo si salió todo
|
|
29
|
+
* `legacyKey` dónde viven las entradas viejas (cada bóveda la guarda en su sitio)
|
|
30
|
+
* @returns {Promise<{already:boolean, entries:number}>}
|
|
31
|
+
*/
|
|
32
|
+
export async function convertToSealed ({
|
|
33
|
+
store, sealed, cek, recipients, author, dropOldKey,
|
|
34
|
+
legacyKey = 'passmanager/entries/v1', log = () => {}
|
|
35
|
+
} = {}) {
|
|
36
|
+
if (!sealed) throw new Error('convertToSealed: missing the sealed store')
|
|
37
|
+
// `recoveryPub: null` vale: se convierte sin copia de recuperación y se añade después
|
|
38
|
+
// (`SealedStore.addRecovery`). Lo que no vale es no haber preguntado.
|
|
39
|
+
if (!recipients || recipients.recoveryPub === undefined) {
|
|
40
|
+
throw new Error('convertToSealed: missing the recipients')
|
|
41
|
+
}
|
|
42
|
+
// Ya convertida: no se vuelve a hacer. Repetirlo estrenaría otra llave de perfil y
|
|
43
|
+
// dejaría las entradas de antes con la anterior — la mitad de la bóveda ilegible.
|
|
44
|
+
if (await sealed.sealed()) return { already: true, entries: 0 }
|
|
45
|
+
|
|
46
|
+
const viejas = (await store.get(legacyKey)) || []
|
|
47
|
+
|
|
48
|
+
// 1. La llave del perfil, de la que salen el índice de sitios y los resúmenes.
|
|
49
|
+
const pk = await buildProfileKey({ recipients })
|
|
50
|
+
await sealed.setProfile({ envelope: pk.envelope, wraps: pk.wraps })
|
|
51
|
+
const keys = await profileKeys(pk.base)
|
|
52
|
+
|
|
53
|
+
// 2. Cada entrada, reescrita: un sobre por campo, la vista sellada, sus huellas.
|
|
54
|
+
let hechas = 0
|
|
55
|
+
for (const vieja of viejas) {
|
|
56
|
+
if (!cek) break
|
|
57
|
+
const abierta = await openEntry(cek, vieja)
|
|
58
|
+
await sealed.putSealed(await buildSealedEntry({ plain: abierta, keys, recipients, author }))
|
|
59
|
+
hechas++
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 3. Y se va lo viejo: las entradas con la llave que las abría, y la llave misma. Solo
|
|
63
|
+
// si TODAS pasaron: quedarse a medias y borrar la llave es perder lo que faltaba.
|
|
64
|
+
if (hechas === viejas.length) {
|
|
65
|
+
await store.set(legacyKey, [])
|
|
66
|
+
await dropOldKey?.()
|
|
67
|
+
}
|
|
68
|
+
log(`passwords: ${hechas} entries converted to the sealed format`)
|
|
69
|
+
return { already: false, entries: hechas }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export default { convertToSealed }
|