@dotrino/identity 0.112.0 → 0.114.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
@@ -179,6 +179,11 @@ const v = await verifySignedFor({ data: pin, signature, publickey, chain, audien
179
179
  `aud` es obligatorio igual que en la prueba; `exp` es opcional, porque la caducidad de lo
180
180
  publicado la lleva el servicio (el TTL del pin) y no el cuerpo.
181
181
 
182
+ Si quien firma es un **servicio** de la cuenta (un miembro con `cn`), pásale `ns` con su
183
+ cajón (0.114.0+): un servicio solo firma dentro del suyo, y sin `ns` no pasa nunca. Así el
184
+ directorio de nombres acepta lo que firma su aparato `names` sin aceptar lo de cualquier
185
+ otro servicio de la misma cuenta.
186
+
182
187
  ### Permiso por origen (0.86.0+)
183
188
 
184
189
  Una prueba dice **quién eres** sin preguntar nada: quien llega al iframe ya pasó el filtro
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/identity",
3
- "version": "0.112.0",
3
+ "version": "0.114.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/vault/acta.js CHANGED
@@ -328,8 +328,9 @@ export async function verifySealerLink (link) {
328
328
  *
329
329
  * Es la hermana de `verifySealerChain`, que hace lo mismo con actas enteras para quien las
330
330
  * tiene. Lo que comprueba, y por qué basta:
331
- * 1. el primero es el GÉNESIS: `seq 1`, sin `prev`, y firmado por `profileId` — el ancla,
332
- * que no se puede fabricar sin la llave que da nombre al perfil;
331
+ * 1. el primero está firmado por `profileId` — el ancla, que no se puede fabricar sin la
332
+ * llave que da nombre al perfil. Normalmente es el génesis (`seq 1`, sin `prev`), pero
333
+ * no tiene por qué: una cuenta que perdió su génesis ancla en un eslabón posterior;
333
334
  * 2. cada siguiente lo firmó alguien a quien el ANTERIOR autorizaba;
334
335
  * 3. y apunta al anterior por `seq` + hash, así que no se cuela uno de otra rama.
335
336
  *
@@ -339,7 +340,9 @@ export async function verifySealerLink (link) {
339
340
  export async function verifySealerLinkChain (chain, { expectedProfileId = null } = {}) {
340
341
  if (!Array.isArray(chain) || !chain.length) return { ok: false, reason: 'cadena-vacia' }
341
342
  const [raiz] = chain
342
- if (raiz?.seq !== 1 || raiz?.prev != null) return { ok: false, reason: 'no-empieza-en-genesis' }
343
+ // La misma regla que en `verifySealerChain`: la raíz la ancla la llave del perfil, no el
344
+ // número 1 (una cuenta puede haber perdido su génesis; ver allí).
345
+ if (!Number.isInteger(raiz?.seq) || raiz.seq < 1) return { ok: false, reason: 'no-empieza-en-genesis' }
343
346
  if (raiz.by !== raiz.profileId) return { ok: false, reason: 'genesis-no-autofirmado' }
344
347
  if (expectedProfileId != null && raiz.profileId !== expectedProfileId) return { ok: false, reason: 'otro-perfil' }
345
348
  const vr = await verifySealerLink(raiz)
@@ -619,7 +622,7 @@ export async function sealActa ({ acta, privateKey, privateJwk }) {
619
622
  * @param {{ data?: any, signature?: string, publickey?: string, chain?: any[], expectedProfileId?: string|null }} [args]
620
623
  */
621
624
  export async function verifySignedBy (args = {}) {
622
- const { data, signature, publickey, chain, expectedProfileId = null } = args
625
+ const { data, signature, publickey, chain, expectedProfileId = null, ns = null } = args
623
626
  if (!data || typeof signature !== 'string' || typeof publickey !== 'string') {
624
627
  return { ok: false, reason: 'shape' }
625
628
  }
@@ -628,7 +631,10 @@ export async function verifySignedBy (args = {}) {
628
631
  const cabeza = chain[chain.length - 1]
629
632
  // Quien firmó tiene que ser miembro CON `sign` en el acta vigente de esa cadena. Si le
630
633
  // quitaste el permiso, sus firmas dejan de contar en cuanto se ve un acta más nueva.
631
- if (!memberCanSign(cabeza, publickey)) return { ok: false, reason: 'firmante-no-autorizado' }
634
+ // Un SERVICIO (miembro con `cn`) solo firma dentro de su cajón: quien verifica dice cuál
635
+ // con `ns` (el directorio de nombres acepta lo firmado por su aparato `names`, no por
636
+ // cualquier servicio de la cuenta). Sin `ns`, un servicio nunca pasa, como siempre.
637
+ if (!memberCanSign(cabeza, publickey, ns)) return { ok: false, reason: 'firmante-no-autorizado' }
632
638
  if (!(await verifyDeviceSig({ publickey, data, signature }))) return { ok: false, reason: 'firma-invalida' }
633
639
  return { ok: true, profileId: c.profileId, seq: c.seq, signer: publickey }
634
640
  }
@@ -637,8 +643,15 @@ export async function verifySealerChain (chain, { expectedProfileId = null } = {
637
643
  if (!Array.isArray(chain) || !chain.length) return { ok: false, reason: 'cadena-vacia' }
638
644
  const [raiz] = chain
639
645
 
640
- if (raiz.seq !== 1 || raiz.sealerAnchor != null) return { ok: false, reason: 'no-empieza-en-genesis' }
641
- // El ancla: el génesis se firma a sí mismo con la llave que da nombre al perfil.
646
+ // EL ANCLA ES LA LLAVE DEL PERFIL, NO EL NÚMERO 1 (dueño, 2026-10-14). La raíz vale si
647
+ // está firmada por la llave que da nombre al perfil, sea el génesis o un acta posterior.
648
+ // Antes se exigía `seq 1`, y eso dejaba sin cadena a toda cuenta anterior a las actas v5:
649
+ // su génesis no llevaba `sealerChanged`, la ventana de retención lo podó, y no hay de
650
+ // dónde recuperarlo. Exigir el génesis no protegía de nada: quien tenga la llave del
651
+ // perfil fabrica un génesis alterno igual que un acta posterior, y contra eso la capa es
652
+ // el registro público. Lo que sí sostiene la cadena —que cada eslabón lo firmó alguien a
653
+ // quien el anterior autorizaba— sigue intacto.
654
+ if (!Number.isInteger(raiz.seq) || raiz.seq < 1) return { ok: false, reason: 'no-empieza-en-genesis' }
642
655
  if (raiz.sealedBy !== raiz.profileId) return { ok: false, reason: 'genesis-no-autofirmado' }
643
656
  if (expectedProfileId != null && raiz.profileId !== expectedProfileId) return { ok: false, reason: 'otro-perfil' }
644
657
  const vr = await verifyActa({ acta: raiz })
@@ -191,10 +191,13 @@ export async function verifyAssertion (assertion, opts = {}) {
191
191
  * quien la lleva es el servicio; si el cuerpo trae `exp`, se respeta.
192
192
  */
193
193
  /**
194
- * @param {{ data?: any, signature?: string, publickey?: string, chain?: any[], audience?: string, expectedProfileId?: string|null, now?: number, maxSkewMs?: number }} [args]
194
+ * `ns`: el cajón del servicio que firma (su `cn` en el acta). Solo hace falta cuando lo
195
+ * firmado viene de un SERVICIO de la cuenta y no de un aparato de la persona.
196
+ *
197
+ * @param {{ data?: any, signature?: string, publickey?: string, chain?: any[], audience?: string, expectedProfileId?: string|null, ns?: string|null, now?: number, maxSkewMs?: number }} [args]
195
198
  */
196
199
  export async function verifySignedFor (args = {}) {
197
- const { data, signature, publickey, chain, audience, expectedProfileId = null, now = Date.now(), maxSkewMs = ASSERTION_MAX_SKEW_MS } = args
200
+ const { data, signature, publickey, chain, audience, expectedProfileId = null, ns = null, now = Date.now(), maxSkewMs = ASSERTION_MAX_SKEW_MS } = args
198
201
  if (typeof audience !== 'string' || !audience.trim()) return { ok: false, reason: 'no-audience' }
199
202
  if (!data || typeof data !== 'object') return { ok: false, reason: 'shape' }
200
203
  if (typeof data.aud !== 'string' || !data.aud) return { ok: false, reason: 'sin-destinatario' }
@@ -207,7 +210,7 @@ export async function verifySignedFor (args = {}) {
207
210
  if (!Number.isFinite(data.iat)) return { ok: false, reason: 'shape' }
208
211
  if (data.iat > now + maxSkewMs) return { ok: false, reason: 'del-futuro' }
209
212
  }
210
- const v = await verifySignedBy({ data, signature, publickey, chain, expectedProfileId })
213
+ const v = await verifySignedBy({ data, signature, publickey, chain, expectedProfileId, ns })
211
214
  if (!v.ok) return { ok: false, reason: 'firma:' + v.reason }
212
215
  return { ok: true, profileId: v.profileId, signer: v.signer, seq: v.seq, aud: data.aud }
213
216
  }
package/vault/core.js CHANGED
@@ -873,7 +873,9 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
873
873
  h.sort((a, b) => a.seq - b.seq)
874
874
  const ventana = h.slice(-ACTA_WINDOW)
875
875
  const enVentana = new Set(ventana.map((a) => a.seq))
876
- const eslabones = h.filter((a) => a.sealerChanged && !enVentana.has(a.seq))
876
+ // El génesis cuenta siempre como eslabón: las actas anteriores a v5 no llevaban la
877
+ // marca, y por eso la poda se llevó el génesis de las cuentas viejas.
878
+ const eslabones = h.filter((a) => (a.sealerChanged || a.seq === 1) && !enVentana.has(a.seq))
877
879
  kv.setItem(ACTA_HISTORY_STORAGE, JSON.stringify([...eslabones, ...ventana].sort((a, b) => a.seq - b.seq)))
878
880
  }
879
881
 
@@ -888,7 +890,7 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
888
890
  // La vigente cuenta como parte del material aunque todavía no esté en la historia
889
891
  // (ahí entra al dejar de serlo). Sin esto, una cuenta recién creada devolvía [].
890
892
  const porSeq = new Map([...loadHistory(), cur].map((a) => [a.seq, a]))
891
- const eslabones = [...porSeq.values()].filter((a) => a.sealerChanged).sort((a, b) => a.seq - b.seq)
893
+ const eslabones = [...porSeq.values()].filter((a) => a.sealerChanged || a.seq === 1).sort((a, b) => a.seq - b.seq)
892
894
  // Y la actual va al final si ella misma no es un eslabón: el verificador necesita ver
893
895
  // la cabeza para saber en qué `seq` está y quién la selló.
894
896
  return eslabones.length && eslabones[eslabones.length - 1].seq === cur.seq ? eslabones : [...eslabones, cur]
@@ -2506,6 +2508,10 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
2506
2508
  * Una cuenta de una sola bóveda devuelve SIEMPRE eso y nada más, y no puede quedarse
2507
2509
  * obsoleta: su conjunto de selladores no puede cambiar, porque el único no puede
2508
2510
  * quitarse a sí mismo y no hay otro que se lo quite.
2511
+ *
2512
+ * SIN GÉNESIS, la cadena es `[acta actual]` y vale igual mientras la actual esté firmada
2513
+ * por la llave del perfil: la raíz la ancla esa llave, no el número 1 (`verifySealerChain`).
2514
+ * Pasa en las cuentas anteriores a v5, cuyo génesis se podó sin la marca de eslabón.
2509
2515
  */
2510
2516
  async sealerChain () { return sealerChain() },
2511
2517