@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
package/vault/facade.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LA IDENTIDAD COMO OBJETO, sobre un core ya creado (`createIdentityCore`).
|
|
3
|
+
*
|
|
4
|
+
* Es la misma API que usa el binario (`@dotrino/identity/node`, que la extiende creando el
|
|
5
|
+
* core sobre archivos) y la que necesita la bóveda del navegador: allí el core ya lo creó el
|
|
6
|
+
* iframe o la extensión, y la bóveda lo envuelve con esto. Una sola envoltura, para que la
|
|
7
|
+
* bóveda llame a lo mismo en las tres versiones.
|
|
8
|
+
*/
|
|
9
|
+
export class IdentityFacade {
|
|
10
|
+
/** @param {object} [core] el resultado de `createIdentityCore` */
|
|
11
|
+
constructor (core = null) {
|
|
12
|
+
this._core = core
|
|
13
|
+
this._listeners = new Map()
|
|
14
|
+
if (core) this._wire()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Engancha los eventos del core a `on()`. Lo llama quien pone el core. */
|
|
18
|
+
_wire () {
|
|
19
|
+
this._core.onSyncStatus?.((payload) => this._emit('sync', payload))
|
|
20
|
+
this._core.onVaultEvent?.((payload) => this._emit('vault', payload))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
destroy () { this._core = null }
|
|
24
|
+
|
|
25
|
+
get me () { return this._core?.me || null }
|
|
26
|
+
|
|
27
|
+
// ----- candado de la maestra -----
|
|
28
|
+
/** `true` si la maestra está sellada: esta identidad NO puede firmar nada. */
|
|
29
|
+
get masterLocked () { return !!this._core?.masterLocked }
|
|
30
|
+
/** Sella la maestra que ya existe (se llama al abrir el perfil, con la llave en la mano). */
|
|
31
|
+
sealMasterKey () { return this._core?.sealMasterKey?.() }
|
|
32
|
+
/** Vuelve a cargar el par tras abrir el candado, sin reabrir la identidad. */
|
|
33
|
+
reloadMasterKey () { return this._core?.reloadMasterKey?.() }
|
|
34
|
+
/** Ver `core.js`: al abrir, los aparatos muertos salen del acta en una sola. */
|
|
35
|
+
pruneExpiredDevices (now) { return this._core?.pruneExpiredDevices?.(now) }
|
|
36
|
+
|
|
37
|
+
_h (method, params = {}) {
|
|
38
|
+
if (!this._core) throw new Error('Identity not ready — call ready()/connect() first')
|
|
39
|
+
return this._core.handlers[method](params)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ----- API pública (espeja src/index.js) -----
|
|
43
|
+
|
|
44
|
+
makeChallenge () { return this._h('makeChallenge') }
|
|
45
|
+
signChallenge (nonce) { return this._h('signChallenge', { nonce }) }
|
|
46
|
+
verifyResponse ({ nonce, publickey, signature, encryptionPubkey }) {
|
|
47
|
+
return this._h('verifyResponse', { nonce, publickey, signature, encryptionPubkey })
|
|
48
|
+
}
|
|
49
|
+
getPeer (publickey) { return this._h('getPeer', { publickey }) }
|
|
50
|
+
setNickname (publickey, nickname) { return this._h('setNickname', { publickey, nickname }) }
|
|
51
|
+
setBlocked (publickey, blocked) { return this._h('setBlocked', { publickey, blocked }) }
|
|
52
|
+
setRating (publickey, rating, notes) { return this._h('setRating', { publickey, rating, notes }) }
|
|
53
|
+
listPeers () { return this._h('listPeers') }
|
|
54
|
+
forgetPeer (publickey) { return this._h('forgetPeer', { publickey }) }
|
|
55
|
+
addContact ({ publickey, nickname, encryptionPubkey, lastToken, notes } = {}) {
|
|
56
|
+
return this._h('addContact', { publickey, nickname, encryptionPubkey, lastToken, notes })
|
|
57
|
+
}
|
|
58
|
+
updateContact (publickey, patch) { return this._h('updateContact', { publickey, patch }) }
|
|
59
|
+
removeContact (publickey) { return this._h('removeContact', { publickey }) }
|
|
60
|
+
listContacts () { return this._h('listContacts') }
|
|
61
|
+
signData (data) { return this._h('signData', { data }) }
|
|
62
|
+
/**
|
|
63
|
+
* Prueba firmada con destinatario y vigencia (ver `vault/assertion.js`). Un servicio en
|
|
64
|
+
* Node la pide igual que una app: `{ audience, nonce, scopes?, ttlMs? }`.
|
|
65
|
+
*/
|
|
66
|
+
async requestAssertion ({ audience, nonce, scopes, ttlMs } = {}) {
|
|
67
|
+
const { assertion } = await this._h('requestAssertion', { audience, nonce, scopes, ttlMs })
|
|
68
|
+
return assertion
|
|
69
|
+
}
|
|
70
|
+
// Delegación de capacidad (sub-clave de dispositivo con scope/exp/revocación)
|
|
71
|
+
signDelegation (sub, scope, opts = {}) { return this._h('signDelegation', { sub, scope, ...opts }) }
|
|
72
|
+
revokeDelegation (nonce) { return this._h('revokeDelegation', { nonce }) }
|
|
73
|
+
/** Quitar el DISPOSITIVO: retira todos sus certificados vigentes, no solo uno. */
|
|
74
|
+
revokeDevice (sub) { return this._h('revokeDevice', { sub }) }
|
|
75
|
+
listDelegations () { return this._h('listDelegations') }
|
|
76
|
+
// Acta de perfil (qué llaves son del perfil y qué puede cada una; ver acta-de-perfil.md)
|
|
77
|
+
profileActa () { return this._h('profileActa') }
|
|
78
|
+
/** La cadena de selladores, para mandarla con una firma (ver `vault/acta.js`). */
|
|
79
|
+
sealerChain () { return this._h('sealerChain') }
|
|
80
|
+
profileMembers () { return this._h('profileMembers') }
|
|
81
|
+
/**
|
|
82
|
+
* Quien SELLA SOBRES (la bóveda) registra aquí cómo estrenar su llave de sellado: se
|
|
83
|
+
* llama al sellar cada acta, porque esa llave rota con el acta (§8.9 de
|
|
84
|
+
* `dotrino-vault/docs/secretos-sellados.md`). Va por aquí y no por el iframe porque
|
|
85
|
+
* este puente es en-proceso: al navegador no se le puede pasar una función, y tampoco
|
|
86
|
+
* la necesita — no sella secretos de nadie.
|
|
87
|
+
*/
|
|
88
|
+
/** Abre un sobre sellado A ESTE aparato (envoltura + sobre). Ver `core.js`. */
|
|
89
|
+
openSealedValue (wrap, envelope) { return this._h('openSealedValue', { wrap, envelope }) }
|
|
90
|
+
/** Re-envuelve para otro miembro una llave que este aparato ya puede abrir. */
|
|
91
|
+
rewrapFor (wrap, encPub) { return this._h('rewrapFor', { wrap, encPub }) }
|
|
92
|
+
setSealKeyProvider (provider) { return this._h('setSealKeyProvider', { provider }) }
|
|
93
|
+
/** Estrena la llave de sellado en un acta nueva, sin tocar a ningún miembro. */
|
|
94
|
+
rotateSealKey () { return this._h('rotateSealKey') }
|
|
95
|
+
/** Con qué llave se comprueban los sobres que se firmaron con el acta `seq`. */
|
|
96
|
+
sealKeyAt (seq) { return this._h('sealKeyAt', { seq }) }
|
|
97
|
+
myMembership () { return this._h('myMembership') }
|
|
98
|
+
/**
|
|
99
|
+
* La CADENA de actas desde `sinceSeq` (para que un dispositivo que estuvo apagado
|
|
100
|
+
* compruebe el encadenamiento en vez de tragarse un salto a ciegas).
|
|
101
|
+
*
|
|
102
|
+
* Vivía en el núcleo pero NO estaba aquí, así que `identity.actaHistory` era
|
|
103
|
+
* `undefined` en el daemon: al pedir la lista de dispositivos con un `sinceSeq`
|
|
104
|
+
* —que es lo que manda SIEMPRE un navegador ya emparejado— reventaba con
|
|
105
|
+
* «is not a function», la bóveda respondía error, y el aparato NUNCA volvía a
|
|
106
|
+
* sincronizar su acta. Se quedaba con la copia del día que emparejó.
|
|
107
|
+
*/
|
|
108
|
+
actaHistory (opts) { return this._h('actaHistory', opts || {}) }
|
|
109
|
+
isMaster () { return this._h('isMaster') }
|
|
110
|
+
admitMember (member) { return this._h('admitMember', member) }
|
|
111
|
+
/** Registra la llave de cifrado de un miembro ya admitido (evita re-enrolarlo). */
|
|
112
|
+
setMemberEncPub (args) { return this._h('setMemberEncPub', args) }
|
|
113
|
+
setCaps (pub, caps) { return this._h('setCaps', { pub, caps }) }
|
|
114
|
+
setCapsMany (changes) { return this._h('setCapsMany', { changes }) }
|
|
115
|
+
setLabel (pub, label) { return this._h('setLabel', { pub, label }) }
|
|
116
|
+
removeMember (pub) { return this._h('removeMember', { pub }) }
|
|
117
|
+
handoverMaster (to, member = null) { return this._h('handoverMaster', { to, member }) }
|
|
118
|
+
renounceCaps (caps) { return this._h('renounceCaps', { caps }) }
|
|
119
|
+
absorbRenounce (record) { return this._h('absorbRenounce', { record }) }
|
|
120
|
+
adoptActa (acta) { return this._h('adoptActa', { acta }) }
|
|
121
|
+
/** Une ESTE perfil a la cuenta de otro (solo si nació para eso: `createProfile(n, { forVault: true })`). */
|
|
122
|
+
joinProfile (acta) { return this._h('joinProfile', { acta }) }
|
|
123
|
+
/** Marca este perfil como nacido para ADOPTAR la cuenta de otro (camino A: lo usa la bóveda). */
|
|
124
|
+
prepareForAdoption () { return this._h('prepareForAdoption') }
|
|
125
|
+
/** MI tarjeta de perfil: lo mínimo que un contacto necesita para cifrarme a todos mis
|
|
126
|
+
* dispositivos (perfil, versión y llaves). Sin etiquetas ni permisos. */
|
|
127
|
+
profileCard () { return this._h('profileCard') }
|
|
128
|
+
/** Guarda la tarjeta de otra persona en su ficha de contacto (verificándola). */
|
|
129
|
+
adoptPeerCard (card) { return this._h('adoptPeerCard', { card }) }
|
|
130
|
+
/** La clave de contenido del perfil, abierta con la llave de cifrado de este dispositivo. */
|
|
131
|
+
contentKey () { return this._h('contentKey') }
|
|
132
|
+
/** Cifra con la clave de contenido del perfil (la privada de cifrado no sale del vault). */
|
|
133
|
+
sealContent (plaintext) { return this._h('sealContent', { plaintext }) }
|
|
134
|
+
/** Abre un sobre de contenido con el llavero del perfil. */
|
|
135
|
+
openContent (envelope) { return this._h('openContent', { envelope }) }
|
|
136
|
+
/** Rota la clave de contenido (corta el acceso al contenido FUTURO de quien ya no está). */
|
|
137
|
+
rotateContentKey () { return this._h('rotateContentKey') }
|
|
138
|
+
// Emparejar ESTE dispositivo con el vault del usuario (Fase 1)
|
|
139
|
+
enrollDevice (qr, { label = '', join = 'current' } = {}) { return this._h('vaultPair', { qr, label, join }) }
|
|
140
|
+
vaultStatus () { return this._h('vaultStatus') }
|
|
141
|
+
unpairDevice () { return this._h('vaultUnpair') }
|
|
142
|
+
vaultSign (payload) { return this._h('vaultSign', { payload }) }
|
|
143
|
+
vaultStore (method, args) { return this._h('vaultStore', { method, args }) }
|
|
144
|
+
listVaultDevices () { return this._h('listVaultDevices') }
|
|
145
|
+
getVaultCert () { return this._h('getVaultCert') }
|
|
146
|
+
vaultApprovals (op, args) { return this._h('vaultApprovals', { op, ...(args || {}) }) }
|
|
147
|
+
/** Los pedidos de TODAS las cuentas que aprueban, sin cambiar la activa. */
|
|
148
|
+
vaultApprovalsAll (args) { return this._h('vaultApprovalsAll', args || {}) }
|
|
149
|
+
onVault (handler) { return this.on('vault', handler) }
|
|
150
|
+
// Multi-perfil por dispositivo (crear/cambiar reinicializa con el nuevo perfil activo).
|
|
151
|
+
listProfiles () { return this._h('listProfiles') }
|
|
152
|
+
currentProfile () { return this._h('currentProfile') }
|
|
153
|
+
createProfile (name, { forVault = false } = {}) { return this._h('createProfile', { name, forVault }) }
|
|
154
|
+
switchProfile (id) { return this._h('switchProfile', { id }) }
|
|
155
|
+
renameProfile (id, name) { return this._h('renameProfile', { id, name }) }
|
|
156
|
+
deleteProfile (id) { return this._h('deleteProfile', { id }) }
|
|
157
|
+
mergeEndorsements (subject, endorsements, askerPubkey) {
|
|
158
|
+
return this._h('mergeEndorsements', { subject, endorsements, askerPubkey })
|
|
159
|
+
}
|
|
160
|
+
getRatingsForSubject (subject) { return this._h('getRatingsForSubject', { subject }) }
|
|
161
|
+
recordQuery (askerPubkey, subject) { return this._h('recordQuery', { askerPubkey, subject }) }
|
|
162
|
+
async setMyNickname (nickname) {
|
|
163
|
+
const result = await this._h('setMyNickname', { nickname })
|
|
164
|
+
return result
|
|
165
|
+
}
|
|
166
|
+
async updateMe (patch) { return this._h('updateMe', { patch }) }
|
|
167
|
+
getMe () { return this._h('getMe') }
|
|
168
|
+
publicMe () { return this._h('publicMe') }
|
|
169
|
+
/** Cómo fue el último empujón del perfil a la bóveda: `{ ok, at, error }`. */
|
|
170
|
+
profilePushState () { return this._h('profilePushState') }
|
|
171
|
+
getEncryptionPubkey () { return this._h('getEncryptionPubkey') }
|
|
172
|
+
encrypt (recipients, plaintext) { return this._h('encrypt', { recipients, plaintext }) }
|
|
173
|
+
decrypt (senderEncryptionPubkey, myToken, envelope) {
|
|
174
|
+
return this._h('decrypt', { senderEncryptionPubkey, myToken, envelope })
|
|
175
|
+
}
|
|
176
|
+
exportIdentity () { return this._h('exportIdentity') }
|
|
177
|
+
async importIdentity (blob) { return this._h('importIdentity', blob || {}) }
|
|
178
|
+
|
|
179
|
+
// Sync (Google Drive) no disponible headless — los handlers responden acorde.
|
|
180
|
+
syncConnect (clientId) { return this._h('syncConnect', { clientId }) }
|
|
181
|
+
syncDisconnect () { return this._h('syncDisconnect') }
|
|
182
|
+
syncUnlock (passphrase) { return this._h('syncUnlock', { passphrase }) }
|
|
183
|
+
syncLock () { return this._h('syncLock') }
|
|
184
|
+
syncStatus () { return this._h('syncStatus') }
|
|
185
|
+
syncNow () { return this._h('syncNow') }
|
|
186
|
+
|
|
187
|
+
onSync (handler) { return this.on('sync', handler) }
|
|
188
|
+
on (event, handler) {
|
|
189
|
+
if (!this._listeners.has(event)) this._listeners.set(event, new Set())
|
|
190
|
+
this._listeners.get(event).add(handler)
|
|
191
|
+
return () => this._listeners.get(event)?.delete(handler)
|
|
192
|
+
}
|
|
193
|
+
_emit (event, payload) {
|
|
194
|
+
const set = this._listeners.get(event)
|
|
195
|
+
if (!set) return
|
|
196
|
+
for (const h of set) { try { h(payload) } catch (e) { console.error(e) } }
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export default IdentityFacade
|
package/vault/index.html
CHANGED
|
@@ -155,7 +155,17 @@
|
|
|
155
155
|
<script type="importmap">
|
|
156
156
|
{ "imports": {
|
|
157
157
|
"@dotrino/proxy-client": "./vendor/proxy-client/index.js",
|
|
158
|
+
"@dotrino/proxy-client/sealing": "./vendor/proxy-client/sealing.js",
|
|
158
159
|
"@dotrino/vault": "./vendor/vault/index.js",
|
|
160
|
+
"@dotrino/vault/browser": "./vendor/vault/browser.js",
|
|
161
|
+
"@dotrino/compat": "./vendor/compat/index.js",
|
|
162
|
+
"@dotrino/compat/ranges": "./vendor/compat/ranges.js",
|
|
163
|
+
"@dotrino/roadmap": "./vendor/roadmap/index.js",
|
|
164
|
+
"@dotrino/passmanager": "./vendor/passmanager/index.js",
|
|
165
|
+
"@dotrino/passmanager/sealed": "./vendor/passmanager/sealed/index.js",
|
|
166
|
+
"@dotrino/store/core": "./vendor/store/core.js",
|
|
167
|
+
"@dotrino/identity/facade": "./facade.js",
|
|
168
|
+
"@dotrino/identity/machine": "./machine.js",
|
|
159
169
|
"@dotrino/vault/password-logins": "./vendor/vault/passwordLogins.js",
|
|
160
170
|
"@dotrino/vault/login-client": "./vendor/vault/loginClient.js",
|
|
161
171
|
"@dotrino/opaque": "./vendor/opaque/src/index.js",
|
package/vault/machine.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QUÉ MÁQUINA ES CADA APARATO: dónde corre, y nada más.
|
|
3
|
+
*
|
|
4
|
+
* En la lista de aparatos de la bóveda solo había un ID (`AB12-CD34`), una etiqueta que pone
|
|
5
|
+
* el propio aparato —muchas veces el apodo del usuario, igual en todos— y la fecha de alta.
|
|
6
|
+
* Con tres navegadores y dos agentes no había forma de saber cuál era cuál (dueño,
|
|
7
|
+
* 2026-10-15). Esto es lo que lo distingue a simple vista:
|
|
8
|
+
*
|
|
9
|
+
* · `kind` — qué es: `node` (un agente o un comando), `browser`, `extension`, `ios`, `android`.
|
|
10
|
+
* · `os` — el sistema donde corre (`Linux 7.0.0`, `Windows`, `macOS`, `Android 14`).
|
|
11
|
+
* · `name` — en Node, el hostname; en un navegador, el navegador (`Chrome`). Un navegador no
|
|
12
|
+
* sabe el nombre de la máquina, y lo que no se sabe no se inventa.
|
|
13
|
+
*
|
|
14
|
+
* Lo DICE el aparato y va dentro de lo que firma, así que nadie más puede escribirlo por él.
|
|
15
|
+
* Tampoco es una prueba de nada: un aparato puede llamarse como quiera. Sirve para reconocerlo,
|
|
16
|
+
* no para decidir — ninguna puerta mira esto.
|
|
17
|
+
*
|
|
18
|
+
* Una sola pieza para las dos puntas: lo limpia igual quien lo manda y quien lo guarda (el acta,
|
|
19
|
+
* la bóveda), para que un campo de más o una cadena de un megabyte no entren por ningún lado.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const MACHINE_KINDS = ['node', 'browser', 'extension', 'ios', 'android']
|
|
23
|
+
|
|
24
|
+
const clip = (s, n) => (typeof s === 'string' ? s.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, n) : '')
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Lo deja en la forma de siempre, o `null` si no dice nada que valga. Nunca lanza: un aparato
|
|
28
|
+
* viejo o raro no puede tumbar un emparejamiento por cómo describe su máquina.
|
|
29
|
+
* @param {any} raw
|
|
30
|
+
* @returns {{ kind: string, os: string, name: string } | null}
|
|
31
|
+
*/
|
|
32
|
+
export function cleanMachine (raw) {
|
|
33
|
+
if (!raw || typeof raw !== 'object') return null
|
|
34
|
+
const kind = MACHINE_KINDS.includes(raw.kind) ? raw.kind : ''
|
|
35
|
+
const os = clip(raw.os, 40)
|
|
36
|
+
const name = clip(raw.name, 64)
|
|
37
|
+
if (!kind && !os && !name) return null
|
|
38
|
+
return { kind, os, name }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Una línea para enseñarlo: `node · Linux 7.0.0 · mi-pc`. Vacía si no hay nada. */
|
|
42
|
+
export function machineLine (m) {
|
|
43
|
+
const c = cleanMachine(m)
|
|
44
|
+
return c ? [c.kind, c.os, c.name].filter(Boolean).join(' · ') : ''
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** ¿Dicen lo mismo? Para no reescribir un archivo cada vez que un aparato repite lo de siempre. */
|
|
48
|
+
export const sameMachine = (a, b) => machineLine(a) === machineLine(b)
|
|
49
|
+
|
|
50
|
+
/** @type {Array<[RegExp, (v: string) => string]>} */
|
|
51
|
+
const UA_OS = [
|
|
52
|
+
[/Android ([\d.]+)/, (v) => 'Android ' + v.split('.')[0]],
|
|
53
|
+
[/(?:iPhone|iPad|iPod).*? OS (\d+)/, (v) => 'iOS ' + v],
|
|
54
|
+
[/Windows NT/, () => 'Windows'],
|
|
55
|
+
[/CrOS/, () => 'ChromeOS'],
|
|
56
|
+
[/Mac OS X/, () => 'macOS'],
|
|
57
|
+
[/Linux/, () => 'Linux']
|
|
58
|
+
]
|
|
59
|
+
/** @type {Array<[RegExp, string]>} */
|
|
60
|
+
const UA_BROWSER = [
|
|
61
|
+
[/Edg\//, 'Edge'], [/OPR\//, 'Opera'], [/Firefox\//, 'Firefox'], [/SamsungBrowser\//, 'Samsung Internet'],
|
|
62
|
+
[/Chrome\//, 'Chrome'], [/Safari\//, 'Safari']
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* La máquina de ESTE navegador. `env` se inyecta en las pruebas; en la página es `globalThis`.
|
|
67
|
+
* Lo nativo se reconoce por el puente de `dotrino-native` (`DotrinoNative.platform()`), la
|
|
68
|
+
* extensión por tener `chrome.runtime.id`, y el resto es un navegador.
|
|
69
|
+
* @param {any} [env]
|
|
70
|
+
*/
|
|
71
|
+
export function browserMachine (env = globalThis) {
|
|
72
|
+
/** @type {any} */
|
|
73
|
+
const nav = env?.navigator || {}
|
|
74
|
+
const ua = String(nav.userAgent || '')
|
|
75
|
+
// Node también tiene `navigator` (`Node.js/22`): no es un navegador, y su máquina la dice
|
|
76
|
+
// quien sabe mirarla (`selfMachine` de `@dotrino/vault`). Aquí no se inventa.
|
|
77
|
+
if (env?.process?.versions?.node || !ua || ua.startsWith('Node.js')) return null
|
|
78
|
+
let kind = 'browser'
|
|
79
|
+
try {
|
|
80
|
+
const p = env?.DotrinoNative?.platform?.()
|
|
81
|
+
if (p === 'ios' || p === 'android') kind = p
|
|
82
|
+
else if (env?.chrome?.runtime?.id) kind = 'extension'
|
|
83
|
+
} catch (_) { /* un puente roto no cambia qué navegador es: se queda en `browser` */ }
|
|
84
|
+
let os = ''
|
|
85
|
+
for (const [re, fmt] of UA_OS) { const m = ua.match(re); if (m) { os = fmt(m[1] || ''); break } }
|
|
86
|
+
// iPadOS se presenta como un Mac de escritorio: lo delata la pantalla táctil.
|
|
87
|
+
if (os === 'macOS' && nav.maxTouchPoints > 1) os = 'iPadOS'
|
|
88
|
+
let name = ''
|
|
89
|
+
if (kind === 'browser' || kind === 'extension') {
|
|
90
|
+
for (const [re, n] of UA_BROWSER) if (re.test(ua)) { name = n; break }
|
|
91
|
+
}
|
|
92
|
+
return cleanMachine({ kind, os, name })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export default { MACHINE_KINDS, cleanMachine, machineLine, sameMachine, browserMachine }
|
package/vault/remote.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { makeDeviceKey, signWithDevice, verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from './capabilities.js'
|
|
17
17
|
import { checkVaultReply } from './acta.js'
|
|
18
|
+
import { cleanMachine, browserMachine } from './machine.js'
|
|
18
19
|
|
|
19
20
|
const MSG = {
|
|
20
21
|
HELLO: 'vault.hello',
|
|
@@ -130,7 +131,7 @@ async function askVault (client, qr) {
|
|
|
130
131
|
* @param {number} [opts.approveTimeoutMs] Espera de la aprobación humana (def 3 min).
|
|
131
132
|
* @returns {Promise<{device, cert, master:string, proxy:string, deviceId:string, account:string}>}
|
|
132
133
|
*/
|
|
133
|
-
export async function enrollDevice ({ qr, device, onChallenge, label = '', continuity = null, encPub = null, approveTimeoutMs = 180000, intent = 'join', profileId = null, onAdopt = null } = {}) {
|
|
134
|
+
export async function enrollDevice ({ qr, device, onChallenge, label = '', continuity = null, encPub = null, approveTimeoutMs = 180000, intent = 'join', profileId = null, onAdopt = null, machine = undefined } = {}) {
|
|
134
135
|
if (!qr?.sn || !(qr.iss || qr.conn)) throw new Error('invalid qr: missing vault or nonce')
|
|
135
136
|
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
136
137
|
const client = new WebSocketProxyClient({ url: qr.proxy || 'wss://proxy.dotrino.com', enableWebRTC: false, autoReconnect: false })
|
|
@@ -162,12 +163,16 @@ export async function enrollDevice ({ qr, device, onChallenge, label = '', conti
|
|
|
162
163
|
// rechaza el que no coincida con el modo con el que ella abrió el emparejamiento. Así
|
|
163
164
|
// ninguno de los dos puede hacer, a mitad de camino, algo distinto de lo que el humano
|
|
164
165
|
// vio anunciado en las dos pantallas.
|
|
166
|
+
// DÓNDE CORRE (`machine.js`), para que la bóveda lo reconozca en su lista. Quien llama
|
|
167
|
+
// puede pasarlo (lo sabe mejor); si no, se mira el navegador. `null` es no decir nada.
|
|
168
|
+
const maquina = cleanMachine(machine === undefined ? browserMachine() : machine)
|
|
165
169
|
const adopting = intent === 'adopt'
|
|
166
170
|
if (adopting && typeof onAdopt !== 'function') throw new Error('enrollDevice(adopt): missing onAdopt')
|
|
167
171
|
const data = {
|
|
168
172
|
op: 'enroll', dpub: dev.publickey, token: qr.token || qr.sn, sn: qr.sn, commit, label, ts: Date.now(), intent,
|
|
169
173
|
...(adopting && profileId ? { profileId } : {}),
|
|
170
|
-
...(continuity ? { continuity } : {}), ...(encPub ? { encPub } : {})
|
|
174
|
+
...(continuity ? { continuity } : {}), ...(encPub ? { encPub } : {}),
|
|
175
|
+
...(maquina ? { machine: maquina } : {})
|
|
171
176
|
}
|
|
172
177
|
// `dev.sign`: la llave vive fuera (el chip del teléfono) y solo se le pide la firma.
|
|
173
178
|
const { signature } = await signWithDevice({ privateJwk: dev.privateJwk, privateKey: dev.privateKey, publickey: dev.publickey, sign: dev.sign, data })
|
|
@@ -276,14 +281,56 @@ function vaultError (p) {
|
|
|
276
281
|
return e
|
|
277
282
|
}
|
|
278
283
|
|
|
279
|
-
|
|
280
|
-
|
|
284
|
+
/**
|
|
285
|
+
* LA BÓVEDA DE ESTE MISMO NAVEGADOR, sin dar la vuelta por el proxio.
|
|
286
|
+
*
|
|
287
|
+
* Cuando esta pestaña o esta extensión ES la bóveda (`@dotrino/vault`, `startBrowserVault`),
|
|
288
|
+
* lo que el aparato le pide a su bóveda —administrar, aprobar, firmar— va directo al núcleo
|
|
289
|
+
* que corre aquí. Es el mostrador local del binario en el navegador: el MISMO protocolo, la
|
|
290
|
+
* misma firma y el mismo certificado, comprobados igual. Alcanzar el atajo no autoriza nada.
|
|
291
|
+
*
|
|
292
|
+
* `open(onReply)` → `{ send(payload), close() }`. Lo registra quien levanta la bóveda.
|
|
293
|
+
*/
|
|
294
|
+
const localVaults = new Map()
|
|
295
|
+
export function registerLocalVault (master, open) {
|
|
296
|
+
if (typeof master !== 'string' || typeof open !== 'function') throw new Error('registerLocalVault: master and open are required')
|
|
297
|
+
localVaults.set(master, open)
|
|
298
|
+
return () => { if (localVaults.get(master) === open) localVaults.delete(master) }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Un cliente con la forma del del proxio que habla con la bóveda local. */
|
|
302
|
+
function localClient (open) {
|
|
303
|
+
const handlers = new Set()
|
|
304
|
+
const chan = open((reply) => { for (const h of [...handlers]) { try { h('local', reply) } catch (_) {} } })
|
|
305
|
+
return {
|
|
306
|
+
connect: async () => {},
|
|
307
|
+
identifyAs: async () => {},
|
|
308
|
+
on (ev, fn) {
|
|
309
|
+
if (ev !== 'message') return () => {}
|
|
310
|
+
handlers.add(fn)
|
|
311
|
+
return () => handlers.delete(fn)
|
|
312
|
+
},
|
|
313
|
+
sendByPubkey (_to, payload) { chan.send(payload) },
|
|
314
|
+
close () { handlers.clear(); chan.close() }
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** El cliente para hablar con `master`: el local si esa bóveda corre aquí, y si no el del proxio. */
|
|
319
|
+
async function clientFor (proxy, master) {
|
|
320
|
+
const open = master ? localVaults.get(master) : null
|
|
321
|
+
if (open) return localClient(open)
|
|
281
322
|
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
282
323
|
const client = new WebSocketProxyClient({ url: proxy, enableWebRTC: false, autoReconnect: false })
|
|
283
324
|
await client.connect()
|
|
325
|
+
return client
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function vaultRpc ({ master, proxy, device, cert, acta = null, sendType, okType, data, onRevoked, timeoutMs = 15000 }) {
|
|
329
|
+
if (!master || !proxy || !(device?.privateJwk || device?.privateKey || typeof device?.sign === 'function') || !cert) throw new Error('missing pairing data')
|
|
330
|
+
const client = await clientFor(proxy, master)
|
|
284
331
|
try {
|
|
285
332
|
try { await identifyAsDevice(client, device, { cert, acta }) } catch (_) { /* sin identify seguimos: solo perdemos la cola */ }
|
|
286
|
-
const signed = { ...data, publickey: device.publickey, ts: Date.now() }
|
|
333
|
+
const signed = { ...data, publickey: device.publickey, ts: Date.now(), ...(cleanMachine(device.machine) ? { machine: cleanMachine(device.machine) } : {}) }
|
|
287
334
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, privateKey: device.privateKey, publickey: device.publickey, sign: device.sign, data: signed })
|
|
288
335
|
const pending = new Promise((resolve, reject) => {
|
|
289
336
|
let graceTimer = null
|
|
@@ -331,14 +378,12 @@ async function vaultRpc ({ master, proxy, device, cert, acta = null, sendType, o
|
|
|
331
378
|
*/
|
|
332
379
|
export async function checkMembership ({ master, proxy, device, onRevoked, timeoutMs = 12000 } = {}) {
|
|
333
380
|
if (!master || !proxy || !(device?.privateJwk || device?.privateKey || typeof device?.sign === 'function')) throw new Error('missing device data')
|
|
334
|
-
const
|
|
335
|
-
const client = new WebSocketProxyClient({ url: proxy, enableWebRTC: false, autoReconnect: false })
|
|
336
|
-
await client.connect()
|
|
381
|
+
const client = await clientFor(proxy, master)
|
|
337
382
|
try {
|
|
338
383
|
// Identificarse hace además que el proxy entregue lo ENCOLADO (un aviso de cuando
|
|
339
384
|
// estaba apagado, si todavía está dentro de las 24 h).
|
|
340
385
|
try { await identifyAsDevice(client, device) } catch (_) {}
|
|
341
|
-
const data = { op: 'check', publickey: device.publickey, ts: Date.now() }
|
|
386
|
+
const data = { op: 'check', publickey: device.publickey, ts: Date.now(), ...(cleanMachine(device.machine) ? { machine: cleanMachine(device.machine) } : {}) }
|
|
342
387
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, privateKey: device.privateKey, publickey: device.publickey, sign: device.sign, data })
|
|
343
388
|
const res = await new Promise((resolve) => {
|
|
344
389
|
let settled = false
|
package/vault/vault.js
CHANGED
|
@@ -185,9 +185,9 @@ import { useBackend as usePeerBackend } from './peerStore.js'
|
|
|
185
185
|
core.onSyncStatus((p) => broadcast('sync', p))
|
|
186
186
|
core.onVaultEvent((p) => broadcast('vault', p))
|
|
187
187
|
|
|
188
|
-
// ---- Modo SELF: este navegador actúa como bóveda
|
|
189
|
-
//
|
|
190
|
-
//
|
|
188
|
+
// ---- Modo SELF: este navegador actúa como bóveda ----
|
|
189
|
+
// Corre la MISMA bóveda que el binario (`startBrowserVault`): emparejar, firmar, cajones,
|
|
190
|
+
// aprobaciones, consola remota, inicios de sesión. Solo UN iframe por origin es el activo
|
|
191
191
|
// (navigator.locks): la pestaña VISIBLE sostiene el lock; al pasar a background lo
|
|
192
192
|
// libera y otra pestaña visible lo toma. Así varias apps abiertas no compiten.
|
|
193
193
|
const SELF_FLAG = 'dotrino.self-vault.enabled' // persistido en localStorage (kv)
|
|
@@ -208,79 +208,47 @@ import { useBackend as usePeerBackend } from './peerStore.js'
|
|
|
208
208
|
return /^wss?:\/\//.test(p || '') ? p : null
|
|
209
209
|
} catch (_) { return null }
|
|
210
210
|
})()
|
|
211
|
-
let daemon = null //
|
|
211
|
+
let daemon = null // la bóveda (`startBrowserVault`) cuando ESTE iframe es el activo
|
|
212
212
|
let _lockResolver = null // resolver del callback del lock (libera al resolverlo)
|
|
213
213
|
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
// `admitMember`/`profileActa` son opcionales para el mostrador de enrolamiento, pero sin
|
|
217
|
-
// ellos aprobar emitiría el cert SIN meter al dispositivo en el acta: se pasan también.
|
|
218
|
-
const selfIdentity = {
|
|
219
|
-
get me () { return core.me },
|
|
220
|
-
signData: (data) => handlers.signData({ data }),
|
|
221
|
-
signDelegation: (sub, scope, opts) => handlers.signDelegation({ sub, scope, ...(opts || {}) }),
|
|
222
|
-
listDelegations: () => handlers.listDelegations({}),
|
|
223
|
-
revokeDelegation: (nonce) => handlers.revokeDelegation({ nonce }),
|
|
224
|
-
revokeDevice: (sub) => handlers.revokeDevice({ sub }),
|
|
225
|
-
admitMember: (m) => handlers.admitMember(m),
|
|
226
|
-
profileActa: () => handlers.profileActa({}),
|
|
227
|
-
// Camino A (`mode: 'adopt'`): la bóveda se queda con la cuenta que trae el aparato,
|
|
228
|
-
// y para eso tiene que poder ENTRAR en su acta. Sin esto, adoptar fallaba con un
|
|
229
|
-
// «no es una función» en vez de con un error del protocolo.
|
|
230
|
-
joinProfile: (acta) => handlers.joinProfile({ acta })
|
|
231
|
-
}
|
|
214
|
+
// La dirección `nombre@AB12-CD34-EF56` sale del acta de la cuenta.
|
|
215
|
+
const selfIdentity = { profileActa: () => handlers.profileActa({}) }
|
|
232
216
|
|
|
233
217
|
/**
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
* `logins-unavailable`, que es distinto de «contraseña incorrecta» y por eso se ve.
|
|
239
|
-
*
|
|
240
|
-
* Cuelga del PERFIL ACTIVO: los inicios de sesión son de una cuenta, no de este
|
|
241
|
-
* navegador, y dos cuentas en el mismo equipo no comparten usuarios.
|
|
242
|
-
*
|
|
243
|
-
* Se carga solo aquí, y no arriba, porque arrastra el OPAQUE en WASM (~270 KB): el
|
|
244
|
-
* iframe lo cargan las ~30 apps del ecosistema y solo esta pestaña es bóveda.
|
|
245
|
-
*
|
|
246
|
-
* Lo que guarda aguanta lo mismo que el disco del daemon: con una copia de este
|
|
247
|
-
* `localStorage` se pueden probar contraseñas sin límite contra el registro de OPAQUE.
|
|
248
|
-
* Lo único que aguanta ahí es que la contraseña sea larga (`temporary-access.md` §4).
|
|
218
|
+
* LOS INICIOS DE SESIÓN QUE ESTA PESTAÑA GUARDABA ANTES de correr la bóveda entera. Ahora
|
|
219
|
+
* viven en el almacén de la bóveda (IndexedDB cifrado, uno por perfil); lo de antes se le
|
|
220
|
+
* pasa una vez y se borra de aquí. Si está y no parsea, REVIENTA a propósito: tomarlo por
|
|
221
|
+
* vacío perdería los inicios de sesión de la cuenta sin que nadie se enterara.
|
|
249
222
|
*/
|
|
250
|
-
async function
|
|
251
|
-
const { createLoginDesk } = await import('@dotrino/vault/password-logins')
|
|
223
|
+
async function legacyLogins () {
|
|
252
224
|
const { id: pid } = await handlers.currentProfile()
|
|
253
|
-
if (!pid)
|
|
225
|
+
if (!pid) return null
|
|
254
226
|
const key = `dotrino.identity.p.${pid}.self-vault.logins`
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
// Si está y no parsea, REVIENTA aquí a propósito: devolver `null` haría nacer el
|
|
259
|
-
// escritorio vacío, y el primer guardado se llevaría por delante todos los inicios
|
|
260
|
-
// de sesión de esta cuenta sin que nadie se enterara.
|
|
261
|
-
return raw ? JSON.parse(raw) : null
|
|
262
|
-
},
|
|
263
|
-
save: (s) => kv.setItem(key, JSON.stringify(s))
|
|
264
|
-
})
|
|
227
|
+
const raw = kv.getItem(key)
|
|
228
|
+
if (!raw) return null
|
|
229
|
+
return { state: JSON.parse(raw), drop: () => kv.removeItem(key) }
|
|
265
230
|
}
|
|
266
231
|
|
|
267
232
|
async function startSelfDaemon () {
|
|
268
233
|
if (daemon) return
|
|
269
234
|
try {
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
|
|
273
|
-
|
|
235
|
+
// LA MISMA BÓVEDA QUE EL BINARIO (`@dotrino/vault/browser`): el núcleo entero, con el
|
|
236
|
+
// disco en IndexedDB. Import dinámico: el iframe lo cargan las ~30 apps y solo esta
|
|
237
|
+
// pestaña es bóveda; además aísla un fallo del vendor del arranque de la identidad.
|
|
238
|
+
const { startBrowserVault } = await import('@dotrino/vault/browser')
|
|
239
|
+
daemon = await startBrowserVault({
|
|
240
|
+
core,
|
|
274
241
|
...(selfProxyUrl ? { proxyUrl: selfProxyUrl } : {}),
|
|
275
|
-
|
|
242
|
+
legacyLogins,
|
|
243
|
+
onPendingChange: () => broadcast('selfVault', { pending: daemon ? daemon.listPending() : [] }),
|
|
244
|
+
log: (...a) => console.log(...a)
|
|
276
245
|
})
|
|
277
|
-
daemon.onPendingChange(() => broadcast('selfVault', { pending: daemon.listPending() }))
|
|
278
246
|
broadcast('selfVault', { running: true })
|
|
279
247
|
} catch (e) { daemon = null; broadcast('selfVault', { error: e?.message || String(e) }) }
|
|
280
248
|
}
|
|
281
249
|
function stopSelfDaemon () {
|
|
282
250
|
if (!daemon) return
|
|
283
|
-
try { daemon.close() } catch {}
|
|
251
|
+
try { daemon.close()?.catch?.(() => {}) } catch {}
|
|
284
252
|
daemon = null
|
|
285
253
|
broadcast('selfVault', { running: false })
|
|
286
254
|
}
|
|
@@ -340,6 +308,9 @@ import { useBackend as usePeerBackend } from './peerStore.js'
|
|
|
340
308
|
// agentes siempre, sin depender de qué pestaña sostenga el lock del daemon.
|
|
341
309
|
listVaultDevices: async () => {
|
|
342
310
|
if (kv.getItem(SELF_FLAG) !== '1') return handlers.listVaultDevices({})
|
|
311
|
+
// Con la bóveda encendida en ESTA pestaña, la lista sale de ella (por el camino
|
|
312
|
+
// local): trae lo mismo que la del binario. Sin ella encendida, se lee lo guardado.
|
|
313
|
+
if (daemon) return handlers.listVaultDevices({})
|
|
343
314
|
const { issued, revoked } = await handlers.listDelegations({})
|
|
344
315
|
const now = Date.now()
|
|
345
316
|
const bySub = new Map()
|
|
@@ -491,7 +462,7 @@ import { useBackend as usePeerBackend } from './peerStore.js'
|
|
|
491
462
|
selfVaultLoginRemove: async ({ user } = {}) => {
|
|
492
463
|
const d = pidaDaemon()
|
|
493
464
|
const fila = d.listLogins().find((x) => x.user === user)
|
|
494
|
-
const r = d.removeLogin({ user })
|
|
465
|
+
const r = await d.removeLogin({ user })
|
|
495
466
|
if (r?.ok && fila?.pub) {
|
|
496
467
|
try { await handlers.revokeDevice({ sub: fila.pub }) } catch (e) {
|
|
497
468
|
throw Object.assign(new Error(`the login is gone but its key is still in the record: ${e.message}`), { code: 'revoke-failed' })
|