@dotrino/identity 0.48.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/identity",
3
- "version": "0.48.1",
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
- if (msg.error) pending.reject(new Error(msg.error))
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
@@ -0,0 +1,5 @@
1
+ interface Element { [key: string]: any; }
2
+ interface EventTarget { [key: string]: any; }
3
+ interface HTMLElement { [key: string]: any; }
4
+ interface Event { [key: string]: any; }
5
+ interface Window { [key: string]: any; }
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'
@@ -350,18 +350,20 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
350
350
  * de perfiles, con su nombre y su foto, sin poder hacer nada y sin que nadie supiera qué
351
351
  * era ni cómo quitarlo.
352
352
  *
353
- * El aparato NUNCA se queda sin cuenta utilizable, que es la otra mitad: si había otras,
354
- * se pasa a una de ellas; si esa era la única, se estrena una vacía. Así al terminar hay
355
- * exactamente lo que tiene que haber: un dispositivo con su cuenta, listo para usarse o
356
- * para volver a conectarse a una bóveda.
353
+ * El aparato no se queda sin cuenta utilizable: al recargar, el arranque estrena una si
354
+ * no quedó ninguna, o entra en la primera que haya. Así al terminar hay exactamente lo
355
+ * que tiene que haber: un dispositivo con su cuenta, listo para usarse o para volver a
356
+ * conectarse a una bóveda.
357
357
  *
358
358
  * Los pasos van EN ESTE ORDEN a propósito:
359
359
  * 1. fuera el enlace y el acta (deja de poder hablar con la bóveda y de enseñar el
360
360
  * perfil del que lo echaron);
361
361
  * 2. 'revoked' → `@dotrino/store` borra el store de ESE perfil (apunta al id que ya
362
362
  * tenía fijado, así que da igual lo que hagamos después con el perfil activo);
363
- * 3. se borra la cuenta y se deja otra puesta;
364
- * 4. 'account-removed' → la app RECARGA (multi-perfil no es reactivo, por diseño).
363
+ * 3. se borra la cuenta;
364
+ * 4. 'account-removed' → la app RECARGA (multi-perfil no es reactivo, por diseño), y
365
+ * es el ARRANQUE quien deja puesta la que toque: si no queda ninguna estrena la
366
+ * primera, y si quedan cae a la primera de la lista. Esa decisión ya vivía ahí.
365
367
  *
366
368
  * El paso 3 es SOLO del navegador (`removeAccountOnExpulsion`). En Node las cuentas las
367
369
  * lleva quien hospeda —el daemon del vault tiene su propio registro de perfiles, en
@@ -377,30 +379,105 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
377
379
  if (removeAccountOnExpulsion) removeThisAccount().catch(() => {})
378
380
  }
379
381
 
380
- /**
381
- * Borra la cuenta de ESTE dispositivo y deja otra activa (existente o recién creada).
382
- * Se usa al ser expulsado; ver `wipeVaultLink`.
383
- */
382
+ /** Borra la cuenta de ESTE dispositivo al ser expulsado; ver `wipeVaultLink`. */
384
383
  let removingAccount = false
385
384
  async function removeThisAccount () {
386
- // UNA VEZ. Puede haber varias peticiones en vuelo y a todas les llega el mismo aviso;
387
- // sin este cerrojo, la segunda borraría la cuenta RECIÉN CREADA (para entonces
388
- // `currentPid` ya es la nueva) en vez de la que echaron.
385
+ // UNA VEZ: puede haber varias peticiones en vuelo y a todas les llega el mismo aviso.
389
386
  if (removingAccount) return
390
387
  removingAccount = true
391
388
  const gone = currentPid
392
- // Si esa era la única cuenta hay que estrenar otra ANTES de borrarla: `deleteProfile`
393
- // se niega a dejar el dispositivo sin ninguna, y con razón. Crear ya deja la nueva
394
- // activa; si había otras, es el propio `deleteProfile` quien pasa a la primera.
395
- const created = loadProfiles().filter((p) => p.id !== gone).length === 0
389
+ try { await purgeProfile(gone) } catch (e) {
390
+ emitVault({ phase: 'account-removed', removed: gone, error: e?.message || String(e) })
391
+ return
392
+ }
393
+ // Y ya está: qué cuenta queda puesta lo resuelve el ARRANQUE al recargar, que es donde
394
+ // esa decisión ya vivía —si no queda ninguna estrena la primera, y si quedan cae a la
395
+ // primera de la lista—. No hace falta decidirlo aquí también.
396
+ emitVault({ phase: 'account-removed', removed: gone, current: currentPid })
397
+ }
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) {
396
431
  try {
397
- if (created) await handlers.createProfile({ name: '' })
398
- await handlers.deleteProfile({ id: gone })
432
+ await purgeProfile(pid)
433
+ if (backTo && loadProfiles().some((p) => p.id === backTo)) await openProfileInMemory(backTo)
399
434
  } catch (e) {
400
- emitVault({ phase: 'account-removed', removed: gone, current: currentPid, error: e?.message || String(e) })
401
- return
435
+ console.warn('[identity] could not discard the account born for the pairing:', e?.message || e)
402
436
  }
403
- emitVault({ phase: 'account-removed', removed: gone, current: currentPid, created })
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
+
453
+ /**
454
+ * Borra un perfil y todo lo suyo. Sin preguntas: el freno de «no te quedes sin ninguna»
455
+ * es de la interfaz y vive en `deleteProfile`.
456
+ */
457
+ async function purgeProfile (id) {
458
+ const list = loadProfiles().filter((p) => p.id !== id)
459
+ saveProfiles(list)
460
+ for (const s of ['keypair', 'enc-keypair', 'me', 'nonces', 'delegations', 'revocations', 'vault.device', 'vault.cert', 'acta', 'renounced']) {
461
+ rawKv.removeItem(`dotrino.identity.p.${id}.${s}`)
462
+ }
463
+ // …y sus CryptoKeys no extractables del keyStore (IndexedDB).
464
+ if (keyStore) {
465
+ for (const s of ['keypair', 'enc-keypair']) {
466
+ try { await keyStore.remove(`dotrino.identity.p.${id}.${s}`) } catch (_) {}
467
+ }
468
+ }
469
+ if (currentPid === id) {
470
+ if (list.length) { currentPid = list[0].id; rawKv.setItem(CURRENT_STORAGE, currentPid) }
471
+ else {
472
+ // No queda ninguna: el arranque estrenará la primera. Se borra el puntero, pero
473
+ // `currentPid` se deja como está a propósito — sin él, el kv deja de estar
474
+ // scopeado y cualquier escritura de aquí a la recarga caería en las claves SIN
475
+ // namespace, que son justo las que el arranque adopta como «Perfil 1». Apuntando
476
+ // a un perfil que ya no existe, lo que se escriba es inerte.
477
+ rawKv.removeItem(CURRENT_STORAGE)
478
+ }
479
+ }
480
+ return { ok: true, current: currentPid }
404
481
  }
405
482
 
406
483
  /**
@@ -609,6 +686,35 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
609
686
  return { joined: true, profileId: candidate.profileId, seq: candidate.seq }
610
687
  }
611
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
+
612
718
  /**
613
719
  * Adopta un acta que llega de otro miembro, si gana según §2.4.1 (seq mayor que encadene,
614
720
  * o el traspaso a igual seq). Nunca retrocede.
@@ -627,12 +733,28 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
627
733
  return last || { adopted: false, reason: 'nada-que-adoptar', seq: loadActa()?.seq ?? null }
628
734
  }
629
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
+ */
630
746
  async function adoptActa (candidate) {
631
747
  const current = loadActa()
632
748
  const r = await Acta.canAdopt({ candidate, current })
633
749
  if (!r.adopt) return { adopted: false, reason: r.reason, seq: current?.seq ?? null }
634
750
  saveActa(candidate)
635
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
+ }
636
758
  return { adopted: true, reason: r.reason, seq: candidate.seq }
637
759
  }
638
760
 
@@ -869,15 +991,62 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
869
991
  clearTimeout(profilePushTimer)
870
992
  profilePushTimer = setTimeout(() => {
871
993
  const { publickey, encryptionPubkey, ...content } = me || {}
872
- 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 })
873
995
  .catch(() => {}) // el vault puede estar apagado; se reintenta en la próxima edición
874
996
  }, 800) // debounce: ediciones seguidas = un solo push
875
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
+ */
876
1040
  async function pullProfileFromVault () {
877
1041
  try {
878
1042
  const v = loadVaultCert(); const device = loadVaultDevice()
879
- if (!v?.cert || !device || v.cert.exp <= Date.now()) return
880
- const res = await remoteStore({ master: v.master, proxy: v.proxy, device, cert: v.cert, method: 'profileGet', args: {} })
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 })
881
1050
  const remoteMe = res?.me
882
1051
  if (!remoteMe) {
883
1052
  // el vault aún no tiene perfil: sembrar con el local (si tiene contenido)
@@ -1233,16 +1402,15 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1233
1402
  */
1234
1403
  async createProfile ({ name, forVault = false } = {}) {
1235
1404
  const pid = 'p' + crypto.randomUUID().slice(0, 8)
1236
- currentPid = pid
1237
- rawKv.setItem(CURRENT_STORAGE, pid)
1238
- await peers.setProfile?.(pid)
1239
- await initPeerStorage()
1240
- keypair = await loadOrCreateKeypair(); publickeyJwkStr = JSON.stringify(keypair.publicJwk)
1241
- 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)
1242
1410
  me = { publickey: publickeyJwkStr, encryptionPubkey: encPublickeyJwkStr, nickname: String(name || '').slice(0, 40) }
1243
1411
  saveMe(me)
1244
1412
  const list = loadProfiles()
1245
- 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 } : {}) } : {}) })
1246
1414
  saveProfiles(list)
1247
1415
  await ensureActa(me.nickname) // el perfil nuevo nace con su acta (él mismo es el master)
1248
1416
  return { id: pid, name: me.nickname, pubkey: publickeyJwkStr, pendingJoin: !!forVault }
@@ -1260,22 +1428,13 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1260
1428
  return { id: e.id, name: e.name }
1261
1429
  },
1262
1430
  async deleteProfile ({ id } = {}) {
1263
- let list = loadProfiles()
1431
+ const list = loadProfiles()
1432
+ // El freno es de la INTERFAZ: el botón «Borrar» de la página de perfiles no puede
1433
+ // dejarte sin ninguna de un clic. La expulsión no pasa por aquí (ver `purgeProfile`):
1434
+ // ahí sí se va la última, porque no es un descuido sino que te echaron.
1264
1435
  if (list.length <= 1) throw new Error('cannot delete the only profile')
1265
1436
  if (!list.find((p) => p.id === id)) throw new Error('perfil no existe')
1266
- list = list.filter((p) => p.id !== id); saveProfiles(list)
1267
- // Borrado directo del namespace del perfil (incluye su store del vault si lo tuviera).
1268
- for (const s of ['keypair', 'enc-keypair', 'me', 'nonces', 'delegations', 'revocations', 'vault.device', 'vault.cert', 'acta', 'renounced']) {
1269
- rawKv.removeItem(`dotrino.identity.p.${id}.${s}`)
1270
- }
1271
- // …y sus CryptoKeys no extractables del keyStore (IndexedDB).
1272
- if (keyStore) {
1273
- for (const s of ['keypair', 'enc-keypair']) {
1274
- try { await keyStore.remove(`dotrino.identity.p.${id}.${s}`) } catch (_) {}
1275
- }
1276
- }
1277
- if (currentPid === id) { currentPid = list[0].id; rawKv.setItem(CURRENT_STORAGE, currentPid) }
1278
- return { ok: true, current: currentPid }
1437
+ return purgeProfile(id)
1279
1438
  },
1280
1439
 
1281
1440
  // ----- ACTA DE PERFIL -----
@@ -1495,6 +1654,11 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1495
1654
  * se adivina):
1496
1655
  * · `'new'` → camino B: crea aquí una cuenta más, con llave nueva, y ES ESA la que
1497
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.
1498
1662
  * · `'current'` → sigue con la cuenta abierta. Solo vale si nació para adoptar
1499
1663
  * (`forVault`) o si ya está emparejada con ESA misma bóveda
1500
1664
  * (re-emparejar). En cualquier otro caso falla **antes de tocar la
@@ -1507,35 +1671,43 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1507
1671
  */
1508
1672
  async vaultPair ({ qr, label = '', join = 'current' }) {
1509
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
1510
1678
  if (join === 'new') {
1511
- await handlers.createProfile({ name: label || me?.nickname || '', forVault: true })
1512
- } else {
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') {
1513
1697
  const yaConEsta = loadVaultCert()?.master === qr?.iss
1514
1698
  if (loadActa() && !isPendingJoin() && !yaConEsta) {
1515
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)')
1516
1700
  }
1517
1701
  }
1518
- // Usa la PROPIA llave de identidad de este navegador como dispositivo: el cert delega
1519
- // TU identidad (P) desde la maestra M → una sola identidad (signData/identify/cert = P).
1520
- // La privada es la CryptoKey del perfil (no extractable): se pasa como `privateKey`
1521
- // y NO se persiste ningún JWK del dispositivo (marcador useIdentityKey).
1522
- const device = { publickey: publickeyJwkStr, privateKey: keypair.privateKey }
1523
- // Si esta identidad ya existía por su cuenta, se lleva un certificado de continuidad
1524
- // firmado por ella misma: es el puente para que su reputación previa siga contando.
1525
- // Solo si esta llave tenía vida propia. Una recién creada para adoptar (camino B) no
1526
- // tiene pasado que salvar: mandarle un puente de continuidad sería puro ruido.
1527
- const mio = loadActa()
1528
- const continuity = (mio && mio.members.length === 1 && !isPendingJoin())
1529
- ? await Acta.makeContinuity({ member: publickeyJwkStr, from: mio.profileId, privateKey: keypair.privateKey })
1530
- : null
1531
- const res = await remoteEnroll({ qr, device, continuity, encPub: encPublickeyJwkStr, label: label || me?.nickname || '', onChallenge: (c) => emitVault({ phase: 'challenge', deviceId: c.deviceId, code: c.code }) })
1532
- kv.setItem(VAULT_DEVICE_STORAGE, JSON.stringify({ useIdentityKey: true, publickey: publickeyJwkStr }))
1533
- kv.setItem(VAULT_CERT_STORAGE, JSON.stringify({ cert: res.cert, master: res.master, proxy: res.proxy, deviceId: res.deviceId, pairedAt: Date.now() }))
1534
- // Conectarse a una bóveda es ENTRAR A SU CUENTA: el acta viene con el cert.
1535
- const unido = res.acta ? await joinProfile(res.acta) : { joined: false, reason: 'sin-acta' }
1536
- emitVault({ phase: 'paired', deviceId: res.deviceId, master: res.master, join: unido })
1537
- pullProfileFromVault() // adoptar el perfil que ya viva en el vault (si hay)
1538
- 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
+ }
1539
1711
  },
1540
1712
 
1541
1713
  /**
@@ -1698,7 +1870,19 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1698
1870
  // Lista (solo lectura) de dispositivos enrolados en tu vault.
1699
1871
  async listVaultDevices () {
1700
1872
  const v = loadVaultCert(); const device = loadVaultDevice()
1701
- if (!v?.cert || !device) throw new Error('this device is not paired with a vault')
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
+ }
1702
1886
  maybeRenewVaultCert()
1703
1887
  try {
1704
1888
  const res = await remoteDevices({ master: v.master, proxy: v.proxy, device, cert: v.cert, sinceSeq: loadActa()?.seq ?? 0, onRevoked: wipeVaultLink })
@@ -1876,6 +2060,30 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1876
2060
 
1877
2061
  // ----- bootstrap -----
1878
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
+
1879
2087
  // Perfil activo (multi-perfil por dispositivo). Si no hay perfiles, se crea el primero; si
1880
2088
  // existe una identidad ÚNICA vieja (pre-multi-perfil, claves sin namespace), se ADOPTA como
1881
2089
  // "Perfil 1" — sin pérdida. A partir de acá `kv` está scopeado a `currentPid`.
@@ -1941,6 +2149,9 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1941
2149
 
1942
2150
  // Perfil compartido: jalar del vault en background (gana el más nuevo).
1943
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()
1944
2155
 
1945
2156
  // Registrar el pubkey (y nombre) del perfil activo en su meta → para avatar/listado sin abrir cada perfil.
1946
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
- reply({ error: e?.message || String(e) })
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.18.0 (lib/src/{index,enroll,protocol}.js, sin dependencias).
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: `este emparejamiento se abrió para «${pend.mode || 'join'}» y el dispositivo pidió «${intent}»` })
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 acta = null
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
- acta = (await identity.profileActa?.())?.acta || null
340
- } catch (e) { log('[vault] no se pudo admitir en el acta:', e.message) }
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] dispositivo aprobado: %s', pend.deviceId)
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 acta = p?.acta
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 (!acta || typeof acta !== 'object') return reply(from, { type: MSG_ERROR, error: 'record missing or unreadable' })
374
- if (acta.sealer !== iss) {
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 (acta.sealedBy !== pend.dpub) {
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 && acta.profileId !== 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(acta)
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: acta.profileId, seq: acta.seq })
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 mia = (await identity.profileActa?.())?.acta || acta
394
- reply(pend.from, { type: MSG_ACTA_ADOPTED, code: p.code, acta: mia })
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: acta.profileId, seq: mia.seq })
399
- log('[vault] cuenta adoptada del dispositivo %s (perfil %s)', pend.deviceId, acta.profileId?.slice(0, 12))
400
- return { ok: true, adopted: true, profileId: acta.profileId, seq: mia.seq }
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] no se pudo adoptar la cuenta: %s', e.message)
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 dele = (issued || []).find((d) => d.nonce === nonce)
441
+ const delegation = (issued || []).find((d) => d.nonce === nonce)
439
442
  const res = await identity.revokeDelegation(nonce)
440
- if (dele?.sub) await emitRevoke(dele.sub, nonce)
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('sin identidad: crea/desbloquea tu identidad antes de usar el dispositivo como bóveda')
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: 'petición inválida' })
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: 'petición inválida' })
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: 'no autorizado: ' + chk.reason })
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
- const mine = (issued || []).find((x) => x.sub === chk.device && x.revokedAt)
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
- // Revoca y AVISA a la máquina con un REVOKED firmado para que se auto-borre (ahora si
208
- // está online, o al reaparecer vía handleDevices).
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)