@dotrino/identity 0.91.0 → 0.93.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.91.0",
3
+ "version": "0.93.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",
package/src/index.d.ts CHANGED
@@ -126,8 +126,11 @@ export class Identity {
126
126
  requestAssertion (args: { audience: string; nonce: string; scopes?: AssertionScope[]; ttlMs?: number; onBehalfOf?: string }): Promise<Assertion>
127
127
  /** Qué le has concedido a cada aplicación (permiso por origen). */
128
128
  listGrants (): Promise<Array<{ origin: string; scopes: AssertionScope[]; at: number; lastUsed: number; onBehalfOf?: string }>>
129
- /** Retirar lo concedido a un origen: la próxima vez que pida, se vuelve a preguntar. */
130
- revokeGrant (origin: string): Promise<{ ok: boolean }>
129
+ /**
130
+ * Retirar lo concedido a una aplicación: la próxima vez que pida, se vuelve a preguntar.
131
+ * `onBehalfOf` identifica a una aplicación que entra por el puente (el mismo origen para todas).
132
+ */
133
+ revokeGrant (origin: string, onBehalfOf?: string): Promise<{ ok: boolean }>
131
134
  setMyNickname (nickname: string): Promise<{ me: Me }>
132
135
  getEncryptionPubkey (): Promise<string>
133
136
  encrypt (recipients: EncryptRecipient[], plaintext: string): Promise<EnvelopeV1>
package/src/index.js CHANGED
@@ -274,8 +274,12 @@ export class Identity {
274
274
  */
275
275
  /** Qué le has concedido a cada aplicación. Sin esto, conceder no significaría nada. */
276
276
  async listGrants () { return this._call('listGrants') }
277
- /** Retirar lo concedido a un origen: la próxima vez que pida, se vuelve a preguntar. */
278
- async revokeGrant (origin) { return this._call('revokeGrant', { origin }) }
277
+ /**
278
+ * Retirar lo concedido a una aplicación: la próxima vez que pida, se vuelve a preguntar.
279
+ * Las que entran por el puente llegan todas desde el mismo origen, así que a esas se las
280
+ * nombra con `onBehalfOf` (el que trae su fila de `listGrants`).
281
+ */
282
+ async revokeGrant (origin, onBehalfOf) { return this._call('revokeGrant', { origin, ...(onBehalfOf ? { onBehalfOf } : {}) }) }
279
283
 
280
284
  async requestAssertion ({ audience, nonce, scopes, ttlMs, onBehalfOf } = {}) {
281
285
  // ESPERA LO QUE TARDE UNA PERSONA. Los cinco segundos de siempre valen para una
package/vault/acta.js CHANGED
@@ -35,7 +35,7 @@
35
35
  * Módulo PURO: sin kv, sin red, sin disco. Cripto de `./capabilities.js`.
36
36
  */
37
37
  import { canonicalStringify } from './core.js'
38
- import { signWithDevice, verifyDeviceSig, pubkeyId } from './capabilities.js'
38
+ import { signWithDevice, verifyDeviceSig, verifyDelegation, pubkeyId } from './capabilities.js'
39
39
 
40
40
  export const ACTA_V = 5
41
41
 
@@ -649,6 +649,52 @@ export async function verifyActa ({ acta, expectedProfileId } = {}) {
649
649
  return mal ? { ok: false, reason: mal } : { ok: true }
650
650
  }
651
651
 
652
+ /**
653
+ * ¿ES DE FIAR LO QUE CONTESTA LA BÓVEDA CON LA QUE TE EMPAREJASTE? El papel y el acta que
654
+ * llegan al enrolarse o al renovar, juzgados contra la llave de ESA bóveda (`vault`: la
655
+ * `iss` del QR, o la que quedó guardada al enrolarse).
656
+ *
657
+ * Antes cada cliente comparaba `acta.profileId` con esa llave. Eso solo es verdad en una
658
+ * cuenta de una bóveda que además nació en ella: el `profileId` es la llave del GÉNESIS, y
659
+ * en una cuenta que la bóveda ADOPTÓ, o en la SEGUNDA bóveda de un multivault, la llave de
660
+ * la bóveda es otra. Ahí ningún aparato podía entrar ni renovar. Y encima no protegía: el
661
+ * acta no se verificaba, así que bastaba con escribir ese `profileId` en una inventada.
662
+ *
663
+ * Lo que se comprueba ahora, y por qué alcanza:
664
+ * 1. el acta está bien firmada por quien dice haberla sellado;
665
+ * 2. esa bóveda PUEDE SELLAR esta acta — es de la cuenta, con el permiso, y una llave
666
+ * está en una sola cuenta, así que esto también fija la cuenta;
667
+ * 3. el papel lo firmó ESA bóveda y no otra selladora: la respuesta es suya, porque nadie
668
+ * por el camino puede firmar con su llave;
669
+ * 4. y el papel vale según esa acta (para esta llave, este permiso, un `seq` que no viene
670
+ * del futuro).
671
+ *
672
+ * `justSealed` es para el ENROLAMIENTO: aprobar es admitir al aparato, así que el acta que
673
+ * vuelve la acaba de sellar esa misma bóveda. Exigirlo ata el acta a su llave y no solo el
674
+ * papel — el llavero que el aparato va a usar viaja ahí dentro. Al RENOVAR no se pide: la
675
+ * última acta pudo sellarla otra selladora de la cuenta.
676
+ *
677
+ * @returns {Promise<{ok:true}|{ok:false, reason:string}>}
678
+ */
679
+ export async function checkVaultReply ({ acta, cert, vault, sub, scope = null, justSealed = false } = {}) {
680
+ if (!acta) return { ok: false, reason: 'sin-acta' }
681
+ if (!isPub(vault)) return { ok: false, reason: 'sin-boveda' }
682
+ // Para QUÉ llave es el papel no es opcional: sin ella `verifyDelegation` no lo mira, y un
683
+ // papel emitido para otra llave pasaría por bueno.
684
+ if (!isPub(sub)) return { ok: false, reason: 'sin-llave-del-aparato' }
685
+ const v = await verifyActa({ acta })
686
+ if (!v.ok) return { ok: false, reason: 'acta:' + v.reason }
687
+ if (!canSeal(acta, vault)) return { ok: false, reason: 'boveda-no-sella' }
688
+ if (justSealed && !samePubkey(acta.sealedBy, vault)) return { ok: false, reason: 'acta-de-otra-selladora' }
689
+ if (!cert || !samePubkey(cert.iss, vault)) return { ok: false, reason: 'papel-de-otra-llave' }
690
+ const d = await verifyDelegation({
691
+ cert, expectedSub: sub, actaSeq: acta.seq, sealers: sealersOf(acta),
692
+ ...(scope ? { expectedScope: scope } : {})
693
+ })
694
+ if (!d.ok) return { ok: false, reason: 'papel:' + d.reason }
695
+ return { ok: true }
696
+ }
697
+
652
698
  /**
653
699
  * Aplica cambios y devuelve el acta SIGUIENTE, **sin firmar** (hay que `sealActa`).
654
700
  * `by` es quien va a sellar: si no es el sellador vigente, se rechaza (regla 1).
@@ -1218,7 +1264,7 @@ export async function canAdopt ({ candidate, current }) {
1218
1264
 
1219
1265
  export default {
1220
1266
  ACTA_V, CAPS, CAP_SCOPE, genesisActa, actaBody, actaHash, memberId, checkShape, verifySealerChain, verifySignedBy,
1221
- sealActa, verifyActa, applyChanges, makeRenounce, verifyRenounce,
1267
+ sealActa, verifyActa, checkVaultReply, applyChanges, makeRenounce, verifyRenounce,
1222
1268
  makeContinuity, verifyContinuity,
1223
1269
  cardBody, makeProfileCard, verifyProfileCard, canAdoptCard, sealersOf, canSeal,
1224
1270
  effectiveCaps, memberCan, memberCanSign, memberCanScope, memberCanReadSecrets, memberScopes, isService, capScope, isValidCn, canAdopt,
package/vault/core.js CHANGED
@@ -725,13 +725,34 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
725
725
  // `acta.js`, que es puro y está probado aparte.
726
726
  // ----- PERMISO POR ORIGEN: qué le concedió el usuario a cada aplicación -----
727
727
  //
728
- // `{ [origin]: { scopes: [...], at } }`. Vive en el kv del PERFIL, así que cambiar de
729
- // perfil cambia lo concedido: lo que le diste a una aplicación desde tu cuenta de trabajo
730
- // no vale para la personal.
731
- const loadGrants = () => { try { return JSON.parse(kv.getItem(GRANTS_STORAGE) || '{}') } catch (_) { return {} } }
728
+ // `{ [grantKey]: { scopes: [...], at, lastUsed, onBehalfOf? } }`. Vive en el kv del
729
+ // PERFIL, así que cambiar de perfil cambia lo concedido: lo que le diste a una aplicación
730
+ // desde tu cuenta de trabajo no vale para la personal.
731
+ //
732
+ // LA CLAVE ES EL ORIGEN Y, SI PIDE POR OTRO, TAMBIÉN EN NOMBRE DE QUIÉN (dueño,
733
+ // 2026-09-17). Hasta 0.92 era solo el origen, y todas las aplicaciones que entran por el
734
+ // puente OIDC llegan desde el MISMO: `sso.dotrino.com`. Lo que el usuario le concedía a
735
+ // una lo heredaban todas las demás sin que saliera el panel, y en «dónde se usó mi
736
+ // identidad» se veían como una sola fila con el nombre de la última.
737
+ //
738
+ // Separar por `onBehalfOf` no le da a nadie más de lo que tenía: el nombre lo pone el
739
+ // origen, así que un origen que mintiera solo conseguiría una concesión aparte —vacía—,
740
+ // nunca la de otro origen. `|` no aparece en un origen, así que la clave no es ambigua.
741
+ const grantKey = (origin, onBehalfOf) => onBehalfOf ? `${origin}|${onBehalfOf}` : origin
742
+ const behalfName = (onBehalfOf) => onBehalfOf ? String(onBehalfOf).slice(0, 60) : ''
743
+ const loadGrants = () => {
744
+ let g
745
+ try { g = JSON.parse(kv.getItem(GRANTS_STORAGE) || '{}') } catch (_) { return {} }
746
+ // MIGRACIÓN DECLARADA (identity 0.93.0) — quitar después del 2026-10-17.
747
+ // Una entrada guardada bajo el origen A SECAS pero con `onBehalfOf` es de antes de 0.93:
748
+ // la compartían todas las aplicaciones del puente, así que no se puede atribuir a
749
+ // ninguna. Se tira, y cada aplicación vuelve a preguntar — que es el lado seguro.
750
+ for (const k of Object.keys(g)) if (!k.includes('|') && g[k]?.onBehalfOf) delete g[k]
751
+ return g
752
+ }
732
753
  const saveGrants = (g) => { try { kv.setItem(GRANTS_STORAGE, JSON.stringify(g)) } catch (_) {} }
733
- /** Lo concedido a un origen, hoy. */
734
- const grantedTo = (origin) => (loadGrants()[String(origin || '')]?.scopes) || []
754
+ /** Lo concedido a un origen (y a nombre de quién pide), hoy. */
755
+ const grantedTo = (key) => (loadGrants()[key]?.scopes) || []
735
756
 
736
757
  const loadActa = () => { try { return JSON.parse(kv.getItem(ACTA_STORAGE) || 'null') } catch (_) { return null } }
737
758
  const saveActa = (a) => kv.setItem(ACTA_STORAGE, JSON.stringify(a))
@@ -1537,6 +1558,8 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1537
1558
  */
1538
1559
  async function consentFor (origin, pedidos, onBehalfOf = null) {
1539
1560
  const org = String(origin || '').trim()
1561
+ const behalf = behalfName(onBehalfOf)
1562
+ const key = grantKey(org, behalf)
1540
1563
  // APUNTA CUÁNDO SE USÓ. Sin esto, «dónde se usó mi identidad» solo puede decir qué
1541
1564
  // concediste, no si sigue usándose — y eso es lo que hace que uno se decida a retirar
1542
1565
  // un permiso que ya no hace falta. Se apunta aunque solo se pida el mínimo: entrar es
@@ -1544,8 +1567,8 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1544
1567
  const marcarUso = () => {
1545
1568
  if (!org) return
1546
1569
  const g = loadGrants()
1547
- const prev = g[org] || { scopes: [], at: Date.now() }
1548
- g[org] = { ...prev, lastUsed: Date.now(), ...(onBehalfOf ? { onBehalfOf: String(onBehalfOf).slice(0, 60) } : {}) }
1570
+ const prev = g[key] || { scopes: [], at: Date.now() }
1571
+ g[key] = { ...prev, lastUsed: Date.now(), ...(behalf ? { onBehalfOf: behalf } : {}) }
1549
1572
  saveGrants(g)
1550
1573
  }
1551
1574
  const base = pedidos.filter((s) => s === 'id:whoami')
@@ -1555,7 +1578,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1555
1578
  // concede nada más que el mínimo. Es el caso de Node y el de una llamada interna.
1556
1579
  if (!org) return base.length ? base : ['id:whoami']
1557
1580
 
1558
- const yaTiene = grantedTo(org)
1581
+ const yaTiene = grantedTo(key)
1559
1582
  const faltan = extra.filter((s) => !yaTiene.includes(s))
1560
1583
  if (!faltan.length) { marcarUso(); return pedidos }
1561
1584
 
@@ -1565,17 +1588,17 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1565
1588
  return conocidos.length ? conocidos : ['id:whoami']
1566
1589
  }
1567
1590
  let ok = false
1568
- try { ok = !!(await askConsent({ origin: org, scopes: faltan, already: yaTiene, onBehalfOf })) } catch (_) { ok = false }
1591
+ try { ok = !!(await askConsent({ origin: org, scopes: faltan, already: yaTiene, onBehalfOf: behalf || null })) } catch (_) { ok = false }
1569
1592
  if (!ok) {
1570
1593
  const conocidos = [...base, ...extra.filter((s) => yaTiene.includes(s))]
1571
1594
  marcarUso()
1572
1595
  return conocidos.length ? conocidos : ['id:whoami']
1573
1596
  }
1574
1597
  const g = loadGrants()
1575
- g[org] = {
1598
+ g[key] = {
1576
1599
  scopes: [...new Set([...yaTiene, ...faltan])].sort(),
1577
- at: (g[org]?.at) || Date.now(), lastUsed: Date.now(),
1578
- ...(onBehalfOf ? { onBehalfOf: String(onBehalfOf).slice(0, 60) } : {})
1600
+ at: (g[key]?.at) || Date.now(), lastUsed: Date.now(),
1601
+ ...(behalf ? { onBehalfOf: behalf } : {})
1579
1602
  }
1580
1603
  saveGrants(g)
1581
1604
  return pedidos
@@ -1846,18 +1869,22 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1846
1869
  */
1847
1870
  async listGrants () {
1848
1871
  const g = loadGrants()
1849
- return Object.entries(g).map(([origin, v]) => ({
1850
- origin, scopes: v?.scopes || [], at: v?.at || 0,
1872
+ return Object.entries(g).map(([key, v]) => ({
1873
+ origin: key.split('|')[0], scopes: v?.scopes || [], at: v?.at || 0,
1851
1874
  lastUsed: v?.lastUsed || v?.at || 0,
1852
1875
  ...(v?.onBehalfOf ? { onBehalfOf: v.onBehalfOf } : {})
1853
1876
  })).sort((a, b) => b.lastUsed - a.lastUsed)
1854
1877
  },
1855
- /** Retirar lo concedido a un origen. La próxima vez que pida, se vuelve a preguntar. */
1856
- async revokeGrant ({ origin } = {}) {
1878
+ /**
1879
+ * Retirar lo concedido a una aplicación. La próxima vez que pida, se vuelve a preguntar.
1880
+ * `onBehalfOf` es obligatorio para las que piden por otro (el puente): retirar el permiso
1881
+ * de UNA no puede quitárselo a todas las que entran por el mismo sitio.
1882
+ */
1883
+ async revokeGrant ({ origin, onBehalfOf } = {}) {
1857
1884
  const g = loadGrants()
1858
- const org = String(origin || '')
1859
- if (!(org in g)) return { ok: false }
1860
- delete g[org]; saveGrants(g)
1885
+ const key = grantKey(String(origin || ''), behalfName(onBehalfOf))
1886
+ if (!(key in g)) return { ok: false }
1887
+ delete g[key]; saveGrants(g)
1861
1888
  return { ok: true }
1862
1889
  },
1863
1890
 
package/vault/remote.js CHANGED
@@ -13,8 +13,8 @@
13
13
  * No reimplementa cripto: usa `@dotrino/identity/capabilities`. Transporte:
14
14
  * `@dotrino/proxy-client` (importado perezosamente; solo se carga al emparejar).
15
15
  */
16
- import { makeDeviceKey, signWithDevice, verifyDelegation, verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from './capabilities.js'
17
- import { sealersOf } from './acta.js'
16
+ import { makeDeviceKey, signWithDevice, verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from './capabilities.js'
17
+ import { checkVaultReply } from './acta.js'
18
18
 
19
19
  const MSG = {
20
20
  HELLO: 'vault.hello',
@@ -218,14 +218,12 @@ export async function enrollDevice ({ qr, device, onChallenge, label = '', conti
218
218
  // (Aquí vivía el margen de reloj: el cert lo sellaba la bóveda con SU reloj y lo validaba
219
219
  // este aparato con el suyo, y 850 ms de diferencia bastaban para no poder enrolarse. Sin
220
220
  // vencimiento no hay ventana que ajustar y el problema no puede volver.)
221
- if (!res.acta) throw new Error('the vault did not send its record: cannot check who signed this cert')
222
- if (res.acta.profileId !== qr.iss) throw new Error('the record is from a profile other than the one you saw')
223
- const v = await verifyDelegation({
224
- cert: res.cert, expectedSub: dev.publickey,
225
- actaSeq: res.acta.seq, sealers: sealersOf(res.acta)
226
- })
227
- if (!v.ok) throw new Error('invalid cert: ' + v.reason)
228
- if (res.cert.sub !== dev.publickey) throw new Error('cert issued for a different device')
221
+ //
222
+ // Se juzga contra la llave de LA BÓVEDA con la que hablas, no contra el `profileId`:
223
+ // eso solo coincidía en una cuenta que nació en esa bóveda, y dejaba fuera a quien se
224
+ // emparejaba con una segunda bóveda o con una que adoptó la cuenta (`checkVaultReply`).
225
+ const chk = await checkVaultReply({ acta: res.acta, cert: res.cert, vault: qr.iss, sub: dev.publickey, justSealed: true })
226
+ if (!chk.ok) throw new Error('the vault reply does not check out: ' + chk.reason)
229
227
  return { device: dev, cert: res.cert, master: qr.iss, proxy: qr.proxy, deviceId, acta: res.acta || null }
230
228
  } finally { try { client.close() } catch (_) {} }
231
229
  }
@@ -381,12 +379,17 @@ export async function requestDevices ({ master, proxy, device, cert, sinceSeq, o
381
379
  */
382
380
  export async function requestRenew ({ master, proxy, device, cert, onRevoked } = {}) {
383
381
  const res = await vaultRpc({ master, proxy, device, cert, onRevoked, sendType: 'vault.renew', okType: 'vault.renewed', data: { op: 'renew' } })
384
- if (!res.cert || res.cert.sub !== device.publickey) throw new Error('invalid renewed cert')
385
382
  // EL ACTA VIAJA CON EL PAPEL y hay que dejarla pasar: quien lo recibe la necesita para
386
383
  // comprobar que lo firmó una SELLADORA de este perfil. Aquí se comparaba `cert.iss` con
387
384
  // la maestra y se tiraba el acta, así que arriba no había con qué juzgar y la renovación
388
385
  // fallaba con «the vault did not send its record» — con el papel correcto en la mano.
389
- return { cert: res.cert, acta: res.acta || null }
386
+ //
387
+ // Y se juzga AQUÍ, en el pilar: antes lo hacía cada cliente por su cuenta —uno comparando
388
+ // el `profileId`, que con dos bóvedas no vale, y el navegador guardando el papel sin mirar
389
+ // nada—. `master` es la bóveda a la que se le pidió.
390
+ const chk = await checkVaultReply({ acta: res.acta, cert: res.cert, vault: master, sub: device.publickey })
391
+ if (!chk.ok) throw new Error('invalid renewed cert: ' + chk.reason)
392
+ return { cert: res.cert, acta: res.acta }
390
393
  }
391
394
 
392
395
  /**
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/vault@0.63.0 (dotrino-vault/lib/src/{index,enroll,protocol}.js).
1
+ Copia vendorizada de @dotrino/vault@0.64.0 (dotrino-vault/lib/src/{index,enroll,protocol}.js).
2
2
  NO se edita a mano: la escribe `node vendor.mjs` y la vigila test/vendor-up-to-date.test.mjs.
3
3
  index.js importa ./enroll.js y ./protocol.js (relativos, van en esta misma copia),
4
4
  @dotrino/identity/{capabilities,acta} (= ../../{capabilities,acta}.js) y