@dotrino/identity 0.115.0 → 0.117.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -1
- package/src/node.js +4 -180
- package/vault/content.js +28 -1
- package/vault/core.js +66 -7
- package/vault/facade.js +200 -0
- package/vault/index.html +10 -0
- package/vault/remote.js +45 -5
- package/vault/vault.js +29 -58
- package/vault/vendor/compat/VERSION.txt +3 -0
- package/vault/vendor/compat/advisory.js +141 -0
- package/vault/vendor/compat/index.js +188 -0
- package/vault/vendor/compat/ranges.js +93 -0
- package/vault/vendor/opaque/VERSION.txt +1 -1
- package/vault/vendor/passmanager/VERSION.txt +3 -0
- package/vault/vendor/passmanager/crypto.js +167 -0
- package/vault/vendor/passmanager/fields.js +183 -0
- package/vault/vendor/passmanager/generate.js +74 -0
- package/vault/vendor/passmanager/import.js +156 -0
- package/vault/vendor/passmanager/index.js +27 -0
- package/vault/vendor/passmanager/match.js +137 -0
- package/vault/vendor/passmanager/model.js +332 -0
- package/vault/vendor/passmanager/pubkey.js +33 -0
- package/vault/vendor/passmanager/sealed/convert.js +72 -0
- package/vault/vendor/passmanager/sealed/device.js +211 -0
- package/vault/vendor/passmanager/sealed/entry.js +209 -0
- package/vault/vendor/passmanager/sealed/index.js +20 -0
- package/vault/vendor/passmanager/sealed/keys.js +120 -0
- package/vault/vendor/passmanager/sealed/recovery.js +136 -0
- package/vault/vendor/passmanager/sealed/store.js +508 -0
- package/vault/vendor/passmanager/session-cache.js +60 -0
- package/vault/vendor/passmanager/totp.js +111 -0
- package/vault/vendor/passmanager/transport/protocol.js +83 -0
- package/vault/vendor/passmanager/transport/proxy.js +112 -0
- package/vault/vendor/passmanager/transport/sealed.js +74 -0
- package/vault/vendor/passmanager/vault/approval.js +63 -0
- package/vault/vendor/passmanager/vault/errors.js +34 -0
- package/vault/vendor/passmanager/vault/guard.js +114 -0
- package/vault/vendor/passmanager/vault/interface.js +34 -0
- package/vault/vendor/passmanager/vault/local.js +213 -0
- package/vault/vendor/passmanager/vault/remote.js +85 -0
- package/vault/vendor/passmanager/vault/responder.js +192 -0
- package/vault/vendor/passmanager/vault/sealed-local.js +199 -0
- package/vault/vendor/passmanager/vault/sealed-responder.js +159 -0
- package/vault/vendor/passmanager/vault/sealed.js +362 -0
- package/vault/vendor/passmanager/webauthn.js +221 -0
- package/vault/vendor/proxy-client/VERSION.txt +1 -1
- package/vault/vendor/proxy-client/client.js +136 -11
- package/vault/vendor/proxy-client/index.js +1 -0
- package/vault/vendor/proxy-client/parts.js +127 -0
- package/vault/vendor/proxy-client/webrtc.js +23 -0
- package/vault/vendor/roadmap/VERSION.txt +3 -0
- package/vault/vendor/roadmap/index.js +108 -0
- package/vault/vendor/roadmap/manifests.js +802 -0
- package/vault/vendor/store/VERSION.txt +3 -0
- package/vault/vendor/store/core.js +309 -0
- package/vault/vendor/vault/VERSION.txt +4 -1
- package/vault/vendor/vault/admin.js +437 -0
- package/vault/vendor/vault/browser.js +167 -0
- package/vault/vendor/vault/core/approvals.js +204 -0
- package/vault/vendor/vault/core/blocked.js +68 -0
- package/vault/vendor/vault/core/browserDisk.js +109 -0
- package/vault/vendor/vault/core/commKey.js +68 -0
- package/vault/vendor/vault/core/disk.js +65 -0
- package/vault/vendor/vault/core/libVersion.js +8 -0
- package/vault/vendor/vault/core/machines.js +66 -0
- package/vault/vendor/vault/core/notices.js +71 -0
- package/vault/vendor/vault/core/notifiable.js +73 -0
- package/vault/vendor/vault/core/passwords.js +125 -0
- package/vault/vendor/vault/core/sealKey.js +77 -0
- package/vault/vendor/vault/core/sealer.js +172 -0
- package/vault/vendor/vault/core/sealers.js +159 -0
- package/vault/vendor/vault/core/secretsStore.js +1326 -0
- package/vault/vendor/vault/core/sha256.js +69 -0
- package/vault/vendor/vault/core/store.js +68 -0
- package/vault/vendor/vault/core/subacta.js +101 -0
- package/vault/vendor/vault/core/threadStore.js +273 -0
- package/vault/vendor/vault/core/transport.js +270 -0
- package/vault/vendor/vault/core/vault.js +4414 -0
- package/vault/vendor/vault/enroll.js +8 -4
- package/vault/vendor/vault/procContext.js +97 -0
- package/vault/vendor/vault/revocation.js +67 -0
- package/vault/vendor/vault/sealed.js +84 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// LA COPIA DE RECUPERACIÓN DE UNA BÓVEDA QUE NO PUEDE LEER LO SUYO.
|
|
2
|
+
//
|
|
3
|
+
// `SealedStore` exige que toda entrada lleve la envoltura `#recovery` (`store.js`), y con
|
|
4
|
+
// razón: sin ella, el día que no quede ningún aparato con `passwords` esas contraseñas no
|
|
5
|
+
// las abre nadie — tampoco su dueño. La bóveda publica la PÚBLICA de ese par para que los
|
|
6
|
+
// aparatos envuelvan; la privada solo hace falta para repartir de nuevo cuando cambia
|
|
7
|
+
// quién puede leer.
|
|
8
|
+
//
|
|
9
|
+
// De dónde sale esa privada es lo que distingue a cada bóveda, y el dueño lo decidió el
|
|
10
|
+
// 2026-09-20: **de una contraseña, en las cuatro**.
|
|
11
|
+
//
|
|
12
|
+
// · el binario (`dotrino-vault`) usa la contraseña del PERFIL — ya la tenía;
|
|
13
|
+
// · `dotrino-passmanager serve` usa la contraseña de su bóveda — ya la tenía;
|
|
14
|
+
// · la pestaña (`vault.dotrino.com/vault`) y la bóveda de dentro de la extensión no
|
|
15
|
+
// tenían ninguna, y por eso la piden al convertir.
|
|
16
|
+
//
|
|
17
|
+
// Lo que esto NO hace, y se dice porque es la mitad del trato: la contraseña **no se puede
|
|
18
|
+
// recuperar**. Es la misma promesa que ya hace `passmanager serve` al crear su bóveda, y
|
|
19
|
+
// es lo que hace que la copia sirva de algo — una que se pudiera reponer desde el propio
|
|
20
|
+
// navegador no sería una copia de recuperación, sería una segunda llave del mismo llavero.
|
|
21
|
+
//
|
|
22
|
+
// Por qué una pieza y no cuatro: la regla de las tres versiones (`sealed-passwords.md`
|
|
23
|
+
// §2.7) dice que lo que escribe una lo abre otra. Si cada una derivara su llave a su
|
|
24
|
+
// manera —otro número de vueltas, otra sal, otro formato— el sobre `#recovery` de una no
|
|
25
|
+
// lo abriría la de al lado, y eso no falla al escribir: falla el día que hace falta.
|
|
26
|
+
|
|
27
|
+
import { deriveKeyFromPassword, makeSalt, makeVerifier, checkVerifier, sealValue, openValue, toBase64, fromBase64 } from '../crypto.js'
|
|
28
|
+
|
|
29
|
+
/** El formato de lo guardado. Sube si cambia la derivación o el sobre. */
|
|
30
|
+
export const RECOVERY_VERSION = 1
|
|
31
|
+
|
|
32
|
+
const subtle = () => {
|
|
33
|
+
const c = globalThis.crypto
|
|
34
|
+
if (!c?.subtle) throw new Error('webcrypto unavailable')
|
|
35
|
+
return c.subtle
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const err = (code, message) => Object.assign(new Error(message), { code })
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* El par de recuperación. ECDH P-256, como el de los cajones del binario
|
|
42
|
+
* (`dotrino-vault/src/sealer.js`): es la misma curva que espera `wrapForMember`, y usar
|
|
43
|
+
* otra haría que el sobre de una bóveda no lo abriera la de al lado.
|
|
44
|
+
*/
|
|
45
|
+
async function makePair () {
|
|
46
|
+
const pair = await subtle().generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey', 'deriveBits'])
|
|
47
|
+
return {
|
|
48
|
+
pub: JSON.stringify(await subtle().exportKey('jwk', pair.publicKey)),
|
|
49
|
+
priv: await subtle().exportKey('jwk', pair.privateKey)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* CREA la copia de recuperación de esta bóveda a partir de una contraseña.
|
|
55
|
+
*
|
|
56
|
+
* Devuelve lo que hay que GUARDAR (`record`) y la pública, que es lo único que sale de
|
|
57
|
+
* aquí en claro. La privada se va con la función: quien la quiera, que abra con la
|
|
58
|
+
* contraseña.
|
|
59
|
+
*
|
|
60
|
+
* @param {object} o
|
|
61
|
+
* `password` la contraseña que abrirá la copia. Mínimo 12: quien tenga el archivo puede
|
|
62
|
+
* probar contraseñas contra el verificador sin límite y sin que nadie se entere, así
|
|
63
|
+
* que lo único que aguanta es su longitud (mismo criterio que los inicios de sesión).
|
|
64
|
+
*/
|
|
65
|
+
export async function makeRecovery ({ password, minLength = 12 } = {}) {
|
|
66
|
+
if (typeof password !== 'string' || password.length < minLength) {
|
|
67
|
+
throw err('weak-password', `the recovery password must be at least ${minLength} characters`)
|
|
68
|
+
}
|
|
69
|
+
const salt = makeSalt()
|
|
70
|
+
const key = await deriveKeyFromPassword(password, salt)
|
|
71
|
+
const pair = await makePair()
|
|
72
|
+
return {
|
|
73
|
+
pub: pair.pub,
|
|
74
|
+
record: {
|
|
75
|
+
v: RECOVERY_VERSION,
|
|
76
|
+
salt: toBase64(salt),
|
|
77
|
+
// El verificador prueba que la contraseña ABRE esto, no que coincide con un hash:
|
|
78
|
+
// es lo que deja decir «esa no es» sin tener que intentar descifrar la privada.
|
|
79
|
+
verifier: await makeVerifier(key),
|
|
80
|
+
pub: pair.pub,
|
|
81
|
+
priv: await sealValue(key, JSON.stringify(pair.priv), 'recovery')
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** La pública de una copia ya creada. Es lo que la bóveda publica a los aparatos. */
|
|
87
|
+
export function recoveryPubOf (record) {
|
|
88
|
+
return record?.pub || null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** ¿Hay copia de recuperación? Lo pregunta quien tiene que crearla al convertir. */
|
|
92
|
+
export const hasRecovery = (record) => !!record?.pub && !!record?.priv
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* ABRE la copia con su contraseña y devuelve la privada como `CryptoKey` de ECDH, que es
|
|
96
|
+
* lo que quiere `openWrap`.
|
|
97
|
+
*
|
|
98
|
+
* Se distingue «esa contraseña no es» de «esto está roto», y no es un detalle: la primera
|
|
99
|
+
* se arregla escribiendo bien y la segunda no se arregla de ninguna manera, así que
|
|
100
|
+
* confundirlas manda al usuario a intentarlo cien veces contra un archivo corrupto.
|
|
101
|
+
*/
|
|
102
|
+
export async function openRecovery ({ record, password } = {}) {
|
|
103
|
+
if (!hasRecovery(record)) throw err('no-recovery', 'this vault has no recovery copy yet')
|
|
104
|
+
const key = await deriveKeyFromPassword(password, fromBase64(record.salt))
|
|
105
|
+
if (!(await checkVerifier(key, record.verifier))) throw err('wrong-password', 'that password does not open the recovery copy')
|
|
106
|
+
let jwk
|
|
107
|
+
try { jwk = JSON.parse(await openValue(key, record.priv, 'recovery')) }
|
|
108
|
+
catch (_) { throw err('bad-recovery', 'the recovery copy is there but cannot be read') }
|
|
109
|
+
// `key_ops` se quita: dice para qué la generó quien la creó, y aquí hace falta además
|
|
110
|
+
// `deriveKey`. Es lo mismo que hace `adoptLogin` al adoptar una llave de cifrado.
|
|
111
|
+
const { key_ops: _o, ext: _e, ...limpio } = jwk
|
|
112
|
+
return subtle().importKey('jwk', limpio, { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveKey', 'deriveBits'])
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* CAMBIA la contraseña de la copia sin tocar el par: los sobres que ya existen siguen
|
|
117
|
+
* valiendo. Hace falta la vieja, porque hay que abrirla para volver a cerrarla.
|
|
118
|
+
*/
|
|
119
|
+
export async function changeRecoveryPassword ({ record, oldPassword, newPassword, minLength = 12 } = {}) {
|
|
120
|
+
if (typeof newPassword !== 'string' || newPassword.length < minLength) {
|
|
121
|
+
throw err('weak-password', `the recovery password must be at least ${minLength} characters`)
|
|
122
|
+
}
|
|
123
|
+
const vieja = await deriveKeyFromPassword(oldPassword, fromBase64(record.salt))
|
|
124
|
+
if (!(await checkVerifier(vieja, record.verifier))) throw err('wrong-password', 'that password does not open the recovery copy')
|
|
125
|
+
const priv = await openValue(vieja, record.priv, 'recovery')
|
|
126
|
+
const salt = makeSalt()
|
|
127
|
+
const key = await deriveKeyFromPassword(newPassword, salt)
|
|
128
|
+
return {
|
|
129
|
+
...record,
|
|
130
|
+
salt: toBase64(salt),
|
|
131
|
+
verifier: await makeVerifier(key),
|
|
132
|
+
priv: await sealValue(key, priv, 'recovery')
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export default { makeRecovery, openRecovery, recoveryPubOf, hasRecovery, changeRecoveryPassword, RECOVERY_VERSION }
|
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
// LA BÓVEDA DE CONTRASEÑAS QUE NO PUEDE LEERLAS (`sealed-passwords.md`).
|
|
2
|
+
//
|
|
3
|
+
// Guarda sobres dirigidos a los aparatos y las envolturas de sus llaves, y **no tiene
|
|
4
|
+
// ninguna llave propia con la que abrir nada** — ni abierta ni cerrada. Es el mismo modelo
|
|
5
|
+
// que los cajones de secretos del vault (`dotrino-vault/src/secretsStore.js`), y se parece
|
|
6
|
+
// a propósito: quien lea uno entiende el otro.
|
|
7
|
+
//
|
|
8
|
+
// Lo que sí hace, porque no necesita la llave para hacerlo:
|
|
9
|
+
//
|
|
10
|
+
// · **comprobar los destinatarios**. Es LA comprobación que sostiene el modelo: un
|
|
11
|
+
// aparato no puede guardar una entrada que solo él pueda abrir, ni dejar fuera la copia
|
|
12
|
+
// de recuperación, ni meter la privada de una passkey en el sobre de las contraseñas;
|
|
13
|
+
// · **filtrar por destinatario** al entregar: a cada aparato, lo suyo;
|
|
14
|
+
// · **numerar las generaciones** y barrer las que ya no usa nadie.
|
|
15
|
+
//
|
|
16
|
+
// El almacén se inyecta (`{ get(k), set(k, v) }`), como en `../vault/local.js`: en el
|
|
17
|
+
// demonio es un archivo, en la pestaña IndexedDB, en las pruebas un Map. Una sola pieza
|
|
18
|
+
// para las tres bóvedas y el `serve`, que es lo que la regla de las tres versiones exige.
|
|
19
|
+
|
|
20
|
+
import { wrapForMember } from '@dotrino/identity/content'
|
|
21
|
+
import { PASSKEY_FIELD } from './entry.js'
|
|
22
|
+
import { entryAuthorBody, patchAuthorBody } from './device.js'
|
|
23
|
+
|
|
24
|
+
export const KEY = 'passmanager/sealed/v2'
|
|
25
|
+
/** La envoltura que abre la frase del perfil. Sin ella, una entrada nace ilegible. */
|
|
26
|
+
export const RECOVERY = '#recovery'
|
|
27
|
+
|
|
28
|
+
const isEnv = (e) => !!e && typeof e.iv === 'string' && typeof e.ct === 'string'
|
|
29
|
+
const sorted = (a) => [...a].sort()
|
|
30
|
+
const same = (a, b) => a.length === b.length && sorted(a).every((x, i) => x === sorted(b)[i])
|
|
31
|
+
|
|
32
|
+
export class SealedError extends Error {
|
|
33
|
+
constructor (code, message) { super(message); this.code = code; this.name = 'SealedError' }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class SealedStore {
|
|
37
|
+
/**
|
|
38
|
+
* @param {object} store `{ get(k), set(k, v) }`
|
|
39
|
+
* @param {object} opts
|
|
40
|
+
* `recipients(kind)` → `[{ pub, encPub }]` quién DEBE tener envoltura. `kind` es
|
|
41
|
+
* `'main'` (los aparatos con `passwords`) o `'passkeys'` (los que además tienen
|
|
42
|
+
* `passkeys`). Lo pone quien conoce el acta: esta pieza no la lee.
|
|
43
|
+
* `verifyAuthor({ body, author })` → ¿la firmó quien dice, y puede escribir? Lo
|
|
44
|
+
* contesta quien tiene el acta. **No es opcional**: sin ella, cualquiera que llegue
|
|
45
|
+
* al almacén podría dejar una entrada a nombre de otro, y la bóveda no puede mirar
|
|
46
|
+
* dentro para notarlo.
|
|
47
|
+
* `recovery()` → ¿la cuenta YA tiene copia de recuperación? Si la tiene, toda envoltura
|
|
48
|
+
* lleva la suya (`#recovery`). Si todavía no, la bóveda funciona igual, sellada solo a
|
|
49
|
+
* los aparatos del acta, y la copia se añade después con `addRecovery`. Por defecto
|
|
50
|
+
* contesta que sí: lo estricto es el valor por omisión, no el permisivo.
|
|
51
|
+
* `now()` el reloj, para poder probar sin esperar.
|
|
52
|
+
*/
|
|
53
|
+
constructor (store, { recipients, verifyAuthor, recovery = async () => true, now = () => Date.now() } = {}) {
|
|
54
|
+
if (!store) throw new Error('SealedStore: missing store')
|
|
55
|
+
if (typeof recipients !== 'function') throw new Error('SealedStore: missing recipients(kind)')
|
|
56
|
+
if (typeof verifyAuthor !== 'function') throw new Error('SealedStore: missing verifyAuthor({ body, author })')
|
|
57
|
+
this.store = store
|
|
58
|
+
this.recipients = recipients
|
|
59
|
+
this.verifyAuthor = verifyAuthor
|
|
60
|
+
this.recovery = recovery
|
|
61
|
+
this.now = now
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async #state () {
|
|
65
|
+
const s = await this.store.get(KEY)
|
|
66
|
+
if (s && typeof s === 'object') return s
|
|
67
|
+
return { v: 2, keyring: [], profile: null, entries: [] }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async #save (s) { await this.store.set(KEY, s) }
|
|
71
|
+
|
|
72
|
+
/** ¿Hay algo guardado en el formato sellado? Lo pregunta quien tiene que convertir. */
|
|
73
|
+
async sealed () {
|
|
74
|
+
const s = await this.#state()
|
|
75
|
+
return !!s.profile
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#gen (s, gen) { return (s.keyring || []).find((g) => g.gen === gen) || null }
|
|
79
|
+
|
|
80
|
+
/** La envoltura de ESTE aparato para esa generación, o `null` si no le tocaba. */
|
|
81
|
+
#wrapFor (s, gen, pub) { return this.#gen(s, gen)?.wraps?.[pub] || null }
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* ¿SON EXACTAMENTE LOS DESTINATARIOS QUE MANDA EL ACTA? Ni de más ni de menos, más la
|
|
85
|
+
* copia de recuperación.
|
|
86
|
+
*
|
|
87
|
+
* Ni de menos, porque un aparato podría guardar algo que los demás no pueden abrir —y la
|
|
88
|
+
* pérdida no se nota hasta que hace falta—. Ni de más, porque entonces cualquiera podría
|
|
89
|
+
* darle acceso a una llave que el acta no reconoce. Y la de recuperación siempre que la
|
|
90
|
+
* cuenta tenga copia: sin ella, ni el dueño con su frase puede volver a repartir esa
|
|
91
|
+
* entrada. Una cuenta que aún no la creó guarda igual, y la pantalla lo avisa.
|
|
92
|
+
*/
|
|
93
|
+
async #checkWraps (kind, wraps) {
|
|
94
|
+
if (!wraps || typeof wraps !== 'object') throw new SealedError('wraps-missing', 'the envelope wraps are missing')
|
|
95
|
+
if (!wraps[RECOVERY] && await this.recovery()) {
|
|
96
|
+
throw new SealedError('recovery-missing',
|
|
97
|
+
'the wraps must include the recovery one (#recovery), or this entry could never be handed out again')
|
|
98
|
+
}
|
|
99
|
+
const deben = (await this.recipients(kind)).filter((m) => m?.encPub).map((m) => m.pub)
|
|
100
|
+
// NADIE A QUIEN ENVOLVÉRSELA NO ES «CERO DESTINATARIOS», ES UN ERROR. Guardar una
|
|
101
|
+
// passkey que ningún aparato puede abrir la deja viva solo para la frase del perfil: no
|
|
102
|
+
// se pierde, pero no sirve, y el usuario se entera el día que la necesita. Se dice
|
|
103
|
+
// ahora, con un código que explica qué falta.
|
|
104
|
+
if (kind === 'passkeys' && !deben.length) {
|
|
105
|
+
throw new SealedError('no-passkeys-device',
|
|
106
|
+
'no device in the account record can open passkeys: grant `passkeys` to one (`dotrino-vault caps <ID> +passkeys`) before saving it')
|
|
107
|
+
}
|
|
108
|
+
const hay = Object.keys(wraps).filter((k) => k !== RECOVERY)
|
|
109
|
+
if (!same(deben, hay)) {
|
|
110
|
+
throw new SealedError('wrong-recipients',
|
|
111
|
+
`the wraps do not match the account record: it expects [${sorted(deben).join(', ')}] and got [${sorted(hay).join(', ')}]`)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** La forma de una entrada sellada, antes de tocar el disco. */
|
|
116
|
+
#checkEntry (entry, tienePasskeys) {
|
|
117
|
+
if (!entry || typeof entry.id !== 'string' || !entry.id) throw new SealedError('bad-entry', 'the entry has no id')
|
|
118
|
+
if (!isEnv(entry.view)) throw new SealedError('bad-entry', 'the entry has no sealed public view')
|
|
119
|
+
if (!entry.fields || typeof entry.fields !== 'object') throw new SealedError('bad-entry', 'the entry has no fields')
|
|
120
|
+
for (const [k, e] of Object.entries(entry.fields)) {
|
|
121
|
+
if (!isEnv(e)) throw new SealedError('bad-entry', `field «${k}» is not a sealed envelope`)
|
|
122
|
+
}
|
|
123
|
+
if (!Array.isArray(entry.idx)) throw new SealedError('bad-entry', 'the entry has no site index')
|
|
124
|
+
const llevaPasskey = !!entry.fields[PASSKEY_FIELD]
|
|
125
|
+
// Una passkey en el sobre de las contraseñas la abriría un aparato sin el permiso, que
|
|
126
|
+
// es exactamente lo que §2.8 separa. Y al revés: una generación de passkeys sin passkey
|
|
127
|
+
// reparte una llave que nadie va a usar.
|
|
128
|
+
if (llevaPasskey && !tienePasskeys) {
|
|
129
|
+
throw new SealedError('passkeys-generation-missing',
|
|
130
|
+
'this entry carries a passkey: it needs its own wraps, only for devices with `passkeys`')
|
|
131
|
+
}
|
|
132
|
+
if (!llevaPasskey && tienePasskeys) {
|
|
133
|
+
throw new SealedError('passkeys-generation-unused', 'there is no passkey in this entry to wrap')
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* GUARDAR. Llega todo cerrado: la bóveda no ve ni un valor, tampoco al escribir.
|
|
139
|
+
*
|
|
140
|
+
* La GENERACIÓN la pone el almacén y no quien escribe —igual que en los cajones—: es lo
|
|
141
|
+
* que ordena el llavero, y dejar elegirla permitiría pisar una anterior.
|
|
142
|
+
*
|
|
143
|
+
* @param {object} o
|
|
144
|
+
* `entry` la entrada de `sealEntry`, con los sobres SIN `gen`
|
|
145
|
+
* `main` `{ wraps }` de la llave con la que se cerró todo menos la passkey
|
|
146
|
+
* `passkeys` `{ wraps }` de la llave de la passkey, si la entrada lleva una
|
|
147
|
+
* `by` el aparato que escribe (para la bitácora; quién PUEDE lo decide quien llama)
|
|
148
|
+
*/
|
|
149
|
+
async putSealed ({ entry, main, passkeys = null, by = null } = {}) {
|
|
150
|
+
this.#checkEntry(entry, !!passkeys)
|
|
151
|
+
await this.#checkWraps('main', main?.wraps)
|
|
152
|
+
if (passkeys) await this.#checkWraps('passkeys', passkeys.wraps)
|
|
153
|
+
// LA FIRMA DEL AUTOR, sobre lo que de verdad se guarda. Es lo que ata cada entrada a un
|
|
154
|
+
// aparato del acta: la bóveda no ve los valores, así que esto es lo único que puede
|
|
155
|
+
// comprobar sobre su procedencia.
|
|
156
|
+
if (!entry.author?.pub || !entry.author?.sig) throw new SealedError('unsigned', 'the entry is not signed by its author')
|
|
157
|
+
const ok = await this.verifyAuthor({ body: entryAuthorBody(entry, entry.author.ts), author: entry.author })
|
|
158
|
+
if (!ok) throw new SealedError('bad-author', 'that entry was not signed by a device allowed to write passwords')
|
|
159
|
+
|
|
160
|
+
const s = await this.#state()
|
|
161
|
+
const t = this.now()
|
|
162
|
+
const siguiente = () => (s.keyring.reduce((max, g) => Math.max(max, g.gen || 0), 0) + 1)
|
|
163
|
+
|
|
164
|
+
const genMain = siguiente()
|
|
165
|
+
s.keyring.push({ gen: genMain, kind: 'main', createdAt: t, wraps: main.wraps })
|
|
166
|
+
let genPk = null
|
|
167
|
+
if (passkeys) {
|
|
168
|
+
genPk = siguiente()
|
|
169
|
+
s.keyring.push({ gen: genPk, kind: 'passkeys', createdAt: t, wraps: passkeys.wraps })
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const fields = {}
|
|
173
|
+
for (const [k, e] of Object.entries(entry.fields)) {
|
|
174
|
+
fields[k] = { ...e, gen: k === PASSKEY_FIELD ? genPk : genMain }
|
|
175
|
+
}
|
|
176
|
+
const guardada = {
|
|
177
|
+
id: entry.id,
|
|
178
|
+
createdAt: entry.createdAt || t,
|
|
179
|
+
updatedAt: t,
|
|
180
|
+
by: by || entry.author.pub,
|
|
181
|
+
author: entry.author,
|
|
182
|
+
idx: [...entry.idx],
|
|
183
|
+
wa: entry.wa || null,
|
|
184
|
+
view: { ...entry.view, gen: genMain },
|
|
185
|
+
fields,
|
|
186
|
+
digests: { ...(entry.digests || {}) },
|
|
187
|
+
priv: [...(entry.priv || [])]
|
|
188
|
+
}
|
|
189
|
+
const i = s.entries.findIndex((e) => e.id === guardada.id)
|
|
190
|
+
if (i >= 0) { guardada.createdAt = s.entries[i].createdAt || guardada.createdAt; s.entries[i] = guardada }
|
|
191
|
+
else s.entries.push(guardada)
|
|
192
|
+
|
|
193
|
+
this.#gc(s)
|
|
194
|
+
await this.#save(s)
|
|
195
|
+
return { id: guardada.id, gen: genMain, updatedAt: guardada.updatedAt }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* CAMBIAR UNOS CAMPOS SIN LEER LA ENTRADA (§2.4).
|
|
200
|
+
*
|
|
201
|
+
* Es la razón de que exista `patch` y no se haya caído con el formato nuevo: antes, para
|
|
202
|
+
* escribir un teléfono había que sacar la entrada ENTERA de la bóveda —contraseña
|
|
203
|
+
* incluida—, y si esa lectura fallaba, lo que se escribía detrás perdía lo que no se pudo
|
|
204
|
+
* leer. Perder datos por una autorización denegada no es un fallo raro.
|
|
205
|
+
*
|
|
206
|
+
* Aquí los campos que no vienen **se quedan con su sobre y su generación**: ni se abren
|
|
207
|
+
* ni se reescriben. Lo único que hay que rehacer siempre es la VISTA, porque dice qué
|
|
208
|
+
* campos lleva la entrada — y eso lo sabe quien la cambia, sin abrir ningún valor.
|
|
209
|
+
*
|
|
210
|
+
* @param {object} o
|
|
211
|
+
* `id` la entrada
|
|
212
|
+
* `view` la vista nueva (sobre), obligatoria: cambia con cualquier campo
|
|
213
|
+
* `fields` solo los campos que cambian `{ clave: sobre }`
|
|
214
|
+
* `drop` las claves que se van
|
|
215
|
+
* `digests` los resúmenes de lo que cambió (los de `drop` se quitan)
|
|
216
|
+
* `idx`/`wa` si cambian los sitios o la passkey
|
|
217
|
+
* `main` / `passkeys` `{ wraps }` de las llaves nuevas
|
|
218
|
+
*/
|
|
219
|
+
async patchSealed ({ id, view, fields = {}, drop = [], digests = {}, priv = null, idx = null, wa, main, passkeys = null, author = null, by = null } = {}) {
|
|
220
|
+
if (!isEnv(view)) throw new SealedError('bad-entry', 'a patch has to bring the public view again')
|
|
221
|
+
for (const [k, e] of Object.entries(fields)) {
|
|
222
|
+
if (!isEnv(e)) throw new SealedError('bad-entry', `field «${k}» is not a sealed envelope`)
|
|
223
|
+
}
|
|
224
|
+
await this.#checkWraps('main', main?.wraps)
|
|
225
|
+
if (passkeys) await this.#checkWraps('passkeys', passkeys.wraps)
|
|
226
|
+
if (fields[PASSKEY_FIELD] && !passkeys) {
|
|
227
|
+
throw new SealedError('passkeys-generation-missing',
|
|
228
|
+
'that patch carries a passkey: it needs its own wraps, only for devices with `passkeys`')
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const s = await this.#state()
|
|
232
|
+
const e = s.entries.find((x) => x.id === id)
|
|
233
|
+
if (!e) throw new SealedError('not-found', 'there is no entry with that id')
|
|
234
|
+
|
|
235
|
+
if (!author?.pub || !author?.sig) throw new SealedError('unsigned', 'the patch is not signed by its author')
|
|
236
|
+
const body = patchAuthorBody({ id, view, fields, drop, digests, priv, idx, wa }, author.ts)
|
|
237
|
+
if (!(await this.verifyAuthor({ body, author }))) {
|
|
238
|
+
throw new SealedError('bad-author', 'that patch was not signed by a device allowed to write passwords')
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const t = this.now()
|
|
242
|
+
const siguiente = () => (s.keyring.reduce((max, g) => Math.max(max, g.gen || 0), 0) + 1)
|
|
243
|
+
const genMain = siguiente()
|
|
244
|
+
s.keyring.push({ gen: genMain, kind: 'main', createdAt: t, wraps: main.wraps })
|
|
245
|
+
let genPk = null
|
|
246
|
+
if (passkeys) {
|
|
247
|
+
genPk = siguiente()
|
|
248
|
+
s.keyring.push({ gen: genPk, kind: 'passkeys', createdAt: t, wraps: passkeys.wraps })
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
for (const k of drop) { delete e.fields[k]; delete e.digests[k] }
|
|
252
|
+
for (const [k, env] of Object.entries(fields)) {
|
|
253
|
+
e.fields[k] = { ...env, gen: k === PASSKEY_FIELD ? genPk : genMain }
|
|
254
|
+
}
|
|
255
|
+
for (const [k, d] of Object.entries(digests)) e.digests[k] = d
|
|
256
|
+
e.view = { ...view, gen: genMain }
|
|
257
|
+
if (Array.isArray(priv)) e.priv = [...priv]
|
|
258
|
+
if (Array.isArray(idx)) e.idx = [...idx]
|
|
259
|
+
if (wa !== undefined) e.wa = wa
|
|
260
|
+
e.updatedAt = t
|
|
261
|
+
e.by = by || author.pub
|
|
262
|
+
e.author = author
|
|
263
|
+
|
|
264
|
+
this.#gc(s)
|
|
265
|
+
await this.#save(s)
|
|
266
|
+
return { id: e.id, gen: genMain, updatedAt: e.updatedAt }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* LAS VISTAS que este aparato puede abrir. Es lo que sustituye a `list`, `search` y
|
|
271
|
+
* `sites`: la bóveda ya no puede buscar por texto —no ve ninguno— así que entrega las
|
|
272
|
+
* vistas selladas y busca quien las abre (§2.2).
|
|
273
|
+
*
|
|
274
|
+
* **Vistas, no valores**: de aquí no sale ninguna contraseña. Y solo las que este aparato
|
|
275
|
+
* puede abrir: a quien lleva una selección de entradas no se le dice ni que existen las
|
|
276
|
+
* demás (§2.6).
|
|
277
|
+
*/
|
|
278
|
+
async views ({ pub } = {}) {
|
|
279
|
+
const s = await this.#state()
|
|
280
|
+
const out = []
|
|
281
|
+
for (const e of s.entries) {
|
|
282
|
+
const wrap = this.#wrapFor(s, e.view.gen, pub)
|
|
283
|
+
if (!wrap) continue
|
|
284
|
+
out.push({ id: e.id, updatedAt: e.updatedAt, idx: e.idx, wa: e.wa, view: e.view, wrap, digests: e.digests, author: e.author })
|
|
285
|
+
}
|
|
286
|
+
return out
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* QUÉ HAY PARA ESTE SITIO. `idx` son las huellas que calculó el aparato con la llave del
|
|
291
|
+
* perfil: la bóveda compara cadenas, sin saber de qué sitio se habla.
|
|
292
|
+
*
|
|
293
|
+
* `anySite` trae además las entradas SIN sitios, que son las que sirven en cualquier
|
|
294
|
+
* parte — es lo que ha significado siempre no tener sitios.
|
|
295
|
+
*/
|
|
296
|
+
async find ({ pub, idx = [], anySite = true } = {}) {
|
|
297
|
+
const busca = new Set(idx)
|
|
298
|
+
return (await this.views({ pub })).filter((v) =>
|
|
299
|
+
v.idx.some((h) => busca.has(h)) || (anySite && v.idx.length === 0))
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Lo mismo para una passkey: por el sitio que la pide o por el id de la credencial. */
|
|
303
|
+
async findPasskey ({ pub, rp = null, cred = null } = {}) {
|
|
304
|
+
return (await this.views({ pub })).filter((v) =>
|
|
305
|
+
v.wa && ((rp && v.wa.rp === rp) || (cred && v.wa.cred === cred)))
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* LOS SOBRES DE LOS CAMPOS PEDIDOS, con la envoltura de quien pregunta.
|
|
310
|
+
*
|
|
311
|
+
* Lo que este aparato no puede abrir no se manda y **se dice** (`withheld`): una passkey
|
|
312
|
+
* para un aparato sin el permiso se queda aquí, y la pantalla lo explica en vez de
|
|
313
|
+
* enseñar un hueco.
|
|
314
|
+
*/
|
|
315
|
+
async get (id, { pub, keys = null } = {}) {
|
|
316
|
+
const s = await this.#state()
|
|
317
|
+
const e = s.entries.find((x) => x.id === id)
|
|
318
|
+
if (!e) throw new SealedError('not-found', 'there is no entry with that id')
|
|
319
|
+
const quiere = Array.isArray(keys) ? new Set(keys) : null
|
|
320
|
+
const envelopes = {}
|
|
321
|
+
const wraps = {}
|
|
322
|
+
const withheld = []
|
|
323
|
+
const vista = this.#wrapFor(s, e.view.gen, pub)
|
|
324
|
+
if (!vista) throw new SealedError('not-yours', 'this device has no key for that entry')
|
|
325
|
+
for (const [k, env] of Object.entries(e.fields)) {
|
|
326
|
+
if (quiere && !quiere.has(k)) continue
|
|
327
|
+
const w = this.#wrapFor(s, env.gen, pub)
|
|
328
|
+
if (!w) { withheld.push(k); continue }
|
|
329
|
+
envelopes[k] = env
|
|
330
|
+
wraps[env.gen] = w
|
|
331
|
+
}
|
|
332
|
+
wraps[e.view.gen] = vista
|
|
333
|
+
return { id: e.id, view: e.view, envelopes, wraps, withheld, updatedAt: e.updatedAt, digests: e.digests }
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* QUÉ CLAVES DE ESA ENTRADA SON PRIVADAS. Es lo único de la vista que la bóveda ve, y lo
|
|
338
|
+
* ve porque la aprobación es suya: sin esto no podría distinguir «rellena mi nombre» de
|
|
339
|
+
* «dame la contraseña» (ver la nota en `entry.js`).
|
|
340
|
+
*/
|
|
341
|
+
async privateKeysOf (id) {
|
|
342
|
+
const s = await this.#state()
|
|
343
|
+
return s.entries.find((e) => e.id === id)?.priv || []
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async remove (id) {
|
|
347
|
+
const s = await this.#state()
|
|
348
|
+
const antes = s.entries.length
|
|
349
|
+
s.entries = s.entries.filter((e) => e.id !== id)
|
|
350
|
+
if (s.entries.length === antes) return { ok: false }
|
|
351
|
+
this.#gc(s)
|
|
352
|
+
await this.#save(s)
|
|
353
|
+
return { ok: true }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* LA LLAVE DEL PERFIL (de la que salen el índice y los resúmenes), sellada como un valor
|
|
358
|
+
* más. Se escribe una vez y se vuelve a repartir cuando cambia quién puede leer.
|
|
359
|
+
*/
|
|
360
|
+
async profile ({ pub } = {}) {
|
|
361
|
+
const s = await this.#state()
|
|
362
|
+
if (!s.profile) throw new SealedError('not-sealed', 'this vault has no sealed password key yet: open it to convert')
|
|
363
|
+
const wrap = this.#wrapFor(s, s.profile.gen, pub)
|
|
364
|
+
if (!wrap) throw new SealedError('not-yours', 'this device has no key for the profile password key')
|
|
365
|
+
return { envelope: s.profile, wrap }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async setProfile ({ envelope, wraps } = {}) {
|
|
369
|
+
if (!isEnv(envelope)) throw new SealedError('bad-entry', 'the profile key is not a sealed envelope')
|
|
370
|
+
await this.#checkWraps('main', wraps)
|
|
371
|
+
const s = await this.#state()
|
|
372
|
+
const gen = s.keyring.reduce((max, g) => Math.max(max, g.gen || 0), 0) + 1
|
|
373
|
+
s.keyring.push({ gen, kind: 'main', createdAt: this.now(), wraps })
|
|
374
|
+
s.profile = { ...envelope, gen }
|
|
375
|
+
this.#gc(s)
|
|
376
|
+
await this.#save(s)
|
|
377
|
+
return { gen }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* LAS GENERACIONES QUE YA NO ABRE NADA se van. Cada escritura estrena una, así que sin
|
|
382
|
+
* este barrido el llavero crece para siempre (§2.4).
|
|
383
|
+
*/
|
|
384
|
+
#gc (s) {
|
|
385
|
+
const vivas = new Set()
|
|
386
|
+
if (s.profile?.gen) vivas.add(s.profile.gen)
|
|
387
|
+
for (const e of s.entries) {
|
|
388
|
+
vivas.add(e.view.gen)
|
|
389
|
+
for (const env of Object.values(e.fields)) vivas.add(env.gen)
|
|
390
|
+
}
|
|
391
|
+
s.keyring = s.keyring.filter((g) => vivas.has(g.gen))
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* PAGAR LA DEUDA: reenvolver cada generación para exactamente quien dice el acta AHORA.
|
|
396
|
+
*
|
|
397
|
+
* Lo hace quien puede abrir la copia de RECUPERACIÓN, o sea la bóveda con la frase del
|
|
398
|
+
* perfil delante — al abrirla, que es cuando la tiene (§2.5). Sin esto, un aparato que
|
|
399
|
+
* entra después de escrita una entrada no puede abrirla nunca: nadie más tiene con qué
|
|
400
|
+
* envolvérsela.
|
|
401
|
+
*
|
|
402
|
+
* Y de paso se va lo que sobra: la envoltura de quien ya no está en la lista. Ni se
|
|
403
|
+
* descifra ni se reescribe ningún valor — solo cambian las envolturas de las llaves.
|
|
404
|
+
*
|
|
405
|
+
* **O lo hace un MIEMBRO que ya tiene su envoltura** (`holder`). Es el caso de la bóveda de
|
|
406
|
+
* dentro de la extensión: ella misma está entre los destinatarios de todo, así que puede
|
|
407
|
+
* completar a un aparato que entró después sin pedirle a nadie la frase. No regala nada:
|
|
408
|
+
* quien hace esto ya podía leerlo, y a quien se lo entrega lo reconoce el acta. La llave
|
|
409
|
+
* tampoco sale de él — `rewrapFor` es de `@dotrino/identity`, que abre y vuelve a
|
|
410
|
+
* envolver sin devolverla.
|
|
411
|
+
*
|
|
412
|
+
* @param {object} o hace falta uno de los dos, y si están los dos manda `holder`
|
|
413
|
+
* @param {(wrap:object) => Promise<string>} [o.openRecovery] abre la envoltura `#recovery`
|
|
414
|
+
* @param {{ pub:string, rewrapFor:({wrap, encPub}) => Promise<object> }} [o.holder]
|
|
415
|
+
* un miembro con envoltura propia: su pública y cómo re-envuelve la llave que ya abre
|
|
416
|
+
*/
|
|
417
|
+
async rewrapAll ({ openRecovery, holder } = {}) {
|
|
418
|
+
const conHolder = !!holder?.pub && typeof holder.rewrapFor === 'function'
|
|
419
|
+
if (typeof openRecovery !== 'function' && !conHolder) {
|
|
420
|
+
throw new Error('rewrapAll: missing openRecovery(wrap) or holder { pub, rewrapFor }')
|
|
421
|
+
}
|
|
422
|
+
const s = await this.#state()
|
|
423
|
+
const out = { generations: 0, wrapped: 0, dropped: 0, failed: [] }
|
|
424
|
+
for (const g of s.keyring) {
|
|
425
|
+
const kind = g.kind || 'main'
|
|
426
|
+
const deben = (await this.recipients(kind)).filter((m) => m?.encPub)
|
|
427
|
+
const antes = Object.keys(g.wraps).filter((k) => k !== RECOVERY)
|
|
428
|
+
const faltan = deben.filter((m) => !g.wraps[m.pub])
|
|
429
|
+
const sobran = antes.filter((p) => !deben.some((m) => m.pub === p))
|
|
430
|
+
if (!faltan.length && !sobran.length) continue
|
|
431
|
+
try {
|
|
432
|
+
if (faltan.length) {
|
|
433
|
+
const suya = conHolder ? g.wraps[holder.pub] : null
|
|
434
|
+
if (suya) {
|
|
435
|
+
for (const m of faltan) { g.wraps[m.pub] = await holder.rewrapFor({ wrap: suya, encPub: m.encPub }); out.wrapped++ }
|
|
436
|
+
} else if (typeof openRecovery === 'function') {
|
|
437
|
+
const cek = await openRecovery(g.wraps[RECOVERY])
|
|
438
|
+
for (const m of faltan) { g.wraps[m.pub] = await wrapForMember({ cek, memberEncPub: m.encPub }); out.wrapped++ }
|
|
439
|
+
} else {
|
|
440
|
+
// Ni envoltura propia en esta generación ni la frase: no hay con qué, y se dice.
|
|
441
|
+
throw new SealedError('no-opener', 'this generation is not wrapped for the holder and no recovery was given')
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
for (const p of sobran) { delete g.wraps[p]; out.dropped++ }
|
|
445
|
+
out.generations++
|
|
446
|
+
} catch (e) {
|
|
447
|
+
out.failed.push({ gen: g.gen, error: e?.message || String(e) })
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (out.wrapped || out.dropped) await this.#save(s)
|
|
451
|
+
return out
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* LA COPIA DE RECUPERACIÓN LLEGA DESPUÉS. Lo guardado antes de crearla solo lo abren los
|
|
456
|
+
* aparatos del acta; esto le añade a cada generación la envoltura `#recovery`, con la
|
|
457
|
+
* llave de un miembro que ya la abre (`holder`, como en `rewrapAll`). La llave no sale
|
|
458
|
+
* de él: `rewrapFor` abre y vuelve a envolver sin devolverla.
|
|
459
|
+
*
|
|
460
|
+
* Lo que no se pudo envolver se devuelve en `failed`, no se calla.
|
|
461
|
+
*/
|
|
462
|
+
async addRecovery ({ recoveryPub, holder } = {}) {
|
|
463
|
+
if (!recoveryPub) throw new Error('addRecovery: missing recoveryPub')
|
|
464
|
+
if (!holder?.pub || typeof holder.rewrapFor !== 'function') throw new Error('addRecovery: missing holder { pub, rewrapFor }')
|
|
465
|
+
const s = await this.#state()
|
|
466
|
+
const out = { wrapped: 0, failed: [] }
|
|
467
|
+
for (const g of s.keyring) {
|
|
468
|
+
if (g.wraps?.[RECOVERY]) continue
|
|
469
|
+
const suya = g.wraps?.[holder.pub]
|
|
470
|
+
if (!suya) { out.failed.push({ gen: g.gen, error: 'this generation is not wrapped for the holder' }); continue }
|
|
471
|
+
try {
|
|
472
|
+
g.wraps[RECOVERY] = await holder.rewrapFor({ wrap: suya, encPub: recoveryPub })
|
|
473
|
+
out.wrapped++
|
|
474
|
+
} catch (e) {
|
|
475
|
+
out.failed.push({ gen: g.gen, error: e?.message || String(e) })
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (out.wrapped) await this.#save(s)
|
|
479
|
+
return out
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* QUIÉN SE QUEDÓ SIN ENVOLTURA, para que la consola lo diga en vez de que un aparato se
|
|
484
|
+
* encuentre con que no puede abrir nada (§2.5). No hace falta ninguna llave: se mira
|
|
485
|
+
* quién debería estar en cada generación y quién está.
|
|
486
|
+
*/
|
|
487
|
+
async incompleteMembers () {
|
|
488
|
+
const s = await this.#state()
|
|
489
|
+
const faltan = new Map()
|
|
490
|
+
for (const kind of ['main', 'passkeys']) {
|
|
491
|
+
const deben = (await this.recipients(kind)).filter((m) => m?.encPub).map((m) => m.pub)
|
|
492
|
+
for (const g of s.keyring.filter((x) => (x.kind || 'main') === kind)) {
|
|
493
|
+
for (const pub of deben) {
|
|
494
|
+
if (!g.wraps?.[pub]) faltan.set(pub, (faltan.get(pub) || 0) + 1)
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return [...faltan].map(([pub, generations]) => ({ pub, generations }))
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Cuántas entradas y cuántas generaciones. Diagnóstico: no abre nada. */
|
|
502
|
+
async stats () {
|
|
503
|
+
const s = await this.#state()
|
|
504
|
+
return { entries: s.entries.length, generations: s.keyring.length, sealed: !!s.profile }
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export default { SealedStore, SealedError, KEY, RECOVERY }
|