@dotrino/identity 0.49.0 → 0.52.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 +6 -3
- package/src/index.js +8 -1
- package/src/types.d.ts +5 -0
- package/vault/core.js +232 -35
- package/vault/remote.js +48 -0
- package/vault/vault.js +9 -1
- package/vault/vendor/vault/VERSION.txt +1 -1
- package/vault/vendor/vault/enroll.js +50 -23
- package/vault/vendor/vault/index.js +16 -8
- package/vault/vendor/vault/protocol.js +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/identity",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.52.0",
|
|
4
4
|
"description": "Identidad y rating de usuarios compartidos entre apps de Dotrino (vault iframe + postMessage)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -39,7 +39,8 @@
|
|
|
39
39
|
"LICENSE"
|
|
40
40
|
],
|
|
41
41
|
"scripts": {
|
|
42
|
-
"test": "node --test test/*.test.js"
|
|
42
|
+
"test": "node --test test/*.test.js",
|
|
43
|
+
"type-check": "tsc --noEmit"
|
|
43
44
|
},
|
|
44
45
|
"keywords": [
|
|
45
46
|
"identity",
|
|
@@ -58,6 +59,8 @@
|
|
|
58
59
|
"@dotrino/proxy-client": "0.10.0"
|
|
59
60
|
},
|
|
60
61
|
"devDependencies": {
|
|
61
|
-
"fake-indexeddb": "^6.2.5"
|
|
62
|
+
"fake-indexeddb": "^6.2.5",
|
|
63
|
+
"typescript": "^5.7.3",
|
|
64
|
+
"@types/node": "^22.0.0"
|
|
62
65
|
}
|
|
63
66
|
}
|
package/src/index.js
CHANGED
|
@@ -133,7 +133,14 @@ export class Identity {
|
|
|
133
133
|
if (!pending) return
|
|
134
134
|
this._pending.delete(msg.id)
|
|
135
135
|
clearTimeout(pending.timer)
|
|
136
|
-
|
|
136
|
+
// El rechazo llega con su `code` (y `detail`) puestos, como si el error se hubiera
|
|
137
|
+
// lanzado aquí: quien lo atrapa comprueba `e.code`, nunca la frase.
|
|
138
|
+
if (msg.error) {
|
|
139
|
+
const err = new Error(msg.error)
|
|
140
|
+
if (msg.code) err.code = msg.code
|
|
141
|
+
if (msg.detail) err.detail = msg.detail
|
|
142
|
+
pending.reject(err)
|
|
143
|
+
}
|
|
137
144
|
else pending.resolve(msg.result)
|
|
138
145
|
return
|
|
139
146
|
}
|
package/src/types.d.ts
ADDED
package/vault/core.js
CHANGED
|
@@ -22,7 +22,7 @@ import { signDelegationWith, MAX_DELEGATION_MS, DEFAULT_DELEGATION_MS } from './
|
|
|
22
22
|
import * as Acta from './acta.js'
|
|
23
23
|
import * as Content from './content.js'
|
|
24
24
|
import { pubkeyId as pubkeyIdOf } from './capabilities.js'
|
|
25
|
-
import { enrollDevice as remoteEnroll, requestSign as remoteSign, requestStore as remoteStore, requestDevices as remoteDevices, requestRenew as remoteRenew, requestAdmin as remoteAdmin, requestRenounce as remoteRenounce } from './remote.js'
|
|
25
|
+
import { enrollDevice as remoteEnroll, requestSign as remoteSign, requestStore as remoteStore, requestDevices as remoteDevices, requestRenew as remoteRenew, requestAdmin as remoteAdmin, requestRenounce as remoteRenounce, checkMembership as remoteCheck } from './remote.js'
|
|
26
26
|
|
|
27
27
|
export const KEY_STORAGE = 'dotrino.identity.keypair'
|
|
28
28
|
export const ENC_KEY_STORAGE = 'dotrino.identity.enc-keypair'
|
|
@@ -396,6 +396,60 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
396
396
|
emitVault({ phase: 'account-removed', removed: gone, current: currentPid })
|
|
397
397
|
}
|
|
398
398
|
|
|
399
|
+
/**
|
|
400
|
+
* Deja ABIERTA otra cuenta de este dispositivo: puntero, peers, llaves y `me`. Es lo que
|
|
401
|
+
* el arranque hace con la cuenta activa, en una función, para que crear una cuenta y
|
|
402
|
+
* volver atrás cuando algo falla sean el mismo camino recorrido en los dos sentidos.
|
|
403
|
+
*
|
|
404
|
+
* Ojo: no es «cambiar de cuenta» de cara a las apps (eso exige recargar, multi-perfil no
|
|
405
|
+
* es reactivo). Es dejar esta identidad coherente consigo misma antes de contestar.
|
|
406
|
+
*/
|
|
407
|
+
async function openProfileInMemory (pid) {
|
|
408
|
+
currentPid = pid
|
|
409
|
+
rawKv.setItem(CURRENT_STORAGE, pid)
|
|
410
|
+
await peers.setProfile?.(pid)
|
|
411
|
+
await initPeerStorage()
|
|
412
|
+
keypair = await loadOrCreateKeypair(); publickeyJwkStr = JSON.stringify(keypair.publicJwk)
|
|
413
|
+
encKeypair = await loadOrCreateEncKeypair(); encPublickeyJwkStr = JSON.stringify(encKeypair.publicJwk)
|
|
414
|
+
const saved = loadMe()
|
|
415
|
+
me = (saved && saved.publickey === publickeyJwkStr)
|
|
416
|
+
? { ...saved, encryptionPubkey: encPublickeyJwkStr }
|
|
417
|
+
: { publickey: publickeyJwkStr, encryptionPubkey: encPublickeyJwkStr }
|
|
418
|
+
return { id: pid, name: me.nickname || '', pubkey: publickeyJwkStr }
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Tira la cuenta que NACIÓ para un emparejamiento que no llegó a término, y vuelve a la
|
|
423
|
+
* que estabas usando.
|
|
424
|
+
*
|
|
425
|
+
* Sin esto, cada intento fallido dejaba una cuenta fantasma —vacía, sin bóveda y encima
|
|
426
|
+
* puesta como activa—: probar tres veces con el código vencido te dejaba tres cuentas
|
|
427
|
+
* que no eran de nadie y la tuya sin abrir. La cuenta que se descarta es siempre una
|
|
428
|
+
* recién creada por `vaultPair`, así que no hay nada dentro que perder.
|
|
429
|
+
*/
|
|
430
|
+
async function discardBornProfile (pid, backTo) {
|
|
431
|
+
try {
|
|
432
|
+
await purgeProfile(pid)
|
|
433
|
+
if (backTo && loadProfiles().some((p) => p.id === backTo)) await openProfileInMemory(backTo)
|
|
434
|
+
} catch (e) {
|
|
435
|
+
console.warn('[identity] could not discard the account born for the pairing:', e?.message || e)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** El certificado de bóveda guardado en OTRA cuenta de este dispositivo (sin abrirla). */
|
|
440
|
+
const vaultCertOf = (pid) => {
|
|
441
|
+
try {
|
|
442
|
+
const raw = rawKv.getItem(VAULT_CERT_STORAGE.replace(/^dotrino\.identity\./, `dotrino.identity.p.${pid}.`))
|
|
443
|
+
return raw ? JSON.parse(raw) : null
|
|
444
|
+
} catch (_) { return null }
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** La cuenta de este dispositivo que YA está emparejada con la bóveda `master`, si la hay. */
|
|
448
|
+
const profilePairedWith = (master) => {
|
|
449
|
+
if (!master) return null
|
|
450
|
+
return loadProfiles().find((p) => vaultCertOf(p.id)?.master === master) || null
|
|
451
|
+
}
|
|
452
|
+
|
|
399
453
|
/**
|
|
400
454
|
* Borra un perfil y todo lo suyo. Sin preguntas: el freno de «no te quedes sin ninguna»
|
|
401
455
|
* es de la interfaz y vive en `deleteProfile`.
|
|
@@ -632,6 +686,35 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
632
686
|
return { joined: true, profileId: candidate.profileId, seq: candidate.seq }
|
|
633
687
|
}
|
|
634
688
|
|
|
689
|
+
/**
|
|
690
|
+
* El emparejamiento propiamente dicho: con la cuenta ya decidida (`vaultPair`), genera el
|
|
691
|
+
* cert contra la bóveda y entra a su cuenta. Aparte para que la decisión de CUÁL cuenta y
|
|
692
|
+
* el deshacerla si esto falla vivan juntos arriba, y aquí solo quede el trámite.
|
|
693
|
+
*/
|
|
694
|
+
async function pairWithVault ({ qr, label = '' }) {
|
|
695
|
+
// Usa la PROPIA llave de identidad de este navegador como dispositivo: el cert delega
|
|
696
|
+
// TU identidad (P) desde la maestra M → una sola identidad (signData/identify/cert = P).
|
|
697
|
+
// La privada es la CryptoKey del perfil (no extractable): se pasa como `privateKey`
|
|
698
|
+
// y NO se persiste ningún JWK del dispositivo (marcador useIdentityKey).
|
|
699
|
+
const device = { publickey: publickeyJwkStr, privateKey: keypair.privateKey }
|
|
700
|
+
// Si esta identidad ya existía por su cuenta, se lleva un certificado de continuidad
|
|
701
|
+
// firmado por ella misma: es el puente para que su reputación previa siga contando.
|
|
702
|
+
// Solo si esta llave tenía vida propia. Una recién creada para adoptar (camino B) no
|
|
703
|
+
// tiene pasado que salvar: mandarle un puente de continuidad sería puro ruido.
|
|
704
|
+
const mio = loadActa()
|
|
705
|
+
const continuity = (mio && mio.members.length === 1 && !isPendingJoin())
|
|
706
|
+
? await Acta.makeContinuity({ member: publickeyJwkStr, from: mio.profileId, privateKey: keypair.privateKey })
|
|
707
|
+
: null
|
|
708
|
+
const res = await remoteEnroll({ qr, device, continuity, encPub: encPublickeyJwkStr, label: label || me?.nickname || '', onChallenge: (c) => emitVault({ phase: 'challenge', deviceId: c.deviceId, code: c.code }) })
|
|
709
|
+
kv.setItem(VAULT_DEVICE_STORAGE, JSON.stringify({ useIdentityKey: true, publickey: publickeyJwkStr }))
|
|
710
|
+
kv.setItem(VAULT_CERT_STORAGE, JSON.stringify({ cert: res.cert, master: res.master, proxy: res.proxy, deviceId: res.deviceId, pairedAt: Date.now() }))
|
|
711
|
+
// Conectarse a una bóveda es ENTRAR A SU CUENTA: el acta viene con el cert.
|
|
712
|
+
const unido = res.acta ? await joinProfile(res.acta) : { joined: false, reason: 'sin-acta' }
|
|
713
|
+
emitVault({ phase: 'paired', deviceId: res.deviceId, master: res.master, join: unido })
|
|
714
|
+
pullProfileFromVault() // adoptar el perfil que ya viva en el vault (si hay)
|
|
715
|
+
return { ok: true, deviceId: res.deviceId, master: res.master, exp: res.cert.exp, scope: res.cert.scope, join: unido }
|
|
716
|
+
}
|
|
717
|
+
|
|
635
718
|
/**
|
|
636
719
|
* Adopta un acta que llega de otro miembro, si gana según §2.4.1 (seq mayor que encadene,
|
|
637
720
|
* o el traspaso a igual seq). Nunca retrocede.
|
|
@@ -650,12 +733,28 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
650
733
|
return last || { adopted: false, reason: 'nada-que-adoptar', seq: loadActa()?.seq ?? null }
|
|
651
734
|
}
|
|
652
735
|
|
|
736
|
+
/**
|
|
737
|
+
* SI EL ACTA NUEVA YA NO ME NOMBRA, ME BORRO. Sin botón y sin preguntar.
|
|
738
|
+
*
|
|
739
|
+
* El acta manda —es quien dice de quién es el perfil— y viene FIRMADA por el master, así
|
|
740
|
+
* que enterarse por ella es tan bueno como el aviso de expulsión: no hay wipe-DoS que
|
|
741
|
+
* valga, porque un tercero no puede fabricar un acta sellada. Antes solo se hacía caso al
|
|
742
|
+
* aviso, que es un mensaje suelto: si se perdía —el aparato apagado, la cola del proxy
|
|
743
|
+
* dura 24 h— el aparato se quedaba enseñando para siempre una cuenta de la que ya lo
|
|
744
|
+
* habían echado, aunque la propia acta que acababa de recibir dijera lo contrario.
|
|
745
|
+
*/
|
|
653
746
|
async function adoptActa (candidate) {
|
|
654
747
|
const current = loadActa()
|
|
655
748
|
const r = await Acta.canAdopt({ candidate, current })
|
|
656
749
|
if (!r.adopt) return { adopted: false, reason: r.reason, seq: current?.seq ?? null }
|
|
657
750
|
saveActa(candidate)
|
|
658
751
|
emitVault({ phase: 'acta', seq: candidate.seq, sealer: candidate.sealer, adopted: r.reason })
|
|
752
|
+
const sigoDentro = (candidate.members || []).some((m) => m?.pub === publickeyJwkStr)
|
|
753
|
+
if (!sigoDentro) {
|
|
754
|
+
console.warn('[identity] the new record no longer lists this device: removing the account')
|
|
755
|
+
wipeVaultLink()
|
|
756
|
+
return { adopted: true, expelled: true, reason: r.reason, seq: candidate.seq }
|
|
757
|
+
}
|
|
659
758
|
return { adopted: true, reason: r.reason, seq: candidate.seq }
|
|
660
759
|
}
|
|
661
760
|
|
|
@@ -892,15 +991,62 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
892
991
|
clearTimeout(profilePushTimer)
|
|
893
992
|
profilePushTimer = setTimeout(() => {
|
|
894
993
|
const { publickey, encryptionPubkey, ...content } = me || {}
|
|
895
|
-
remoteStore({ master: v.master, proxy: v.proxy, device, cert: v.cert, method: 'profileSet', args: { me: content } })
|
|
994
|
+
remoteStore({ master: v.master, proxy: v.proxy, device, cert: v.cert, method: 'profileSet', args: { me: content }, onRevoked: wipeVaultLink })
|
|
896
995
|
.catch(() => {}) // el vault puede estar apagado; se reintenta en la próxima edición
|
|
897
996
|
}, 800) // debounce: ediciones seguidas = un solo push
|
|
898
997
|
}
|
|
998
|
+
/**
|
|
999
|
+
* SIN PAPEL, SE PREGUNTA. Y AL ARRANCAR, SOLO.
|
|
1000
|
+
*
|
|
1001
|
+
* Un aparato que perdió su certificado no puede firmar, ni leer, ni renovar: TODO lo que
|
|
1002
|
+
* habla con la bóveda lo exige. Así que tampoco tenía forma de enterarse de que lo habían
|
|
1003
|
+
* echado —el aviso firmado se emite al quitarlo, y si estaba apagado la cola del proxy
|
|
1004
|
+
* dura 24 h— y se quedaba enseñando para siempre una cuenta que ya no era suya.
|
|
1005
|
+
*
|
|
1006
|
+
* Se pregunta con la llave del propio aparato, que es la que el acta nombra, y a la
|
|
1007
|
+
* maestra que dice la propia acta (`sealer`). Si sigue dentro, no pasa nada. Si no, la
|
|
1008
|
+
* bóveda contesta con el aviso FIRMADO y aquí se ejecuta el borrado.
|
|
1009
|
+
*/
|
|
1010
|
+
async function preguntarSiSigoDentro () {
|
|
1011
|
+
try {
|
|
1012
|
+
const acta = loadActa()
|
|
1013
|
+
if (!acta || acta.sealer === publickeyJwkStr) return // no hay cuenta ajena que confirmar: mando yo
|
|
1014
|
+
// El acta que ya tengo no me nombra: no hace falta preguntar nada, el acta manda y va
|
|
1015
|
+
// firmada. (Normalmente esto lo resuelve `adoptActa` al recibirla.)
|
|
1016
|
+
if (!(acta.members || []).some((m) => m?.pub === publickeyJwkStr)) {
|
|
1017
|
+
console.warn('[identity] the stored record does not list this device: removing the account')
|
|
1018
|
+
return wipeVaultLink()
|
|
1019
|
+
}
|
|
1020
|
+
const v = loadVaultCert()
|
|
1021
|
+
if (v?.cert && loadVaultDevice()) return // con papel, ya lo comprueba el camino normal
|
|
1022
|
+
const r = await remoteCheck({
|
|
1023
|
+
master: acta.sealer,
|
|
1024
|
+
proxy: v?.proxy || 'wss://proxy.dotrino.com',
|
|
1025
|
+
device: { publickey: publickeyJwkStr, privateKey: keypair.privateKey },
|
|
1026
|
+
onRevoked: wipeVaultLink
|
|
1027
|
+
})
|
|
1028
|
+
if (r?.error) console.warn('[identity] could not confirm membership with the vault:', r.error)
|
|
1029
|
+
} catch (e) { console.warn('[identity] could not confirm membership with the vault:', e?.message || e) }
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* EL TOQUE AL ARRANCAR. Corre en cada apertura de la identidad, así que es también el
|
|
1034
|
+
* momento en el que este aparato se entera de que lo echaron: la bóveda contesta al
|
|
1035
|
+
* revocado —tiene que hacerlo— con el aviso FIRMADO, que es lo único que le borra la
|
|
1036
|
+
* cuenta. Sin `onRevoked` ese aviso llegaba y se tiraba a la basura, y el aparato se
|
|
1037
|
+
* quedaba enseñando un perfil del que ya no era, para siempre, sin que nadie pulsara
|
|
1038
|
+
* nada porque no había nada que pulsar.
|
|
1039
|
+
*/
|
|
899
1040
|
async function pullProfileFromVault () {
|
|
900
1041
|
try {
|
|
901
1042
|
const v = loadVaultCert(); const device = loadVaultDevice()
|
|
902
|
-
|
|
903
|
-
|
|
1043
|
+
// SE TOCA AUNQUE EL PAPEL ESTÉ VENCIDO. Antes se salía sin llamar, y ese es justo el
|
|
1044
|
+
// aparato que más falta le hace: no puede firmar, ni leer, ni renovar —o sea que ya
|
|
1045
|
+
// está roto para todo— y encima era el único que no tenía forma de enterarse de que
|
|
1046
|
+
// lo habían echado. La bóveda contesta «vencido» y no pasa nada; y si además ya no
|
|
1047
|
+
// está en el acta, contesta con el aviso firmado y aquí se le borra la cuenta.
|
|
1048
|
+
if (!v?.cert || !device) return
|
|
1049
|
+
const res = await remoteStore({ master: v.master, proxy: v.proxy, device, cert: v.cert, method: 'profileGet', args: {}, onRevoked: wipeVaultLink })
|
|
904
1050
|
const remoteMe = res?.me
|
|
905
1051
|
if (!remoteMe) {
|
|
906
1052
|
// el vault aún no tiene perfil: sembrar con el local (si tiene contenido)
|
|
@@ -1256,16 +1402,15 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1256
1402
|
*/
|
|
1257
1403
|
async createProfile ({ name, forVault = false } = {}) {
|
|
1258
1404
|
const pid = 'p' + crypto.randomUUID().slice(0, 8)
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
encKeypair = await loadOrCreateEncKeypair(); encPublickeyJwkStr = JSON.stringify(encKeypair.publicJwk)
|
|
1405
|
+
// `from`: de qué cuenta se venía. Solo se anota en la que nace para una bóveda, y es
|
|
1406
|
+
// lo que deja volver a casa si el emparejamiento no llega a término —también cuando
|
|
1407
|
+
// se cortó por lo bruto (cerrar la pestaña) y quien limpia es el arranque siguiente.
|
|
1408
|
+
const from = currentPid
|
|
1409
|
+
await openProfileInMemory(pid)
|
|
1265
1410
|
me = { publickey: publickeyJwkStr, encryptionPubkey: encPublickeyJwkStr, nickname: String(name || '').slice(0, 40) }
|
|
1266
1411
|
saveMe(me)
|
|
1267
1412
|
const list = loadProfiles()
|
|
1268
|
-
list.push({ id: pid, name: me.nickname, pubkey: publickeyJwkStr, ...(forVault ? { pendingJoin: true } : {}) })
|
|
1413
|
+
list.push({ id: pid, name: me.nickname, pubkey: publickeyJwkStr, ...(forVault ? { pendingJoin: true, ...(from ? { from } : {}) } : {}) })
|
|
1269
1414
|
saveProfiles(list)
|
|
1270
1415
|
await ensureActa(me.nickname) // el perfil nuevo nace con su acta (él mismo es el master)
|
|
1271
1416
|
return { id: pid, name: me.nickname, pubkey: publickeyJwkStr, pendingJoin: !!forVault }
|
|
@@ -1509,6 +1654,11 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1509
1654
|
* se adivina):
|
|
1510
1655
|
* · `'new'` → camino B: crea aquí una cuenta más, con llave nueva, y ES ESA la que
|
|
1511
1656
|
* entra al acta de la bóveda. La que estabas usando **no se toca**.
|
|
1657
|
+
* Con dos frenos, porque una cuenta más solo vale si de verdad es
|
|
1658
|
+
* otra: si ESTE dispositivo ya tiene la cuenta de esa bóveda, no nace
|
|
1659
|
+
* ninguna (se re-empareja la que hay, o se avisa con `ALREADY_PAIRED`
|
|
1660
|
+
* de que vive en otra cuenta de aquí); y si el intento falla, la que
|
|
1661
|
+
* nació para él se descarta en vez de quedarse de fantasma.
|
|
1512
1662
|
* · `'current'` → sigue con la cuenta abierta. Solo vale si nació para adoptar
|
|
1513
1663
|
* (`forVault`) o si ya está emparejada con ESA misma bóveda
|
|
1514
1664
|
* (re-emparejar). En cualquier otro caso falla **antes de tocar la
|
|
@@ -1521,35 +1671,43 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1521
1671
|
*/
|
|
1522
1672
|
async vaultPair ({ qr, label = '', join = 'current' }) {
|
|
1523
1673
|
if (join === 'adopt') return handlers.vaultAdopt({ qr, label })
|
|
1674
|
+
// La cuenta abierta AQUÍ y la que se cree para este intento. `born` es la que hay que
|
|
1675
|
+
// tirar si el emparejamiento no llega a término: nació para él y no tiene nada dentro.
|
|
1676
|
+
const from = currentPid
|
|
1677
|
+
let born = null
|
|
1524
1678
|
if (join === 'new') {
|
|
1525
|
-
|
|
1526
|
-
|
|
1679
|
+
// RE-EMPAREJAR NO ES UNA CUENTA MÁS. Volver a esta pantalla con la bóveda que ya te
|
|
1680
|
+
// tiene —porque el papel venció, porque lo retiraron, porque se rehízo el
|
|
1681
|
+
// emparejamiento— pedía otra cuenta nueva y te dejaba la MISMA cuenta dos veces en
|
|
1682
|
+
// el conmutador, con dos llaves distintas metidas en el acta de la bóveda.
|
|
1683
|
+
if (qr?.iss && loadVaultCert()?.master === qr.iss) join = 'current'
|
|
1684
|
+
else {
|
|
1685
|
+
const otra = qr?.iss ? profilePairedWith(qr.iss) : null
|
|
1686
|
+
// Y si la que tiene esa bóveda es OTRA cuenta de este mismo dispositivo, tampoco se
|
|
1687
|
+
// duplica: cambiar de cuenta exige recargar (multi-perfil no es reactivo), así que
|
|
1688
|
+
// esto se dice con código para que la consola ofrezca ir a ella.
|
|
1689
|
+
if (otra) {
|
|
1690
|
+
throw Object.assign(new Error(`this device already has the account of that vault (profile ${otra.id})`),
|
|
1691
|
+
{ code: 'ALREADY_PAIRED', detail: { profile: otra.id, name: otra.name || '' } })
|
|
1692
|
+
}
|
|
1693
|
+
born = await handlers.createProfile({ name: label || me?.nickname || '', forVault: true })
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
if (join !== 'new') {
|
|
1527
1697
|
const yaConEsta = loadVaultCert()?.master === qr?.iss
|
|
1528
1698
|
if (loadActa() && !isPendingJoin() && !yaConEsta) {
|
|
1529
1699
|
throw new Error('this device is already using an account: to also use your vault account, create a new account here (the open one is untouched)')
|
|
1530
1700
|
}
|
|
1531
1701
|
}
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
const mio = loadActa()
|
|
1542
|
-
const continuity = (mio && mio.members.length === 1 && !isPendingJoin())
|
|
1543
|
-
? await Acta.makeContinuity({ member: publickeyJwkStr, from: mio.profileId, privateKey: keypair.privateKey })
|
|
1544
|
-
: null
|
|
1545
|
-
const res = await remoteEnroll({ qr, device, continuity, encPub: encPublickeyJwkStr, label: label || me?.nickname || '', onChallenge: (c) => emitVault({ phase: 'challenge', deviceId: c.deviceId, code: c.code }) })
|
|
1546
|
-
kv.setItem(VAULT_DEVICE_STORAGE, JSON.stringify({ useIdentityKey: true, publickey: publickeyJwkStr }))
|
|
1547
|
-
kv.setItem(VAULT_CERT_STORAGE, JSON.stringify({ cert: res.cert, master: res.master, proxy: res.proxy, deviceId: res.deviceId, pairedAt: Date.now() }))
|
|
1548
|
-
// Conectarse a una bóveda es ENTRAR A SU CUENTA: el acta viene con el cert.
|
|
1549
|
-
const unido = res.acta ? await joinProfile(res.acta) : { joined: false, reason: 'sin-acta' }
|
|
1550
|
-
emitVault({ phase: 'paired', deviceId: res.deviceId, master: res.master, join: unido })
|
|
1551
|
-
pullProfileFromVault() // adoptar el perfil que ya viva en el vault (si hay)
|
|
1552
|
-
return { ok: true, deviceId: res.deviceId, master: res.master, exp: res.cert.exp, scope: res.cert.scope, join: unido }
|
|
1702
|
+
try {
|
|
1703
|
+
return await pairWithVault({ qr, label })
|
|
1704
|
+
} catch (e) {
|
|
1705
|
+
// El intento falló (código vencido, la bóveda dijo que no, se agotó la espera): la
|
|
1706
|
+
// cuenta que nació para él se va con él. Si no, cada reintento dejaba una cuenta
|
|
1707
|
+
// fantasma —y encima puesta como activa—.
|
|
1708
|
+
if (born) await discardBornProfile(born.id, from)
|
|
1709
|
+
throw e
|
|
1710
|
+
}
|
|
1553
1711
|
},
|
|
1554
1712
|
|
|
1555
1713
|
/**
|
|
@@ -1712,7 +1870,19 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1712
1870
|
// Lista (solo lectura) de dispositivos enrolados en tu vault.
|
|
1713
1871
|
async listVaultDevices () {
|
|
1714
1872
|
const v = loadVaultCert(); const device = loadVaultDevice()
|
|
1715
|
-
if (!v?.cert || !device)
|
|
1873
|
+
if (!v?.cert || !device) {
|
|
1874
|
+
// SOLO SE AVISA SI ES RARO. Un aparato que manda en su propia cuenta —o que aún no
|
|
1875
|
+
// entró en ninguna bóveda— no está paired y no pasa absolutamente nada: avisar ahí
|
|
1876
|
+
// es ensuciar la consola de todo el mundo con el estado normal. Lo que sí es raro,
|
|
1877
|
+
// y hasta ahora era mudo, es guardar el acta de OTRO y no tener con qué llamarle.
|
|
1878
|
+
const acta = loadActa()
|
|
1879
|
+
if (acta && acta.sealer !== publickeyJwkStr) {
|
|
1880
|
+
console.warn('[identity] cannot reach the vault that seals this account:', JSON.stringify({
|
|
1881
|
+
cert: !!v?.cert, device: !!device, acta: acta.seq
|
|
1882
|
+
}))
|
|
1883
|
+
}
|
|
1884
|
+
throw new Error('this device is not paired with a vault')
|
|
1885
|
+
}
|
|
1716
1886
|
maybeRenewVaultCert()
|
|
1717
1887
|
try {
|
|
1718
1888
|
const res = await remoteDevices({ master: v.master, proxy: v.proxy, device, cert: v.cert, sinceSeq: loadActa()?.seq ?? 0, onRevoked: wipeVaultLink })
|
|
@@ -1890,6 +2060,30 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1890
2060
|
|
|
1891
2061
|
// ----- bootstrap -----
|
|
1892
2062
|
|
|
2063
|
+
// Cuentas que NACIERON para un emparejamiento que nunca llegó a término y se quedaron ahí:
|
|
2064
|
+
// se cerró la pestaña con el código en pantalla, o se recargó a mitad. No tienen bóveda y
|
|
2065
|
+
// no pueden llegar a tenerla —el intento vivía en la llamada que se cortó—, así que son
|
|
2066
|
+
// cuentas fantasma: vacías, sin dueño y ensuciando el conmutador. Aquí se recogen, y si la
|
|
2067
|
+
// activa era una de ellas se vuelve a la que estabas usando antes (`from`).
|
|
2068
|
+
//
|
|
2069
|
+
// Nunca se borra la última: quedarse sin ninguna es peor que quedarse con una vacía. Y solo
|
|
2070
|
+
// se van las marcadas `pendingJoin` SIN certificado: en cuanto una se une a la bóveda la
|
|
2071
|
+
// marca se consume, así que ninguna cuenta de verdad entra en este barrido.
|
|
2072
|
+
{
|
|
2073
|
+
const list = loadProfiles()
|
|
2074
|
+
const orphan = (p) => p.pendingJoin && !vaultCertOf(p.id)
|
|
2075
|
+
const dead = list.filter(orphan)
|
|
2076
|
+
const alive = list.filter((p) => !orphan(p))
|
|
2077
|
+
if (dead.length && alive.length) {
|
|
2078
|
+
const stored = rawKv.getItem(CURRENT_STORAGE)
|
|
2079
|
+
const back = dead.find((p) => p.id === stored)?.from
|
|
2080
|
+
for (const p of dead) {
|
|
2081
|
+
try { await purgeProfile(p.id) } catch (e) { console.warn('[identity] could not discard a ghost account:', e?.message || e) }
|
|
2082
|
+
}
|
|
2083
|
+
if (back && alive.some((p) => p.id === back)) rawKv.setItem(CURRENT_STORAGE, back)
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
|
|
1893
2087
|
// Perfil activo (multi-perfil por dispositivo). Si no hay perfiles, se crea el primero; si
|
|
1894
2088
|
// existe una identidad ÚNICA vieja (pre-multi-perfil, claves sin namespace), se ADOPTA como
|
|
1895
2089
|
// "Perfil 1" — sin pérdida. A partir de acá `kv` está scopeado a `currentPid`.
|
|
@@ -1955,6 +2149,9 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1955
2149
|
|
|
1956
2150
|
// Perfil compartido: jalar del vault en background (gana el más nuevo).
|
|
1957
2151
|
pullProfileFromVault()
|
|
2152
|
+
// Y, si a este aparato no le queda papel con el que llamar, preguntar si sigue siendo de
|
|
2153
|
+
// la casa. Es lo único que le queda por hacer, y hasta ahora no lo hacía nadie.
|
|
2154
|
+
preguntarSiSigoDentro()
|
|
1958
2155
|
|
|
1959
2156
|
// Registrar el pubkey (y nombre) del perfil activo en su meta → para avatar/listado sin abrir cada perfil.
|
|
1960
2157
|
{
|
package/vault/remote.js
CHANGED
|
@@ -28,6 +28,9 @@ const MSG = {
|
|
|
28
28
|
ACTA_SEALED: 'vault.acta.sealed',
|
|
29
29
|
ACTA_ADOPTED: 'vault.acta.adopted',
|
|
30
30
|
REVOKED: 'vault.revoked',
|
|
31
|
+
// «¿sigo siendo de esta casa?»: la única pregunta que la bóveda atiende SIN certificado.
|
|
32
|
+
CHECK: 'vault.check',
|
|
33
|
+
CHECKED: 'vault.checked',
|
|
31
34
|
// Consola remota: administrar el perfil desde un dispositivo (scope `vault:admin`).
|
|
32
35
|
ADMIN: 'vault.admin',
|
|
33
36
|
ADMIN_RESULT: 'vault.admin.result',
|
|
@@ -278,6 +281,51 @@ async function vaultRpc ({ master, proxy, device, cert, acta = null, sendType, o
|
|
|
278
281
|
} finally { try { client.close() } catch (_) {} }
|
|
279
282
|
}
|
|
280
283
|
|
|
284
|
+
/**
|
|
285
|
+
* PREGUNTA SIN CERTIFICADO: «¿sigo estando en el acta?».
|
|
286
|
+
*
|
|
287
|
+
* Es el único camino que le queda al aparato que perdió su papel — sin cert no puede
|
|
288
|
+
* firmar, ni leer, ni renovar, así que tampoco podía enterarse de que lo habían echado y se
|
|
289
|
+
* quedaba enseñando una cuenta que ya no era suya. Va firmada con SU llave, que es lo que
|
|
290
|
+
* el acta nombra.
|
|
291
|
+
*
|
|
292
|
+
* La bóveda contesta sí o no, y nada más. Si el no viene acompañado del aviso FIRMADO de
|
|
293
|
+
* expulsión, ESE es el que borra la cuenta aquí (`onRevoked`); el «no» pelado no borra
|
|
294
|
+
* nada, como cualquier otro mensaje sin firma.
|
|
295
|
+
*/
|
|
296
|
+
export async function checkMembership ({ master, proxy, device, onRevoked, timeoutMs = 12000 } = {}) {
|
|
297
|
+
if (!master || !proxy || !(device?.privateJwk || device?.privateKey)) throw new Error('faltan datos del dispositivo')
|
|
298
|
+
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
299
|
+
const client = new WebSocketProxyClient({ url: proxy, enableWebRTC: false, autoReconnect: false })
|
|
300
|
+
await client.connect()
|
|
301
|
+
try {
|
|
302
|
+
// Identificarse hace además que el proxy entregue lo ENCOLADO (un aviso de cuando
|
|
303
|
+
// estaba apagado, si todavía está dentro de las 24 h).
|
|
304
|
+
try { await identifyAsDevice(client, device) } catch (_) {}
|
|
305
|
+
const data = { op: 'check', publickey: device.publickey, ts: Date.now() }
|
|
306
|
+
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, privateKey: device.privateKey, publickey: device.publickey, data })
|
|
307
|
+
const res = await new Promise((resolve) => {
|
|
308
|
+
let hecho = false
|
|
309
|
+
const fin = (v) => { if (!hecho) { hecho = true; cleanup(); resolve(v) } }
|
|
310
|
+
const off = client.on('message', (_f, p) => {
|
|
311
|
+
if (!p || typeof p !== 'object') return
|
|
312
|
+
if (p.type === MSG.REVOKED) {
|
|
313
|
+
isAuthenticRevoke({ body: p.body, signature: p.signature, master, devicePubkey: device.publickey, currentNonce: null })
|
|
314
|
+
.then((ok) => { if (ok) { try { onRevoked?.() } catch (_) {} ; fin({ in: false, wiped: true }) } })
|
|
315
|
+
.catch(() => {})
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
if (p.type === MSG.CHECKED) fin({ in: !!p.in })
|
|
319
|
+
else if (p.type === MSG.ERROR) fin({ error: p.error })
|
|
320
|
+
})
|
|
321
|
+
const t = setTimeout(() => fin({ error: 'the vault did not reply' }), timeoutMs)
|
|
322
|
+
const cleanup = () => { off(); clearTimeout(t) }
|
|
323
|
+
client.sendByPubkey(master, { type: MSG.CHECK, data, signature })
|
|
324
|
+
})
|
|
325
|
+
return res
|
|
326
|
+
} finally { try { client.close() } catch (_) {} }
|
|
327
|
+
}
|
|
328
|
+
|
|
281
329
|
/** Lee/escribe el store de hilos+aperturas EN el vault (con el cert del dispositivo). */
|
|
282
330
|
export async function requestStore ({ master, proxy, device, cert, method, args, enc, onRevoked } = {}) {
|
|
283
331
|
// `enc`: argumentos cifrados con la clave de contenido del perfil (el proxy no los ve).
|
package/vault/vault.js
CHANGED
|
@@ -277,7 +277,15 @@ import { pubkeyId } from './capabilities.js'
|
|
|
277
277
|
const result = await handler(params || {})
|
|
278
278
|
reply({ result })
|
|
279
279
|
} catch (e) {
|
|
280
|
-
|
|
280
|
+
// `code` (y su `detail`) CRUZAN. Sin ellos, al otro lado solo llegaba la frase, y una
|
|
281
|
+
// app que quiere reaccionar a un rechazo concreto —«esa bóveda ya está en otra cuenta
|
|
282
|
+
// de este aparato»— no tenía más remedio que emparejarla por su texto: se traduce o se
|
|
283
|
+
// reescribe y deja de funcionar sin que nadie se entere.
|
|
284
|
+
reply({
|
|
285
|
+
error: e?.message || String(e),
|
|
286
|
+
...(e?.code ? { code: e.code } : {}),
|
|
287
|
+
...(e?.detail ? { detail: e.detail } : {})
|
|
288
|
+
})
|
|
281
289
|
}
|
|
282
290
|
})
|
|
283
291
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Copia vendorizada de @dotrino/vault@0.
|
|
1
|
+
Copia vendorizada de @dotrino/vault@0.24.0 (lib/src/{index,enroll,protocol}.js, sin dependencias).
|
|
2
2
|
El iframe de identity se sirve estatico (vanilla, sin build); asi startDeviceVault
|
|
3
3
|
resuelve en el navegador sin bundler. index.js importa ./enroll.js y ./protocol.js
|
|
4
4
|
(relativos, se vendorizan tambien) y @dotrino/identity/capabilities (=../../capabilities.js)
|
|
@@ -104,13 +104,16 @@ export async function deviceIdOf (pub) {
|
|
|
104
104
|
* @param {(...a:any[])=>void} [opts.log]
|
|
105
105
|
* @param {(c:{deviceId:string, scope:any, label:string})=>void} [opts.onChallenge] un dispositivo espera aprobación.
|
|
106
106
|
* @param {()=>void} [opts.onPendingChange]
|
|
107
|
+
* @param {(sub:string)=>void} [opts.onDeviceRemoved] se quitó un aparato (fuera del acta y sin papeles):
|
|
108
|
+
* para que quien guarde algo indexado por esa llave lo suelte. Se avisa desde AQUÍ y no desde
|
|
109
|
+
* quien llama porque a `revokeDevice` se entra por dos puertas (el PC y la consola remota).
|
|
107
110
|
* @param {string[]} [opts.defaultScope]
|
|
108
111
|
* @param {number} [opts.defaultTtlMs]
|
|
109
112
|
*/
|
|
110
113
|
export function createEnrollDesk ({
|
|
111
114
|
identity, iss, proxy, send, sendByPubkey,
|
|
112
115
|
audit = () => {}, log = () => {},
|
|
113
|
-
onChallenge = () => {}, onPendingChange = () => {}, onAdopted = () => {},
|
|
116
|
+
onChallenge = () => {}, onPendingChange = () => {}, onAdopted = () => {}, onDeviceRemoved = () => {},
|
|
114
117
|
defaultScope = ['vault:sign'], defaultTtlMs = DEVICE_TTL_MS,
|
|
115
118
|
// Camino A: lo que ESTA bóveda le manda al aparato para que la meta en su acta. `encPub`
|
|
116
119
|
// es su llave de CIFRADO — sin ella entra mandando pero sin poder leer el contenido.
|
|
@@ -231,7 +234,7 @@ export function createEnrollDesk ({
|
|
|
231
234
|
}
|
|
232
235
|
if (intent !== (pend.mode || 'join')) {
|
|
233
236
|
audit('rejected', { what: 'enroll', reason: 'intent-mismatch' })
|
|
234
|
-
return reply(from, { type: MSG_ERROR, error: `
|
|
237
|
+
return reply(from, { type: MSG_ERROR, error: `this pairing was opened for "${pend.mode || 'join'}" and the device asked for "${intent}"` })
|
|
235
238
|
}
|
|
236
239
|
if (!isFresh(d)) {
|
|
237
240
|
audit('rejected', { what: 'enroll', reason: 'stale' })
|
|
@@ -329,25 +332,25 @@ export function createEnrollDesk ({
|
|
|
329
332
|
// Aprobar un emparejamiento ES admitir al dispositivo en el perfil: el cert es la
|
|
330
333
|
// credencial y el acta es la política, y no tiene sentido emitir una sin la otra.
|
|
331
334
|
// Las capacidades salen del scope que se pidió al emparejar (cert ∩ acta, §2.3).
|
|
332
|
-
let
|
|
335
|
+
let record = null
|
|
333
336
|
try {
|
|
334
337
|
if (typeof identity.admitMember === 'function') {
|
|
335
338
|
const cn = scopeToCn(pend.scope)
|
|
336
339
|
const caps = cn ? ['secrets'] : scopeToCaps(pend.scope)
|
|
337
340
|
if (caps.length) await identity.admitMember({ pub: pend.dpub, encPub: pend.encPub || null, label: pend.label || '', cn, caps, cert, continuity: pend.continuity || null })
|
|
338
341
|
}
|
|
339
|
-
|
|
340
|
-
} catch (e) { log('[vault]
|
|
342
|
+
record = (await identity.profileActa?.())?.acta || null
|
|
343
|
+
} catch (e) { log('[vault] could not admit into the record:', e.message) }
|
|
341
344
|
|
|
342
345
|
audit('enroll', { device: pend.deviceId, label: pend.label || '', scope: pend.scope })
|
|
343
346
|
// Echamos el código tipeado junto al cert: el DISPOSITIVO acepta solo si coincide
|
|
344
347
|
// con el que generó → una bóveda falsa (que no lo conoce) no puede enrolarlo.
|
|
345
348
|
// El acta viaja con el cert: el dispositivo ya sabe de quién es el perfil al que entra.
|
|
346
|
-
reply(pend.from, { type: MSG_ENROLLED, code, cert, iss, acta })
|
|
349
|
+
reply(pend.from, { type: MSG_ENROLLED, code, cert, iss, acta: record })
|
|
347
350
|
pend.state = 'DONE'
|
|
348
351
|
pending.delete(pend.token)
|
|
349
352
|
fire(onPendingChange)
|
|
350
|
-
log('[vault]
|
|
353
|
+
log('[vault] device approved: %s', pend.deviceId)
|
|
351
354
|
return { ok: true, deviceId: pend.deviceId, cert }
|
|
352
355
|
}
|
|
353
356
|
|
|
@@ -367,39 +370,39 @@ export function createEnrollDesk ({
|
|
|
367
370
|
* pisar una cuenta con datos, y eso no puede pasar por accidente.
|
|
368
371
|
*/
|
|
369
372
|
async function handleActaSealed (from, p) {
|
|
370
|
-
const
|
|
373
|
+
const record = p?.acta
|
|
371
374
|
const pend = [...pending.values()].find((x) => x.state === 'AWAITING_ACTA' && (x.from === from || x.dpub))
|
|
372
375
|
if (!pend) return reply(from, { type: MSG_ERROR, error: 'no adoption awaiting a record' })
|
|
373
|
-
if (!
|
|
374
|
-
if (
|
|
376
|
+
if (!record || typeof record !== 'object') return reply(from, { type: MSG_ERROR, error: 'record missing or unreadable' })
|
|
377
|
+
if (record.sealer !== iss) {
|
|
375
378
|
audit('rejected', { what: 'adopt', reason: 'not-sealer' })
|
|
376
379
|
return reply(from, { type: MSG_ERROR, error: 'that record does not name this vault as the sealer' })
|
|
377
380
|
}
|
|
378
|
-
if (
|
|
381
|
+
if (record.sealedBy !== pend.dpub) {
|
|
379
382
|
audit('rejected', { what: 'adopt', reason: 'sealed-by-other' })
|
|
380
383
|
return reply(from, { type: MSG_ERROR, error: 'that record was not sealed by the device of this pairing' })
|
|
381
384
|
}
|
|
382
|
-
if (pend.profileId &&
|
|
385
|
+
if (pend.profileId && record.profileId !== pend.profileId) {
|
|
383
386
|
audit('rejected', { what: 'adopt', reason: 'other-profile' })
|
|
384
387
|
return reply(from, { type: MSG_ERROR, error: 'that record belongs to an account other than the one the device announced' })
|
|
385
388
|
}
|
|
386
389
|
|
|
387
390
|
try {
|
|
388
|
-
const r = await identity.joinProfile(
|
|
391
|
+
const r = await identity.joinProfile(record)
|
|
389
392
|
if (!r?.joined) throw new Error(r?.reason || 'could not adopt')
|
|
390
|
-
audit('adopt', { device: pend.deviceId, profile:
|
|
393
|
+
audit('adopt', { device: pend.deviceId, profile: record.profileId, seq: record.seq })
|
|
391
394
|
// El acta que vuelve es la que la bóveda tiene guardada: el aparato la adopta y los
|
|
392
395
|
// dos quedan en la misma versión.
|
|
393
|
-
const
|
|
394
|
-
reply(pend.from, { type: MSG_ACTA_ADOPTED, code: p.code, acta:
|
|
396
|
+
const mine = (await identity.profileActa?.())?.acta || record
|
|
397
|
+
reply(pend.from, { type: MSG_ACTA_ADOPTED, code: p.code, acta: mine })
|
|
395
398
|
pend.state = 'DONE'
|
|
396
399
|
pending.delete(pend.token)
|
|
397
400
|
fire(onPendingChange)
|
|
398
|
-
fire(onAdopted, { deviceId: pend.deviceId, profileId:
|
|
399
|
-
log('[vault]
|
|
400
|
-
return { ok: true, adopted: true, profileId:
|
|
401
|
+
fire(onAdopted, { deviceId: pend.deviceId, profileId: record.profileId, seq: mine.seq })
|
|
402
|
+
log('[vault] account adopted from device %s (profile %s)', pend.deviceId, record.profileId?.slice(0, 12))
|
|
403
|
+
return { ok: true, adopted: true, profileId: record.profileId, seq: mine.seq }
|
|
401
404
|
} catch (e) {
|
|
402
|
-
log('[vault]
|
|
405
|
+
log('[vault] could not adopt the account: %s', e.message)
|
|
403
406
|
reply(pend.from, { type: MSG_ERROR, error: 'the vault could not adopt the account: ' + e.message })
|
|
404
407
|
return { ok: false, error: e.message }
|
|
405
408
|
}
|
|
@@ -435,15 +438,39 @@ export function createEnrollDesk ({
|
|
|
435
438
|
async function revoke (nonce) {
|
|
436
439
|
audit('revoke', { nonce })
|
|
437
440
|
const { issued } = await identity.listDelegations()
|
|
438
|
-
const
|
|
441
|
+
const delegation = (issued || []).find((d) => d.nonce === nonce)
|
|
439
442
|
const res = await identity.revokeDelegation(nonce)
|
|
440
|
-
if (
|
|
443
|
+
if (delegation?.sub) await emitRevoke(delegation.sub, nonce)
|
|
444
|
+
return res
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* QUITA EL DISPOSITIVO entero: retira TODOS sus certificados vigentes y le manda una
|
|
449
|
+
* sola orden de autoborrado. `revoke(nonce)` retira un papel, y un aparato puede tener
|
|
450
|
+
* varios (una renovación dejaba vivo el anterior): quitarle uno no lo echaba, y podía
|
|
451
|
+
* quedarse dentro justo con el que llevaba `vault:admin`.
|
|
452
|
+
*/
|
|
453
|
+
async function revokeDevice (sub) {
|
|
454
|
+
if (!sub) throw new Error('sub (device pubkey) required')
|
|
455
|
+
const { issued } = await identity.listDelegations()
|
|
456
|
+
const mine = (issued || []).filter((d) => d.sub === sub)
|
|
457
|
+
audit('revoke-device', { certs: mine.length })
|
|
458
|
+
// Si el núcleo no trae `revokeDevice` (bóveda vieja), se cae a retirarlos uno a uno.
|
|
459
|
+
const res = identity.revokeDevice
|
|
460
|
+
? await identity.revokeDevice(sub)
|
|
461
|
+
: { ok: true, nonces: await (async () => {
|
|
462
|
+
const done = []
|
|
463
|
+
for (const d of mine) { await identity.revokeDelegation(d.nonce); done.push(d.nonce) }
|
|
464
|
+
return done
|
|
465
|
+
})() }
|
|
466
|
+
await emitRevoke(sub, mine[0]?.nonce || null)
|
|
467
|
+
fire(onDeviceRemoved, sub)
|
|
441
468
|
return res
|
|
442
469
|
}
|
|
443
470
|
|
|
444
471
|
return {
|
|
445
472
|
startPairing, stopPairing, handleEnroll, handleActaSealed, handleHello, approve, reject,
|
|
446
|
-
listPending, findPending, emitRevoke, revoke,
|
|
473
|
+
listPending, findPending, emitRevoke, revoke, revokeDevice,
|
|
447
474
|
get pendingCount () { return pending.size }
|
|
448
475
|
}
|
|
449
476
|
}
|
|
@@ -52,7 +52,7 @@ export { deviceIdOf }
|
|
|
52
52
|
*/
|
|
53
53
|
export async function startDeviceVault (identity, { proxyUrl, client: injectedClient } = {}) {
|
|
54
54
|
const iss = identity.me?.publickey
|
|
55
|
-
if (!iss) throw new Error('
|
|
55
|
+
if (!iss) throw new Error('no identity: create/unlock your identity before using this device as a vault')
|
|
56
56
|
const proxy = proxyUrl || 'wss://proxy.dotrino.com'
|
|
57
57
|
|
|
58
58
|
// ----- self-cert P ← P (para que este dispositivo pueda además actuar de cliente
|
|
@@ -133,7 +133,7 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
133
133
|
*/
|
|
134
134
|
async function handleRenew (from, p) {
|
|
135
135
|
const d = p?.data
|
|
136
|
-
if (!d || !p.signature || !p.cert) return send(from, { type: MSG.ERROR, error: '
|
|
136
|
+
if (!d || !p.signature || !p.cert) return send(from, { type: MSG.ERROR, error: 'invalid request' })
|
|
137
137
|
if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) {
|
|
138
138
|
return send(from, { type: MSG.ERROR, error: 'stale request: ts outside the ±5 min window (possible replay, or the device clock is off)' })
|
|
139
139
|
}
|
|
@@ -151,18 +151,20 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
151
151
|
// QUIEN consulta es una máquina ya revocada (reapareció), le re-emite el REVOKED firmado.
|
|
152
152
|
async function handleDevices (from, p) {
|
|
153
153
|
const d = p?.data
|
|
154
|
-
if (!d || !p.signature || !p.cert) return send(from, { type: MSG.ERROR, error: '
|
|
154
|
+
if (!d || !p.signature || !p.cert) return send(from, { type: MSG.ERROR, error: 'invalid request' })
|
|
155
155
|
if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) return
|
|
156
156
|
const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, trustedIssuer: iss })
|
|
157
|
-
if (!chk.ok) return send(from, { type: MSG.ERROR, error: '
|
|
158
|
-
const { issued, revoked } = await identity.listDelegations()
|
|
157
|
+
if (!chk.ok) return send(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
|
|
158
|
+
const { issued, revoked, revokedCerts } = await identity.listDelegations()
|
|
159
159
|
const devices = await Promise.all((issued || []).map(async (x) => ({
|
|
160
160
|
deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null,
|
|
161
161
|
label: x.label || '', scope: x.scope, exp: x.exp, nonce: x.nonce
|
|
162
162
|
})))
|
|
163
163
|
send(from, { type: MSG.DEVICES_RESULT, devices, revoked: (revoked || []).map((r) => r.nonce || r) })
|
|
164
164
|
// ¿el que consulta es una máquina revocada que reapareció? → re-emite el REVOKED firmado.
|
|
165
|
-
|
|
165
|
+
// Se busca en `revokedCerts`: desde que `issued` solo trae lo vigente, los retirados
|
|
166
|
+
// ya no están ahí y este aviso dejaba de dispararse (se caía en silencio).
|
|
167
|
+
const mine = (revokedCerts || issued || []).find((x) => x.sub === chk.device && x.revokedAt)
|
|
166
168
|
if (mine) desk.emitRevoke(chk.device, mine.nonce)
|
|
167
169
|
}
|
|
168
170
|
|
|
@@ -204,8 +206,14 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
204
206
|
reject: (deviceId) => desk.reject(deviceId),
|
|
205
207
|
listPending: desk.listPending,
|
|
206
208
|
listMachines,
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
+
// QUITA LA MÁQUINA entera (por su llave): fuera del acta y sin ningún certificado
|
|
210
|
+
// vigente, que son las dos caras del mismo acto. Y AVISA con un REVOKED firmado para
|
|
211
|
+
// que se auto-borre (ahora si está online, o al reaparecer vía handleDevices).
|
|
212
|
+
revokeDevice: (sub) => desk.revokeDevice(sub),
|
|
213
|
+
// Retira UN certificado. No es quitar la máquina: sigue siendo miembro del acta, y
|
|
214
|
+
// quien queda así ya no recibe el aviso de expulsión (mientras siga en el acta, un
|
|
215
|
+
// papel retirado significa «renueva»). Usar `revokeDevice` salvo que quieras
|
|
216
|
+
// exactamente esto.
|
|
209
217
|
revoke: (nonce) => desk.revoke(nonce),
|
|
210
218
|
getSelfCert,
|
|
211
219
|
onPendingChange (fn) { _onPendingChange = fn || (() => {}) },
|
|
@@ -33,6 +33,11 @@ export const MSG = Object.freeze({
|
|
|
33
33
|
ACTA_SEALED: 'vault.acta.sealed', // dispositivo → vault: { acta, code }
|
|
34
34
|
ACTA_ADOPTED: 'vault.acta.adopted', // vault → dispositivo: { acta }
|
|
35
35
|
REVOKED: 'vault.revoked', // vault → dispositivo: { body:{op,sub,nonce,iat,exp}, signature }
|
|
36
|
+
// «¿sigo siendo de esta casa?» — la ÚNICA pregunta que se puede hacer SIN certificado:
|
|
37
|
+
// va firmada con la llave del propio aparato, que es lo que el acta nombra. Existe para
|
|
38
|
+
// el aparato que perdió su papel: sin ella no tiene forma de enterarse de que lo echaron.
|
|
39
|
+
CHECK: 'vault.check', // dispositivo → vault: { data:{op:'check',publickey,ts}, signature }
|
|
40
|
+
CHECKED: 'vault.checked', // vault → dispositivo: { in:boolean } — y si no, el REVOKED firmado
|
|
36
41
|
SIGN: 'vault.sign', // dispositivo → vault: { data, signature, cert }
|
|
37
42
|
SIGNED: 'vault.signed', // vault → dispositivo: { signature, publickey, device }
|
|
38
43
|
GET: 'vault.get', // dispositivo → vault: { data, signature, cert }
|
|
@@ -42,6 +47,8 @@ export const MSG = Object.freeze({
|
|
|
42
47
|
DEVICES: 'vault.devices', // dispositivo → vault: { data:{publickey,ts}, signature, cert }
|
|
43
48
|
DEVICES_RESULT: 'vault.devices.result', // vault → dispositivo: { devices, revoked }
|
|
44
49
|
RENEW: 'vault.renew', // dispositivo → vault: { data:{op,publickey,ts}, signature, cert }
|
|
50
|
+
RENOUNCE: 'vault.renounce', // dispositivo → vault: { record } (RENUNCIA firmada por el propio miembro)
|
|
51
|
+
RENOUNCE_RESULT: 'vault.renounce.result', // vault → dispositivo: { ok, seq }
|
|
45
52
|
RENEWED: 'vault.renewed', // vault → dispositivo: { cert } (cert fresco, misma sub-clave/scope)
|
|
46
53
|
SECRETS: 'vault.secrets', // servicio → vault: { data:{op,ns,ek,publickey,ts}, signature, cert }
|
|
47
54
|
SECRETS_RESULT: 'vault.secrets.result', // vault → servicio: { body:{op,ns,enc,ts}, signature } (enc SELLADO a ek; body firmado por la maestra)
|
|
@@ -91,3 +98,10 @@ export const SCOPE = Object.freeze({
|
|
|
91
98
|
export const SECRETS_SCOPE_PREFIX = 'vault:secrets:'
|
|
92
99
|
export const secretsScope = (ns) => SECRETS_SCOPE_PREFIX + ns
|
|
93
100
|
export const isValidSecretsNs = (ns) => typeof ns === 'string' && /^[a-z0-9-]{1,32}$/.test(ns)
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Nombre de una variable de entorno: `MAYUSCULAS_CON_GUION_BAJO`, hasta 64. Vive aquí
|
|
104
|
+
* —y no en el cajón que la guarda— porque la comprueban también la TUI, la consola
|
|
105
|
+
* remota y el lector de `.env`, y tres copias de una regla son tres reglas.
|
|
106
|
+
*/
|
|
107
|
+
export const isValidVarKey = (key) => typeof key === 'string' && /^[A-Z0-9_]{1,64}$/.test(key)
|