@dotrino/identity 0.67.0 → 0.68.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.67.0",
3
+ "version": "0.68.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
@@ -37,7 +37,7 @@
37
37
  import { canonicalStringify } from './core.js'
38
38
  import { signWithDevice, verifyDeviceSig, pubkeyId } from './capabilities.js'
39
39
 
40
- export const ACTA_V = 3
40
+ export const ACTA_V = 4
41
41
 
42
42
  /**
43
43
  * Versiones de acta que se ACEPTAN al leer. La 1 sigue entrando porque una v1 en el disco
@@ -57,7 +57,11 @@ export const ACTA_V = 3
57
57
  * entiende; la PRIMERA vez que esa cuenta cambie algo, la nueva sale ya en v3 y el campo
58
58
  * desaparece solo.
59
59
  */
60
- const ACTA_LEIBLES = Object.freeze([1, 2, 3])
60
+ const ACTA_LEIBLES = Object.freeze([1, 2, 3, 4])
61
+ /** Desde esta versión, el acta lleva su eslabón de la cadena de selladores. */
62
+ const V_CON_CADENA = 4
63
+ /** ¿Esta acta lleva el eslabón? Las anteriores se leen igual; no pueden encadenar. */
64
+ const conCadena = (acta) => Number(acta?.v) >= V_CON_CADENA
61
65
  /** Desde esta versión, sellar es solo un permiso y no hay campo `sealer`. */
62
66
  const V_SIN_CAMPO_SELLADOR = 3
63
67
  /** ¿Este acta es de las viejas, con el campo? */
@@ -225,6 +229,12 @@ export function genesisActa ({ pub, encPub = null, sealPub = null, label = '', n
225
229
  sealedBy: pub,
226
230
  seq: 1,
227
231
  prev: null,
232
+ // LA CADENA DE SELLADORES. El génesis es el ancla: está autofirmado por la llave que
233
+ // da nombre al perfil, así que no apunta a nada y no hay nada que falsificar sin ella.
234
+ sealerAnchor: null,
235
+ // `true` porque el génesis ESTABLECE el primer conjunto de selladores: es el eslabón
236
+ // 1 de la cadena, y por eso la siguiente acta tiene que apuntarle a él.
237
+ sealerChanged: true,
228
238
  members: [{ pub, encPub, label: String(label || '').slice(0, 60), cn: null, caps: [...PAIRED_CAPS, 'sealer'], addedAt: now, cert: null }],
229
239
  revoked: [],
230
240
  renounced: [],
@@ -294,6 +304,18 @@ export function checkShape (acta) {
294
304
  // Es el hermano del cierre de `sign`, y sustituye a «el sellador tiene que ser miembro»
295
305
  // ahora que sellar es solo un permiso y los permisos ya son de miembros por definición.
296
306
  if (!sealersOf(acta).length) return 'sin-sellador'
307
+ // La cadena de selladores: el génesis es el ancla (`null`); cualquier otra apunta a un
308
+ // eslabón anterior. Sin `hash` no se puede encadenar nada, así que se exige la forma.
309
+ if (acta.sealerAnchor != null) {
310
+ const a = acta.sealerAnchor
311
+ if (typeof a !== 'object' || !Number.isInteger(a.seq) || a.seq < 1 || a.seq >= acta.seq) return 'sealeranchor'
312
+ if (typeof a.hash !== 'string' || !/^[0-9a-f]{64}$/.test(a.hash)) return 'sealeranchor'
313
+ }
314
+ // El ancla NO se exige: una cuenta que viene de antes de v4 escribe su primera acta
315
+ // nueva sin poder apuntar a nada (su padre no era eslabón), y exigirla la dejaría
316
+ // inválida. Lo que sostiene la cadena no es el ancla sino que **cada eslabón lo selle
317
+ // alguien a quien el anterior autorizaba**; el ancla añade que no se pueda empalmar un
318
+ // eslabón de otra rama, y se comprueba cuando viene (ver `verifySealerChain`).
297
319
  if (!acta.members.some((m) => m.caps.includes('sign'))) return 'sin-firmante'
298
320
  return null
299
321
  }
@@ -315,6 +337,60 @@ export async function sealActa ({ acta, privateKey, privateJwk }) {
315
337
  * decide aquí sino al adoptarla (`canAdopt`), comparándola con la que ya tienes.
316
338
  * @returns {{ok:boolean, reason?:string}}
317
339
  */
340
+ /**
341
+ * ¿HABLA ESTA ACTA POR ESTA IDENTIDAD? Lo que comprueba un EXTRAÑO, sin saber nada de ti.
342
+ *
343
+ * Recibe la cadena de selladores: `[génesis, …cambios de sellador…, acta actual]`. No son
344
+ * todas tus actas —eso crecería con cada emparejamiento— sino solo los eslabones donde
345
+ * cambió quién sella, que casi nunca pasa. Lo normal es longitud 1 o 2.
346
+ *
347
+ * Qué se comprueba, y por qué basta:
348
+ * 1. el primer eslabón es el GÉNESIS y está autofirmado por `profileId`. Ese es el
349
+ * ancla: sin esa llave no se puede fabricar, y el `profileId` ya lo conoce quien
350
+ * verifica (viaja en cada firma tuya);
351
+ * 2. cada eslabón siguiente lo selló alguien a quien el ANTERIOR autorizaba;
352
+ * 3. y apunta al anterior por `seq` + hash, así que no se puede colar uno de otra rama.
353
+ *
354
+ * Sin esto, un acta suelta es falsificable: con tu `profileId` cualquiera fabrica una
355
+ * donde él sella, y `verifyActa` la da por buena — está firmada, solo que por él.
356
+ *
357
+ * Lo que NO resuelve, y no lo esconde: la frescura. Quien guardó una cadena vieja sigue
358
+ * aceptando a un sellador que ya retiraste. Es el problema de siempre y hace falta un
359
+ * oráculo, no más firmas.
360
+ */
361
+ export async function verifySealerChain (chain, { expectedProfileId = null } = {}) {
362
+ if (!Array.isArray(chain) || !chain.length) return { ok: false, reason: 'cadena-vacia' }
363
+ const [raiz] = chain
364
+
365
+ if (raiz.seq !== 1 || raiz.sealerAnchor != null) return { ok: false, reason: 'no-empieza-en-genesis' }
366
+ // El ancla: el génesis se firma a sí mismo con la llave que da nombre al perfil.
367
+ if (raiz.sealedBy !== raiz.profileId) return { ok: false, reason: 'genesis-no-autofirmado' }
368
+ if (expectedProfileId != null && raiz.profileId !== expectedProfileId) return { ok: false, reason: 'otro-perfil' }
369
+ const vr = await verifyActa({ acta: raiz })
370
+ if (!vr.ok) return { ok: false, reason: 'genesis:' + vr.reason }
371
+
372
+ for (let i = 1; i < chain.length; i++) {
373
+ const previo = chain[i - 1]
374
+ const actual = chain[i]
375
+ if (actual.profileId !== raiz.profileId) return { ok: false, reason: 'otro-perfil' }
376
+ const v = await verifyActa({ acta: actual })
377
+ if (!v.ok) return { ok: false, reason: `eslabon-${i}:` + v.reason }
378
+ // Encadena con el anterior: mismo `seq` y mismo hash. Sin el hash bastaría con
379
+ // acertar un número para colar un eslabón de otra rama.
380
+ // El ancla, SI viene: fija que este eslabón sale del anterior y no de otra rama. Una
381
+ // cuenta anterior a v4 puede no traerla, y entonces lo que sostiene la cadena es lo de
382
+ // abajo — que es lo que de verdad impide falsificarla.
383
+ const a = actual.sealerAnchor
384
+ if (a && (a.seq !== previo.seq || a.hash !== await actaHash(previo))) {
385
+ return { ok: false, reason: `eslabon-${i}:no-encadena` }
386
+ }
387
+ // Y lo que da la autoridad: quien la selló tenía permiso SEGÚN EL ESLABÓN ANTERIOR.
388
+ if (!canSeal(previo, actual.sealedBy)) return { ok: false, reason: `eslabon-${i}:sellador-no-autorizado` }
389
+ }
390
+ const ultima = chain[chain.length - 1]
391
+ return { ok: true, profileId: raiz.profileId, seq: ultima.seq, sealers: sealersOf(ultima) }
392
+ }
393
+
318
394
  export async function verifyActa ({ acta, expectedProfileId } = {}) {
319
395
  const shape = checkShape(acta)
320
396
  if (shape) return { ok: false, reason: shape }
@@ -513,6 +589,35 @@ export async function applyChanges (acta, changes, { by, now = Date.now(), sealP
513
589
  if (!sealersOf(next).length) {
514
590
  throw new Error('the change would leave the record with nobody able to seal it')
515
591
  }
592
+
593
+ /**
594
+ * LA CADENA DE SELLADORES. Es lo que deja a un EXTRAÑO comprobar que quien firmó esto
595
+ * habla por esta identidad, sin tener que mandarle todas las actas.
596
+ *
597
+ * El problema: el acta suelta es falsificable —con tu `profileId`, que es público,
598
+ * cualquiera fabrica una donde él sella—. Y mandar la cadena entera no sale a cuenta
599
+ * porque crece con cada emparejamiento, que es lo que más cambia.
600
+ *
601
+ * La salida: solo encadenar los eslabones donde CAMBIA quién sella, que casi nunca pasa.
602
+ * Un usuario normal tiene cadena de longitud 1 —el génesis— para siempre; con una
603
+ * segunda bóveda, 2. Ese es el tamaño real, no el número de actas.
604
+ *
605
+ * Cada acta apunta al último cambio ANTERIOR a ella, nunca a sí misma (sería circular:
606
+ * el puntero va dentro de lo que se firma). Y `sealerChanged` dice si ELLA es uno, para
607
+ * que la siguiente sepa a dónde apuntar teniendo solo a su padre delante.
608
+ *
609
+ * Y lo que esto resuelve y el génesis firmando no resolvía: **no hace falta la llave del
610
+ * génesis para sumar un sellador.** La bóveda que ya está autorizada sella el acta que
611
+ * autoriza a la siguiente, y ese eslabón vale porque el anterior la autorizaba a ella.
612
+ * El génesis firmó el eslabón 1 y puede estar perdido desde entonces — que es justo el
613
+ * desastre para el que existe el multivault.
614
+ */
615
+ const antes = sealersOf(acta).slice().sort().join('|')
616
+ const despues = sealersOf(next).slice().sort().join('|')
617
+ next.sealerChanged = antes !== despues
618
+ next.sealerAnchor = acta.sealerChanged
619
+ ? { seq: acta.seq, hash: await actaHash(acta) }
620
+ : (acta.sealerAnchor || null)
516
621
  // EL DOBLE FILTRO (dueño, 2026-08-31): un acta nueva tiene que pasar el filtro de la
517
622
  // ANTERIOR —quién podía sellar, arriba— y también el de LA QUE SE VA A FIRMAR. Si no
518
623
  // pasa los dos, no se firma.
@@ -804,7 +909,7 @@ export async function canAdopt ({ candidate, current }) {
804
909
  }
805
910
 
806
911
  export default {
807
- ACTA_V, CAPS, CAP_SCOPE, genesisActa, actaBody, actaHash, memberId, checkShape,
912
+ ACTA_V, CAPS, CAP_SCOPE, genesisActa, actaBody, actaHash, memberId, checkShape, verifySealerChain,
808
913
  sealActa, verifyActa, applyChanges, makeRenounce, verifyRenounce,
809
914
  makeContinuity, verifyContinuity,
810
915
  cardBody, makeProfileCard, verifyProfileCard, canAdoptCard, sealersOf, canSeal,
package/vault/core.js CHANGED
@@ -528,12 +528,43 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
528
528
  // que la nueva desciende de la que uno tenía. Más viejo que la ventana ⇒ re-admitirse.
529
529
  const ACTA_WINDOW = 50
530
530
  const loadHistory = () => { try { return JSON.parse(kv.getItem(ACTA_HISTORY_STORAGE) || '[]') || [] } catch (_) { return [] } }
531
+ /**
532
+ * LOS ESLABONES DE LA CADENA DE SELLADORES NO CADUCAN (dueño, 2026-08-31).
533
+ *
534
+ * El resto sí: la ventana existe para que un miembro que estuvo apagado compruebe el
535
+ * encadenamiento al volver, y para eso las últimas bastan. Pero los eslabones donde
536
+ * CAMBIÓ quién sella son otra cosa — son lo que deja a un EXTRAÑO anclar en el génesis
537
+ * y saber que hablas tú. Si la poda se los lleva, nadie de fuera puede verificar nada
538
+ * tuyo nunca más, y no hay forma de reconstruirlos.
539
+ *
540
+ * El comentario de la ventana decía «un tercero no las necesita, le basta el snapshot
541
+ * actual». Era cierto mientras el ancla fuera una llave fija; dejó de serlo en cuanto
542
+ * varias llaves pueden sellar.
543
+ *
544
+ * No crecen: solo suman al añadir o quitar una bóveda, que casi nunca pasa. Un usuario
545
+ * normal guarda uno —el génesis— para siempre.
546
+ */
531
547
  const pushHistory = (acta) => {
532
548
  if (!acta) return
533
549
  const h = loadHistory().filter((a) => a.seq !== acta.seq)
534
550
  h.push(acta)
535
551
  h.sort((a, b) => a.seq - b.seq)
536
- kv.setItem(ACTA_HISTORY_STORAGE, JSON.stringify(h.slice(-ACTA_WINDOW)))
552
+ const ventana = h.slice(-ACTA_WINDOW)
553
+ const enVentana = new Set(ventana.map((a) => a.seq))
554
+ const eslabones = h.filter((a) => a.sealerChanged && !enVentana.has(a.seq))
555
+ kv.setItem(ACTA_HISTORY_STORAGE, JSON.stringify([...eslabones, ...ventana].sort((a, b) => a.seq - b.seq)))
556
+ }
557
+
558
+ /**
559
+ * La CADENA DE SELLADORES para mandarla con una firma: `[génesis, …cambios…, actual]`.
560
+ * Es lo que un extraño necesita para anclar en el `profileId`, y es corta a propósito —
561
+ * no lleva las actas de emparejar aparatos, que son casi todas.
562
+ */
563
+ const sealerChain = () => {
564
+ const cur = loadActa()
565
+ if (!cur) return []
566
+ const eslabones = loadHistory().filter((a) => a.sealerChanged).sort((a, b) => a.seq - b.seq)
567
+ return cur.sealerChanged ? eslabones : [...eslabones, cur]
537
568
  }
538
569
 
539
570
  const loadRenounces = () => { try { return JSON.parse(kv.getItem(RENOUNCE_STORAGE) || '[]') || [] } catch (_) { return [] } }