@dotrino/identity 0.85.0 → 0.86.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/README.md CHANGED
@@ -160,6 +160,24 @@ const v = await verifySignedFor({ data: pin, signature, publickey, chain, audien
160
160
  `aud` es obligatorio igual que en la prueba; `exp` es opcional, porque la caducidad de lo
161
161
  publicado la lleva el servicio (el TTL del pin) y no el cuerpo.
162
162
 
163
+ ### Permiso por origen (0.86.0+)
164
+
165
+ Una prueba dice **quién eres** sin preguntar nada: quien llega al iframe ya pasó el filtro
166
+ de orígenes, y pedir permiso treinta veces al día por aplicaciones del mismo dueño es
167
+ ceremonia, no seguridad. Cualquier **dato tuyo** —nombre, foto, correo, enlaces— es otra
168
+ cosa: hace falta que lo concedas a ESE origen, y lo concedido se guarda y se puede retirar.
169
+
170
+ ```js
171
+ await id.listGrants() // [{ origin, scopes, at }]
172
+ await id.revokeGrant(origin) // y la próxima vez se vuelve a preguntar
173
+ ```
174
+
175
+ **El panel lo pinta la bóveda, no la aplicación.** Vive en otro origen, así que la página
176
+ que pide no puede pulsar ahí dentro ni leerlo; lo único que puede hacer es no mostrarlo, y
177
+ entonces no consigue el permiso — que es el lado correcto en el que fallar. Sin nadie a
178
+ quien preguntar (Node, o un cliente que no muestra nada), la respuesta es **no**: nunca se
179
+ amplía en silencio.
180
+
163
181
  ### Entrar sin enrolar: las sesiones (0.85.0+)
164
182
 
165
183
  Enlazar un aparato y entrar en uno no son lo mismo. Enlazar mete una llave en el acta —hay
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/identity",
3
- "version": "0.85.0",
3
+ "version": "0.86.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
@@ -124,6 +124,10 @@ export class Identity {
124
124
  listContacts (): Promise<PeerInfo[]>
125
125
  signData (data: any): Promise<{ signature: string; publickey: string }>
126
126
  requestAssertion (args: { audience: string; nonce: string; scopes?: AssertionScope[]; ttlMs?: number }): Promise<Assertion>
127
+ /** Qué le has concedido a cada aplicación (permiso por origen). */
128
+ listGrants (): Promise<Array<{ origin: string; scopes: AssertionScope[]; at: number }>>
129
+ /** Retirar lo concedido a un origen: la próxima vez que pida, se vuelve a preguntar. */
130
+ revokeGrant (origin: string): Promise<{ ok: boolean }>
127
131
  setMyNickname (nickname: string): Promise<{ me: Me }>
128
132
  getEncryptionPubkey (): Promise<string>
129
133
  encrypt (recipients: EncryptRecipient[], plaintext: string): Promise<EnvelopeV1>
package/src/index.js CHANGED
@@ -146,6 +146,12 @@ export class Identity {
146
146
  }
147
147
 
148
148
  if (msg.type === 'event') {
149
+ // MOSTRAR EL IFRAME PARA QUE PREGUNTE. El panel de permiso lo pinta la bóveda,
150
+ // en su propio origen: esta página no puede pulsar ahí dentro ni leerlo. Lo
151
+ // único que hace aquí es dejar sitio. Si una aplicación decidiera no hacerlo,
152
+ // no obtiene el permiso — que es el lado correcto en el que fallar.
153
+ if (msg.event === 'consent:open') this._showVault(true)
154
+ if (msg.event === 'consent:close') this._showVault(false)
149
155
  this._emit(msg.event, msg.payload)
150
156
  }
151
157
  }
@@ -157,6 +163,16 @@ export class Identity {
157
163
  return this._ready
158
164
  }
159
165
 
166
+ /** Deja ver la bóveda (a pantalla completa) mientras pregunta, y la devuelve a su sitio. */
167
+ _showVault (visible) {
168
+ const f = this._iframe
169
+ if (!f) return
170
+ f.style.cssText = visible
171
+ ? 'position:fixed;inset:0;width:100%;height:100%;border:0;z-index:2147483000'
172
+ : 'display:none'
173
+ f.setAttribute('aria-hidden', visible ? 'false' : 'true')
174
+ }
175
+
160
176
  destroy () {
161
177
  if (this._handler) window.removeEventListener('message', this._handler)
162
178
  if (this._iframe && this._iframe.parentNode) this._iframe.parentNode.removeChild(this._iframe)
@@ -256,6 +272,11 @@ export class Identity {
256
272
  * @returns {Promise<object>} la prueba, lista para mandar. Se comprueba con
257
273
  * `verifyAssertion(prueba, { audience, nonce })`.
258
274
  */
275
+ /** Qué le has concedido a cada aplicación. Sin esto, conceder no significaría nada. */
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 }) }
279
+
259
280
  async requestAssertion ({ audience, nonce, scopes, ttlMs } = {}) {
260
281
  const { assertion } = await this._call('requestAssertion', { audience, nonce, scopes, ttlMs })
261
282
  return assertion
package/vault/core.js CHANGED
@@ -37,6 +37,7 @@ export const ACTA_STORAGE = 'dotrino.identity.acta' // acta de p
37
37
  export const ACTA_HISTORY_STORAGE = 'dotrino.identity.acta.history' // últimas actas selladas (§1.3)
38
38
  export const PENDING_JOIN_STORAGE = 'dotrino.identity.pendingJoin' // «nací para adoptar la cuenta de otro»
39
39
  export const RENOUNCE_STORAGE = 'dotrino.identity.renounced' // renuncias propias aún no absorbidas por el master
40
+ export const GRANTS_STORAGE = 'dotrino.identity.grants' // qué le concediste a cada origen (permiso por origen)
40
41
  // Multi-perfil por dispositivo: lista de perfiles + el activo. Cada perfil tiene su propio
41
42
  // namespace `dotrino.identity.p.<id>.<suffix>` para TODAS las claves de arriba (keypair, me, etc.).
42
43
  export const PROFILES_STORAGE = 'dotrino.identity.profiles' // [{ id, name, pubkey }]
@@ -183,7 +184,15 @@ function sanitizeProfilePatch (patch = {}) {
183
184
  return out
184
185
  }
185
186
 
186
- export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, keyStore = null, sessionKv = null, removeAccountOnExpulsion = true, keyLock = null }) {
187
+ /**
188
+ * `askConsent` es CÓMO SE PREGUNTA, y lo pone quien monta el núcleo (el iframe pinta su
189
+ * propio panel; en Node no hay a quién preguntar). Se inyecta en vez de vivir aquí porque
190
+ * este módulo no toca interfaz — y porque QUIÉN pinta importa: un panel dibujado por la
191
+ * aplicación que pide sería la aplicación aprobándose a sí misma.
192
+ *
193
+ * Si no se inyecta, no se concede nada nuevo: sin forma de preguntar, la respuesta es no.
194
+ */
195
+ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, keyStore = null, sessionKv = null, removeAccountOnExpulsion = true, keyLock = null, askConsent = null }) {
187
196
  const {
188
197
  initPeerStorage, loadPeers, savePeers, setPeersDirect, upsertPeer, onDirty
189
198
  } = peers
@@ -594,6 +603,16 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
594
603
  // Diseño en `dotrino-vault/docs/acta-de-perfil.md`. Aquí solo se guarda, se lee y se
595
604
  // sella; las reglas (sellador único, seq/prev, no dejar el perfil sin firmante) viven en
596
605
  // `acta.js`, que es puro y está probado aparte.
606
+ // ----- PERMISO POR ORIGEN: qué le concedió el usuario a cada aplicación -----
607
+ //
608
+ // `{ [origin]: { scopes: [...], at } }`. Vive en el kv del PERFIL, así que cambiar de
609
+ // perfil cambia lo concedido: lo que le diste a una aplicación desde tu cuenta de trabajo
610
+ // no vale para la personal.
611
+ const loadGrants = () => { try { return JSON.parse(kv.getItem(GRANTS_STORAGE) || '{}') } catch (_) { return {} } }
612
+ const saveGrants = (g) => { try { kv.setItem(GRANTS_STORAGE, JSON.stringify(g)) } catch (_) {} }
613
+ /** Lo concedido a un origen, hoy. */
614
+ const grantedTo = (origin) => (loadGrants()[String(origin || '')]?.scopes) || []
615
+
597
616
  const loadActa = () => { try { return JSON.parse(kv.getItem(ACTA_STORAGE) || 'null') } catch (_) { return null } }
598
617
  const saveActa = (a) => kv.setItem(ACTA_STORAGE, JSON.stringify(a))
599
618
  // VENTANA DE RETENCIÓN (§1.3): el master conserva las últimas actas para que un miembro
@@ -1382,6 +1401,49 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1382
1401
  'profileActa', 'profileMembers', 'myMembership', 'isMaster', 'sealerChain'
1383
1402
  ])
1384
1403
 
1404
+ /**
1405
+ * ¿QUÉ SE LE DEJA VER A ESTE ORIGEN? El corazón del permiso por origen.
1406
+ *
1407
+ * `id:whoami` —solo quién eres, sin ningún dato— se concede sin preguntar: quien llega
1408
+ * aquí ya pasó el filtro de orígenes del iframe, y preguntarlo treinta veces al día por
1409
+ * aplicaciones del mismo dueño es ceremonia, no seguridad.
1410
+ *
1411
+ * Todo lo DEMÁS (nombre, foto, correo, redes) exige una concesión guardada, y si no la
1412
+ * hay se pregunta. Lo que el usuario diga se guarda por origen y se puede retirar.
1413
+ *
1414
+ * Y si no hay a quién preguntar —Node, o un cliente que no muestra el panel— se devuelve
1415
+ * lo que ya estuviera concedido y nada más. **No se amplía en silencio**: sin respuesta,
1416
+ * la respuesta es no.
1417
+ */
1418
+ async function consentFor (origin, pedidos) {
1419
+ const org = String(origin || '').trim()
1420
+ const base = pedidos.filter((s) => s === 'id:whoami')
1421
+ const extra = pedidos.filter((s) => s !== 'id:whoami')
1422
+ if (!extra.length) return pedidos
1423
+ // Sin origen no se puede llevar la cuenta de a quién se le concedió qué, así que no se
1424
+ // concede nada más que el mínimo. Es el caso de Node y el de una llamada interna.
1425
+ if (!org) return base.length ? base : ['id:whoami']
1426
+
1427
+ const yaTiene = grantedTo(org)
1428
+ const faltan = extra.filter((s) => !yaTiene.includes(s))
1429
+ if (!faltan.length) return pedidos
1430
+
1431
+ if (typeof askConsent !== 'function') {
1432
+ const conocidos = [...base, ...extra.filter((s) => yaTiene.includes(s))]
1433
+ return conocidos.length ? conocidos : ['id:whoami']
1434
+ }
1435
+ let ok = false
1436
+ try { ok = !!(await askConsent({ origin: org, scopes: faltan, already: yaTiene })) } catch (_) { ok = false }
1437
+ if (!ok) {
1438
+ const conocidos = [...base, ...extra.filter((s) => yaTiene.includes(s))]
1439
+ return conocidos.length ? conocidos : ['id:whoami']
1440
+ }
1441
+ const g = loadGrants()
1442
+ g[org] = { scopes: [...new Set([...yaTiene, ...faltan])].sort(), at: Date.now() }
1443
+ saveGrants(g)
1444
+ return pedidos
1445
+ }
1446
+
1385
1447
  const handlers = {
1386
1448
  async profileLockStatus () {
1387
1449
  refreshLockState()
@@ -1641,13 +1703,31 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1641
1703
  * (`dotrino-vault/docs/inicio-de-sesion.md`); hasta entonces esto no entrega nada que
1642
1704
  * no se entregue ya, que es la forma de no adelantar una decisión del usuario.
1643
1705
  */
1644
- async requestAssertion ({ audience, nonce, scopes, ttlMs } = {}) {
1706
+ /**
1707
+ * QUÉ LE HAS CONCEDIDO A CADA APLICACIÓN. Es la mitad que hace que el permiso sea del
1708
+ * usuario y no un trámite: si no se puede ver ni retirar, conceder no significa nada.
1709
+ */
1710
+ async listGrants () {
1711
+ const g = loadGrants()
1712
+ return Object.entries(g).map(([origin, v]) => ({ origin, scopes: v?.scopes || [], at: v?.at || 0 }))
1713
+ .sort((a, b) => b.at - a.at)
1714
+ },
1715
+ /** Retirar lo concedido a un origen. La próxima vez que pida, se vuelve a preguntar. */
1716
+ async revokeGrant ({ origin } = {}) {
1717
+ const g = loadGrants()
1718
+ const org = String(origin || '')
1719
+ if (!(org in g)) return { ok: false }
1720
+ delete g[org]; saveGrants(g)
1721
+ return { ok: true }
1722
+ },
1723
+
1724
+ async requestAssertion ({ audience, nonce, scopes, ttlMs, __origin } = {}) {
1645
1725
  if (typeof audience !== 'string' || !audience.trim()) throw new Error('audience required')
1646
1726
  if (typeof nonce !== 'string' || !nonce) throw new Error('nonce required')
1647
1727
  const acta = loadActa()
1648
1728
  // A NOMBRE DE QUIÉN va: la identidad es el `profileId`, no la llave de este aparato.
1649
1729
  const sub = acta?.profileId || publickeyJwkStr
1650
- const granted = cleanScopes(scopes)
1730
+ const granted = await consentFor(__origin, cleanScopes(scopes))
1651
1731
  const permitido = claimsAllowed(granted)
1652
1732
  const claims = {}
1653
1733
  if (permitido.size) {
package/vault/vault.js CHANGED
@@ -55,12 +55,62 @@ import { pubkeyId } from './capabilities.js'
55
55
  removeItem: (k) => sessionStorage.removeItem(k)
56
56
  }
57
57
 
58
+ /**
59
+ * EL PANEL DE PERMISO LO PINTA ESTE IFRAME, no la aplicación que pide.
60
+ *
61
+ * Es la diferencia entre un permiso y un trámite: la aplicación vive en otro origen, así
62
+ * que no puede pulsar aquí dentro ni leer lo que hay. Lo único que puede hacer es NO
63
+ * mostrarnos —y entonces no consigue el permiso, que es el lado correcto en el que
64
+ * fallar—. Por eso se le pide que nos muestre (`consent:open`) y, si no lo hace, la
65
+ * pregunta se queda sin responder y se deniega sola.
66
+ */
67
+ const T_CONSENT = (() => {
68
+ const en = (navigator.language || 'es').startsWith('en')
69
+ return en
70
+ ? { title: 'wants to see', allow: 'Allow', deny: 'No', who: 'Your Dotrino identity', once: 'Only what you allow leaves here.' }
71
+ : { title: 'quiere ver', allow: 'Permitir', deny: 'No', who: 'Tu identidad de Dotrino', once: 'De aquí solo sale lo que permitas.' }
72
+ })()
73
+ const SCOPE_TXT = (() => {
74
+ const en = (navigator.language || 'es').startsWith('en')
75
+ return en
76
+ ? { 'profile:name': 'your name', 'profile:avatar': 'your picture', 'profile:email': 'your email', 'profile:social': 'your links', 'id:whoami': 'who you are' }
77
+ : { 'profile:name': 'tu nombre', 'profile:avatar': 'tu foto', 'profile:email': 'tu correo', 'profile:social': 'tus enlaces', 'id:whoami': 'quién eres' }
78
+ })()
79
+
80
+ let consentAbierto = null
81
+ function askConsent ({ origin, scopes }) {
82
+ if (consentAbierto) return Promise.resolve(false) // una pregunta a la vez
83
+ return new Promise((resolve) => {
84
+ const host = document.createElement('div')
85
+ host.style.cssText = 'position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;background:rgba(10,8,20,.86);font-family:system-ui,-apple-system,Segoe UI,sans-serif'
86
+ const lista = scopes.map((x) => `<li>${SCOPE_TXT[x] || x}</li>`).join('')
87
+ host.innerHTML = `<div style="background:#171331;border:1px solid #2a2350;border-radius:16px;padding:22px;min-width:min(320px,90vw);max-width:90vw;color:#e7e3ff">
88
+ <div style="opacity:.7;font-size:13px">${T_CONSENT.who}</div>
89
+ <div style="font-weight:700;margin:8px 0 4px">${String(origin).replace(/^https?:\/\//, '')} ${T_CONSENT.title}:</div>
90
+ <ul style="margin:6px 0 12px 18px;padding:0">${lista}</ul>
91
+ <div style="opacity:.7;font-size:12px;margin-bottom:12px">${T_CONSENT.once}</div>
92
+ <div style="display:flex;gap:8px">
93
+ <button data-yes style="flex:1;padding:10px;border-radius:10px;border:0;background:#7c3aed;color:#fff;font:inherit;font-weight:600;cursor:pointer">${T_CONSENT.allow}</button>
94
+ <button data-no style="flex:1;padding:10px;border-radius:10px;border:1px solid #2a2350;background:transparent;color:inherit;font:inherit;cursor:pointer">${T_CONSENT.deny}</button>
95
+ </div></div>`
96
+ const cerrar = (v) => { try { host.remove() } catch (_) {} consentAbierto = null; broadcast('consent:close', {}); resolve(v) }
97
+ host.querySelector('[data-yes]').addEventListener('click', () => cerrar(true))
98
+ host.querySelector('[data-no]').addEventListener('click', () => cerrar(false))
99
+ consentAbierto = host
100
+ document.body.appendChild(host)
101
+ broadcast('consent:open', { origin })
102
+ // Si nadie contesta —porque nadie nos mostró—, se deniega. Nunca al revés.
103
+ setTimeout(() => { if (consentAbierto === host) cerrar(false) }, 60000)
104
+ })
105
+ }
106
+
58
107
  const core = await createIdentityCore({
59
108
  kv,
60
109
  peers: { initPeerStorage, loadPeers, savePeers, setPeersDirect, upsertPeer, onDirty },
61
110
  makeSync: createSync,
62
111
  keyStore,
63
- sessionKv
112
+ sessionKv,
113
+ askConsent
64
114
  })
65
115
 
66
116
  const { handlers } = core
@@ -290,7 +340,10 @@ import { pubkeyId } from './capabilities.js'
290
340
  const handler = selfHandlers[method] || handlers[method]
291
341
  if (!handler) return reply({ error: `Unknown method: ${method}` })
292
342
  try {
293
- const result = await handler(params || {})
343
+ // EL ORIGEN LO PONE EL IFRAME, no quien llama: va pisado a propósito. Es el único
344
+ // dato que la aplicación no puede falsificar —el navegador lo garantiza— y de él
345
+ // depende a quién se le concedió qué.
346
+ const result = await handler({ ...(params || {}), __origin: event.origin })
294
347
  reply({ result })
295
348
  } catch (e) {
296
349
  // `code` (y su `detail`) CRUZAN. Sin ellos, al otro lado solo llegaba la frase, y una