@dotrino/identity 0.114.1 → 0.116.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -1
- package/src/index.js +9 -1
- package/src/node.js +4 -180
- package/vault/acta.js +5 -1
- package/vault/core.js +95 -15
- package/vault/facade.js +200 -0
- package/vault/index.html +10 -0
- package/vault/machine.js +95 -0
- package/vault/remote.js +54 -9
- package/vault/vault.js +29 -58
- package/vault/vendor/compat/VERSION.txt +3 -0
- package/vault/vendor/compat/advisory.js +141 -0
- package/vault/vendor/compat/index.js +188 -0
- package/vault/vendor/compat/ranges.js +93 -0
- package/vault/vendor/opaque/VERSION.txt +1 -1
- package/vault/vendor/passmanager/VERSION.txt +3 -0
- package/vault/vendor/passmanager/crypto.js +167 -0
- package/vault/vendor/passmanager/fields.js +183 -0
- package/vault/vendor/passmanager/generate.js +74 -0
- package/vault/vendor/passmanager/import.js +156 -0
- package/vault/vendor/passmanager/index.js +27 -0
- package/vault/vendor/passmanager/match.js +137 -0
- package/vault/vendor/passmanager/model.js +332 -0
- package/vault/vendor/passmanager/pubkey.js +33 -0
- package/vault/vendor/passmanager/sealed/convert.js +72 -0
- package/vault/vendor/passmanager/sealed/device.js +211 -0
- package/vault/vendor/passmanager/sealed/entry.js +209 -0
- package/vault/vendor/passmanager/sealed/index.js +20 -0
- package/vault/vendor/passmanager/sealed/keys.js +120 -0
- package/vault/vendor/passmanager/sealed/recovery.js +136 -0
- package/vault/vendor/passmanager/sealed/store.js +508 -0
- package/vault/vendor/passmanager/session-cache.js +60 -0
- package/vault/vendor/passmanager/totp.js +111 -0
- package/vault/vendor/passmanager/transport/protocol.js +83 -0
- package/vault/vendor/passmanager/transport/proxy.js +112 -0
- package/vault/vendor/passmanager/transport/sealed.js +74 -0
- package/vault/vendor/passmanager/vault/approval.js +63 -0
- package/vault/vendor/passmanager/vault/errors.js +34 -0
- package/vault/vendor/passmanager/vault/guard.js +114 -0
- package/vault/vendor/passmanager/vault/interface.js +34 -0
- package/vault/vendor/passmanager/vault/local.js +213 -0
- package/vault/vendor/passmanager/vault/remote.js +85 -0
- package/vault/vendor/passmanager/vault/responder.js +192 -0
- package/vault/vendor/passmanager/vault/sealed-local.js +199 -0
- package/vault/vendor/passmanager/vault/sealed-responder.js +159 -0
- package/vault/vendor/passmanager/vault/sealed.js +362 -0
- package/vault/vendor/passmanager/webauthn.js +221 -0
- package/vault/vendor/proxy-client/VERSION.txt +1 -1
- package/vault/vendor/proxy-client/client.js +36 -0
- package/vault/vendor/roadmap/VERSION.txt +3 -0
- package/vault/vendor/roadmap/index.js +108 -0
- package/vault/vendor/roadmap/manifests.js +797 -0
- package/vault/vendor/store/VERSION.txt +3 -0
- package/vault/vendor/store/core.js +309 -0
- package/vault/vendor/vault/VERSION.txt +4 -1
- package/vault/vendor/vault/admin.js +437 -0
- package/vault/vendor/vault/browser.js +167 -0
- package/vault/vendor/vault/core/approvals.js +204 -0
- package/vault/vendor/vault/core/blocked.js +68 -0
- package/vault/vendor/vault/core/browserDisk.js +109 -0
- package/vault/vendor/vault/core/commKey.js +68 -0
- package/vault/vendor/vault/core/disk.js +65 -0
- package/vault/vendor/vault/core/libVersion.js +8 -0
- package/vault/vendor/vault/core/machines.js +66 -0
- package/vault/vendor/vault/core/notices.js +71 -0
- package/vault/vendor/vault/core/notifiable.js +73 -0
- package/vault/vendor/vault/core/passwords.js +125 -0
- package/vault/vendor/vault/core/sealKey.js +77 -0
- package/vault/vendor/vault/core/sealer.js +172 -0
- package/vault/vendor/vault/core/sealers.js +159 -0
- package/vault/vendor/vault/core/secretsStore.js +1326 -0
- package/vault/vendor/vault/core/sha256.js +69 -0
- package/vault/vendor/vault/core/store.js +68 -0
- package/vault/vendor/vault/core/subacta.js +101 -0
- package/vault/vendor/vault/core/threadStore.js +273 -0
- package/vault/vendor/vault/core/transport.js +270 -0
- package/vault/vendor/vault/core/vault.js +4414 -0
- package/vault/vendor/vault/enroll.js +8 -4
- package/vault/vendor/vault/procContext.js +97 -0
- package/vault/vendor/vault/revocation.js +67 -0
- package/vault/vendor/vault/sealed.js +84 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// EL LADO QUE RESPONDE, con el formato sellado.
|
|
2
|
+
//
|
|
3
|
+
// Hace lo mismo que `VaultResponder` —es donde vive la política: quién puede pedir, qué
|
|
4
|
+
// exige un dedo encima y qué queda anotado— con una diferencia que lo cambia todo: **esta
|
|
5
|
+
// bóveda no puede abrir nada**. Así que lo que entrega son sobres, y lo que comprueba es lo
|
|
6
|
+
// que se puede comprobar sin ninguna llave.
|
|
7
|
+
//
|
|
8
|
+
// Va aparte del responder de siempre y no dentro de él porque el protocolo es OTRO
|
|
9
|
+
// (`pm2.*`, ver `../transport/protocol.js`): mezclarlos daría una clase con dos modos, que
|
|
10
|
+
// es como se acaba sirviendo el formato viejo «mientras tanto» — o sea, el agujero otra vez.
|
|
11
|
+
|
|
12
|
+
import { isRequest, reply, replyError, SEALED_OPS } from '../transport/protocol.js'
|
|
13
|
+
import { ApprovalGate } from './approval.js'
|
|
14
|
+
import { ALWAYS_PRIVATE } from '../fields.js'
|
|
15
|
+
import { CODES } from './errors.js'
|
|
16
|
+
import { PASSKEY_FIELD } from '../sealed/entry.js'
|
|
17
|
+
|
|
18
|
+
/** Lo que es privado POR LO QUE ES, sin mirar ninguna marca. */
|
|
19
|
+
const SIEMPRE_PRIVADO = new Set([...ALWAYS_PRIVATE.filter((k) => k !== 'webauthn'), PASSKEY_FIELD, 'wa.userHandle'])
|
|
20
|
+
|
|
21
|
+
export class SealedResponder {
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} opts
|
|
24
|
+
* `client` cliente de `@dotrino/proxy-client` conectado e identificado
|
|
25
|
+
* `store` una `SealedStore`
|
|
26
|
+
* `recipients()` → `{ recoveryPub, main, passkeys }`, del acta
|
|
27
|
+
* `isAllowed(pubkey)` quién puede pedir (el acta: `passwords`). Por defecto NADIE.
|
|
28
|
+
* `needsApproval(pubkey)` si ese aparato tiene que pedir permiso (el acta:
|
|
29
|
+
* `unattended`). Se compone con el criterio de abajo: **solo lo privado pregunta**.
|
|
30
|
+
* `approve({ op, payload, pubkey })` pide el visto bueno y espera
|
|
31
|
+
* `encPubOf(pubkey)` para sellarle la respuesta
|
|
32
|
+
* `onRequest(record)` la bitácora
|
|
33
|
+
*/
|
|
34
|
+
constructor ({ client, store, recipients, isAllowed, needsApproval, approve, encPubOf, onRequest } = {}) {
|
|
35
|
+
this.client = client
|
|
36
|
+
this.store = store
|
|
37
|
+
this.recipients = recipients || (async () => ({ recoveryPub: null, main: [], passkeys: [] }))
|
|
38
|
+
this.isAllowed = isAllowed || (() => false)
|
|
39
|
+
this.encPubOf = encPubOf || (() => null)
|
|
40
|
+
this.needsApproval = needsApproval || (async () => true)
|
|
41
|
+
this.approve = approve || (async () => false)
|
|
42
|
+
this.onRequest = onRequest || (() => {})
|
|
43
|
+
this._handler = null
|
|
44
|
+
this.gate = new ApprovalGate({
|
|
45
|
+
ask: ({ op, payload, pubkey }) => this.approve({ op, payload, pubkey }),
|
|
46
|
+
scope: ({ pubkey }) => pubkey
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
revokeApproval (pubkey) { this.gate.revoke(pubkey || null) }
|
|
51
|
+
isApproved (pubkey) { return this.gate.granted(pubkey) }
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* ¿LO QUE SE PIDE ES PRIVADO? Aquí se contesta SIN abrir nada, y por eso hace falta la
|
|
55
|
+
* lista en claro de la entrada (`priv`): la bóveda no ve la vista.
|
|
56
|
+
*
|
|
57
|
+
* Sin `keys` se pide la entrada entera, y eso siempre es privado: pedir todo es pedir la
|
|
58
|
+
* contraseña.
|
|
59
|
+
*/
|
|
60
|
+
async wantsPrivate (op, payload) {
|
|
61
|
+
if (op !== 'pm2.get') return false
|
|
62
|
+
const keys = payload?.keys
|
|
63
|
+
if (!Array.isArray(keys)) return true
|
|
64
|
+
const priv = new Set(await this.store.privateKeysOf(payload?.id))
|
|
65
|
+
return keys.some((k) => SIEMPRE_PRIVADO.has(k) || priv.has(k))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
start () {
|
|
69
|
+
if (this._handler) return
|
|
70
|
+
this._handler = async (from, msg, meta) => {
|
|
71
|
+
const p = typeof msg === 'string' ? (() => { try { return JSON.parse(msg) } catch (_) { return null } })() : msg
|
|
72
|
+
if (!isRequest(p)) return
|
|
73
|
+
// QUIÉN PREGUNTA lo dice el proxio (`fromPubkey`: la llave con la que se identificó esa
|
|
74
|
+
// conexión) o un saludo previo (`pubkeyOfToken`). Aquí se leía `meta.pubkey`, que no
|
|
75
|
+
// existe, y se caía al token: ninguna bóveda sabía a quién sellarle la respuesta, no
|
|
76
|
+
// contestaba, y del otro lado se veía «nadie respondió». Sin llave no hay a quién
|
|
77
|
+
// contestar sellado, así que no se contesta — el token no es una llave.
|
|
78
|
+
const pubkey = meta?.fromPubkey || this.client?.pubkeyOfToken?.(from) || null
|
|
79
|
+
if (!pubkey) { this.onRequest({ op: p.op, outcome: 'anonymous', from: null }); return }
|
|
80
|
+
await this.handle({ from, pubkey, msg: p, sealed: meta?.sealed === true })
|
|
81
|
+
}
|
|
82
|
+
this.client.on('message', this._handler)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
stop () {
|
|
86
|
+
if (!this._handler) return
|
|
87
|
+
this.client.off?.('message', this._handler)
|
|
88
|
+
this._handler = null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Contesta UNA petición. Público para que las pruebas no tengan que fingir un socket. */
|
|
92
|
+
async handle ({ from, pubkey, msg, sealed = true }) {
|
|
93
|
+
const { rid, op, payload } = msg
|
|
94
|
+
const anota = (outcome, extra = {}) => this.onRequest({ op, outcome, from: pubkey, ...extra })
|
|
95
|
+
// EL SELLADO DEL TRANSPORTE NO ES OPCIONAL (CONVENCIONES §4.1): lo que llega en claro
|
|
96
|
+
// no se atiende, porque contestarlo sería mandar sobres a quien no sabemos que es.
|
|
97
|
+
//
|
|
98
|
+
// Los códigos son los del responder de siempre (`responder.js`): «la política dice que
|
|
99
|
+
// no» es `denied` y «el humano dijo que no» es `not-approved`. Se arreglan distinto —uno
|
|
100
|
+
// dando permiso al aparato, el otro volviendo a pedir— y quien pide los distingue así.
|
|
101
|
+
if (!sealed) { anota('unsealed'); return this.#send(from, pubkey, replyError(rid, CODES.UNSEALED, 'unsealed request')) }
|
|
102
|
+
if (!SEALED_OPS.includes(op)) { anota('unknown-op'); return this.#send(from, pubkey, replyError(rid, CODES.DENIED, 'operation not allowed: ' + op)) }
|
|
103
|
+
if (!this.isAllowed(pubkey)) { anota('denied'); return this.#send(from, pubkey, replyError(rid, CODES.DENIED, 'this device cannot ask for passwords')) }
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
if (await this.#needsApproval(op, payload, pubkey)) {
|
|
107
|
+
// `allow`, no `ask`: es el que RECUERDA el sí y junta las simultáneas. `ask` es la
|
|
108
|
+
// función cruda de preguntar, y usarla directamente hacía sonar el teléfono en cada
|
|
109
|
+
// petición — que es como se enseña a aprobar sin mirar.
|
|
110
|
+
const ok = await this.gate.allow({ op, payload, pubkey })
|
|
111
|
+
if (!ok) { anota('rejected'); return this.#send(from, pubkey, replyError(rid, CODES.NOT_APPROVED, 'not approved on the trusted device')) }
|
|
112
|
+
}
|
|
113
|
+
const result = await this.#run(op, payload, pubkey)
|
|
114
|
+
anota('ok')
|
|
115
|
+
return this.#send(from, pubkey, reply(rid, result))
|
|
116
|
+
} catch (e) {
|
|
117
|
+
anota('error', { code: e?.code })
|
|
118
|
+
return this.#send(from, pubkey, replyError(rid, e?.code || CODES.UNKNOWN, e?.message || 'error'))
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async #needsApproval (op, payload, pubkey) {
|
|
123
|
+
return await this.needsApproval(pubkey) && await this.wantsPrivate(op, payload)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async #run (op, payload = {}, pubkey) {
|
|
127
|
+
switch (op) {
|
|
128
|
+
case 'pm2.recipients': return this.recipients()
|
|
129
|
+
case 'pm2.profile':
|
|
130
|
+
// Con `set` se estrena (al convertir); sin él, se pide la que hay.
|
|
131
|
+
if (payload.set) return this.store.setProfile(payload.set)
|
|
132
|
+
return this.store.profile({ pub: pubkey })
|
|
133
|
+
case 'pm2.views': return this.store.views({ pub: pubkey })
|
|
134
|
+
case 'pm2.find': return this.store.find({ pub: pubkey, idx: payload.idx || [] })
|
|
135
|
+
case 'pm2.passkey': return this.store.findPasskey({ pub: pubkey, rp: payload.rp, cred: payload.cred })
|
|
136
|
+
case 'pm2.get': return this.store.get(payload.id, { pub: pubkey, keys: payload.keys })
|
|
137
|
+
case 'pm2.put': return this.store.putSealed({ ...payload, by: pubkey })
|
|
138
|
+
case 'pm2.patch': return this.store.patchSealed({ ...payload, by: pubkey })
|
|
139
|
+
case 'pm2.remove': return this.store.remove(payload.id)
|
|
140
|
+
default: throw Object.assign(new Error('operation not allowed: ' + op), { code: CODES.DENIED })
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** `to` es por dónde se contesta (el token); `pubkey`, a quién se le sella. */
|
|
145
|
+
#send (to, pubkey, msg) {
|
|
146
|
+
const encPub = this.encPubOf(pubkey)
|
|
147
|
+
// La respuesta va SELLADA: el proxio enruta pero no cifra. Si no se puede sellar, no se
|
|
148
|
+
// manda — mandarlo en claro sería entregar los sobres y las envolturas a quien mire.
|
|
149
|
+
if (encPub && typeof this.client.sendSealedTo === 'function') {
|
|
150
|
+
return this.client.sendSealedTo(to, msg, { peerEncPub: encPub })
|
|
151
|
+
}
|
|
152
|
+
if (typeof this.client.sendSealed === 'function' && encPub) {
|
|
153
|
+
return this.client.sendSealed([to], msg, { peerEncPub: encPub })
|
|
154
|
+
}
|
|
155
|
+
throw Object.assign(new Error('cannot seal the reply to that device: it has no encryption key in the record'), { code: 'no-encpub' })
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export default SealedResponder
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
// LA BÓVEDA SELLADA, VISTA DESDE EL APARATO.
|
|
2
|
+
//
|
|
3
|
+
// Cumple el MISMO contrato que `RemoteVault` (`./interface.js`: find/get/put/patch/search/
|
|
4
|
+
// sites/remove) para que la interfaz no tenga que aprender nada nuevo. Lo que cambia está
|
|
5
|
+
// debajo: al otro lado ya no hay una bóveda que abre entradas, hay una que guarda sobres.
|
|
6
|
+
// Así que todo lo que antes hacía ella lo hace esto, aquí:
|
|
7
|
+
//
|
|
8
|
+
// · **buscar por sitio** — se calculan las huellas de la URL que hay delante y la bóveda
|
|
9
|
+
// compara cadenas. El emparejamiento de verdad (subdominios, comodines) se rehace aquí
|
|
10
|
+
// con `match.js` sobre las vistas ya abiertas, así que la regla no cambia;
|
|
11
|
+
// · **abrir** — cada sobre con la envoltura de este aparato;
|
|
12
|
+
// · **escribir** — cifrar, envolver a los destinatarios que dice el acta y firmar;
|
|
13
|
+
// · **buscar por texto** — la bóveda no ve ningún texto: se piden las vistas y se busca
|
|
14
|
+
// aquí (§2.2). Son vistas, no valores.
|
|
15
|
+
//
|
|
16
|
+
// Lo que este aparato no puede abrir se dice en `withheld` en vez de aparecer vacío.
|
|
17
|
+
|
|
18
|
+
import { SEALED_OPS } from '../transport/protocol.js'
|
|
19
|
+
import { VaultError, CODES } from './errors.js'
|
|
20
|
+
import { hostOf, registrableDomain, findForUrl } from '../match.js'
|
|
21
|
+
import { profileKeys, siteIndex, fieldIndex, passkeyIndex, valueDigest } from '../sealed/keys.js'
|
|
22
|
+
import { buildSealedEntry, buildSealedPatch, buildProfileKey, openSealedEntry, makeOpener } from '../sealed/device.js'
|
|
23
|
+
import { openView, PASSKEY_FIELD } from '../sealed/entry.js'
|
|
24
|
+
import { fieldKey, normalizeFields } from '../fields.js'
|
|
25
|
+
|
|
26
|
+
/** Las claves de campo de la interfaz, tal como las pide `get(id, { keys })`. */
|
|
27
|
+
const FIXED = ['name', 'username', 'secret', 'totp', 'notes']
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* DE LA CLAVE QUE USA LA INTERFAZ A LA QUE GUARDA LA BÓVEDA.
|
|
31
|
+
*
|
|
32
|
+
* Las fijas viajan por su nombre —vocabulario cerrado, igual para todo el mundo— y las
|
|
33
|
+
* libres por HUELLA: «cédula» es del usuario y la bóveda no tiene por qué leerlo. La
|
|
34
|
+
* traducción vive aquí y en un solo sitio; si quien guarda y quien pide la hicieran
|
|
35
|
+
* distinta, el sobre no se encontraría y no habría forma de verlo.
|
|
36
|
+
*/
|
|
37
|
+
const esFija = (k) => FIXED.includes(k) || k.startsWith('wa.')
|
|
38
|
+
|
|
39
|
+
export class SealedVault {
|
|
40
|
+
/**
|
|
41
|
+
* @param {object} transport `{ request(op, payload) }` — el mismo de `RemoteVault`
|
|
42
|
+
* @param {object} opts
|
|
43
|
+
* `identity` `{ publickey, sign(body), openSealed({ wrap, envelope }) }`. La privada de
|
|
44
|
+
* cifrado no sale de donde viva: aquí solo se pide abrir.
|
|
45
|
+
* `needsApproval` si lo privado exige un dedo encima al otro lado (solo informativo)
|
|
46
|
+
*/
|
|
47
|
+
constructor (transport, { identity, needsApproval = true } = {}) {
|
|
48
|
+
if (!transport?.request) throw new Error('SealedVault: missing transport')
|
|
49
|
+
if (typeof identity?.sign !== 'function' || typeof identity?.openSealed !== 'function') {
|
|
50
|
+
throw new Error('SealedVault: identity needs { publickey, sign(body), openSealed({wrap,envelope}) }')
|
|
51
|
+
}
|
|
52
|
+
this.transport = transport
|
|
53
|
+
this.identity = identity
|
|
54
|
+
this.needsApproval = needsApproval
|
|
55
|
+
this._keys = null
|
|
56
|
+
this._recipients = null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get capabilities () {
|
|
60
|
+
return { canWrite: true, canList: false, needsApproval: this.needsApproval, sealed: true }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Lo que se cachea muere al dormirse el worker, que es justo cuando hay que releerlo. */
|
|
64
|
+
forget () { this._keys = null; this._recipients = null }
|
|
65
|
+
|
|
66
|
+
async #request (op, payload) {
|
|
67
|
+
if (!SEALED_OPS.includes(op)) throw new VaultError(CODES.NOT_ALLOWED, 'operación desconocida: ' + op)
|
|
68
|
+
try {
|
|
69
|
+
return await this.transport.request(op, payload)
|
|
70
|
+
} catch (e) {
|
|
71
|
+
if (e instanceof VaultError) throw e
|
|
72
|
+
throw new VaultError(e?.code || CODES.UNREACHABLE, e?.message || 'no hay bóveda al otro lado')
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* LAS LLAVES DEL PERFIL. Sin ellas no se puede ni buscar (el índice va con huella) ni
|
|
78
|
+
* comparar, así que es lo primero que pide este aparato al despertar.
|
|
79
|
+
*/
|
|
80
|
+
async keys () {
|
|
81
|
+
if (this._keys) return this._keys
|
|
82
|
+
const { envelope, wrap } = await this.#request('pm2.profile', {})
|
|
83
|
+
const base = await this.identity.openSealed({ wrap, envelope })
|
|
84
|
+
this._keys = await profileKeys(base)
|
|
85
|
+
return this._keys
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A quién hay que envolverle cada llave. La lista sale del acta y la sabe la bóveda. */
|
|
89
|
+
async recipients () {
|
|
90
|
+
if (this._recipients) return this._recipients
|
|
91
|
+
this._recipients = await this.#request('pm2.recipients', {})
|
|
92
|
+
return this._recipients
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async #sealedKey (k) {
|
|
96
|
+
if (esFija(k)) return k
|
|
97
|
+
const { kidx } = await this.keys()
|
|
98
|
+
return 'f:' + await fieldIndex(kidx, k)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
#opener (wraps) {
|
|
102
|
+
return makeOpener({ wraps, openSealed: (x) => this.identity.openSealed(x) })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async #openView (v) {
|
|
106
|
+
const abierta = await openView(
|
|
107
|
+
{ id: v.id, updatedAt: v.updatedAt, view: v.view, digests: v.digests },
|
|
108
|
+
this.#opener({ [v.view.gen]: v.wrap })
|
|
109
|
+
)
|
|
110
|
+
// `fieldHashes` conserva el nombre que ya usa la interfaz para comparar. Dos cosas
|
|
111
|
+
// cambian debajo: los resúmenes están GUARDADOS (antes era un HMAC con un nonce de la
|
|
112
|
+
// respuesta), y su clave viaja por huella — así que aquí se traducen de vuelta a los
|
|
113
|
+
// nombres de verdad, que la vista sí lleva porque va sellada.
|
|
114
|
+
const fieldHashes = {}
|
|
115
|
+
for (const k of [...abierta.fieldKeys, 'notes']) {
|
|
116
|
+
const clave = await this.#sealedKey(k)
|
|
117
|
+
if (abierta.digests[clave] !== undefined) fieldHashes[k] = abierta.digests[clave]
|
|
118
|
+
}
|
|
119
|
+
return { ...abierta, fieldHashes }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* LAS HUELLAS DE LA URL QUE HAY DELANTE. El sitio guardado puede ser el dominio y la URL
|
|
124
|
+
* un subdominio suyo (`empresa.com` vale en `login.empresa.com`), así que se calculan las
|
|
125
|
+
* huellas de CADA variante: el host, sus padres hasta el dominio registrable, y las de
|
|
126
|
+
* comodín. La bóveda solo compara cadenas.
|
|
127
|
+
*/
|
|
128
|
+
async #hashesFor (url) {
|
|
129
|
+
const { kidx } = await this.keys()
|
|
130
|
+
const host = hostOf(url)
|
|
131
|
+
if (!host) return []
|
|
132
|
+
const base = registrableDomain(host)
|
|
133
|
+
const variantes = new Set([host])
|
|
134
|
+
let actual = host
|
|
135
|
+
while (actual.includes('.') && actual !== base) {
|
|
136
|
+
actual = actual.slice(actual.indexOf('.') + 1)
|
|
137
|
+
variantes.add(actual)
|
|
138
|
+
}
|
|
139
|
+
const out = []
|
|
140
|
+
for (const v of variantes) {
|
|
141
|
+
out.push(await siteIndex(kidx, v))
|
|
142
|
+
out.push(await siteIndex(kidx, '*.' + v))
|
|
143
|
+
}
|
|
144
|
+
return out
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* QUÉ HAY PARA ESTE SITIO. Las huellas son un filtro previo; el emparejamiento de verdad
|
|
149
|
+
* —el que decide si `empresa.com` vale aquí— se rehace con `match.js` sobre las vistas ya
|
|
150
|
+
* abiertas, para que la regla siga siendo UNA y no dos que se desincronizan.
|
|
151
|
+
*/
|
|
152
|
+
async find (url) {
|
|
153
|
+
const hits = await this.#request('pm2.find', { idx: await this.#hashesFor(url) })
|
|
154
|
+
const vistas = []
|
|
155
|
+
for (const v of hits) vistas.push(await this.#openView(v))
|
|
156
|
+
return findForUrl(vistas, url).map((x) => x.entry)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** La passkey que este sitio pide, o la que tiene ese id de credencial. */
|
|
160
|
+
async findPasskey ({ rpId = null, credentialId = null } = {}) {
|
|
161
|
+
const { kidx } = await this.keys()
|
|
162
|
+
const hits = await this.#request('pm2.passkey', {
|
|
163
|
+
...(rpId ? { rp: await passkeyIndex(kidx, 'rp', rpId) } : {}),
|
|
164
|
+
...(credentialId ? { cred: await passkeyIndex(kidx, 'cred', credentialId) } : {})
|
|
165
|
+
})
|
|
166
|
+
const out = []
|
|
167
|
+
for (const v of hits) out.push(await this.#openView(v))
|
|
168
|
+
return out
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** UNA credencial, y solo los campos pedidos. */
|
|
172
|
+
async get (id, opts = {}) {
|
|
173
|
+
const keys = Array.isArray(opts.keys) ? await Promise.all(opts.keys.map((k) => this.#sealedKey(k))) : null
|
|
174
|
+
const got = await this.#request('pm2.get', { id, ...(keys ? { keys } : {}) })
|
|
175
|
+
return openSealedEntry({ got, openSealed: (x) => this.identity.openSealed(x) })
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async put (plain) {
|
|
179
|
+
const [keys, recipients] = await Promise.all([this.keys(), this.recipients()])
|
|
180
|
+
const sobre = await buildSealedEntry({ plain, keys, recipients, author: this.#author() })
|
|
181
|
+
await this.#request('pm2.put', sobre)
|
|
182
|
+
// La vista se devuelve ARMADA AQUÍ: quien guardó ya la tiene en claro, y pedírsela a la
|
|
183
|
+
// bóveda sería un viaje para que nos devuelva lo que acabamos de sellar.
|
|
184
|
+
return this.#viewOf(plain)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* CAMBIAR unos campos sin leer los demás (§2.4).
|
|
189
|
+
*
|
|
190
|
+
* La vista se rehace aquí con lo que ya se sabe: los NOMBRES de los campos y cuáles son
|
|
191
|
+
* privados viven en ella, no en los sobres, así que marcar un teléfono como privado no
|
|
192
|
+
* saca el teléfono de la bóveda.
|
|
193
|
+
*/
|
|
194
|
+
async patch (id, changes = {}) {
|
|
195
|
+
const [keys, recipients] = await Promise.all([this.keys(), this.recipients()])
|
|
196
|
+
const actual = await this.#viewById(id)
|
|
197
|
+
if (!actual) throw new VaultError(CODES.NOT_FOUND, 'no hay ninguna entrada con ese id')
|
|
198
|
+
|
|
199
|
+
const values = {}
|
|
200
|
+
const drop = []
|
|
201
|
+
for (const k of FIXED) if (typeof changes[k] === 'string') values[k] = { name: k, value: changes[k] }
|
|
202
|
+
if (typeof changes.title === 'string') actual.title = changes.title
|
|
203
|
+
|
|
204
|
+
const campos = normalizeFields(Array.isArray(changes.fields) ? changes.fields : [])
|
|
205
|
+
for (const f of campos) {
|
|
206
|
+
const k = fieldKey(f)
|
|
207
|
+
// LA MARCA VA DENTRO DEL SOBRE, así que cambiarla es reescribir el campo — y para eso
|
|
208
|
+
// hay que tener su valor. Quien edita lo tiene: si solo trae la marca, hay que leerlo
|
|
209
|
+
// primero. Leer uno PÚBLICO no cuesta aprobación; quitarle la marca a uno privado sí,
|
|
210
|
+
// y está bien, porque vas a exponerlo.
|
|
211
|
+
let valor = f.value
|
|
212
|
+
if (typeof valor !== 'string' || !valor) {
|
|
213
|
+
if (f.private === undefined) continue
|
|
214
|
+
const leido = await this.get(id, { keys: [k] })
|
|
215
|
+
valor = (JSON.parse(leido.fields || '[]').find((x) => fieldKey(x) === k) || {}).value || ''
|
|
216
|
+
if (!valor) continue
|
|
217
|
+
}
|
|
218
|
+
values[await this.#sealedKey(k)] = {
|
|
219
|
+
name: k,
|
|
220
|
+
value: JSON.stringify({ label: f.label, value: valor, kind: f.kind, ...(f.private ? { private: true } : {}) })
|
|
221
|
+
}
|
|
222
|
+
const privados = new Set(actual.privateKeys)
|
|
223
|
+
if (f.private) privados.add(k); else if (f.private === false) privados.delete(k)
|
|
224
|
+
actual.privateKeys = [...privados]
|
|
225
|
+
if (!actual.fieldKeys.includes(k)) actual.fieldKeys.push(k)
|
|
226
|
+
}
|
|
227
|
+
for (const k of (changes.removeFields || [])) {
|
|
228
|
+
drop.push(await this.#sealedKey(k))
|
|
229
|
+
actual.fieldKeys = actual.fieldKeys.filter((x) => x !== k)
|
|
230
|
+
actual.privateKeys = actual.privateKeys.filter((x) => x !== k)
|
|
231
|
+
}
|
|
232
|
+
for (const k of FIXED) {
|
|
233
|
+
if (typeof changes[k] !== 'string') continue
|
|
234
|
+
if (changes[k]) { if (!actual.fieldKeys.includes(k) && k !== 'name') actual.fieldKeys.push(k) } else {
|
|
235
|
+
drop.push(k)
|
|
236
|
+
actual.fieldKeys = actual.fieldKeys.filter((x) => x !== k)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// Los sitios: la lista entera manda, y `addSite` solo suma si ya tenía alguno — una
|
|
240
|
+
// entrada sin sitios sirve en cualquier parte y atarla al primer formulario donde se
|
|
241
|
+
// usó sería cambiarle el sentido.
|
|
242
|
+
let sites = null
|
|
243
|
+
if (Array.isArray(changes.sites)) sites = changes.sites
|
|
244
|
+
else if (changes.addSite && actual.sites.length) sites = [...new Set([...actual.sites, changes.addSite])]
|
|
245
|
+
if (sites) actual.sites = sites
|
|
246
|
+
|
|
247
|
+
if (typeof changes.name === 'string' || typeof changes.username === 'string') {
|
|
248
|
+
actual.hint = changes.name || actual.hint
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const vista = {
|
|
252
|
+
type: actual.type,
|
|
253
|
+
title: actual.title,
|
|
254
|
+
sites: actual.sites,
|
|
255
|
+
hint: actual.hint,
|
|
256
|
+
fieldKeys: actual.fieldKeys,
|
|
257
|
+
privateKeys: actual.privateKeys,
|
|
258
|
+
has: {
|
|
259
|
+
secret: drop.includes('secret') ? false : (actual.hasSecret || !!values.secret),
|
|
260
|
+
totp: drop.includes('totp') ? false : (actual.hasTotp || !!values.totp),
|
|
261
|
+
notes: drop.includes('notes') ? false : (actual.hasNotes || !!values.notes),
|
|
262
|
+
fields: actual.fieldKeys.some((k) => !FIXED.includes(k)),
|
|
263
|
+
webauthn: actual.hasWebauthn
|
|
264
|
+
},
|
|
265
|
+
...(actual.webauthn ? { webauthn: actual.webauthn } : {})
|
|
266
|
+
}
|
|
267
|
+
const idx = sites ? await Promise.all(sites.map((s) => siteIndex(keys.kidx, s))) : null
|
|
268
|
+
const parche = await buildSealedPatch({
|
|
269
|
+
id, view: vista, values, drop,
|
|
270
|
+
priv: await Promise.all(vista.privateKeys.map((k) => this.#sealedKey(k))),
|
|
271
|
+
...(idx ? { idx } : {}), keys, recipients, author: this.#author()
|
|
272
|
+
})
|
|
273
|
+
await this.#request('pm2.patch', parche)
|
|
274
|
+
return { id, ...vista, updatedAt: Date.now() }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Buscar por texto: la bóveda no ve ninguno, así que se busca aquí sobre las vistas. */
|
|
278
|
+
async search (q, opts = {}) {
|
|
279
|
+
const termino = String(q || '').trim().toLowerCase()
|
|
280
|
+
if (!termino) return []
|
|
281
|
+
const limite = Number(opts.limit) > 0 ? Number(opts.limit) : 20
|
|
282
|
+
const out = []
|
|
283
|
+
for (const v of await this.#views()) {
|
|
284
|
+
const texto = [v.title, v.hint, ...(v.sites || [])].join(' ').toLowerCase()
|
|
285
|
+
if (texto.includes(termino)) out.push(v)
|
|
286
|
+
if (out.length >= limite) break
|
|
287
|
+
}
|
|
288
|
+
return out
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** En qué sitios hay algo. Los nombres salen de abrir las vistas, no de la bóveda. */
|
|
292
|
+
async sites () {
|
|
293
|
+
const cuenta = new Map()
|
|
294
|
+
for (const v of await this.#views()) {
|
|
295
|
+
for (const s of v.sites || []) cuenta.set(s, (cuenta.get(s) || 0) + 1)
|
|
296
|
+
}
|
|
297
|
+
return [...cuenta].map(([site, count]) => ({ site, count })).sort((a, b) => a.site.localeCompare(b.site))
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async remove (id) {
|
|
301
|
+
await this.#request('pm2.remove', { id })
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* LA BÓVEDA ENTERA, ABIERTA AQUÍ. No es `list` de la bóveda: son las VISTAS, que no
|
|
306
|
+
* llevan ningún valor, y las abre este aparato con su llave. Es lo que sustituye a
|
|
307
|
+
* buscar dentro de la bóveda (§2.2), y por eso existe donde antes no podía existir.
|
|
308
|
+
*/
|
|
309
|
+
async list () {
|
|
310
|
+
return this.#views()
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** El resumen de un valor, para decir si ya está guardado igual sin abrir nada (§2.6). */
|
|
314
|
+
async digest (id, key, value) {
|
|
315
|
+
const { kcmp } = await this.keys()
|
|
316
|
+
return valueDigest(kcmp, id, key, String(value))
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async #views () {
|
|
320
|
+
const crudas = await this.#request('pm2.views', {})
|
|
321
|
+
const out = []
|
|
322
|
+
for (const v of crudas) out.push(await this.#openView(v))
|
|
323
|
+
return out
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async #viewById (id) {
|
|
327
|
+
return (await this.#views()).find((v) => v.id === id) || null
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
#author () {
|
|
331
|
+
return { publickey: this.identity.publickey, sign: (body) => this.identity.sign(body) }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** La vista de lo que se acaba de guardar, con la forma que espera la interfaz. */
|
|
335
|
+
#viewOf (plain) {
|
|
336
|
+
const campos = normalizeFields(Array.isArray(plain.fields) ? plain.fields : [])
|
|
337
|
+
return {
|
|
338
|
+
id: plain.id,
|
|
339
|
+
type: plain.type || 'login',
|
|
340
|
+
title: plain.title || '',
|
|
341
|
+
sites: plain.sites || [],
|
|
342
|
+
hasSecret: !!plain.secret,
|
|
343
|
+
hasTotp: !!plain.totp,
|
|
344
|
+
hasNotes: !!plain.notes,
|
|
345
|
+
hasFields: campos.length > 0,
|
|
346
|
+
hasWebauthn: !!plain.webauthn,
|
|
347
|
+
updatedAt: Date.now()
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Estrenar la llave del perfil. Solo al convertir: después ya está y se pide. */
|
|
352
|
+
async initProfileKey () {
|
|
353
|
+
const recipients = await this.recipients()
|
|
354
|
+
const pk = await buildProfileKey({ recipients })
|
|
355
|
+
await this.#request('pm2.profile', { set: { envelope: pk.envelope, wraps: pk.wraps } })
|
|
356
|
+
this._keys = await profileKeys(pk.base)
|
|
357
|
+
return this._keys
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export { PASSKEY_FIELD }
|
|
362
|
+
export default SealedVault
|