@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,332 @@
|
|
|
1
|
+
// Una entrada de la bóveda: cómo se sella, cómo se abre y qué se puede enseñar sin
|
|
2
|
+
// abrirla. Ver DISENO §5.
|
|
3
|
+
//
|
|
4
|
+
// Qué va en claro y por qué: `sites` y `title`. Los `sites` hacen falta para
|
|
5
|
+
// emparejar con la página SIN tener la CEK — es lo que permite que la extensión
|
|
6
|
+
// pregunte "¿tienes algo para salesforce.com?" sin poder abrir nada. Es una decisión
|
|
7
|
+
// consciente: revela EN QUÉ SITIOS tienes cuenta, que es bastante menos que la
|
|
8
|
+
// credencial. Todo lo demás (usuario, contraseña, TOTP, notas, llave WebAuthn) va
|
|
9
|
+
// cifrado.
|
|
10
|
+
|
|
11
|
+
import { sealValue, openValue, fieldTag } from './crypto.js'
|
|
12
|
+
import { normalizeFields, entryFieldKeys, privateKeysOf, fieldKey, KINDS } from './fields.js'
|
|
13
|
+
|
|
14
|
+
export const TYPES = ['login', 'note', 'card', 'webauthn', 'data']
|
|
15
|
+
|
|
16
|
+
// `fields` es la lista de campos libres del usuario, y va cifrada como todo lo
|
|
17
|
+
// demás: un correo o una cédula identifican a una persona igual que una contraseña
|
|
18
|
+
// da acceso. Se sella como JSON, en un solo criptograma.
|
|
19
|
+
//
|
|
20
|
+
// `name` —el nombre que el usuario le pone a la entrada— va SELLADO y no en claro como
|
|
21
|
+
// `title`: «la cuenta de mi mamá» dice bastante más de una persona que el dominio donde
|
|
22
|
+
// la usa. Quien puede abrir la entrada lo pone en la vista pública (§4.0.2), igual que
|
|
23
|
+
// el nombre visible de siempre.
|
|
24
|
+
const SEALED_FIELDS = ['name', 'username', 'secret', 'totp', 'notes', 'fields']
|
|
25
|
+
|
|
26
|
+
function uuid () {
|
|
27
|
+
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
|
|
28
|
+
const b = new Uint8Array(16)
|
|
29
|
+
globalThis.crypto.getRandomValues(b)
|
|
30
|
+
b[6] = (b[6] & 0x0f) | 0x40
|
|
31
|
+
b[8] = (b[8] & 0x3f) | 0x80
|
|
32
|
+
const h = [...b].map(x => x.toString(16).padStart(2, '0')).join('')
|
|
33
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Entrada nueva en claro, lista para `sealEntry`. */
|
|
37
|
+
export function newEntry (fields = {}) {
|
|
38
|
+
const now = Date.now()
|
|
39
|
+
const type = TYPES.includes(fields.type) ? fields.type : 'login'
|
|
40
|
+
return {
|
|
41
|
+
id: fields.id || uuid(),
|
|
42
|
+
type,
|
|
43
|
+
title: String(fields.title || '').trim(),
|
|
44
|
+
// El nombre que le puso el usuario. Vacío = el que se calcula solo (`entryWho`).
|
|
45
|
+
name: String(fields.name || '').trim(),
|
|
46
|
+
sites: normalizeSites(fields.sites),
|
|
47
|
+
username: fields.username || '',
|
|
48
|
+
secret: fields.secret || '',
|
|
49
|
+
// Los campos libres viajan como JSON dentro de un único criptograma. Una lista
|
|
50
|
+
// vacía es NADA, no un criptograma vacío: si no, la entrada se anuncia con campos
|
|
51
|
+
// que no tiene y la UI ofrece rellenar lo que no existe.
|
|
52
|
+
fields: (() => {
|
|
53
|
+
if (!Array.isArray(fields.fields)) return fields.fields || ''
|
|
54
|
+
const limpios = normalizeFields(fields.fields)
|
|
55
|
+
return limpios.length ? JSON.stringify(limpios) : ''
|
|
56
|
+
})(),
|
|
57
|
+
totp: fields.totp || '',
|
|
58
|
+
notes: fields.notes || '',
|
|
59
|
+
webauthn: fields.webauthn || null,
|
|
60
|
+
createdAt: fields.createdAt || now,
|
|
61
|
+
updatedAt: now,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function normalizeSites (sites) {
|
|
66
|
+
const out = []
|
|
67
|
+
for (const s of Array.isArray(sites) ? sites : [sites]) {
|
|
68
|
+
const v = String(s || '').trim().toLowerCase()
|
|
69
|
+
if (v && !out.includes(v)) out.push(v)
|
|
70
|
+
}
|
|
71
|
+
return out
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Cifra los campos sensibles. Devuelve lo que se guarda de verdad. */
|
|
75
|
+
export async function sealEntry (key, plain) {
|
|
76
|
+
const e = newEntry(plain)
|
|
77
|
+
const out = {
|
|
78
|
+
id: e.id,
|
|
79
|
+
type: e.type,
|
|
80
|
+
title: e.title,
|
|
81
|
+
sites: e.sites,
|
|
82
|
+
createdAt: e.createdAt,
|
|
83
|
+
updatedAt: e.updatedAt,
|
|
84
|
+
}
|
|
85
|
+
for (const f of SEALED_FIELDS) {
|
|
86
|
+
out[f] = e[f] ? await sealValue(key, String(e[f]), fieldTag(e.id, f)) : null
|
|
87
|
+
}
|
|
88
|
+
if (e.webauthn) {
|
|
89
|
+
// `rpId` y `credentialId` quedan en claro: el navegador los necesita para saber
|
|
90
|
+
// qué credencial ofrecer antes de que nadie desbloquee nada.
|
|
91
|
+
out.webauthn = {
|
|
92
|
+
credentialId: e.webauthn.credentialId,
|
|
93
|
+
rpId: e.webauthn.rpId,
|
|
94
|
+
signCount: e.webauthn.signCount || 0,
|
|
95
|
+
userHandle: e.webauthn.userHandle
|
|
96
|
+
? await sealValue(key, String(e.webauthn.userHandle), fieldTag(e.id, 'wa.userHandle'))
|
|
97
|
+
: null,
|
|
98
|
+
privateKey: e.webauthn.privateKey
|
|
99
|
+
? await sealValue(key, String(e.webauthn.privateKey), fieldTag(e.id, 'wa.privateKey'))
|
|
100
|
+
: null,
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
out.webauthn = null
|
|
104
|
+
}
|
|
105
|
+
return out
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Abre una entrada sellada. Exige la CEK, o sea: no lo hace la extensión. */
|
|
109
|
+
export async function openEntry (key, entry) {
|
|
110
|
+
const out = {
|
|
111
|
+
id: entry.id,
|
|
112
|
+
type: entry.type,
|
|
113
|
+
title: entry.title,
|
|
114
|
+
sites: entry.sites || [],
|
|
115
|
+
createdAt: entry.createdAt,
|
|
116
|
+
updatedAt: entry.updatedAt,
|
|
117
|
+
webauthn: null,
|
|
118
|
+
}
|
|
119
|
+
for (const f of SEALED_FIELDS) {
|
|
120
|
+
out[f] = entry[f] ? await openValue(key, entry[f], fieldTag(entry.id, f)) : ''
|
|
121
|
+
}
|
|
122
|
+
if (entry.webauthn) {
|
|
123
|
+
out.webauthn = {
|
|
124
|
+
credentialId: entry.webauthn.credentialId,
|
|
125
|
+
rpId: entry.webauthn.rpId,
|
|
126
|
+
signCount: entry.webauthn.signCount || 0,
|
|
127
|
+
userHandle: entry.webauthn.userHandle
|
|
128
|
+
? await openValue(key, entry.webauthn.userHandle, fieldTag(entry.id, 'wa.userHandle'))
|
|
129
|
+
: '',
|
|
130
|
+
privateKey: entry.webauthn.privateKey
|
|
131
|
+
? await openValue(key, entry.webauthn.privateKey, fieldTag(entry.id, 'wa.privateKey'))
|
|
132
|
+
: '',
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return out
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Lo que puede ver quien NO tiene la CEK: para elegir entre varias cuentas del mismo
|
|
140
|
+
* sitio, no para usarlas. El `hint` lo calcula quien sí puede abrir (§2).
|
|
141
|
+
*
|
|
142
|
+
* `open` es la entrada abierta, y solo la tiene quien puede abrirla. Con ella salen los
|
|
143
|
+
* NOMBRES de los campos que lleva —nunca sus valores—, que es lo que permite ofrecer
|
|
144
|
+
* «rellenar el número de socio» sin sacar nada de la bóveda.
|
|
145
|
+
*
|
|
146
|
+
* `digest` son los RESÚMENES de esos campos (`{ nonce, fields }`, ver `fieldHasher`):
|
|
147
|
+
* con ellos, quien pregunta puede decir si lo que tiene delante ya está guardado igual
|
|
148
|
+
* sin abrir nada y sin pedir una autorización. Van para todos los campos, públicos y
|
|
149
|
+
* privados: un solo método de comparación (dueño, 2026-08-29).
|
|
150
|
+
*
|
|
151
|
+
* `privateKeys` dice **cuáles de esos campos son privados**. No es lo mismo que el valor:
|
|
152
|
+
* es lo que permite pedir los públicos —y solo los públicos— sin disparar una
|
|
153
|
+
* autorización, que es lo que hace falta para enseñar en una lista con qué se va a
|
|
154
|
+
* rellenar cada casilla.
|
|
155
|
+
*/
|
|
156
|
+
export function publicView (entry, hint, open, digest) {
|
|
157
|
+
return {
|
|
158
|
+
id: entry.id,
|
|
159
|
+
type: entry.type,
|
|
160
|
+
title: entry.title,
|
|
161
|
+
sites: entry.sites || [],
|
|
162
|
+
hasSecret: !!entry.secret,
|
|
163
|
+
hasTotp: !!entry.totp,
|
|
164
|
+
hasNotes: !!entry.notes,
|
|
165
|
+
hasFields: !!entry.fields,
|
|
166
|
+
hasWebauthn: !!entry.webauthn,
|
|
167
|
+
updatedAt: entry.updatedAt,
|
|
168
|
+
...(hint ? { hint } : {}),
|
|
169
|
+
...(open ? { fieldKeys: entryFieldKeys(open), privateKeys: [...privateKeysOf(open)] } : {}),
|
|
170
|
+
...(digest ? { nonce: digest.nonce, fieldHashes: digest.fields } : {}),
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* CÓMO SE LLAMA una entrada para quien tiene que elegir entre varias.
|
|
176
|
+
*
|
|
177
|
+
* **El que le puso el usuario**, si le puso alguno. Y si no, su dato público más claro,
|
|
178
|
+
* **sin esconder caracteres** (dueño, 2026-08-28): el usuario; si no tiene, el correo; y
|
|
179
|
+
* si tampoco, el primer campo que lleve. Un `d•••g` no le dice nada a nadie — hay que
|
|
180
|
+
* poder reconocer la cuenta de un vistazo.
|
|
181
|
+
*
|
|
182
|
+
* **Los campos privados no salen aquí.** Para eso está la marca (§4.2): lo privado solo
|
|
183
|
+
* sale con confirmación, así que nunca es el nombre visible de una entrada.
|
|
184
|
+
*/
|
|
185
|
+
export function entryWho (open) {
|
|
186
|
+
// Lo que el usuario escribió MANDA. Lo de abajo es solo el nombre que se calcula cuando
|
|
187
|
+
// no ha escrito ninguno: útil para no dejar filas en blanco, pero es una suposición, y
|
|
188
|
+
// una suposición no le gana a una decisión (dueño, 2026-08-29).
|
|
189
|
+
if (open?.name) return String(open.name)
|
|
190
|
+
if (open?.username) return String(open.username)
|
|
191
|
+
const campos = (() => {
|
|
192
|
+
if (Array.isArray(open?.fields)) return open.fields
|
|
193
|
+
try { return JSON.parse(open?.fields || '[]') } catch { return [] }
|
|
194
|
+
})()
|
|
195
|
+
const publicos = campos.filter(f => f && !f.private && f.value)
|
|
196
|
+
const correo = publicos.find(f => f.kind === 'email')
|
|
197
|
+
return String((correo || publicos[0])?.value || '')
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* LA ENTRADA RECORTADA a los campos que se pidieron.
|
|
202
|
+
*
|
|
203
|
+
* Pedir «el nombre» no puede devolver también la contraseña. Sin esto, `get` era todo o
|
|
204
|
+
* nada y cualquier relleno de un dato público sacaba de la bóveda la credencial entera —
|
|
205
|
+
* que además es lo que obligaba a pedir autorización para rellenar un nombre.
|
|
206
|
+
*
|
|
207
|
+
* Sin `keys` devuelve la entrada tal cual: hay quien necesita todo (una passkey al
|
|
208
|
+
* firmar), y eso sí se autoriza.
|
|
209
|
+
*/
|
|
210
|
+
export function projectEntry (open, keys) {
|
|
211
|
+
if (!Array.isArray(keys)) return open
|
|
212
|
+
const quiere = new Set(keys)
|
|
213
|
+
const campos = (() => {
|
|
214
|
+
if (Array.isArray(open.fields)) return open.fields
|
|
215
|
+
try { return JSON.parse(open.fields || '[]') } catch { return [] }
|
|
216
|
+
})()
|
|
217
|
+
return {
|
|
218
|
+
...open,
|
|
219
|
+
username: quiere.has('username') ? open.username : '',
|
|
220
|
+
secret: quiere.has('secret') ? open.secret : '',
|
|
221
|
+
totp: quiere.has('totp') ? open.totp : '',
|
|
222
|
+
notes: quiere.has('notes') ? open.notes : '',
|
|
223
|
+
webauthn: quiere.has('webauthn') ? open.webauthn : null,
|
|
224
|
+
fields: JSON.stringify(campos.filter(f => quiere.has(fieldKey(f)))),
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** `sandrade@dotrino.com` → `s•••e@dotrino.com`. Sigue para la CLI; la extensión ya no. */
|
|
229
|
+
export function maskUsername (username) {
|
|
230
|
+
const u = String(username || '')
|
|
231
|
+
if (!u) return ''
|
|
232
|
+
const at = u.indexOf('@')
|
|
233
|
+
if (at > 0) return mask(u.slice(0, at)) + u.slice(at)
|
|
234
|
+
return mask(u)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function mask (s) {
|
|
238
|
+
if (s.length <= 2) return s[0] + '•'
|
|
239
|
+
return s[0] + '•••' + s[s.length - 1]
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* LA FUSIÓN DE UN PARCHE con lo que ya había, que es más sutil de lo que parece y por eso
|
|
244
|
+
* vive aquí y no en cada bóveda.
|
|
245
|
+
*
|
|
246
|
+
* La diferencia que lo ordena todo: **un campo sin `value` dice «déjale el que tenga»**,
|
|
247
|
+
* no «bórralo». Marcar uno como privado llega así —solo la etiqueta y la marca—, y una
|
|
248
|
+
* bóveda que lo tomara al pie de la letra borraría su valor al marcarlo. Pasó: la bóveda
|
|
249
|
+
* sellada de la extensión se quedó sin campos al primer `patch`.
|
|
250
|
+
*
|
|
251
|
+
* @param {object} base la entrada ABIERTA tal como está
|
|
252
|
+
* @param {object} changes lo que se quiere cambiar
|
|
253
|
+
* @returns {object} la entrada completa, lista para volver a cerrar
|
|
254
|
+
*/
|
|
255
|
+
export function mergePatch (base, changes = {}) {
|
|
256
|
+
const fields = normalizeFields((() => {
|
|
257
|
+
if (Array.isArray(base.fields)) return base.fields
|
|
258
|
+
try { return JSON.parse(base.fields || '[]') } catch { return [] }
|
|
259
|
+
})())
|
|
260
|
+
|
|
261
|
+
// Como `normalizeFields`, pero **conservando la ausencia de `value`**: ahí está la
|
|
262
|
+
// diferencia entre «ponle este valor» y «déjale el que tenga».
|
|
263
|
+
const propuestos = (Array.isArray(changes.fields) ? changes.fields : [])
|
|
264
|
+
.filter((f) => f && (f.label || f.value !== undefined))
|
|
265
|
+
.map((f) => ({
|
|
266
|
+
label: String(f.label || '').trim(),
|
|
267
|
+
kind: KINDS.includes(f.kind) ? f.kind : undefined,
|
|
268
|
+
...(f.value === undefined ? {} : { value: String(f.value) }),
|
|
269
|
+
...(f.private === undefined ? {} : { private: !!f.private }),
|
|
270
|
+
}))
|
|
271
|
+
|
|
272
|
+
for (const f of propuestos) {
|
|
273
|
+
const k = fieldKey(f)
|
|
274
|
+
// Se casa por su clave; y si no aparece, **por su etiqueta**. Un mismo campo puede
|
|
275
|
+
// llegar con clase una vez y sin ella otra —la página declara el `autocomplete` en un
|
|
276
|
+
// formulario y no en el siguiente—, y sin este respaldo eso creaba un duplicado.
|
|
277
|
+
const j = (() => {
|
|
278
|
+
const exacto = fields.findIndex((x) => fieldKey(x) === k)
|
|
279
|
+
if (exacto >= 0) return exacto
|
|
280
|
+
return f.label ? fields.findIndex((x) => x.label === f.label) : -1
|
|
281
|
+
})()
|
|
282
|
+
if (j >= 0) {
|
|
283
|
+
// La etiqueta que ya tenía MANDA: es la identidad de un campo libre, y cambiarla
|
|
284
|
+
// sería crear otro. Y lo que era privado sigue siéndolo salvo que se diga.
|
|
285
|
+
fields[j] = {
|
|
286
|
+
...fields[j],
|
|
287
|
+
...(f.value === undefined ? {} : { value: f.value }),
|
|
288
|
+
...(f.kind ? { kind: f.kind } : {}),
|
|
289
|
+
}
|
|
290
|
+
if (f.private === undefined ? fields[j].private : f.private) fields[j].private = true
|
|
291
|
+
else delete fields[j].private
|
|
292
|
+
} else if (f.value !== undefined) {
|
|
293
|
+
// Un campo que no existe y del que no se dice el valor no es nada que crear.
|
|
294
|
+
fields.push({ ...f, value: f.value })
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// QUITAR. Va al final, para que quitar gane si en la misma tanda también se editaba.
|
|
299
|
+
// Los de siempre no se pueden borrar —son parte de la forma de una entrada—, así que
|
|
300
|
+
// quitarlos es dejarlos vacíos, que para todo lo demás es lo mismo.
|
|
301
|
+
const fuera = new Set(Array.isArray(changes.removeFields) ? changes.removeFields : [])
|
|
302
|
+
const vaciados = {}
|
|
303
|
+
for (const k of ['username', 'secret', 'totp', 'notes']) if (fuera.has(k)) vaciados[k] = ''
|
|
304
|
+
const quedan = fields
|
|
305
|
+
.filter((f) => !fuera.has(fieldKey(f)))
|
|
306
|
+
// Un campo sin valor no es un campo: no se rellena, no se compara y no se enseña.
|
|
307
|
+
.filter((f) => f.value)
|
|
308
|
+
|
|
309
|
+
const sites = (() => {
|
|
310
|
+
// La lista puesta a mano MANDA sobre el «súmame este sitio» del guardado automático:
|
|
311
|
+
// una es una decisión del usuario y el otro una consecuencia de dónde estabas.
|
|
312
|
+
if (Array.isArray(changes.sites)) return normalizeSites(changes.sites)
|
|
313
|
+
const suyos = base.sites || []
|
|
314
|
+
if (!changes.addSite || !suyos.length || suyos.includes(changes.addSite)) return suyos
|
|
315
|
+
return [...suyos, changes.addSite]
|
|
316
|
+
})()
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
...base,
|
|
320
|
+
...(changes.name !== undefined ? { name: changes.name } : {}),
|
|
321
|
+
...(changes.title !== undefined ? { title: changes.title } : {}),
|
|
322
|
+
...(changes.username !== undefined ? { username: changes.username } : {}),
|
|
323
|
+
...(changes.secret !== undefined ? { secret: changes.secret } : {}),
|
|
324
|
+
...(changes.totp !== undefined ? { totp: changes.totp } : {}),
|
|
325
|
+
...(changes.notes !== undefined ? { notes: changes.notes } : {}),
|
|
326
|
+
...vaciados,
|
|
327
|
+
sites,
|
|
328
|
+
fields: quedan,
|
|
329
|
+
id: base.id,
|
|
330
|
+
createdAt: base.createdAt,
|
|
331
|
+
}
|
|
332
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Comparar llaves públicas.
|
|
2
|
+
//
|
|
3
|
+
// **Nunca con `===` sobre el JSON.** Una misma llave se serializa de varias formas: el
|
|
4
|
+
// orden de las claves del JWK cambia según quién la escriba, y sobran campos como
|
|
5
|
+
// `ext` o `key_ops` que no son parte de la llave. Comparar cadenas hace que un aparato
|
|
6
|
+
// autorizado aparezca como desconocido, y eso se manifiesta como un «denegado» que no
|
|
7
|
+
// hay forma de explicar mirando los dos valores, porque *parecen* iguales.
|
|
8
|
+
//
|
|
9
|
+
// Es una lección que el ecosistema ya tenía escrita (en `@dotrino/reputation`: comparar
|
|
10
|
+
// con `samePubkey`, nunca con `===`) y que aquí se volvió a tropezar.
|
|
11
|
+
|
|
12
|
+
/** Los campos que IDENTIFICAN una llave EC; lo demás es adorno de la serialización. */
|
|
13
|
+
function canonical (jwkOrString) {
|
|
14
|
+
let k = jwkOrString
|
|
15
|
+
if (typeof k === 'string') {
|
|
16
|
+
try { k = JSON.parse(k) } catch { return null }
|
|
17
|
+
}
|
|
18
|
+
if (!k || typeof k !== 'object') return null
|
|
19
|
+
if (!k.kty || !k.crv || !k.x) return null
|
|
20
|
+
return `${k.kty}|${k.crv}|${k.x}|${k.y || ''}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** ¿Son la misma llave, aunque vengan escritas distinto? */
|
|
24
|
+
export function samePubkey (a, b) {
|
|
25
|
+
const ca = canonical(a)
|
|
26
|
+
const cb = canonical(b)
|
|
27
|
+
return !!ca && ca === cb
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Una forma estable de la llave, para usarla como clave de un mapa o de un log. */
|
|
31
|
+
export function pubkeyId (jwkOrString) {
|
|
32
|
+
return canonical(jwkOrString)
|
|
33
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// CONVERTIR UNA BÓVEDA AL FORMATO SELLADO (`docs/sealed-passwords.md` §2.7).
|
|
2
|
+
//
|
|
3
|
+
// Es un acto único y hay que hacerlo en las CUATRO bóvedas —el binario, la pestaña, la de
|
|
4
|
+
// dentro de la extensión y `passmanager serve`—, así que vive aquí y no en ninguna de
|
|
5
|
+
// ellas. Cuatro copias de esto serían cuatro formas sutilmente distintas de reescribir lo
|
|
6
|
+
// mismo, y la que se quedara atrás no fallaría al convertir: fallaría al leerlo otra
|
|
7
|
+
// bóveda, meses después.
|
|
8
|
+
//
|
|
9
|
+
// Durante la conversión —y SOLO durante la conversión— quien convierte ve los valores: hay
|
|
10
|
+
// que abrirlos con la llave vieja para volver a cerrarlos uno a uno. Por eso lo último que
|
|
11
|
+
// hace es **borrar la llave vieja**: mientras siga ahí, el agujero sigue abierto.
|
|
12
|
+
//
|
|
13
|
+
// Y por eso no se convierte «en segundo plano»: hace falta la contraseña de la copia de
|
|
14
|
+
// recuperación, que es lo único que puede volver a abrir esto el día que no quede ningún
|
|
15
|
+
// aparato. Sin ella no se empieza.
|
|
16
|
+
|
|
17
|
+
import { openEntry } from '../model.js'
|
|
18
|
+
import { buildSealedEntry, buildProfileKey } from './device.js'
|
|
19
|
+
import { profileKeys } from './keys.js'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {object} o
|
|
23
|
+
* `store` el almacén `{ get(k), set(k, v) }` con las entradas viejas
|
|
24
|
+
* `sealed` la `SealedStore` de esta bóveda
|
|
25
|
+
* `cek` la llave vieja (`CryptoKey`), o `null` si no hay nada que convertir
|
|
26
|
+
* `recipients` `{ recoveryPub, main, passkeys }` — a quién se le envuelve
|
|
27
|
+
* `author` `{ publickey, sign(body) }` — quien firma lo que se escribe
|
|
28
|
+
* `dropOldKey` borra la llave vieja. Se llama AL FINAL y solo si salió todo
|
|
29
|
+
* `legacyKey` dónde viven las entradas viejas (cada bóveda la guarda en su sitio)
|
|
30
|
+
* @returns {Promise<{already:boolean, entries:number}>}
|
|
31
|
+
*/
|
|
32
|
+
export async function convertToSealed ({
|
|
33
|
+
store, sealed, cek, recipients, author, dropOldKey,
|
|
34
|
+
legacyKey = 'passmanager/entries/v1', log = () => {}
|
|
35
|
+
} = {}) {
|
|
36
|
+
if (!sealed) throw new Error('convertToSealed: missing the sealed store')
|
|
37
|
+
// `recoveryPub: null` vale: se convierte sin copia de recuperación y se añade después
|
|
38
|
+
// (`SealedStore.addRecovery`). Lo que no vale es no haber preguntado.
|
|
39
|
+
if (!recipients || recipients.recoveryPub === undefined) {
|
|
40
|
+
throw new Error('convertToSealed: missing the recipients')
|
|
41
|
+
}
|
|
42
|
+
// Ya convertida: no se vuelve a hacer. Repetirlo estrenaría otra llave de perfil y
|
|
43
|
+
// dejaría las entradas de antes con la anterior — la mitad de la bóveda ilegible.
|
|
44
|
+
if (await sealed.sealed()) return { already: true, entries: 0 }
|
|
45
|
+
|
|
46
|
+
const viejas = (await store.get(legacyKey)) || []
|
|
47
|
+
|
|
48
|
+
// 1. La llave del perfil, de la que salen el índice de sitios y los resúmenes.
|
|
49
|
+
const pk = await buildProfileKey({ recipients })
|
|
50
|
+
await sealed.setProfile({ envelope: pk.envelope, wraps: pk.wraps })
|
|
51
|
+
const keys = await profileKeys(pk.base)
|
|
52
|
+
|
|
53
|
+
// 2. Cada entrada, reescrita: un sobre por campo, la vista sellada, sus huellas.
|
|
54
|
+
let hechas = 0
|
|
55
|
+
for (const vieja of viejas) {
|
|
56
|
+
if (!cek) break
|
|
57
|
+
const abierta = await openEntry(cek, vieja)
|
|
58
|
+
await sealed.putSealed(await buildSealedEntry({ plain: abierta, keys, recipients, author }))
|
|
59
|
+
hechas++
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 3. Y se va lo viejo: las entradas con la llave que las abría, y la llave misma. Solo
|
|
63
|
+
// si TODAS pasaron: quedarse a medias y borrar la llave es perder lo que faltaba.
|
|
64
|
+
if (hechas === viejas.length) {
|
|
65
|
+
await store.set(legacyKey, [])
|
|
66
|
+
await dropOldKey?.()
|
|
67
|
+
}
|
|
68
|
+
log(`passwords: ${hechas} entries converted to the sealed format`)
|
|
69
|
+
return { already: false, entries: hechas }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export default { convertToSealed }
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// EL LADO DEL APARATO: construir los sobres y abrirlos.
|
|
2
|
+
//
|
|
3
|
+
// La bóveda ya no puede hacer nada de esto —no tiene ninguna llave— así que lo hace quien
|
|
4
|
+
// pide: cifra lo que guarda, lo envuelve a cada destinatario y firma el conjunto; y al
|
|
5
|
+
// leer, abre su envoltura y con ella el sobre. Es lo mismo que `buildSealedVar` hace con
|
|
6
|
+
// una variable de un cajón (`@dotrino/vault/admin`), con una diferencia: una entrada tiene
|
|
7
|
+
// muchos campos y **dos juegos de destinatarios** (§2.8), no uno.
|
|
8
|
+
//
|
|
9
|
+
// QUIÉN FIRMA VA COMO FUNCIÓN, no como llave, por el mismo motivo que allí: en un navegador
|
|
10
|
+
// la privada del aparato vive dentro del iframe de identidad y no sale. Aquí se pide la
|
|
11
|
+
// firma, no la llave.
|
|
12
|
+
|
|
13
|
+
// ESTÁTICO, no `import()`: un service worker MV3 no admite el import dinámico (lo prohíbe
|
|
14
|
+
// la especificación), y esta pieza corre justamente ahí — dentro de la extensión del gestor.
|
|
15
|
+
import { makeContentKey, wrapForMember, encryptWithCek } from '@dotrino/identity/content'
|
|
16
|
+
import { sealEntry, openView, openFields, PASSKEY_FIELD } from './entry.js'
|
|
17
|
+
import { RECOVERY } from './store.js'
|
|
18
|
+
import { makePasswordKey, profileKeys, valueDigest } from './keys.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* LO QUE FIRMA EL AUTOR. Un solo sitio: quien firma y quien comprueba miran exactamente lo
|
|
22
|
+
* mismo, o la comprobación pasa a ser decorativa.
|
|
23
|
+
*
|
|
24
|
+
* Va lo que identifica el contenido —los criptogramas, no los valores— y las huellas: si la
|
|
25
|
+
* bóveda cambiara un sobre por otro, o el índice de sitios de una entrada, la firma deja de
|
|
26
|
+
* cuadrar. Lo que NO va es la generación, que la pone el almacén después.
|
|
27
|
+
*/
|
|
28
|
+
export function entryAuthorBody (entry, ts) {
|
|
29
|
+
return {
|
|
30
|
+
op: 'pm.entry',
|
|
31
|
+
id: entry.id,
|
|
32
|
+
idx: [...(entry.idx || [])].sort(),
|
|
33
|
+
wa: entry.wa || null,
|
|
34
|
+
view: entry.view.iv + '.' + entry.view.ct,
|
|
35
|
+
fields: Object.keys(entry.fields).sort().map((k) => k + ':' + entry.fields[k].iv + '.' + entry.fields[k].ct),
|
|
36
|
+
digests: entry.digests || {},
|
|
37
|
+
ts
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* LO QUE FIRMA QUIEN CAMBIA UNOS CAMPOS. Cubre exactamente lo que el parche pide hacer —
|
|
43
|
+
* qué sobres entran, cuáles se van y con qué vista queda— y nada más: lo que no se toca
|
|
44
|
+
* ya lo firmó quien lo escribió.
|
|
45
|
+
*/
|
|
46
|
+
export function patchAuthorBody ({ id, view, fields = {}, drop = [], digests = {}, priv = null, idx = null, wa }, ts) {
|
|
47
|
+
return {
|
|
48
|
+
op: 'pm.patch',
|
|
49
|
+
id,
|
|
50
|
+
view: view.iv + '.' + view.ct,
|
|
51
|
+
fields: Object.keys(fields).sort().map((k) => k + ':' + fields[k].iv + '.' + fields[k].ct),
|
|
52
|
+
drop: [...drop].sort(),
|
|
53
|
+
digests,
|
|
54
|
+
priv: Array.isArray(priv) ? [...priv].sort() : null,
|
|
55
|
+
idx: Array.isArray(idx) ? [...idx].sort() : null,
|
|
56
|
+
wa: wa === undefined ? null : (wa || null),
|
|
57
|
+
ts
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// `recoveryPub: null` es «la cuenta aún no tiene copia de recuperación» y se sella solo a
|
|
62
|
+
// los aparatos; `undefined` es que nadie preguntó a quién, y eso es un error.
|
|
63
|
+
const wrapAll = async (cek, { recoveryPub, members }) => {
|
|
64
|
+
if (recoveryPub === undefined) throw new Error('sealed: ask the vault for the recipients first')
|
|
65
|
+
const wraps = recoveryPub ? { [RECOVERY]: await wrapForMember({ cek, memberEncPub: recoveryPub }) } : {}
|
|
66
|
+
for (const m of members || []) wraps[m.pub] = await wrapForMember({ cek, memberEncPub: m.encPub })
|
|
67
|
+
return wraps
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* UNA ENTRADA LISTA PARA GUARDAR: cifrada, envuelta a quien toca y firmada.
|
|
72
|
+
*
|
|
73
|
+
* @param {object} o
|
|
74
|
+
* `plain` la entrada en claro
|
|
75
|
+
* `keys` las llaves del perfil (`profileKeys`)
|
|
76
|
+
* `recipients` `{ recoveryPub, main: [{pub,encPub}], passkeys: [{pub,encPub}] }`, tal
|
|
77
|
+
* como las contesta la bóveda: la lista sale del acta y la sabe ella.
|
|
78
|
+
* `author` `{ publickey, sign(body) }`
|
|
79
|
+
*/
|
|
80
|
+
export async function buildSealedEntry ({ plain, keys, recipients, author } = {}) {
|
|
81
|
+
if (typeof author?.sign !== 'function' || typeof author?.publickey !== 'string') {
|
|
82
|
+
throw new Error('buildSealedEntry: author needs { publickey, sign(body) }')
|
|
83
|
+
}
|
|
84
|
+
const cek = await makeContentKey()
|
|
85
|
+
// La privada de la passkey va con SU llave y sus destinatarios. Se estrena solo si la
|
|
86
|
+
// entrada lleva una: repartir una llave que nadie va a usar es ruido en el llavero.
|
|
87
|
+
const llevaPasskey = !!plain?.webauthn?.privateKey
|
|
88
|
+
const cekPk = llevaPasskey ? await makeContentKey() : null
|
|
89
|
+
|
|
90
|
+
// `gen: 0` a propósito: la generación DE VERDAD la pone el almacén al guardar, como en
|
|
91
|
+
// los cajones. Aquí solo hace falta un número para que el sobre tenga forma.
|
|
92
|
+
const entry = await sealEntry({
|
|
93
|
+
plain,
|
|
94
|
+
main: { gen: 0, cek },
|
|
95
|
+
...(cekPk ? { passkeys: { gen: 0, cek: cekPk } } : {}),
|
|
96
|
+
keys
|
|
97
|
+
})
|
|
98
|
+
const ts = Date.now()
|
|
99
|
+
const { signature } = await author.sign(entryAuthorBody(entry, ts))
|
|
100
|
+
return {
|
|
101
|
+
entry: { ...entry, author: { pub: author.publickey, sig: signature, ts } },
|
|
102
|
+
main: { wraps: await wrapAll(cek, { recoveryPub: recipients?.recoveryPub, members: recipients?.main }) },
|
|
103
|
+
...(cekPk
|
|
104
|
+
? { passkeys: { wraps: await wrapAll(cekPk, { recoveryPub: recipients?.recoveryPub, members: recipients?.passkeys }) } }
|
|
105
|
+
: {})
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** La llave del perfil, estrenada y envuelta. Se hace una vez, al convertir. */
|
|
110
|
+
export async function buildProfileKey ({ recipients } = {}) {
|
|
111
|
+
const base = await makePasswordKey()
|
|
112
|
+
const cek = await makeContentKey()
|
|
113
|
+
return {
|
|
114
|
+
base,
|
|
115
|
+
envelope: await encryptWithCek({ cek, gen: 0, plaintext: base }),
|
|
116
|
+
wraps: await wrapAll(cek, { recoveryPub: recipients?.recoveryPub, members: recipients?.main })
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* EL ABRIDOR de este aparato: con su envoltura saca la llave y con ella el sobre.
|
|
122
|
+
*
|
|
123
|
+
* `openSealed({ wrap, envelope })` es `identity.openSealedValue`: la privada de cifrado no
|
|
124
|
+
* sale del iframe (ni del núcleo, en la extensión). Aquí solo se empareja cada sobre con la
|
|
125
|
+
* envoltura de su generación.
|
|
126
|
+
*/
|
|
127
|
+
export function makeOpener ({ wraps, openSealed }) {
|
|
128
|
+
if (typeof openSealed !== 'function') throw new Error('makeOpener: missing openSealed({ wrap, envelope })')
|
|
129
|
+
return async ({ envelope }) => {
|
|
130
|
+
const wrap = wraps?.[envelope?.gen]
|
|
131
|
+
if (!wrap) {
|
|
132
|
+
throw Object.assign(new Error('this device has no key for that content generation'), { code: 'no-wrap' })
|
|
133
|
+
}
|
|
134
|
+
return openSealed({ wrap, envelope })
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** La vista de una entrada, abierta con las envolturas que vinieron con ella. */
|
|
139
|
+
export async function openSealedView ({ view, wrap, id, updatedAt, digests, openSealed }) {
|
|
140
|
+
const open = makeOpener({ wraps: { [view.gen]: wrap }, openSealed })
|
|
141
|
+
return openView({ id, updatedAt, view, digests }, open)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Los campos que vinieron de `get`, con la forma de siempre. */
|
|
145
|
+
export async function openSealedEntry ({ got, openSealed, view = null }) {
|
|
146
|
+
const open = makeOpener({ wraps: got.wraps, openSealed })
|
|
147
|
+
const vista = view || await openView({ id: got.id, updatedAt: got.updatedAt, view: got.view, digests: got.digests }, open)
|
|
148
|
+
const abierta = await openFields({
|
|
149
|
+
sealed: { id: got.id, updatedAt: got.updatedAt },
|
|
150
|
+
envelopes: got.envelopes,
|
|
151
|
+
open,
|
|
152
|
+
view: vista
|
|
153
|
+
})
|
|
154
|
+
return { ...abierta, withheld: got.withheld || [] }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* UN PARCHE LISTO PARA MANDAR: los campos que cambian, cifrados y envueltos, y la vista
|
|
159
|
+
* nueva. Lo que no viene aquí se queda en la bóveda como estaba, sin abrirse.
|
|
160
|
+
*
|
|
161
|
+
* @param {object} o
|
|
162
|
+
* `id`, `view` la entrada y su vista NUEVA en claro (la arma quien cambia, que ya la
|
|
163
|
+
* tenía abierta: la vista no es un valor)
|
|
164
|
+
* `values` solo lo que cambia: `{ <clave que guarda la bóveda>: { name, value } }`.
|
|
165
|
+
* La clave de un campo libre es una HUELLA y el `name` es su nombre de
|
|
166
|
+
* verdad — hace falta porque el RESUMEN se calcula sobre el nombre, no
|
|
167
|
+
* sobre la huella, o dejaría de casar con lo que escribió quien creó la
|
|
168
|
+
* entrada.
|
|
169
|
+
* `drop` las claves que se van (ya traducidas)
|
|
170
|
+
* `keys`, `recipients`, `author` como en `buildSealedEntry`
|
|
171
|
+
*/
|
|
172
|
+
export async function buildSealedPatch ({ id, view, values = {}, drop = [], priv = null, idx = null, wa, keys, recipients, author } = {}) {
|
|
173
|
+
if (typeof author?.sign !== 'function') throw new Error('buildSealedPatch: author needs { publickey, sign(body) }')
|
|
174
|
+
const cek = await makeContentKey()
|
|
175
|
+
const hayPasskey = Object.prototype.hasOwnProperty.call(values, PASSKEY_FIELD)
|
|
176
|
+
const cekPk = hayPasskey ? await makeContentKey() : null
|
|
177
|
+
|
|
178
|
+
const fields = {}
|
|
179
|
+
const digests = {}
|
|
180
|
+
for (const [k, v] of Object.entries(values)) {
|
|
181
|
+
const { name, value } = (v && typeof v === 'object') ? v : { name: k.replace(/^f:/, ''), value: v }
|
|
182
|
+
const conQue = k === PASSKEY_FIELD ? cekPk : cek
|
|
183
|
+
fields[k] = await encryptWithCek({ cek: conQue, gen: 0, plaintext: String(value) })
|
|
184
|
+
// El resumen es del VALOR y del NOMBRE, con el id de la entrada dentro (§2.6). La
|
|
185
|
+
// privada de una passkey no se compara nunca: no se teclea, así que no hay nada que
|
|
186
|
+
// cotejar.
|
|
187
|
+
if (k !== PASSKEY_FIELD) digests[k] = await valueDigest(keys.kcmp, id, name, String(value))
|
|
188
|
+
}
|
|
189
|
+
const vista = await encryptWithCek({ cek, gen: 0, plaintext: JSON.stringify(view) })
|
|
190
|
+
const ts = Date.now()
|
|
191
|
+
const cuerpo = patchAuthorBody({ id, view: vista, fields, drop, digests, priv, idx, wa }, ts)
|
|
192
|
+
const { signature } = await author.sign(cuerpo)
|
|
193
|
+
return {
|
|
194
|
+
id,
|
|
195
|
+
view: vista,
|
|
196
|
+
fields,
|
|
197
|
+
drop,
|
|
198
|
+
digests,
|
|
199
|
+
...(Array.isArray(priv) ? { priv } : {}),
|
|
200
|
+
...(Array.isArray(idx) ? { idx } : {}),
|
|
201
|
+
...(wa === undefined ? {} : { wa }),
|
|
202
|
+
author: { pub: author.publickey, sig: signature, ts },
|
|
203
|
+
main: { wraps: await wrapAll(cek, { recoveryPub: recipients?.recoveryPub, members: recipients?.main }) },
|
|
204
|
+
...(cekPk
|
|
205
|
+
? { passkeys: { wraps: await wrapAll(cekPk, { recoveryPub: recipients?.recoveryPub, members: recipients?.passkeys }) } }
|
|
206
|
+
: {})
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export { PASSKEY_FIELD, profileKeys }
|
|
211
|
+
export default { buildSealedEntry, buildSealedPatch, buildProfileKey, openSealedEntry, openSealedView, makeOpener, entryAuthorBody, patchAuthorBody }
|