@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.
Files changed (81) hide show
  1. package/package.json +7 -1
  2. package/src/index.js +9 -1
  3. package/src/node.js +4 -180
  4. package/vault/acta.js +5 -1
  5. package/vault/core.js +95 -15
  6. package/vault/facade.js +200 -0
  7. package/vault/index.html +10 -0
  8. package/vault/machine.js +95 -0
  9. package/vault/remote.js +54 -9
  10. package/vault/vault.js +29 -58
  11. package/vault/vendor/compat/VERSION.txt +3 -0
  12. package/vault/vendor/compat/advisory.js +141 -0
  13. package/vault/vendor/compat/index.js +188 -0
  14. package/vault/vendor/compat/ranges.js +93 -0
  15. package/vault/vendor/opaque/VERSION.txt +1 -1
  16. package/vault/vendor/passmanager/VERSION.txt +3 -0
  17. package/vault/vendor/passmanager/crypto.js +167 -0
  18. package/vault/vendor/passmanager/fields.js +183 -0
  19. package/vault/vendor/passmanager/generate.js +74 -0
  20. package/vault/vendor/passmanager/import.js +156 -0
  21. package/vault/vendor/passmanager/index.js +27 -0
  22. package/vault/vendor/passmanager/match.js +137 -0
  23. package/vault/vendor/passmanager/model.js +332 -0
  24. package/vault/vendor/passmanager/pubkey.js +33 -0
  25. package/vault/vendor/passmanager/sealed/convert.js +72 -0
  26. package/vault/vendor/passmanager/sealed/device.js +211 -0
  27. package/vault/vendor/passmanager/sealed/entry.js +209 -0
  28. package/vault/vendor/passmanager/sealed/index.js +20 -0
  29. package/vault/vendor/passmanager/sealed/keys.js +120 -0
  30. package/vault/vendor/passmanager/sealed/recovery.js +136 -0
  31. package/vault/vendor/passmanager/sealed/store.js +508 -0
  32. package/vault/vendor/passmanager/session-cache.js +60 -0
  33. package/vault/vendor/passmanager/totp.js +111 -0
  34. package/vault/vendor/passmanager/transport/protocol.js +83 -0
  35. package/vault/vendor/passmanager/transport/proxy.js +112 -0
  36. package/vault/vendor/passmanager/transport/sealed.js +74 -0
  37. package/vault/vendor/passmanager/vault/approval.js +63 -0
  38. package/vault/vendor/passmanager/vault/errors.js +34 -0
  39. package/vault/vendor/passmanager/vault/guard.js +114 -0
  40. package/vault/vendor/passmanager/vault/interface.js +34 -0
  41. package/vault/vendor/passmanager/vault/local.js +213 -0
  42. package/vault/vendor/passmanager/vault/remote.js +85 -0
  43. package/vault/vendor/passmanager/vault/responder.js +192 -0
  44. package/vault/vendor/passmanager/vault/sealed-local.js +199 -0
  45. package/vault/vendor/passmanager/vault/sealed-responder.js +159 -0
  46. package/vault/vendor/passmanager/vault/sealed.js +362 -0
  47. package/vault/vendor/passmanager/webauthn.js +221 -0
  48. package/vault/vendor/proxy-client/VERSION.txt +1 -1
  49. package/vault/vendor/proxy-client/client.js +36 -0
  50. package/vault/vendor/roadmap/VERSION.txt +3 -0
  51. package/vault/vendor/roadmap/index.js +108 -0
  52. package/vault/vendor/roadmap/manifests.js +797 -0
  53. package/vault/vendor/store/VERSION.txt +3 -0
  54. package/vault/vendor/store/core.js +309 -0
  55. package/vault/vendor/vault/VERSION.txt +4 -1
  56. package/vault/vendor/vault/admin.js +437 -0
  57. package/vault/vendor/vault/browser.js +167 -0
  58. package/vault/vendor/vault/core/approvals.js +204 -0
  59. package/vault/vendor/vault/core/blocked.js +68 -0
  60. package/vault/vendor/vault/core/browserDisk.js +109 -0
  61. package/vault/vendor/vault/core/commKey.js +68 -0
  62. package/vault/vendor/vault/core/disk.js +65 -0
  63. package/vault/vendor/vault/core/libVersion.js +8 -0
  64. package/vault/vendor/vault/core/machines.js +66 -0
  65. package/vault/vendor/vault/core/notices.js +71 -0
  66. package/vault/vendor/vault/core/notifiable.js +73 -0
  67. package/vault/vendor/vault/core/passwords.js +125 -0
  68. package/vault/vendor/vault/core/sealKey.js +77 -0
  69. package/vault/vendor/vault/core/sealer.js +172 -0
  70. package/vault/vendor/vault/core/sealers.js +159 -0
  71. package/vault/vendor/vault/core/secretsStore.js +1326 -0
  72. package/vault/vendor/vault/core/sha256.js +69 -0
  73. package/vault/vendor/vault/core/store.js +68 -0
  74. package/vault/vendor/vault/core/subacta.js +101 -0
  75. package/vault/vendor/vault/core/threadStore.js +273 -0
  76. package/vault/vendor/vault/core/transport.js +270 -0
  77. package/vault/vendor/vault/core/vault.js +4414 -0
  78. package/vault/vendor/vault/enroll.js +8 -4
  79. package/vault/vendor/vault/procContext.js +97 -0
  80. package/vault/vendor/vault/revocation.js +67 -0
  81. 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