@dotrino/identity 0.86.2 → 0.88.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 +11 -1
- package/package.json +1 -1
- package/src/index.d.ts +7 -2
- package/src/index.js +20 -2
- package/src/node.js +3 -0
- package/vault/core.js +144 -10
- package/vault/vault.js +8 -5
package/README.md
CHANGED
|
@@ -168,10 +168,20 @@ ceremonia, no seguridad. Cualquier **dato tuyo** —nombre, foto, correo, enlace
|
|
|
168
168
|
cosa: hace falta que lo concedas a ESE origen, y lo concedido se guarda y se puede retirar.
|
|
169
169
|
|
|
170
170
|
```js
|
|
171
|
-
await id.listGrants() // [{ origin, scopes, at }]
|
|
171
|
+
await id.listGrants() // [{ origin, scopes, at, lastUsed, onBehalfOf? }]
|
|
172
172
|
await id.revokeGrant(origin) // y la próxima vez se vuelve a preguntar
|
|
173
173
|
```
|
|
174
174
|
|
|
175
|
+
`lastUsed` se apunta cada vez que ese origen pide una prueba, **aunque solo pida el
|
|
176
|
+
mínimo**: entrar es usar, lleve datos o no. Sin ese dato, «dónde se usó mi identidad» solo
|
|
177
|
+
podría decir qué concediste, no si sigue usándose — que es lo que hace que uno se decida a
|
|
178
|
+
retirar un permiso que ya no hace falta.
|
|
179
|
+
|
|
180
|
+
`onBehalfOf` lo declara el origen cuando pide **por otro** (lo usa el puente OIDC, que pide
|
|
181
|
+
por una aplicación de fuera; sin esto todas ellas se verían como una sola entrada). Es una
|
|
182
|
+
afirmación suya, no un hecho comprobable, así que se enseña siempre subordinada al origen —
|
|
183
|
+
«Tal App · a través de sso.dotrino.com»— y nunca en su lugar.
|
|
184
|
+
|
|
175
185
|
**El panel lo pinta la bóveda, no la aplicación.** Vive en otro origen, así que la página
|
|
176
186
|
que pide no puede pulsar ahí dentro ni leerlo; lo único que puede hacer es no mostrarlo, y
|
|
177
187
|
entonces no consigue el permiso — que es el lado correcto en el que fallar. Sin nadie a
|
package/package.json
CHANGED
package/src/index.d.ts
CHANGED
|
@@ -123,9 +123,9 @@ export class Identity {
|
|
|
123
123
|
removeContact (publickey: string): Promise<PeerInfo | null>
|
|
124
124
|
listContacts (): Promise<PeerInfo[]>
|
|
125
125
|
signData (data: any): Promise<{ signature: string; publickey: string }>
|
|
126
|
-
requestAssertion (args: { audience: string; nonce: string; scopes?: AssertionScope[]; ttlMs?: number }): Promise<Assertion>
|
|
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
|
-
listGrants (): Promise<Array<{ origin: string; scopes: AssertionScope[]; at: number }>>
|
|
128
|
+
listGrants (): Promise<Array<{ origin: string; scopes: AssertionScope[]; at: number; lastUsed: number; onBehalfOf?: string }>>
|
|
129
129
|
/** Retirar lo concedido a un origen: la próxima vez que pida, se vuelve a preguntar. */
|
|
130
130
|
revokeGrant (origin: string): Promise<{ ok: boolean }>
|
|
131
131
|
setMyNickname (nickname: string): Promise<{ me: Me }>
|
|
@@ -167,6 +167,11 @@ export class Identity {
|
|
|
167
167
|
vaultSign (payload: any): Promise<{ signature: string; publickey: string }>
|
|
168
168
|
vaultStore (method: string, args?: any): Promise<any>
|
|
169
169
|
listVaultDevices (): Promise<{ devices: any[]; revoked: any[] }>
|
|
170
|
+
/** Pedidos de aprobación de la cuenta activa (o de otra, con `profile`). */
|
|
171
|
+
vaultApprovals (op: 'approvals' | 'approve' | 'deny', args?: { id?: string; profile?: string }): Promise<any>
|
|
172
|
+
/** Los pedidos de TODAS las cuentas de este dispositivo que aprueban, sin cambiar la activa. */
|
|
173
|
+
vaultApprovalsAll (): Promise<Array<{ profile: string; name: string; current: boolean; items: any[]; error?: string }>>
|
|
174
|
+
canApproveVault (): Promise<boolean>
|
|
170
175
|
getVaultCert (): Promise<any>
|
|
171
176
|
onVault (handler: (payload: any) => void): () => void
|
|
172
177
|
// self-vault (este dispositivo ES el vault, daemon dentro del iframe)
|
package/src/index.js
CHANGED
|
@@ -277,14 +277,14 @@ export class Identity {
|
|
|
277
277
|
/** Retirar lo concedido a un origen: la próxima vez que pida, se vuelve a preguntar. */
|
|
278
278
|
async revokeGrant (origin) { return this._call('revokeGrant', { origin }) }
|
|
279
279
|
|
|
280
|
-
async requestAssertion ({ audience, nonce, scopes, ttlMs } = {}) {
|
|
280
|
+
async requestAssertion ({ audience, nonce, scopes, ttlMs, onBehalfOf } = {}) {
|
|
281
281
|
// ESPERA LO QUE TARDE UNA PERSONA. Los cinco segundos de siempre valen para una
|
|
282
282
|
// llamada que solo hace cuentas; esta puede abrir la pantalla de permiso (§permiso por
|
|
283
283
|
// origen) y ahí quien contesta es un humano. Con el plazo corto, el cliente se rendía
|
|
284
284
|
// con «Vault timeout» mientras el usuario todavía miraba el panel — y pulsar «Permitir»
|
|
285
285
|
// ya no servía de nada. El tope es un poco mayor que el del propio panel, para que el
|
|
286
286
|
// error que llegue sea «no lo concedió», que dice lo que pasó.
|
|
287
|
-
const { assertion } = await this._call('requestAssertion', { audience, nonce, scopes, ttlMs }, 65000)
|
|
287
|
+
const { assertion } = await this._call('requestAssertion', { audience, nonce, scopes, ttlMs, onBehalfOf }, 65000)
|
|
288
288
|
return assertion
|
|
289
289
|
}
|
|
290
290
|
|
|
@@ -463,11 +463,29 @@ export class Identity {
|
|
|
463
463
|
/**
|
|
464
464
|
* PEDIDOS DE APROBACIÓN de la bóveda (cajones con `approval`): `op` = `approvals` ·
|
|
465
465
|
* `approve` · `deny` (estos dos con `{ id }`). Requiere `vault:approve` en el cert.
|
|
466
|
+
*
|
|
467
|
+
* `args.profile` apunta a OTRA cuenta de este dispositivo (la que diga
|
|
468
|
+
* `vaultApprovalsAll`); sin él es la activa.
|
|
466
469
|
*/
|
|
467
470
|
async vaultApprovals (op, args) {
|
|
468
471
|
return this._call('vaultApprovals', { op, ...(args || {}) }, 20000)
|
|
469
472
|
}
|
|
470
473
|
|
|
474
|
+
/**
|
|
475
|
+
* LOS PEDIDOS DE TODAS TUS CUENTAS, SIN CAMBIARTE DE CUENTA.
|
|
476
|
+
*
|
|
477
|
+
* Una entrada por cuenta de este dispositivo que pueda aprobar: `{ profile, name,
|
|
478
|
+
* current, items }`, o `{ ..., error }` si a esa no se le pudo preguntar. El timbre no
|
|
479
|
+
* dice a qué cuenta llamó, así que quien enseña Pedidos tiene que mirar en todas —y
|
|
480
|
+
* hacerlo cambiando la cuenta activa significaba una recarga por cuenta, con el avatar
|
|
481
|
+
* y el icono de la app cambiando a la vista.
|
|
482
|
+
*
|
|
483
|
+
* Aprobar uno de otra cuenta: `vaultApprovals('approve', { id, profile })`.
|
|
484
|
+
*/
|
|
485
|
+
async vaultApprovalsAll () {
|
|
486
|
+
return this._call('vaultApprovalsAll', {}, 30000)
|
|
487
|
+
}
|
|
488
|
+
|
|
471
489
|
/** Registra el token de push de la app nativa (FCM/APNs) bajo la llave de este aparato. */
|
|
472
490
|
async registerPush (args) {
|
|
473
491
|
return this._call('registerPush', args || {}, 20000)
|
package/src/node.js
CHANGED
|
@@ -257,6 +257,9 @@ export class Identity {
|
|
|
257
257
|
vaultStore (method, args) { return this._h('vaultStore', { method, args }) }
|
|
258
258
|
listVaultDevices () { return this._h('listVaultDevices') }
|
|
259
259
|
getVaultCert () { return this._h('getVaultCert') }
|
|
260
|
+
vaultApprovals (op, args) { return this._h('vaultApprovals', { op, ...(args || {}) }) }
|
|
261
|
+
/** Los pedidos de TODAS las cuentas que aprueban, sin cambiar la activa. */
|
|
262
|
+
vaultApprovalsAll () { return this._h('vaultApprovalsAll') }
|
|
260
263
|
onVault (handler) { return this.on('vault', handler) }
|
|
261
264
|
// Multi-perfil por dispositivo (crear/cambiar reinicializa con el nuevo perfil activo).
|
|
262
265
|
listProfiles () { return this._h('listProfiles') }
|
package/vault/core.js
CHANGED
|
@@ -527,6 +527,42 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
527
527
|
} catch (_) { return null }
|
|
528
528
|
}
|
|
529
529
|
|
|
530
|
+
/**
|
|
531
|
+
* LA LLAVE DE FIRMA DE OTRA CUENTA DE ESTE DISPOSITIVO, sin abrirla ni ponerla activa.
|
|
532
|
+
*
|
|
533
|
+
* Existe para poder PREGUNTAR por los pedidos de cada cuenta sin cambiarse a ella (ver
|
|
534
|
+
* `vaultApprovalsAll`). La privada nunca se ve: en el almacén de llaves es una `CryptoKey`
|
|
535
|
+
* no extraíble y se devuelve tal cual, para firmar y nada más.
|
|
536
|
+
*
|
|
537
|
+
* Tres cosas que NO se hacen aquí, y las tres importan:
|
|
538
|
+
*
|
|
539
|
+
* · **No se mueve `currentPid`.** Abrir otra cuenta «un momento» pisa la identidad en
|
|
540
|
+
* memoria y además persiste cuál es la activa: cualquier otra llamada que caiga en medio
|
|
541
|
+
* se atendería con la cuenta equivocada, y una recarga a mitad te deja en otra cuenta.
|
|
542
|
+
* · **No se genera ninguna llave.** Si no está, se dice. Un `catch` que cae a `generateKey`
|
|
543
|
+
* le cambiaría la identidad a esa cuenta y la dejaría fuera de su propio perfil para
|
|
544
|
+
* siempre — el mismo fallo que ya está avisado en `loadOrCreatePair`.
|
|
545
|
+
* · **No se abre lo sellado.** Una cuenta del camino legado guarda su privada sellada bajo
|
|
546
|
+
* su contraseña; sin la frase no hay con qué, y se contesta `profile-locked` en vez de
|
|
547
|
+
* fingir que no existe.
|
|
548
|
+
*/
|
|
549
|
+
async function signerForProfile (pid) {
|
|
550
|
+
const nombre = KEY_STORAGE.replace(/^dotrino\.identity\./, `dotrino.identity.p.${pid}.`)
|
|
551
|
+
if (keyStore) {
|
|
552
|
+
const guardado = await keyStore.get(nombre).catch(() => null)
|
|
553
|
+
if (guardado?.privateKey && guardado?.publicJwk) {
|
|
554
|
+
return { publickey: JSON.stringify(guardado.publicJwk), privateKey: guardado.privateKey }
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
const raw = rawKv.getItem(nombre)
|
|
558
|
+
if (raw) {
|
|
559
|
+
const g = JSON.parse(raw)
|
|
560
|
+
if (g?.sealed) throw Object.assign(new Error('that profile is locked: its key is sealed under its password'), { code: 'profile-locked' })
|
|
561
|
+
if (g?.privateJwk && g?.publicJwk) return { publickey: JSON.stringify(g.publicJwk), privateJwk: g.privateJwk }
|
|
562
|
+
}
|
|
563
|
+
throw Object.assign(new Error('no signing key stored for that profile'), { code: 'no-key' })
|
|
564
|
+
}
|
|
565
|
+
|
|
530
566
|
/** La cuenta de este dispositivo que YA está emparejada con la bóveda `master`, si la hay. */
|
|
531
567
|
const profilePairedWith = (master) => {
|
|
532
568
|
if (!master) return null
|
|
@@ -575,6 +611,25 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
575
611
|
if (e && /\brevoked\b/.test(e.message || '')) emitVault({ phase: 'rejected', reason: e.message })
|
|
576
612
|
throw e
|
|
577
613
|
}
|
|
614
|
+
/**
|
|
615
|
+
* APROBAR O DENEGAR un pedido de OTRA cuenta de este dispositivo.
|
|
616
|
+
*
|
|
617
|
+
* Es el par de `vaultApprovalsAll`: si la pantalla enseña los pedidos de las tres cuentas,
|
|
618
|
+
* el botón «Aprobar» tiene que funcionar en las tres — si no, seguirías teniendo que
|
|
619
|
+
* cambiarte, que es lo que esto vino a quitar. Firma con la llave de ESA cuenta y no toca
|
|
620
|
+
* nada de la activa.
|
|
621
|
+
*
|
|
622
|
+
* Solo lo suyo: sin cert o sin `vault:approve` en ese cert, no hay nada que hacer aquí.
|
|
623
|
+
*/
|
|
624
|
+
async function approvalsDeOtroPerfil (pid, { op, id } = {}) {
|
|
625
|
+
if (!loadProfiles().some((p) => p.id === pid)) throw Object.assign(new Error('that profile does not exist on this device'), { code: 'no-profile' })
|
|
626
|
+
const v = vaultCertOf(pid)
|
|
627
|
+
if (!v?.cert) throw Object.assign(new Error('that profile is not paired with a vault'), { code: 'not-paired' })
|
|
628
|
+
if (!(v.cert.scope || []).includes('vault:approve')) throw Object.assign(new Error('that profile does not approve requests'), { code: 'no-approve' })
|
|
629
|
+
const device = await signerForProfile(pid)
|
|
630
|
+
return remoteApproval({ master: v.master, proxy: v.proxy, device, cert: v.cert, op, id })
|
|
631
|
+
}
|
|
632
|
+
|
|
578
633
|
/** Id estable y corto de una llave de cifrado: con esto se indexan las envolturas. */
|
|
579
634
|
const encKeyId = async (encPub) => (await pubkeyIdOf(encPub)).slice(0, 16)
|
|
580
635
|
|
|
@@ -1415,31 +1470,48 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1415
1470
|
* lo que ya estuviera concedido y nada más. **No se amplía en silencio**: sin respuesta,
|
|
1416
1471
|
* la respuesta es no.
|
|
1417
1472
|
*/
|
|
1418
|
-
async function consentFor (origin, pedidos) {
|
|
1473
|
+
async function consentFor (origin, pedidos, onBehalfOf = null) {
|
|
1419
1474
|
const org = String(origin || '').trim()
|
|
1475
|
+
// APUNTA CUÁNDO SE USÓ. Sin esto, «dónde se usó mi identidad» solo puede decir qué
|
|
1476
|
+
// concediste, no si sigue usándose — y eso es lo que hace que uno se decida a retirar
|
|
1477
|
+
// un permiso que ya no hace falta. Se apunta aunque solo se pida el mínimo: entrar es
|
|
1478
|
+
// usar, lleve datos o no.
|
|
1479
|
+
const marcarUso = () => {
|
|
1480
|
+
if (!org) return
|
|
1481
|
+
const g = loadGrants()
|
|
1482
|
+
const prev = g[org] || { scopes: [], at: Date.now() }
|
|
1483
|
+
g[org] = { ...prev, lastUsed: Date.now(), ...(onBehalfOf ? { onBehalfOf: String(onBehalfOf).slice(0, 60) } : {}) }
|
|
1484
|
+
saveGrants(g)
|
|
1485
|
+
}
|
|
1420
1486
|
const base = pedidos.filter((s) => s === 'id:whoami')
|
|
1421
1487
|
const extra = pedidos.filter((s) => s !== 'id:whoami')
|
|
1422
|
-
if (!extra.length) return pedidos
|
|
1488
|
+
if (!extra.length) { marcarUso(); return pedidos }
|
|
1423
1489
|
// Sin origen no se puede llevar la cuenta de a quién se le concedió qué, así que no se
|
|
1424
1490
|
// concede nada más que el mínimo. Es el caso de Node y el de una llamada interna.
|
|
1425
1491
|
if (!org) return base.length ? base : ['id:whoami']
|
|
1426
1492
|
|
|
1427
1493
|
const yaTiene = grantedTo(org)
|
|
1428
1494
|
const faltan = extra.filter((s) => !yaTiene.includes(s))
|
|
1429
|
-
if (!faltan.length) return pedidos
|
|
1495
|
+
if (!faltan.length) { marcarUso(); return pedidos }
|
|
1430
1496
|
|
|
1431
1497
|
if (typeof askConsent !== 'function') {
|
|
1432
1498
|
const conocidos = [...base, ...extra.filter((s) => yaTiene.includes(s))]
|
|
1499
|
+
marcarUso()
|
|
1433
1500
|
return conocidos.length ? conocidos : ['id:whoami']
|
|
1434
1501
|
}
|
|
1435
1502
|
let ok = false
|
|
1436
|
-
try { ok = !!(await askConsent({ origin: org, scopes: faltan, already: yaTiene })) } catch (_) { ok = false }
|
|
1503
|
+
try { ok = !!(await askConsent({ origin: org, scopes: faltan, already: yaTiene, onBehalfOf })) } catch (_) { ok = false }
|
|
1437
1504
|
if (!ok) {
|
|
1438
1505
|
const conocidos = [...base, ...extra.filter((s) => yaTiene.includes(s))]
|
|
1506
|
+
marcarUso()
|
|
1439
1507
|
return conocidos.length ? conocidos : ['id:whoami']
|
|
1440
1508
|
}
|
|
1441
1509
|
const g = loadGrants()
|
|
1442
|
-
g[org] = {
|
|
1510
|
+
g[org] = {
|
|
1511
|
+
scopes: [...new Set([...yaTiene, ...faltan])].sort(),
|
|
1512
|
+
at: (g[org]?.at) || Date.now(), lastUsed: Date.now(),
|
|
1513
|
+
...(onBehalfOf ? { onBehalfOf: String(onBehalfOf).slice(0, 60) } : {})
|
|
1514
|
+
}
|
|
1443
1515
|
saveGrants(g)
|
|
1444
1516
|
return pedidos
|
|
1445
1517
|
}
|
|
@@ -1709,8 +1781,11 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1709
1781
|
*/
|
|
1710
1782
|
async listGrants () {
|
|
1711
1783
|
const g = loadGrants()
|
|
1712
|
-
return Object.entries(g).map(([origin, v]) => ({
|
|
1713
|
-
|
|
1784
|
+
return Object.entries(g).map(([origin, v]) => ({
|
|
1785
|
+
origin, scopes: v?.scopes || [], at: v?.at || 0,
|
|
1786
|
+
lastUsed: v?.lastUsed || v?.at || 0,
|
|
1787
|
+
...(v?.onBehalfOf ? { onBehalfOf: v.onBehalfOf } : {})
|
|
1788
|
+
})).sort((a, b) => b.lastUsed - a.lastUsed)
|
|
1714
1789
|
},
|
|
1715
1790
|
/** Retirar lo concedido a un origen. La próxima vez que pida, se vuelve a preguntar. */
|
|
1716
1791
|
async revokeGrant ({ origin } = {}) {
|
|
@@ -1721,13 +1796,22 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1721
1796
|
return { ok: true }
|
|
1722
1797
|
},
|
|
1723
1798
|
|
|
1724
|
-
|
|
1799
|
+
/**
|
|
1800
|
+
* `onBehalfOf` es EN NOMBRE DE QUIÉN dice pedir el origen. Lo usa el puente OIDC, que
|
|
1801
|
+
* pide por una aplicación de fuera: sin esto, todas ellas se verían como una sola
|
|
1802
|
+
* entrada («Sso») y el usuario no sabría a quién le está dejando entrar.
|
|
1803
|
+
*
|
|
1804
|
+
* Es una AFIRMACIÓN del origen, no un hecho comprobable — y por eso se enseña siempre
|
|
1805
|
+
* subordinado a él («sso.dotrino.com dice: Tal App»), nunca en su lugar. Quien
|
|
1806
|
+
* responde por lo que pase sigue siendo el origen.
|
|
1807
|
+
*/
|
|
1808
|
+
async requestAssertion ({ audience, nonce, scopes, ttlMs, onBehalfOf, __origin } = {}) {
|
|
1725
1809
|
if (typeof audience !== 'string' || !audience.trim()) throw new Error('audience required')
|
|
1726
1810
|
if (typeof nonce !== 'string' || !nonce) throw new Error('nonce required')
|
|
1727
1811
|
const acta = loadActa()
|
|
1728
1812
|
// A NOMBRE DE QUIÉN va: la identidad es el `profileId`, no la llave de este aparato.
|
|
1729
1813
|
const sub = acta?.profileId || publickeyJwkStr
|
|
1730
|
-
const granted = await consentFor(__origin, cleanScopes(scopes))
|
|
1814
|
+
const granted = await consentFor(__origin, cleanScopes(scopes), onBehalfOf)
|
|
1731
1815
|
const permitido = claimsAllowed(granted)
|
|
1732
1816
|
const claims = {}
|
|
1733
1817
|
if (permitido.size) {
|
|
@@ -2472,8 +2556,12 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
2472
2556
|
* PEDIDOS DE APROBACIÓN: lo que le toca al teléfono cuando un cajón de la bóveda exige
|
|
2473
2557
|
* el visto bueno por uso. `op`: `approvals` (listar) · `approve` · `deny` (con `id`).
|
|
2474
2558
|
* Requiere `vault:approve` en el cert, que se concede a mano (`caps <ID> +aprueba`).
|
|
2559
|
+
*
|
|
2560
|
+
* `profile` apunta a OTRA cuenta de este mismo dispositivo (ver `vaultApprovalsAll`):
|
|
2561
|
+
* sin él es la activa, como siempre.
|
|
2475
2562
|
*/
|
|
2476
|
-
async vaultApprovals ({ op, id } = {}) {
|
|
2563
|
+
async vaultApprovals ({ op, id, profile = null } = {}) {
|
|
2564
|
+
if (profile && profile !== currentPid) return approvalsDeOtroPerfil(profile, { op, id })
|
|
2477
2565
|
const v = loadVaultCert(); const device = loadVaultDevice()
|
|
2478
2566
|
if (!v?.cert || !device) throw new Error('this device is not paired with a vault')
|
|
2479
2567
|
maybeRenewVaultCert()
|
|
@@ -2481,6 +2569,52 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
2481
2569
|
catch (e) { return handleVaultError(e) }
|
|
2482
2570
|
},
|
|
2483
2571
|
|
|
2572
|
+
/**
|
|
2573
|
+
* LOS PEDIDOS DE TODAS TUS CUENTAS, SIN CAMBIARTE DE CUENTA.
|
|
2574
|
+
*
|
|
2575
|
+
* El timbre del teléfono no dice a qué perfil llamó —viaja por FCM, o sea por Google, y
|
|
2576
|
+
* ahí no se mete nada que identifique al dueño—, así que la pantalla de Pedidos tiene
|
|
2577
|
+
* que mirar en todos los que aprueban. Antes lo hacía cambiando el perfil activo y
|
|
2578
|
+
* recargando la página una vez por cuenta: funcionaba y era insufrible, porque el avatar
|
|
2579
|
+
* y el icono de la app cambiaban dos o tres veces por abrir la pantalla (dueño,
|
|
2580
|
+
* 2026-09-10: «rotan los perfiles, cambia el icono y es molesto»).
|
|
2581
|
+
*
|
|
2582
|
+
* Y era innecesario: **este iframe tiene las llaves de todos los perfiles**. Cada cuenta
|
|
2583
|
+
* guarda la suya en el mismo almacén, bajo su propio nombre, y son `CryptoKey` NO
|
|
2584
|
+
* EXTRAÍBLES: se puede firmar con ellas sin verlas y sin tocar cuál es la activa. Eso es
|
|
2585
|
+
* justo lo que hace falta, y es lo único que se hace aquí.
|
|
2586
|
+
*
|
|
2587
|
+
* Lo que NO hace, a propósito: no renueva certificados ajenos ni borra enlaces ajenos
|
|
2588
|
+
* (eso escribe, y escribir en otra cuenta desde la pantalla de otra es pedir un lío).
|
|
2589
|
+
* Mirar es de solo lectura; si el papel de una cuenta está desfasado, se dice y punto.
|
|
2590
|
+
*
|
|
2591
|
+
* @returns {Promise<Array<{ profile, name, items, error? }>>} una entrada por cuenta que
|
|
2592
|
+
* aprueba — con sus pedidos, o con el motivo por el que no se pudo preguntar.
|
|
2593
|
+
*/
|
|
2594
|
+
async vaultApprovalsAll () {
|
|
2595
|
+
// La activa sí se pone al día: es la única en la que esta pantalla puede escribir.
|
|
2596
|
+
try { maybeRenewVaultCert() } catch (_) {}
|
|
2597
|
+
const perfiles = loadProfiles()
|
|
2598
|
+
const salida = await Promise.all(perfiles.map(async (p) => {
|
|
2599
|
+
const v = p.id === currentPid ? loadVaultCert() : vaultCertOf(p.id)
|
|
2600
|
+
if (!v?.cert) return null // sin bóveda: no es asunto suyo
|
|
2601
|
+
if (!(v.cert.scope || []).includes('vault:approve')) return null // no aprueba: tampoco
|
|
2602
|
+
const base = { profile: p.id, name: p.name || '', current: p.id === currentPid }
|
|
2603
|
+
let device = null
|
|
2604
|
+
try { device = p.id === currentPid ? loadVaultDevice() : await signerForProfile(p.id) } catch (e) {
|
|
2605
|
+
return { ...base, items: [], error: e?.code || 'no-key' }
|
|
2606
|
+
}
|
|
2607
|
+
if (!device) return { ...base, items: [], error: 'no-key' }
|
|
2608
|
+
try {
|
|
2609
|
+
const r = await remoteApproval({ master: v.master, proxy: v.proxy, device, cert: v.cert, op: 'approvals' })
|
|
2610
|
+
return { ...base, items: Array.isArray(r?.items) ? r.items : [] }
|
|
2611
|
+
} catch (e) {
|
|
2612
|
+
return { ...base, items: [], error: e?.code || e?.message || 'error' }
|
|
2613
|
+
}
|
|
2614
|
+
}))
|
|
2615
|
+
return salida.filter(Boolean)
|
|
2616
|
+
},
|
|
2617
|
+
|
|
2484
2618
|
/**
|
|
2485
2619
|
* PUSH DE LA APP NATIVA: registra el token (FCM/APNs) de este aparato en el proxio,
|
|
2486
2620
|
* bajo la llave del dispositivo, para que la bóveda pueda «timbrarlo» cuando haya
|
package/vault/vault.js
CHANGED
|
@@ -67,8 +67,8 @@ import { pubkeyId } from './capabilities.js'
|
|
|
67
67
|
const T_CONSENT = (() => {
|
|
68
68
|
const en = (navigator.language || 'es').startsWith('en')
|
|
69
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.' }
|
|
70
|
+
? { title: 'wants to see', allow: 'Allow', deny: 'No', who: 'Your Dotrino identity', once: 'Only what you allow leaves here.', via: 'through' }
|
|
71
|
+
: { title: 'quiere ver', allow: 'Permitir', deny: 'No', who: 'Tu identidad de Dotrino', once: 'De aquí solo sale lo que permitas.', via: 'a través de' }
|
|
72
72
|
})()
|
|
73
73
|
const SCOPE_TXT = (() => {
|
|
74
74
|
const en = (navigator.language || 'es').startsWith('en')
|
|
@@ -85,6 +85,9 @@ import { pubkeyId } from './capabilities.js'
|
|
|
85
85
|
* No se importa el catálogo de aplicaciones: eso ataría este iframe al repositorio del
|
|
86
86
|
* home y habría que subirlo cada vez que nace una app. El subdominio ya es el nombre.
|
|
87
87
|
*/
|
|
88
|
+
/** Lo que dice el origen se pinta como texto, nunca como marcado: quien lo escribe es él. */
|
|
89
|
+
const escaparTexto = (t) => String(t).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
|
|
90
|
+
|
|
88
91
|
function nombreDeOrigen (origin) {
|
|
89
92
|
try {
|
|
90
93
|
const h = new URL(origin).hostname
|
|
@@ -96,7 +99,7 @@ import { pubkeyId } from './capabilities.js'
|
|
|
96
99
|
}
|
|
97
100
|
|
|
98
101
|
let consentAbierto = null
|
|
99
|
-
function askConsent ({ origin, scopes }) {
|
|
102
|
+
function askConsent ({ origin, scopes, onBehalfOf }) {
|
|
100
103
|
if (consentAbierto) return Promise.resolve(false) // una pregunta a la vez
|
|
101
104
|
return new Promise((resolve) => {
|
|
102
105
|
const host = document.createElement('div')
|
|
@@ -104,8 +107,8 @@ import { pubkeyId } from './capabilities.js'
|
|
|
104
107
|
const lista = scopes.map((x) => `<li>${SCOPE_TXT[x] || x}</li>`).join('')
|
|
105
108
|
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">
|
|
106
109
|
<div style="opacity:.7;font-size:13px">${T_CONSENT.who}</div>
|
|
107
|
-
<div style="font-weight:700;margin:8px 0 4px">${nombreDeOrigen(origin)} ${T_CONSENT.title}:</div>
|
|
108
|
-
<div style="opacity:.55;font-size:12px;margin-bottom:6px">${String(origin).replace(/^https?:\/\//, '')}</div>
|
|
110
|
+
<div style="font-weight:700;margin:8px 0 4px">${onBehalfOf ? escaparTexto(onBehalfOf) : nombreDeOrigen(origin)} ${T_CONSENT.title}:</div>
|
|
111
|
+
<div style="opacity:.55;font-size:12px;margin-bottom:6px">${onBehalfOf ? T_CONSENT.via + ' ' : ''}${String(origin).replace(/^https?:\/\//, '')}</div>
|
|
109
112
|
<ul style="margin:6px 0 12px 18px;padding:0">${lista}</ul>
|
|
110
113
|
<div style="opacity:.7;font-size:12px;margin-bottom:12px">${T_CONSENT.once}</div>
|
|
111
114
|
<div style="display:flex;gap:8px">
|