@dotrino/identity 0.87.0 → 0.89.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.87.0",
3
+ "version": "0.89.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
@@ -167,6 +167,17 @@ 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
+ /**
171
+ * Pedidos de aprobación de la cuenta activa (o de otra, con `profile`).
172
+ *
173
+ * Cada pedido trae `ctx` —qué comando está pidiendo las claves y desde qué carpeta— ya
174
+ * abierto: viaja sellado a la llave de cifrado de este aparato y se descifra aquí dentro.
175
+ * `ctxError` si no se pudo abrir; sin ninguno de los dos, el pedido no dijo qué corría.
176
+ */
177
+ vaultApprovals (op: 'approvals' | 'approve' | 'deny', args?: { id?: string; profile?: string }): Promise<any>
178
+ /** Los pedidos de TODAS las cuentas de este dispositivo que aprueban, sin cambiar la activa. */
179
+ vaultApprovalsAll (): Promise<Array<{ profile: string; name: string; current: boolean; items: ApprovalRequest[]; error?: string }>>
180
+ canApproveVault (): Promise<boolean>
170
181
  getVaultCert (): Promise<any>
171
182
  onVault (handler: (payload: any) => void): () => void
172
183
  // self-vault (este dispositivo ES el vault, daemon dentro del iframe)
@@ -318,3 +329,33 @@ export function signSession (args: { sid: string; s: string; by: string; origin:
318
329
  export function verifySession (paper: SessionPaper, opts: { chain: any[]; expectedProfileId?: string | null; origin?: string | null; now?: number; maxSkewMs?: number }): Promise<VerifiedSession>
319
330
  /** ¿Firmó esta sesión esto, y su papel lo cubría? */
320
331
  export function verifySessionSigned (args: { data: any; signature: string; session: SessionPaper; chain: any[]; scope?: SessionScope | null; origin?: string | null; expectedProfileId?: string | null; now?: number }): Promise<VerifiedSession>
332
+
333
+ /** Un pedido de aprobación tal y como lo ve la pantalla que dice que sí o que no. */
334
+ export interface ApprovalRequest {
335
+ id: string
336
+ ns: string
337
+ deviceId: string | null
338
+ label: string
339
+ ts: number
340
+ exp: number
341
+ /** Qué comando está pidiendo las claves, ya descifrado. `null`/ausente si no lo dijo. */
342
+ ctx?: ProcessContext | null
343
+ /** Por qué no se pudo abrir el comando (`no-key`, `profile-locked`, `cannot-open`). */
344
+ ctxError?: string
345
+ /** La bóveda no pudo sellarlo para este aparato (`no-enc-key`, `seal-failed`). */
346
+ ctxSealed?: boolean
347
+ ctxReason?: string
348
+ }
349
+
350
+ /** Qué proceso pide, y si lo comprobó el kernel (`proc`) o solo lo dice él (`declared`). */
351
+ export interface ProcessContext {
352
+ pid: number | null
353
+ uid: number | null
354
+ exe: string
355
+ cwd: string
356
+ argv: string[]
357
+ truncated: boolean
358
+ user: string
359
+ host: string
360
+ verified: 'proc' | 'declared'
361
+ }
package/src/index.js CHANGED
@@ -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,102 @@ 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
+
566
+ /**
567
+ * LA LLAVE DE CIFRADO DE OTRA CUENTA DE ESTE DISPOSITIVO, sin abrirla ni ponerla activa.
568
+ *
569
+ * El par de `signerForProfile`, y por el mismo motivo: la pantalla de Pedidos enseña los
570
+ * de TODAS las cuentas a la vez, y desde 2026-09-11 cada pedido trae **qué comando está
571
+ * pidiendo las claves** dentro de un sobre cerrado a la llave de ESA cuenta. Sin esto, los
572
+ * pedidos de las demás cuentas se verían sin comando — que es justo el dato por el que se
573
+ * mira la pantalla.
574
+ *
575
+ * Mismas tres reglas que allí: no se mueve `currentPid`, no se genera ninguna llave (si no
576
+ * está, se dice) y una cuenta sellada bajo su contraseña se contesta `profile-locked`.
577
+ */
578
+ async function encKeyForProfile (pid) {
579
+ if (pid === currentPid) return encKeypair?.privateKey || null
580
+ const { algo, privUses } = ALGO_OF.enc
581
+ const nombre = ENC_KEY_STORAGE.replace(/^dotrino\.identity\./, `dotrino.identity.p.${pid}.`)
582
+ if (keyStore) {
583
+ const guardado = await keyStore.get(nombre).catch(() => null)
584
+ if (guardado?.privateKey) return guardado.privateKey
585
+ }
586
+ const raw = rawKv.getItem(nombre)
587
+ if (raw) {
588
+ const g = JSON.parse(raw)
589
+ if (g?.sealed) throw Object.assign(new Error('that profile is locked: its encryption key is sealed under its password'), { code: 'profile-locked' })
590
+ if (g?.privateJwk) return crypto.subtle.importKey('jwk', g.privateJwk, algo, true, privUses)
591
+ }
592
+ throw Object.assign(new Error('no encryption key stored for that profile'), { code: 'no-key' })
593
+ }
594
+
595
+ /**
596
+ * ABRE EL COMANDO DE CADA PEDIDO, aquí dentro, donde están las llaves.
597
+ *
598
+ * La bóveda manda el comando y el path sellados a la llave de cifrado del aparato que
599
+ * pregunta (no los manda en claro: el camino hasta aquí es el proxio, que no cifra). Se
600
+ * abren en el iframe y salen a la página ya legibles: la página no ve ninguna llave, y el
601
+ * único tramo en claro es el `postMessage` entre dos ventanas del mismo navegador.
602
+ *
603
+ * Lo que no se puede abrir se dice (`ctxError`) en vez de quedarse como un pedido sin
604
+ * comando: son cosas distintas y en la pantalla hay que poder distinguirlas.
605
+ */
606
+ async function abrirContextos (items, pid) {
607
+ if (!Array.isArray(items) || !items.length) return items
608
+ let priv = null
609
+ let fallo = null
610
+ try { priv = await encKeyForProfile(pid) } catch (e) { fallo = e?.code || 'no-key' }
611
+ const salida = []
612
+ for (const raw of items) {
613
+ const { ctxWrap, ctxEnvelope, ...it } = raw || {}
614
+ if (!ctxWrap || !ctxEnvelope) { salida.push(it); continue }
615
+ if (!priv) { salida.push({ ...it, ctxError: fallo || 'no-key' }); continue }
616
+ try {
617
+ const cek = await Content.openWrap({ wrap: ctxWrap, myEncPrivateKey: priv })
618
+ salida.push({ ...it, ctx: JSON.parse(await Content.decryptWithCek({ cek, envelope: ctxEnvelope })) })
619
+ } catch (e) {
620
+ salida.push({ ...it, ctxError: e?.code || 'cannot-open' })
621
+ }
622
+ }
623
+ return salida
624
+ }
625
+
530
626
  /** La cuenta de este dispositivo que YA está emparejada con la bóveda `master`, si la hay. */
531
627
  const profilePairedWith = (master) => {
532
628
  if (!master) return null
@@ -575,6 +671,25 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
575
671
  if (e && /\brevoked\b/.test(e.message || '')) emitVault({ phase: 'rejected', reason: e.message })
576
672
  throw e
577
673
  }
674
+ /**
675
+ * APROBAR O DENEGAR un pedido de OTRA cuenta de este dispositivo.
676
+ *
677
+ * Es el par de `vaultApprovalsAll`: si la pantalla enseña los pedidos de las tres cuentas,
678
+ * el botón «Aprobar» tiene que funcionar en las tres — si no, seguirías teniendo que
679
+ * cambiarte, que es lo que esto vino a quitar. Firma con la llave de ESA cuenta y no toca
680
+ * nada de la activa.
681
+ *
682
+ * Solo lo suyo: sin cert o sin `vault:approve` en ese cert, no hay nada que hacer aquí.
683
+ */
684
+ async function approvalsDeOtroPerfil (pid, { op, id } = {}) {
685
+ if (!loadProfiles().some((p) => p.id === pid)) throw Object.assign(new Error('that profile does not exist on this device'), { code: 'no-profile' })
686
+ const v = vaultCertOf(pid)
687
+ if (!v?.cert) throw Object.assign(new Error('that profile is not paired with a vault'), { code: 'not-paired' })
688
+ if (!(v.cert.scope || []).includes('vault:approve')) throw Object.assign(new Error('that profile does not approve requests'), { code: 'no-approve' })
689
+ const device = await signerForProfile(pid)
690
+ return remoteApproval({ master: v.master, proxy: v.proxy, device, cert: v.cert, op, id })
691
+ }
692
+
578
693
  /** Id estable y corto de una llave de cifrado: con esto se indexan las envolturas. */
579
694
  const encKeyId = async (encPub) => (await pubkeyIdOf(encPub)).slice(0, 16)
580
695
 
@@ -2501,13 +2616,69 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
2501
2616
  * PEDIDOS DE APROBACIÓN: lo que le toca al teléfono cuando un cajón de la bóveda exige
2502
2617
  * el visto bueno por uso. `op`: `approvals` (listar) · `approve` · `deny` (con `id`).
2503
2618
  * Requiere `vault:approve` en el cert, que se concede a mano (`caps <ID> +aprueba`).
2619
+ *
2620
+ * `profile` apunta a OTRA cuenta de este mismo dispositivo (ver `vaultApprovalsAll`):
2621
+ * sin él es la activa, como siempre.
2504
2622
  */
2505
- async vaultApprovals ({ op, id } = {}) {
2623
+ async vaultApprovals ({ op, id, profile = null } = {}) {
2624
+ if (profile && profile !== currentPid) return approvalsDeOtroPerfil(profile, { op, id })
2506
2625
  const v = loadVaultCert(); const device = loadVaultDevice()
2507
2626
  if (!v?.cert || !device) throw new Error('this device is not paired with a vault')
2508
2627
  maybeRenewVaultCert()
2509
- try { return await remoteApproval({ master: v.master, proxy: v.proxy, device, cert: v.cert, op, id, onRevoked: wipeVaultLink }) }
2510
- catch (e) { return handleVaultError(e) }
2628
+ try {
2629
+ const r = await remoteApproval({ master: v.master, proxy: v.proxy, device, cert: v.cert, op, id, onRevoked: wipeVaultLink })
2630
+ // El comando de cada pedido viene sellado a este aparato: se abre aquí, que es
2631
+ // donde está la llave, y sale ya legible.
2632
+ if (Array.isArray(r?.items)) return { ...r, items: await abrirContextos(r.items, currentPid) }
2633
+ return r
2634
+ } catch (e) { return handleVaultError(e) }
2635
+ },
2636
+
2637
+ /**
2638
+ * LOS PEDIDOS DE TODAS TUS CUENTAS, SIN CAMBIARTE DE CUENTA.
2639
+ *
2640
+ * El timbre del teléfono no dice a qué perfil llamó —viaja por FCM, o sea por Google, y
2641
+ * ahí no se mete nada que identifique al dueño—, así que la pantalla de Pedidos tiene
2642
+ * que mirar en todos los que aprueban. Antes lo hacía cambiando el perfil activo y
2643
+ * recargando la página una vez por cuenta: funcionaba y era insufrible, porque el avatar
2644
+ * y el icono de la app cambiaban dos o tres veces por abrir la pantalla (dueño,
2645
+ * 2026-09-10: «rotan los perfiles, cambia el icono y es molesto»).
2646
+ *
2647
+ * Y era innecesario: **este iframe tiene las llaves de todos los perfiles**. Cada cuenta
2648
+ * guarda la suya en el mismo almacén, bajo su propio nombre, y son `CryptoKey` NO
2649
+ * EXTRAÍBLES: se puede firmar con ellas sin verlas y sin tocar cuál es la activa. Eso es
2650
+ * justo lo que hace falta, y es lo único que se hace aquí.
2651
+ *
2652
+ * Lo que NO hace, a propósito: no renueva certificados ajenos ni borra enlaces ajenos
2653
+ * (eso escribe, y escribir en otra cuenta desde la pantalla de otra es pedir un lío).
2654
+ * Mirar es de solo lectura; si el papel de una cuenta está desfasado, se dice y punto.
2655
+ *
2656
+ * @returns {Promise<Array<{ profile, name, items, error? }>>} una entrada por cuenta que
2657
+ * aprueba — con sus pedidos, o con el motivo por el que no se pudo preguntar.
2658
+ */
2659
+ async vaultApprovalsAll () {
2660
+ // La activa sí se pone al día: es la única en la que esta pantalla puede escribir.
2661
+ try { maybeRenewVaultCert() } catch (_) {}
2662
+ const perfiles = loadProfiles()
2663
+ const salida = await Promise.all(perfiles.map(async (p) => {
2664
+ const v = p.id === currentPid ? loadVaultCert() : vaultCertOf(p.id)
2665
+ if (!v?.cert) return null // sin bóveda: no es asunto suyo
2666
+ if (!(v.cert.scope || []).includes('vault:approve')) return null // no aprueba: tampoco
2667
+ const base = { profile: p.id, name: p.name || '', current: p.id === currentPid }
2668
+ let device = null
2669
+ try { device = p.id === currentPid ? loadVaultDevice() : await signerForProfile(p.id) } catch (e) {
2670
+ return { ...base, items: [], error: e?.code || 'no-key' }
2671
+ }
2672
+ if (!device) return { ...base, items: [], error: 'no-key' }
2673
+ try {
2674
+ const r = await remoteApproval({ master: v.master, proxy: v.proxy, device, cert: v.cert, op: 'approvals' })
2675
+ const items = Array.isArray(r?.items) ? await abrirContextos(r.items, p.id) : []
2676
+ return { ...base, items }
2677
+ } catch (e) {
2678
+ return { ...base, items: [], error: e?.code || e?.message || 'error' }
2679
+ }
2680
+ }))
2681
+ return salida.filter(Boolean)
2511
2682
  },
2512
2683
 
2513
2684
  /**
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/proxy-client@0.18.2 (dotrino-proxy-client/src/{index,client,signature,canonical,sealing,webrtc}.js).
1
+ Copia vendorizada de @dotrino/proxy-client@0.19.0 (dotrino-proxy-client/src/{index,client,signature,canonical,sealing,webrtc}.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
  sealing.js resuelve @dotrino/identity/content de forma PEREZOSA (= ../../content.js
4
4
  por el import map): solo se carga si de verdad se sella algo.
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/vault@0.62.1 (dotrino-vault/lib/src/{index,enroll,protocol}.js).
1
+ Copia vendorizada de @dotrino/vault@0.63.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