@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,213 @@
|
|
|
1
|
+
// Bóveda local: la que corre donde SÍ está la CEK — el vault del PC, la app nativa,
|
|
2
|
+
// y la caché opt-in de solo lectura de la extensión (DISENO §3).
|
|
3
|
+
//
|
|
4
|
+
// El almacén se inyecta para no atar la librería a nada: en la app es
|
|
5
|
+
// `@dotrino/store`, en la extensión `chrome.storage.local`, en las pruebas un Map.
|
|
6
|
+
// Solo tiene que cumplir `{ get(k), set(k, v) }`.
|
|
7
|
+
|
|
8
|
+
import { openEntry, sealEntry, mergePatch, publicView, entryWho, normalizeSites } from '../model.js'
|
|
9
|
+
import { entryDigestValues, fieldKey, normalizeFields, KINDS } from '../fields.js'
|
|
10
|
+
import { makeNonce, fieldHasher } from '../crypto.js'
|
|
11
|
+
import { findForUrl } from '../match.js'
|
|
12
|
+
import { VaultError, CODES } from './errors.js'
|
|
13
|
+
|
|
14
|
+
const KEY = 'passmanager/entries/v1'
|
|
15
|
+
|
|
16
|
+
export class LocalVault {
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} store almacén `{ get(k), set(k, v) }`
|
|
19
|
+
* @param {object} opts `{ readOnly }` — la caché de la extensión va en `true`
|
|
20
|
+
*/
|
|
21
|
+
constructor (store, opts = {}) {
|
|
22
|
+
this.store = store
|
|
23
|
+
this.readOnly = !!opts.readOnly
|
|
24
|
+
this.key = null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get capabilities () {
|
|
28
|
+
return { canWrite: !this.readOnly, canList: true, needsApproval: false }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Entrega la CEK. Sin esto, todo lo demás falla con `locked`. */
|
|
32
|
+
unlock (cek) {
|
|
33
|
+
this.key = cek
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
lock () {
|
|
37
|
+
this.key = null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#requireKey () {
|
|
41
|
+
if (!this.key) throw new VaultError(CODES.LOCKED, 'la bóveda está cerrada')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async #all () {
|
|
45
|
+
return (await this.store.get(KEY)) || []
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Los RESÚMENES de los campos de una entrada, para que quien pregunta pueda comparar
|
|
50
|
+
* sin abrir nada (§4.0.2). Un nonce por respuesta, no por entrada: así dos entradas de
|
|
51
|
+
* la misma respuesta se pueden comparar contra lo mismo, y entre dos respuestas no hay
|
|
52
|
+
* nada que correlacionar.
|
|
53
|
+
*/
|
|
54
|
+
async #digest (nonce, hash, open) {
|
|
55
|
+
const fields = {}
|
|
56
|
+
for (const f of entryDigestValues(open)) fields[f.key] = await hash(f.key, f.value)
|
|
57
|
+
return { nonce, fields }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async find (url) {
|
|
61
|
+
this.#requireKey()
|
|
62
|
+
const hits = findForUrl(await this.#all(), url)
|
|
63
|
+
const nonce = makeNonce()
|
|
64
|
+
const hash = await fieldHasher(nonce)
|
|
65
|
+
// El nombre visible obliga a abrir la entrada: por eso lo calcula quien tiene la CEK,
|
|
66
|
+
// y no quien pregunta. Lo mismo los resúmenes.
|
|
67
|
+
return Promise.all(hits.map(async ({ entry }) => {
|
|
68
|
+
const open = await openEntry(this.key, entry)
|
|
69
|
+
return publicView(entry, entryWho(open), open, await this.#digest(nonce, hash, open))
|
|
70
|
+
}))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async get (id) {
|
|
74
|
+
this.#requireKey()
|
|
75
|
+
const entry = (await this.#all()).find(e => e.id === id)
|
|
76
|
+
if (!entry) throw new VaultError(CODES.NOT_FOUND, 'no hay ninguna entrada con ese id')
|
|
77
|
+
return openEntry(this.key, entry)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async put (plain) {
|
|
81
|
+
this.#requireKey()
|
|
82
|
+
if (this.readOnly) throw new VaultError(CODES.READ_ONLY, 'esta copia solo puede leer')
|
|
83
|
+
const sealed = await sealEntry(this.key, plain)
|
|
84
|
+
const all = await this.#all()
|
|
85
|
+
const i = all.findIndex(e => e.id === sealed.id)
|
|
86
|
+
if (i >= 0) {
|
|
87
|
+
sealed.createdAt = all[i].createdAt
|
|
88
|
+
all[i] = sealed
|
|
89
|
+
} else {
|
|
90
|
+
all.push(sealed)
|
|
91
|
+
}
|
|
92
|
+
await this.store.set(KEY, all)
|
|
93
|
+
return publicView(sealed)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* CAMBIAR unos campos de una entrada, dejando el resto como estaba.
|
|
98
|
+
*
|
|
99
|
+
* Existe para que quien guarda **no tenga que leerla antes**. Antes el aparato hacía
|
|
100
|
+
* `get` + fusionar + `put`, y eso tenía dos costes que no se veían: sacaba de la bóveda
|
|
101
|
+
* la entrada entera —contraseña incluida— para escribir un teléfono, y si esa lectura
|
|
102
|
+
* fallaba, el `put` que venía detrás escribía la entrada **sin lo que no pudo leer**.
|
|
103
|
+
* Eso no es un fallo raro: es perder datos por una autorización denegada.
|
|
104
|
+
*
|
|
105
|
+
* Aquí la fusión ocurre donde vive el dato. Lo que no venga en `changes` no se toca, y
|
|
106
|
+
* si la entrada no existe no se crea nada — para crear está `put`.
|
|
107
|
+
*
|
|
108
|
+
* `changes`:
|
|
109
|
+
* `name`, `title`, `username`, `secret`, `totp`, `notes` cada uno, si viene, reemplaza
|
|
110
|
+
* `fields` lista `{ label, value, kind, private }`; se casan por su clave (§4.2),
|
|
111
|
+
* los que no aparecen se quedan. **`value` sin poner no toca el valor**:
|
|
112
|
+
* sirve para cambiar solo la marca de privado, que es lo único que se puede
|
|
113
|
+
* hacer desde el gestor con un campo cuyo valor no se tiene delante
|
|
114
|
+
* `removeFields` claves que se van. Un campo libre desaparece; uno de los de siempre
|
|
115
|
+
* (`username`, `secret`, `totp`, `notes`) se queda vacío, que es lo mismo
|
|
116
|
+
* `sites` la lista ENTERA, tal cual queda. Es lo que manda el gestor, que la edita
|
|
117
|
+
* a mano; vacía significa «sirve en cualquier sitio», que es lo que la
|
|
118
|
+
* ausencia de sitios ha significado siempre. Si viene, `addSite` se ignora
|
|
119
|
+
* `addSite` suma este sitio **solo si la entrada ya tenía alguno**: una entrada sin
|
|
120
|
+
* sitios sirve en cualquier parte, y atarla al primer formulario donde se
|
|
121
|
+
* usó sería quitarle eso sin que nadie lo pidiera
|
|
122
|
+
*/
|
|
123
|
+
async patch (id, changes = {}) {
|
|
124
|
+
this.#requireKey()
|
|
125
|
+
if (this.readOnly) throw new VaultError(CODES.READ_ONLY, 'esta copia solo puede leer')
|
|
126
|
+
const all = await this.#all()
|
|
127
|
+
const i = all.findIndex(e => e.id === id)
|
|
128
|
+
if (i < 0) throw new VaultError(CODES.NOT_FOUND, 'no hay ninguna entrada con ese id')
|
|
129
|
+
|
|
130
|
+
const base = await openEntry(this.key, all[i])
|
|
131
|
+
// La fusión vive en `model.js` y la comparten esta bóveda y la sellada: no es un
|
|
132
|
+
// `{...a, ...b}` —un campo sin `value` dice «déjale el que tenga»— y dos copias de esa
|
|
133
|
+
// sutileza son una que se queda atrás.
|
|
134
|
+
const sealed = await sealEntry(this.key, mergePatch(base, changes))
|
|
135
|
+
all[i] = sealed
|
|
136
|
+
await this.store.set(KEY, all)
|
|
137
|
+
return publicView(sealed)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* EN QUÉ SITIOS HAY ALGO GUARDADO, y cuánto en cada uno.
|
|
142
|
+
*
|
|
143
|
+
* Es lo que le permite al gestor abrir con la lista de dominios en vez de con un
|
|
144
|
+
* buscador en blanco (dueño, 2026-08-29). **No es `list`**, y la diferencia no es de
|
|
145
|
+
* grado: de aquí no sale ni un id, ni un nombre, ni qué campos lleva nadie, ni un
|
|
146
|
+
* resumen con el que comparar. Sale el dominio, que **ya viaja en claro** —`sites` no
|
|
147
|
+
* se cifra, porque hace falta para emparejar con la página sin tener la CEK (§5)— y
|
|
148
|
+
* cuántas entradas hay en él.
|
|
149
|
+
*
|
|
150
|
+
* Lo que sí concede, y conviene decirlo: hoy hay que preguntar sitio por sitio para
|
|
151
|
+
* saber dónde tienes cuenta, y esto lo contesta de una vez. Es la razón de que el
|
|
152
|
+
* dueño lo decidiera a propósito y no se colara como un detalle.
|
|
153
|
+
*
|
|
154
|
+
* `site: ''` son las entradas SIN sitio, que sirven en cualquier parte.
|
|
155
|
+
*/
|
|
156
|
+
async sites () {
|
|
157
|
+
this.#requireKey()
|
|
158
|
+
const cuenta = new Map()
|
|
159
|
+
for (const e of await this.#all()) {
|
|
160
|
+
const suyos = e.sites?.length ? e.sites : ['']
|
|
161
|
+
for (const s of suyos) cuenta.set(s, (cuenta.get(s) || 0) + 1)
|
|
162
|
+
}
|
|
163
|
+
return [...cuenta.entries()]
|
|
164
|
+
.map(([site, count]) => ({ site, count }))
|
|
165
|
+
.sort((a, b) => (a.site === '' ? 1 : b.site === '' ? -1 : a.site.localeCompare(b.site)))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* BUSCAR por texto en toda la bóveda. Devuelve lo público, y como mucho `limit`.
|
|
170
|
+
*
|
|
171
|
+
* No es `list` con otro nombre, y la diferencia importa (DISENO §2): `list` lo puede
|
|
172
|
+
* pedir el código en cualquier momento y devuelve todo; esto exige un término que
|
|
173
|
+
* **escribe una persona** y devuelve un puñado. Existe porque a veces la cuenta que
|
|
174
|
+
* sirve está guardada en otro dominio —el subdominio cambió y la clave es la misma— y
|
|
175
|
+
* sin buscarla no hay forma de llegar a ella.
|
|
176
|
+
*
|
|
177
|
+
* Se busca en lo que identifica a la entrada: su título, sus sitios y su nombre
|
|
178
|
+
* visible. **Nunca en los valores de los campos**: buscar «1700» y que aparezca tu
|
|
179
|
+
* documento es exactamente lo que no puede pasar.
|
|
180
|
+
*/
|
|
181
|
+
async search (q, { limit = 20 } = {}) {
|
|
182
|
+
this.#requireKey()
|
|
183
|
+
const texto = String(q || '').trim().toLowerCase()
|
|
184
|
+
if (texto.length < 2) return []
|
|
185
|
+
const nonce = makeNonce()
|
|
186
|
+
const hash = await fieldHasher(nonce)
|
|
187
|
+
const out = []
|
|
188
|
+
for (const entry of await this.#all()) {
|
|
189
|
+
if (out.length >= limit) break
|
|
190
|
+
const open = await openEntry(this.key, entry)
|
|
191
|
+
const quien = entryWho(open)
|
|
192
|
+
const heno = [entry.title, quien, ...(entry.sites || [])].filter(Boolean).join(' ').toLowerCase()
|
|
193
|
+
if (heno.includes(texto)) {
|
|
194
|
+
out.push(publicView(entry, quien, open, await this.#digest(nonce, hash, open)))
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return out
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async remove (id) {
|
|
201
|
+
this.#requireKey()
|
|
202
|
+
if (this.readOnly) throw new VaultError(CODES.READ_ONLY, 'esta copia solo puede leer')
|
|
203
|
+
const all = await this.#all()
|
|
204
|
+
const rest = all.filter(e => e.id !== id)
|
|
205
|
+
if (rest.length === all.length) throw new VaultError(CODES.NOT_FOUND, 'no hay ninguna entrada con ese id')
|
|
206
|
+
await this.store.set(KEY, rest)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async list () {
|
|
210
|
+
this.#requireKey()
|
|
211
|
+
return (await this.#all()).map(e => publicView(e))
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Bóveda remota: la que usa la extensión de Chrome por defecto (DISENO §2).
|
|
2
|
+
//
|
|
3
|
+
// No tiene la CEK y no guarda nada. Pregunta por un dominio, recibe metadatos, y
|
|
4
|
+
// pide UNA credencial cuando el usuario elige. Quién responde al otro lado del
|
|
5
|
+
// transporte —el vault del PC o el teléfono— le da igual.
|
|
6
|
+
|
|
7
|
+
import { VaultError, CODES } from './errors.js'
|
|
8
|
+
|
|
9
|
+
export class RemoteVault {
|
|
10
|
+
/**
|
|
11
|
+
* @param {object} transport `{ request(op, payload) }` — proxy, WebSocket o
|
|
12
|
+
* `chrome.runtime` según dónde corra. Devuelve la respuesta ya deserializada.
|
|
13
|
+
*/
|
|
14
|
+
constructor (transport, opts = {}) {
|
|
15
|
+
this.transport = transport
|
|
16
|
+
this.needsApproval = opts.needsApproval !== false
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
get capabilities () {
|
|
20
|
+
return { canWrite: true, canList: false, needsApproval: this.needsApproval }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async find (url) {
|
|
24
|
+
return this.#request('find', { url })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* UNA credencial, y **solo los campos que se piden**.
|
|
29
|
+
*
|
|
30
|
+
* `keys` no es una optimización: es lo que hace que rellenar un nombre no saque de la
|
|
31
|
+
* bóveda la contraseña. Al otro lado, pedir algo privado puede exigir la huella; pedir
|
|
32
|
+
* lo público, no (DISENO §4.2).
|
|
33
|
+
*/
|
|
34
|
+
async get (id, opts = {}) {
|
|
35
|
+
return this.#request('get', { id, ...(Array.isArray(opts.keys) ? { keys: opts.keys } : {}) })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async put (entry) {
|
|
39
|
+
return this.#request('put', { entry })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* CAMBIAR unos campos sin leer la entrada antes. Lo demás se queda como estaba, y la
|
|
44
|
+
* fusión la hace quien tiene el dato — así guardar un teléfono no saca una contraseña.
|
|
45
|
+
*/
|
|
46
|
+
async patch (id, changes) {
|
|
47
|
+
return this.#request('patch', { id, changes })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* No se puede, y no es una limitación técnica: si la extensión pudiera listar la
|
|
52
|
+
* bóveda entera, el "pide de a una" no significaría nada (DISENO §2).
|
|
53
|
+
*/
|
|
54
|
+
/** Buscar por texto en toda la bóveda. Lo público, y con tope (ver `LocalVault`). */
|
|
55
|
+
async search (q, opts = {}) {
|
|
56
|
+
return this.#request('search', { q, limit: opts.limit })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* En qué sitios hay algo guardado, y cuántas entradas en cada uno. Ni un id ni un
|
|
61
|
+
* nombre: el dominio, que ya viaja en claro para poder emparejar (§5).
|
|
62
|
+
*/
|
|
63
|
+
async sites () {
|
|
64
|
+
return this.#request('sites', {})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Quitar una entrada. La borra quien la tiene, no quien la pide. */
|
|
68
|
+
async remove (id) {
|
|
69
|
+
return this.#request('remove', { id })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async list () {
|
|
73
|
+
throw new VaultError(CODES.NO_KEY, 'una bóveda remota no lista: pide de a una')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async #request (op, payload) {
|
|
77
|
+
try {
|
|
78
|
+
return await this.transport.request(op, payload)
|
|
79
|
+
} catch (e) {
|
|
80
|
+
// El transporte puede fallar de muchas formas; hacia arriba solo hay códigos.
|
|
81
|
+
if (e instanceof VaultError) throw e
|
|
82
|
+
throw new VaultError(e?.code || CODES.UNREACHABLE, e?.message || 'no hay bóveda al otro lado')
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// El lado que RESPONDE: la bóveda de verdad (el vault del PC, la app nativa).
|
|
2
|
+
//
|
|
3
|
+
// Es donde vive la política, y por eso está aquí y no repartida por cada aparato:
|
|
4
|
+
// quién puede pedir, qué puede pedir, qué exige aprobación y qué queda anotado. Un
|
|
5
|
+
// aparato que pide no decide nada.
|
|
6
|
+
|
|
7
|
+
import { isRequest, reply, replyError, REMOTE_OPS, ADMIN_OPS } from '../transport/protocol.js'
|
|
8
|
+
import { ApprovalGate } from './approval.js'
|
|
9
|
+
import { privateKeysOf } from '../fields.js'
|
|
10
|
+
import { projectEntry } from '../model.js'
|
|
11
|
+
import { CODES } from './errors.js'
|
|
12
|
+
|
|
13
|
+
export class VaultResponder {
|
|
14
|
+
/**
|
|
15
|
+
* @param {object} opts
|
|
16
|
+
* `client` cliente de @dotrino/proxy-client conectado e identificado
|
|
17
|
+
* `vault` una bóveda con la CEK (LocalVault desbloqueada)
|
|
18
|
+
* `isAllowed(pubkey) -> boolean` qué aparatos pueden pedir. Por defecto NADIE:
|
|
19
|
+
* una bóveda que responde a cualquiera no es una bóveda.
|
|
20
|
+
* `needsApproval(op, payload, pubkey) -> boolean|Promise` qué exige un dedo encima.
|
|
21
|
+
* Por defecto: **solo `get`, y solo si lo pedido incluye algo privado**
|
|
22
|
+
* (dueño, 2026-08-29). Rellenar un nombre no es sacar un secreto.
|
|
23
|
+
* `approve({ op, payload, pubkey }) -> Promise<boolean>` cómo se pide ese dedo
|
|
24
|
+
* `onRequest(record)` la bitácora
|
|
25
|
+
* `admin` `{ devices(), unlink(pubkey) }` — opcional. Sin esto, un aparato no
|
|
26
|
+
* puede administrar nada, que es el valor por defecto correcto.
|
|
27
|
+
*
|
|
28
|
+
* **La aprobación es del APARATO, no de cada credencial.** Se pide una vez y vale
|
|
29
|
+
* mientras esta bóveda siga encendida; apagarla la retira. Es el mismo modelo que
|
|
30
|
+
* `pair --approval` del vault: una por arranque, sin ventana de tiempo que vigilar.
|
|
31
|
+
*
|
|
32
|
+
* No se ata a la conexión a propósito: un service worker se duerme cada poco y
|
|
33
|
+
* reconecta constantemente, así que «por conexión» sería pedir el dedo todo el rato.
|
|
34
|
+
* Quien manda es la bóveda, que es la que el usuario apaga cuando quiere cortar.
|
|
35
|
+
*/
|
|
36
|
+
constructor ({ client, vault, isAllowed, needsApproval, approve, onRequest, encPubOf, admin } = {}) {
|
|
37
|
+
this.admin = admin || null
|
|
38
|
+
this.client = client
|
|
39
|
+
this.vault = vault
|
|
40
|
+
// La pública de cifrado de cada aparato, para sellarle la respuesta. Abrir lo que
|
|
41
|
+
// llega es cosa del cliente del pilar (`requireSealed` + `myEncPrivateKey`).
|
|
42
|
+
this.encPubOf = encPubOf || (() => null)
|
|
43
|
+
this.isAllowed = isAllowed || (() => false)
|
|
44
|
+
this.needsApproval = needsApproval || ((op, payload) => this.wantsPrivate(op, payload))
|
|
45
|
+
this.approve = approve || (async () => false)
|
|
46
|
+
this.onRequest = onRequest || (() => {})
|
|
47
|
+
this._handler = null
|
|
48
|
+
// La puerta es la MISMA que la de la bóveda de dentro de la extensión
|
|
49
|
+
// (`GuardedVault`): dedupe de simultáneas, el sí recordado, el no nunca. Aquí se
|
|
50
|
+
// recuerda por APARATO, que es lo que dice §2.0, y vive en memoria — apagar la
|
|
51
|
+
// bóveda retira lo aprobado, que es el diseño.
|
|
52
|
+
this.gate = new ApprovalGate({
|
|
53
|
+
ask: ({ op, payload, pubkey }) => this.approve({ op, payload, pubkey }),
|
|
54
|
+
scope: ({ pubkey }) => pubkey,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Retira la aprobación de un aparato sin apagar la bóveda. */
|
|
59
|
+
revokeApproval (pubkey) {
|
|
60
|
+
this.gate.revoke(pubkey || null)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
isApproved (pubkey) {
|
|
64
|
+
return this.gate.granted(pubkey)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* ¿Lo que se pide incluye algo PRIVADO? Es la pregunta que decide si hace falta un dedo
|
|
69
|
+
* encima, y solo la puede contestar quien tiene la llave: hay que abrir la entrada para
|
|
70
|
+
* saber qué campos marcó el usuario (§4.2).
|
|
71
|
+
*
|
|
72
|
+
* Es **pública** para que una bóveda con su propia política la componga en vez de
|
|
73
|
+
* reescribirla: el vault del ecosistema, por ejemplo, aprueba por aparato *y* quiere
|
|
74
|
+
* este criterio encima (`needsApproval(pubkey) && responder.wantsPrivate(op, payload)`).
|
|
75
|
+
* Sin esto, cada bóveda acabaría con su propia idea de qué es privado.
|
|
76
|
+
*
|
|
77
|
+
* Si no se puede abrir, se contesta que **sí**: ante la duda, se pregunta.
|
|
78
|
+
*/
|
|
79
|
+
async wantsPrivate (op, payload) {
|
|
80
|
+
if (op !== 'get') return false
|
|
81
|
+
try {
|
|
82
|
+
const open = await this.vault.get(payload.id)
|
|
83
|
+
const privadas = privateKeysOf(open)
|
|
84
|
+
if (!Array.isArray(payload.keys)) return privadas.size > 0
|
|
85
|
+
return payload.keys.some(k => privadas.has(k))
|
|
86
|
+
} catch { return true }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
start () {
|
|
90
|
+
if (this._handler) return
|
|
91
|
+
this._handler = (from, payload, meta) => {
|
|
92
|
+
this.#handle(payload, meta?.fromPubkey || from, meta).catch(() => {})
|
|
93
|
+
}
|
|
94
|
+
this.client.on('message', this._handler)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
stop () {
|
|
98
|
+
if (this._handler && this.client.off) this.client.off('message', this._handler)
|
|
99
|
+
this._handler = null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async #send (pubkey, msg) {
|
|
103
|
+
try {
|
|
104
|
+
// La respuesta lleva la credencial: se sella al aparato que la pidió, o no sale.
|
|
105
|
+
const encPub = this.encPubOf(pubkey)
|
|
106
|
+
if (!encPub) return
|
|
107
|
+
await this.client.sendSealed([pubkey], msg, { peerEncPub: encPub })
|
|
108
|
+
} catch { /* el que pidió reintentará */ }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async #handle (msg, fromPubkey, meta) {
|
|
112
|
+
if (!isRequest(msg)) return
|
|
113
|
+
|
|
114
|
+
// **Nada sin cifrar entra.** El cliente del pilar abre los sobres y marca
|
|
115
|
+
// `meta.sealed`; aquí se exige esa marca. Sin ella bastaría con mandar la petición
|
|
116
|
+
// en claro para saltarse el sellado entero, y el proxio vería qué se pide.
|
|
117
|
+
if (meta?.sealed !== true) {
|
|
118
|
+
this.onRequest({ op: msg.op, from: fromPubkey, ts: Date.now(), outcome: 'unsealed' })
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const record = { op: msg.op, from: fromPubkey, ts: Date.now() }
|
|
123
|
+
|
|
124
|
+
// 1. ¿Es un aparato que conozco? Si no, ni se le contesta qué falló.
|
|
125
|
+
if (!fromPubkey || !this.isAllowed(fromPubkey)) {
|
|
126
|
+
this.onRequest({ ...record, outcome: 'denied' })
|
|
127
|
+
return this.#send(fromPubkey, replyError(msg.rid, CODES.DENIED, 'aparato no autorizado'))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 2. `list` no está en la lista, y esa ausencia es el diseño: si un aparato
|
|
131
|
+
// pudiera pedir la bóveda entera, el "de a una" no significaría nada.
|
|
132
|
+
const esAdmin = ADMIN_OPS.includes(msg.op)
|
|
133
|
+
if (!REMOTE_OPS.includes(msg.op) && !esAdmin) {
|
|
134
|
+
this.onRequest({ ...record, outcome: 'denied' })
|
|
135
|
+
return this.#send(fromPubkey, replyError(msg.rid, CODES.DENIED, 'operación no permitida en remoto'))
|
|
136
|
+
}
|
|
137
|
+
if (esAdmin && !this.admin) {
|
|
138
|
+
this.onRequest({ ...record, outcome: 'denied' })
|
|
139
|
+
return this.#send(fromPubkey, replyError(msg.rid, CODES.DENIED, 'esta bóveda no se administra en remoto'))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const payload = msg.payload || {}
|
|
144
|
+
|
|
145
|
+
// Administrar SIEMPRE se aprueba, y aparte: retirar un aparato desde otro
|
|
146
|
+
// aparato es tan delicado como entregar una contraseña, y en el otro sentido.
|
|
147
|
+
// Que el aparato ya esté aprobado para pedir credenciales no le da esto.
|
|
148
|
+
if (esAdmin) {
|
|
149
|
+
const ok = await this.approve({ op: msg.op, payload, pubkey: fromPubkey, admin: true })
|
|
150
|
+
if (!ok) {
|
|
151
|
+
this.onRequest({ ...record, outcome: 'refused' })
|
|
152
|
+
return this.#send(fromPubkey, replyError(msg.rid, CODES.NOT_APPROVED, 'no aprobado'))
|
|
153
|
+
}
|
|
154
|
+
const result = msg.op === 'devices'
|
|
155
|
+
? await this.admin.devices()
|
|
156
|
+
: await this.admin.unlink(payload.pubkey)
|
|
157
|
+
this.onRequest({ ...record, outcome: 'served' })
|
|
158
|
+
return this.#send(fromPubkey, reply(msg.rid, result ?? null))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (await this.needsApproval(msg.op, payload, fromPubkey)) {
|
|
162
|
+
const ok = await this.gate.allow({ op: msg.op, payload, pubkey: fromPubkey })
|
|
163
|
+
if (!ok) {
|
|
164
|
+
this.onRequest({ ...record, outcome: 'refused' })
|
|
165
|
+
return this.#send(fromPubkey, replyError(msg.rid, CODES.NOT_APPROVED, 'no aprobado'))
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let result
|
|
170
|
+
switch (msg.op) {
|
|
171
|
+
case 'find': result = await this.vault.find(payload.url); break
|
|
172
|
+
// Solo lo pedido sale por el cable: sin este recorte, rellenar un nombre mandaba
|
|
173
|
+
// la entrada entera —contraseña incluida— al aparato que preguntó.
|
|
174
|
+
case 'get':
|
|
175
|
+
result = projectEntry(await this.vault.get(payload.id),
|
|
176
|
+
Array.isArray(payload.keys) ? payload.keys : null)
|
|
177
|
+
break
|
|
178
|
+
case 'put': result = await this.vault.put(payload.entry); break
|
|
179
|
+
case 'patch': result = await this.vault.patch(payload.id, payload.changes); break
|
|
180
|
+
case 'sites': result = await this.vault.sites(); break
|
|
181
|
+
case 'search': result = await this.vault.search(payload.q, { limit: payload.limit }); break
|
|
182
|
+
case 'remove': result = await this.vault.remove(payload.id); break
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
this.onRequest({ ...record, outcome: 'served', id: payload.id })
|
|
186
|
+
await this.#send(fromPubkey, reply(msg.rid, result ?? null))
|
|
187
|
+
} catch (e) {
|
|
188
|
+
this.onRequest({ ...record, outcome: 'error', code: e?.code })
|
|
189
|
+
await this.#send(fromPubkey, replyError(msg.rid, e?.code || 'error', e?.message))
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// LA BÓVEDA SELLADA CUANDO QUIEN GUARDA Y QUIEN LEE SON EL MISMO PROCESO.
|
|
2
|
+
//
|
|
3
|
+
// `SealedVault` es el aparato hablando con una bóveda que está al otro lado de un
|
|
4
|
+
// transporte. Esto es el otro caso, y existe de verdad en dos sitios del ecosistema:
|
|
5
|
+
//
|
|
6
|
+
// · **`dotrino-passmanager serve`** — el mismo proceso atiende por el proxio y es
|
|
7
|
+
// también la línea de comandos con la que el dueño mira lo suyo;
|
|
8
|
+
// · **la bóveda de dentro de la extensión** — no hay nadie más a quien preguntar.
|
|
9
|
+
//
|
|
10
|
+
// En los dos, quien lee lo hace con UNA envoltura: la de `#recovery` (el dueño con su
|
|
11
|
+
// contraseña) o la del propio aparato. Se le dice cuál al construirla y no se adivina:
|
|
12
|
+
// adivinarlo sería leer con la que hubiera, y entonces «no puedo abrir esto» y «esto no es
|
|
13
|
+
// para mí» se verían igual.
|
|
14
|
+
//
|
|
15
|
+
// Cumple el contrato de siempre (`./interface.js`) para que nada de lo que hay encima
|
|
16
|
+
// tenga que aprender otra forma de pedir las cosas.
|
|
17
|
+
|
|
18
|
+
import { VaultError, CODES } from './errors.js'
|
|
19
|
+
import { findForUrl } from '../match.js'
|
|
20
|
+
import { siteIndex, fieldIndex, valueDigest } from '../sealed/keys.js'
|
|
21
|
+
import { buildSealedEntry, openSealedEntry, openSealedView } from '../sealed/device.js'
|
|
22
|
+
import { mergePatch } from '../model.js'
|
|
23
|
+
|
|
24
|
+
/** Los nombres FIJOS, que viajan tal cual: vocabulario cerrado e igual para todos. */
|
|
25
|
+
const FIXED = ['name', 'username', 'secret', 'totp', 'notes']
|
|
26
|
+
|
|
27
|
+
export class SealedLocalVault {
|
|
28
|
+
/**
|
|
29
|
+
* @param {SealedStore} sealed el almacén de sobres
|
|
30
|
+
* @param {object} opts
|
|
31
|
+
* `readerPub` con qué envoltura se lee: `RECOVERY` o la pública de este aparato
|
|
32
|
+
* `openSealed({ wrap, envelope })` abre un sobre. La privada no sale de donde viva.
|
|
33
|
+
* `author` `{ publickey, sign(body) }` quien firma lo que se escribe
|
|
34
|
+
* `recipients()` → `{ recoveryPub, main, passkeys }` a quién se le envuelve
|
|
35
|
+
* `keys()` → las llaves del perfil (`profileKeys`), para huellas y resúmenes
|
|
36
|
+
*/
|
|
37
|
+
constructor (sealed, { readerPub, openSealed, author, recipients, keys } = {}) {
|
|
38
|
+
if (!sealed) throw new Error('SealedLocalVault: missing the sealed store')
|
|
39
|
+
if (!readerPub) throw new Error('SealedLocalVault: say which wrap this one reads with')
|
|
40
|
+
for (const n of ['openSealed', 'author', 'recipients', 'keys']) {
|
|
41
|
+
if (!arguments[1]?.[n]) throw new Error(`SealedLocalVault: missing ${n}`)
|
|
42
|
+
}
|
|
43
|
+
this.sealed = sealed
|
|
44
|
+
this.readerPub = readerPub
|
|
45
|
+
this.openSealed = openSealed
|
|
46
|
+
this.author = author
|
|
47
|
+
this.recipients = recipients
|
|
48
|
+
this.keys = keys
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get capabilities () {
|
|
52
|
+
// `canList` es `true` y aquí sí es verdad: quien pregunta es el dueño en su propia
|
|
53
|
+
// máquina, no un aparato pidiendo por el proxio. Lo que no existe en remoto sigue sin
|
|
54
|
+
// existir — esto no se asoma por ningún transporte.
|
|
55
|
+
return { canWrite: true, canList: true, needsApproval: false, sealed: true }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** De la clave que usa la interfaz a la que guarda la bóveda. La misma que `SealedVault`. */
|
|
59
|
+
async #sealedKey (k) {
|
|
60
|
+
if (FIXED.includes(k) || k.startsWith('wa.')) return k
|
|
61
|
+
const { kidx } = await this.keys()
|
|
62
|
+
return 'f:' + await fieldIndex(kidx, k)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Las vistas, abiertas. Es lo que se enseña en una lista: sin valores privados. */
|
|
66
|
+
async list () {
|
|
67
|
+
const vistas = await this.sealed.views({ pub: this.readerPub })
|
|
68
|
+
const out = []
|
|
69
|
+
for (const v of vistas) {
|
|
70
|
+
const abierta = await openSealedView({ ...v, openSealed: this.openSealed })
|
|
71
|
+
// `fieldHashes` con el nombre que ya usa la interfaz para comparar sin abrir nada.
|
|
72
|
+
// Los resúmenes están GUARDADOS y su clave viaja por huella, así que aquí se
|
|
73
|
+
// traducen de vuelta — la vista sí lleva los nombres de verdad, porque va sellada.
|
|
74
|
+
// Sin esto, «¿ya está guardado igual?» se contesta que no siempre, y el aviso de
|
|
75
|
+
// guardar sale en cada formulario que ya tenías.
|
|
76
|
+
const fieldHashes = {}
|
|
77
|
+
for (const k of [...(abierta.fieldKeys || []), 'notes']) {
|
|
78
|
+
const clave = await this.#sealedKey(k)
|
|
79
|
+
if (abierta.digests?.[clave] !== undefined) fieldHashes[k] = abierta.digests[clave]
|
|
80
|
+
}
|
|
81
|
+
out.push({ ...abierta, fieldHashes })
|
|
82
|
+
}
|
|
83
|
+
return out
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* El resumen de un valor, para decir si ya está guardado igual **sin abrir nada** (§2.6).
|
|
88
|
+
* No hay nonce que compartir: el `id` de la entrada va dentro, que es lo que impide que
|
|
89
|
+
* un valor repetido en dos entradas se note.
|
|
90
|
+
*/
|
|
91
|
+
async digest (id, key, value) {
|
|
92
|
+
const { kcmp } = await this.keys()
|
|
93
|
+
return valueDigest(kcmp, id, key, String(value))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Lo que hay para una URL, ya ORDENADO por lo bien que casa: las entradas, no los pares
|
|
98
|
+
* `{entry, match}` de `findForUrl`. Es la misma forma que devuelve `SealedVault.find`,
|
|
99
|
+
* y tiene que serlo — quien llama no debería notar de qué bóveda le están contestando.
|
|
100
|
+
*/
|
|
101
|
+
async find (url) {
|
|
102
|
+
return findForUrl(await this.list(), url).map((x) => x.entry)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* EN QUÉ SITIOS HAY ALGO, y cuánto en cada uno. Misma forma que `LocalVault.sites()`
|
|
107
|
+
* —`[{ site, count }]`— porque es lo que pinta el gestor: devolver una lista de cadenas
|
|
108
|
+
* dejaba la fila de dominios vacía sin un solo error.
|
|
109
|
+
*
|
|
110
|
+
* El sitio vacío es «sirve en cualquier parte» y va al final, que es donde se busca.
|
|
111
|
+
*/
|
|
112
|
+
async sites () {
|
|
113
|
+
const cuenta = new Map()
|
|
114
|
+
for (const e of await this.list()) {
|
|
115
|
+
for (const s of (e.sites?.length ? e.sites : [''])) cuenta.set(s, (cuenta.get(s) || 0) + 1)
|
|
116
|
+
}
|
|
117
|
+
return [...cuenta.entries()]
|
|
118
|
+
.map(([site, count]) => ({ site, count }))
|
|
119
|
+
.sort((a, b) => (a.site === '' ? 1 : b.site === '' ? -1 : a.site.localeCompare(b.site)))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* BUSCAR por texto. Se mira lo mismo que mira `SealedVault`: el nombre, el SITIO y la
|
|
124
|
+
* **pista** —el usuario tapado que la vista lleva—, que es por lo que la gente busca de
|
|
125
|
+
* verdad («ana@…»). Dejarla fuera hacía que buscar la cuenta de otro dominio no
|
|
126
|
+
* encontrara nada, sin un solo error.
|
|
127
|
+
*
|
|
128
|
+
* Se busca sobre las VISTAS, no sobre los valores: aquí no se abre ninguna contraseña
|
|
129
|
+
* para contestar a un buscador.
|
|
130
|
+
*/
|
|
131
|
+
async search (texto, { limit = 20 } = {}) {
|
|
132
|
+
const q = String(texto || '').trim().toLowerCase()
|
|
133
|
+
if (!q) return []
|
|
134
|
+
const out = []
|
|
135
|
+
for (const v of await this.list()) {
|
|
136
|
+
if (out.length >= limit) break
|
|
137
|
+
if ([v.title, v.hint, ...(v.sites || [])].filter(Boolean).join(' ').toLowerCase().includes(q)) out.push(v)
|
|
138
|
+
}
|
|
139
|
+
return out
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Una entrada ENTERA, o solo los campos que se pidan. */
|
|
143
|
+
async get (id, { keys = null } = {}) {
|
|
144
|
+
let got
|
|
145
|
+
try { got = await this.sealed.get(id, { pub: this.readerPub, keys }) }
|
|
146
|
+
catch (e) {
|
|
147
|
+
if (e?.code === 'not-found') throw new VaultError(CODES.NOT_FOUND, 'no hay ninguna entrada con ese id')
|
|
148
|
+
// «no me la envolvieron a mí» no es «no existe», y confundirlas manda a buscar el
|
|
149
|
+
// problema al sitio equivocado.
|
|
150
|
+
if (e?.code === 'not-yours') throw new VaultError(CODES.NOT_ALLOWED, 'esta punta no tiene llave para esa entrada')
|
|
151
|
+
throw e
|
|
152
|
+
}
|
|
153
|
+
return openSealedEntry({ got, openSealed: this.openSealed })
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* GUARDAR. Se cierra aquí y sale cerrado: la entrada se cifra, su llave se envuelve a
|
|
158
|
+
* los destinatarios que diga el acta AHORA y el conjunto va firmado.
|
|
159
|
+
*
|
|
160
|
+
* Los destinatarios se piden en cada escritura y no se cachean: un aparato que acaba de
|
|
161
|
+
* entrar tiene que poder leer lo siguiente que se guarde.
|
|
162
|
+
*/
|
|
163
|
+
async put (entry) {
|
|
164
|
+
const sobre = await buildSealedEntry({
|
|
165
|
+
plain: entry,
|
|
166
|
+
keys: await this.keys(),
|
|
167
|
+
recipients: await this.recipients(),
|
|
168
|
+
author: this.author
|
|
169
|
+
})
|
|
170
|
+
await this.sealed.putSealed(sobre)
|
|
171
|
+
// La VISTA, como devuelve `LocalVault.put`: quien llama enseña el nombre de lo que
|
|
172
|
+
// acaba de guardar. Devolver solo el id dejaba un «Guardada: undefined» en la CLI.
|
|
173
|
+
return { id: sobre.entry.id, title: entry.title || '', sites: entry.sites || [] }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Cambiar unos campos. Se hace leyendo y volviendo a escribir porque aquí quien lee y
|
|
178
|
+
* quien escribe son el mismo: no hay nada que ahorrar ocultándose un valor a sí mismo.
|
|
179
|
+
* (En remoto SÍ lo hay, y por eso allí existe `patchSealed`.)
|
|
180
|
+
*
|
|
181
|
+
* La fusión la hace `mergePatch`, la MISMA que `LocalVault` — y tiene que ser la misma,
|
|
182
|
+
* porque no es un `{...a, ...b}`: un campo sin `value` dice «déjale el que tenga», no
|
|
183
|
+
* «bórralo». Marcar uno como privado llega así, y fusionarlo a lo bruto le borraba el
|
|
184
|
+
* valor. Costó encontrarlo porque no falla: guarda, y el campo ya no está.
|
|
185
|
+
*/
|
|
186
|
+
async patch (id, changes = {}) {
|
|
187
|
+
return this.put(mergePatch(await this.get(id), changes))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async remove (id) { return this.sealed.remove(id) }
|
|
191
|
+
|
|
192
|
+
/** La huella de un sitio, por si quien llama quiere preguntar por ella directamente. */
|
|
193
|
+
async siteIndexOf (site) {
|
|
194
|
+
const { kidx } = await this.keys()
|
|
195
|
+
return siteIndex(kidx, site)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export default { SealedLocalVault }
|