@dotrino/identity 0.66.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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/vault/acta.js +171 -45
  3. package/vault/core.js +74 -15
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/identity",
3
- "version": "0.66.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 = 2
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
@@ -46,7 +46,26 @@ export const ACTA_V = 2
46
46
  * asciende sola: el acta siguiente que selle la maestra ya sale v2 (ver `applyChanges`).
47
47
  * La única diferencia es la llave de sellado (§8.9), que en una v1 simplemente no hay.
48
48
  */
49
- const ACTA_LEIBLES = Object.freeze([1, 2])
49
+ /**
50
+ * v3 quita `acta.sealer` (dueño, 2026-08-31: «SEAL es el nuevo master»): sellar es un
51
+ * permiso y nada más.
52
+ *
53
+ * Las anteriores SE SIGUEN LEYENDO, y no por nostalgia: dejar de hacerlo dejaría sin abrir
54
+ * TODAS las cuentas que existen hoy —cada una tendría que rehacerse aparato por aparato—
55
+ * cuando lo único que hace falta es traducir el campo a un permiso al leerlas. Un acta
56
+ * vieja no se puede reescribir (está firmada), así que se lee tal cual y `canSeal` la
57
+ * entiende; la PRIMERA vez que esa cuenta cambie algo, la nueva sale ya en v3 y el campo
58
+ * desaparece solo.
59
+ */
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
65
+ /** Desde esta versión, sellar es solo un permiso y no hay campo `sealer`. */
66
+ const V_SIN_CAMPO_SELLADOR = 3
67
+ /** ¿Este acta es de las viejas, con el campo? */
68
+ const conCampoSellador = (acta) => Number(acta?.v) < V_SIN_CAMPO_SELLADOR
50
69
 
51
70
  /**
52
71
  * Lista CERRADA de capacidades.
@@ -182,26 +201,20 @@ export async function memberId (pub) {
182
201
  */
183
202
  export const sealersOf = (acta, renounces = []) => {
184
203
  if (!acta) return []
185
- const extra = (acta.members || [])
186
- .filter((m) => m.pub !== acta.sealer && memberCan(acta, m.pub, 'sealer', renounces))
204
+ const porPermiso = (acta.members || [])
205
+ .filter((m) => memberCan(acta, m.pub, 'sealer', renounces))
187
206
  .map((m) => m.pub)
188
- return [acta.sealer, ...extra]
207
+ // Acta vieja: el que estaba en el campo sellaba SIN tener el permiso. Se traduce al
208
+ // leerla, que es la única forma —está firmada y no se puede reescribir—.
209
+ if (conCampoSellador(acta) && acta.sealer && !porPermiso.includes(acta.sealer)) {
210
+ return [acta.sealer, ...porPermiso]
211
+ }
212
+ return porPermiso
189
213
  }
190
214
 
191
215
  /** ¿Puede `pub` sellar la siguiente acta de este perfil? */
192
216
  export const canSeal = (acta, pub, renounces = []) =>
193
- !!acta && (pub === acta.sealer || memberCan(acta, pub, 'sealer', renounces))
194
-
195
- /**
196
- * El acta la firmó alguien DISTINTO del sellador que ella misma nombra.
197
- *
198
- * ⚠️ Desde el multivault esto ya NO significa «es un traspaso», y confundirlo costó un
199
- * desempate mal resuelto: cuando sella la segunda bóveda, el acta sigue nombrando
200
- * sellador a la primera y la firma la segunda, así que esto da `true` sin que nadie
201
- * traspase nada. Es condición NECESARIA de un traspaso, no suficiente — lo que lo
202
- * distingue es que un traspaso CAMBIA a quién nombra (ver `canAdopt`).
203
- */
204
- export const isHandover = (acta) => !!acta && acta.sealer !== acta.sealedBy
217
+ !!acta && (memberCan(acta, pub, 'sealer', renounces) || (conCampoSellador(acta) && pub === acta.sealer))
205
218
 
206
219
  /**
207
220
  * Acta de génesis: un perfil recién nacido tiene UN miembro (esta llave), que además es el
@@ -213,11 +226,16 @@ export function genesisActa ({ pub, encPub = null, sealPub = null, label = '', n
213
226
  return {
214
227
  v: ACTA_V,
215
228
  profileId: pub,
216
- sealer: pub,
217
229
  sealedBy: pub,
218
230
  seq: 1,
219
231
  prev: null,
220
- members: [{ pub, encPub, label: String(label || '').slice(0, 60), cn: null, caps: [...PAIRED_CAPS], addedAt: now, cert: 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,
238
+ members: [{ pub, encPub, label: String(label || '').slice(0, 60), cn: null, caps: [...PAIRED_CAPS, 'sealer'], addedAt: now, cert: null }],
221
239
  revoked: [],
222
240
  renounced: [],
223
241
  // Llavero del contenido: una entrada por generación, con la clave del perfil ENVUELTA
@@ -239,7 +257,10 @@ export function genesisActa ({ pub, encPub = null, sealPub = null, label = '', n
239
257
  export function checkShape (acta) {
240
258
  if (!acta || typeof acta !== 'object') return 'no-acta'
241
259
  if (!ACTA_LEIBLES.includes(acta.v)) return 'version'
242
- if (!isPub(acta.profileId) || !isPub(acta.sealer) || !isPub(acta.sealedBy)) return 'shape'
260
+ if (!isPub(acta.profileId) || !isPub(acta.sealedBy)) return 'shape'
261
+ // El campo solo existe —y solo se exige— en las viejas.
262
+ if (conCampoSellador(acta) && !isPub(acta.sealer)) return 'shape'
263
+ if (!conCampoSellador(acta) && acta.sealer != null) return 'sealer-no-va-en-v3'
243
264
  if (!Number.isInteger(acta.seq) || acta.seq < 1) return 'seq'
244
265
  if (acta.seq > 1 && typeof acta.prev !== 'string') return 'prev'
245
266
  if (!Array.isArray(acta.members) || acta.members.length === 0) return 'members'
@@ -279,7 +300,22 @@ export function checkShape (acta) {
279
300
  }
280
301
  }
281
302
  if (new Set(acta.members.map((m) => m.pub)).size !== acta.members.length) return 'miembro-duplicado'
282
- if (!acta.members.some((m) => m.pub === acta.sealer)) return 'sealer-no-es-miembro'
303
+ // SIN NADIE QUE SELLE, el acta está muerta: no habría forma de volver a cambiarla nunca.
304
+ // Es el hermano del cierre de `sign`, y sustituye a «el sellador tiene que ser miembro»
305
+ // ahora que sellar es solo un permiso y los permisos ya son de miembros por definición.
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`).
283
319
  if (!acta.members.some((m) => m.caps.includes('sign'))) return 'sin-firmante'
284
320
  return null
285
321
  }
@@ -301,6 +337,60 @@ export async function sealActa ({ acta, privateKey, privateJwk }) {
301
337
  * decide aquí sino al adoptarla (`canAdopt`), comparándola con la que ya tienes.
302
338
  * @returns {{ok:boolean, reason?:string}}
303
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
+
304
394
  export async function verifyActa ({ acta, expectedProfileId } = {}) {
305
395
  const shape = checkShape(acta)
306
396
  if (shape) return { ok: false, reason: shape }
@@ -436,7 +526,6 @@ export async function applyChanges (acta, changes, { by, now = Date.now(), sealP
436
526
  case 'remove': {
437
527
  const i = next.members.findIndex((m) => m.pub === ch.pub)
438
528
  if (i < 0) throw new Error('remove: that member is not in the record')
439
- if (next.members[i].pub === next.sealer) throw new Error('remove: cannot remove the master; hand the sealing over first')
440
529
  // Quitar al miembro se lleva su permiso de sellar: no hay lista aparte que limpiar.
441
530
  const fuera = next.members.splice(i, 1)[0]
442
531
  // Sus envolturas se van con él: sin ellas no puede abrir ninguna generación. (El
@@ -447,11 +536,6 @@ export async function applyChanges (acta, changes, { by, now = Date.now(), sealP
447
536
  })
448
537
  break
449
538
  }
450
- case 'handover': {
451
- if (!find(ch.to)) throw new Error('handover: the new master must be a member (admit them in the same change)')
452
- next.sealer = ch.to
453
- break
454
- }
455
539
  case 'keyring': {
456
540
  // Generación NUEVA de la clave de contenido (al rotar: expulsar a alguien).
457
541
  const g = ch.generation
@@ -493,8 +577,60 @@ export async function applyChanges (acta, changes, { by, now = Date.now(), sealP
493
577
  if (!next.members.some((m) => m.caps.includes('sign'))) {
494
578
  throw new Error('the change would leave the profile with no member able to sign')
495
579
  }
496
- if (!next.members.some((m) => m.pub === next.sealer)) {
497
- throw new Error('the change would leave the record with no master')
580
+ // ASCENDER DE UNA VIEJA: el que sellaba por el CAMPO pasa a sellar por el PERMISO. Es
581
+ // toda la migración, y va aquí porque este es el momento en que la cuenta escribe su
582
+ // primera v3 — el acta vieja no se puede tocar, está firmada.
583
+ if (conCampoSellador(acta) && acta.sealer) {
584
+ const m = next.members.find((x) => x.pub === acta.sealer)
585
+ if (m && !m.caps.includes('sealer')) m.caps = cleanCaps([...m.caps, 'sealer'])
586
+ // Y el campo se va con la vieja: la nueva es v3 y en v3 no existe.
587
+ delete next.sealer
588
+ }
589
+ if (!sealersOf(next).length) {
590
+ throw new Error('the change would leave the record with nobody able to seal it')
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)
621
+ // EL DOBLE FILTRO (dueño, 2026-08-31): un acta nueva tiene que pasar el filtro de la
622
+ // ANTERIOR —quién podía sellar, arriba— y también el de LA QUE SE VA A FIRMAR. Si no
623
+ // pasa los dos, no se firma.
624
+ //
625
+ // Lo que cierra: un sellador que se quita a sí mismo el permiso en la misma acta que
626
+ // firma. Es absurdo —se firma con una autoridad que uno acaba de retirarse— y no tiene
627
+ // vuelta atrás: deshacerlo requeriría sellar, que es justo lo que se acaba de perder.
628
+ //
629
+ // Y por qué aquí y no con una marca de «traspaso»: mirando el acta SOLA, ceder el mando
630
+ // y quitarse el permiso se ven igual. Con las dos actas delante no hay ambigüedad, y las
631
+ // dos veces que esto importa —construirla, y adoptar una ajena— se tienen las dos.
632
+ if (!canSeal(next, by)) {
633
+ throw new Error('the change would leave whoever seals it unable to seal: a record must pass the filter of the one it replaces AND its own')
498
634
  }
499
635
  return next
500
636
  }
@@ -761,21 +897,11 @@ export async function canAdopt ({ candidate, current }) {
761
897
  // exactamente lo que dice que las dos salieron de ella.
762
898
  const mismoFirmante = candidate.sealedBy === current.sealedBy
763
899
  if (!mismoFirmante && !canSeal(current, candidate.sealedBy)) return { adopt: false, reason: 'otro-sellador' }
764
- // UN TRASPASO ES EL QUE CAMBIA A QUIÉN NOMBRA, no el que firma otro. Con una sola
765
- // bóveda daba igual —solo un traspaso podía firmar sin ser el sellador—, pero con dos
766
- // la segunda firma sin serlo cada vez que sella, y entonces TODA acta suya se leía
767
- // como un traspaso y ganaba el desempate por la puerta de atrás.
768
- //
769
- // Las dos ramas salen del mismo padre, así que comparar a quién nombra cada una
770
- // resuelve los tres casos: si nombran al mismo (dos coselladores trabajando), no hay
771
- // traspaso y decide el hash; si una nombra a otro, esa es la que traspasa y gana; y
772
- // si las dos traspasan a destinos distintos, vuelven a empatar y decide el hash,
773
- // que es lo que §2.4.1 ya decía.
774
- const mismoSellador = candidate.sealer === current.sealer
775
- const canT = isHandover(candidate) && !mismoSellador
776
- const curT = isHandover(current) && !mismoSellador
777
- if (canT && !curT) return { adopt: true, reason: 'traspaso-gana' }
778
- if (!canT && curT) return { adopt: false, reason: 'traspaso-gana' }
900
+ // SOLO QUEDA EL HASH. Antes había una regla más —«gana la que traspasa»— que existía
901
+ // para arbitrar el campo `sealer`: dos ramas que cedían el mando a destinos distintos.
902
+ // Ese campo ya no existe (dueño, 2026-08-31: «SEAL es el nuevo master»), y con él se
903
+ // va la regla y la ambigüedad que arrastraba: `sealer !== sealedBy` significaba
904
+ // «traspaso» con un solo master y dejó de significarlo con varios selladores.
779
905
  return hCan < hCur ? { adopt: true, reason: 'desempate-hash' } : { adopt: false, reason: 'desempate-hash' }
780
906
  }
781
907
 
@@ -783,7 +909,7 @@ export async function canAdopt ({ candidate, current }) {
783
909
  }
784
910
 
785
911
  export default {
786
- ACTA_V, CAPS, CAP_SCOPE, genesisActa, actaBody, actaHash, memberId, checkShape, isHandover,
912
+ ACTA_V, CAPS, CAP_SCOPE, genesisActa, actaBody, actaHash, memberId, checkShape, verifySealerChain,
787
913
  sealActa, verifyActa, applyChanges, makeRenounce, verifyRenounce,
788
914
  makeContinuity, verifyContinuity,
789
915
  cardBody, makeProfileCard, verifyProfileCard, canAdoptCard, sealersOf, canSeal,
package/vault/core.js CHANGED
@@ -528,19 +528,55 @@ 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 [] } }
540
571
  const saveRenounces = (l) => kv.setItem(RENOUNCE_STORAGE, JSON.stringify(l))
541
572
 
542
573
  /** ¿Es ESTE dispositivo el master (el único que puede sellar)? */
543
- const amMaster = () => loadActa()?.sealer === publickeyJwkStr
574
+ /**
575
+ * ¿PUEDO SELLAR? Antes era «¿soy el master?» y miraba un campo. Con sellar convertido en
576
+ * permiso, la pregunta correcta es esta — y su respuesta puede ser «sí» en más de un
577
+ * aparato a la vez, que es justo el punto del multivault.
578
+ */
579
+ const amMaster = () => Acta.canSeal(loadActa(), publickeyJwkStr, loadRenounces())
544
580
 
545
581
  /**
546
582
  * Quien sella SOBRES (la bóveda) pone aquí una función que estrena una llave de sellado
@@ -571,7 +607,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
571
607
  const sealed = await seal(next)
572
608
  pushHistory(acta) // la que deja de ser vigente entra en la ventana de retención
573
609
  saveActa(sealed)
574
- emitVault({ phase: 'acta', seq: sealed.seq, sealer: sealed.sealer })
610
+ emitVault({ phase: 'acta', seq: sealed.seq, sealedBy: sealed.sealedBy })
575
611
  return sealed
576
612
  }
577
613
 
@@ -697,7 +733,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
697
733
  // retener; guardarla aquí solo mezclaría dos cuentas en la misma ventana (§1.3).
698
734
  kv.setItem(ACTA_HISTORY_STORAGE, '[]')
699
735
  clearPendingJoin()
700
- emitVault({ phase: 'acta', seq: candidate.seq, sealer: candidate.sealer, joined: true })
736
+ emitVault({ phase: 'acta', seq: candidate.seq, sealedBy: candidate.sealedBy, joined: true })
701
737
  return { joined: true, profileId: candidate.profileId, seq: candidate.seq }
702
738
  }
703
739
 
@@ -763,7 +799,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
763
799
  const r = await Acta.canAdopt({ candidate, current })
764
800
  if (!r.adopt) return { adopted: false, reason: r.reason, seq: current?.seq ?? null }
765
801
  saveActa(candidate)
766
- emitVault({ phase: 'acta', seq: candidate.seq, sealer: candidate.sealer, adopted: r.reason })
802
+ emitVault({ phase: 'acta', seq: candidate.seq, sealedBy: candidate.sealedBy, adopted: r.reason })
767
803
  const sigoDentro = (candidate.members || []).some((m) => m?.pub === publickeyJwkStr)
768
804
  if (!sigoDentro) {
769
805
  console.warn('[identity] the new record no longer lists this device: removing the account')
@@ -1025,7 +1061,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1025
1061
  async function askIfStillAMember () {
1026
1062
  try {
1027
1063
  const acta = loadActa()
1028
- if (!acta || acta.sealer === publickeyJwkStr) return // no hay cuenta ajena que confirmar: mando yo
1064
+ if (!acta || Acta.canSeal(acta, publickeyJwkStr)) return // sello yo: no hay cuenta ajena que confirmar
1029
1065
  // El acta que ya tengo no me nombra: no hace falta preguntar nada, el acta manda y va
1030
1066
  // firmada. (Normalmente esto lo resuelve `adoptActa` al recibirla.)
1031
1067
  if (!(acta.members || []).some((m) => m?.pub === publickeyJwkStr)) {
@@ -1035,7 +1071,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1035
1071
  const v = loadVaultCert()
1036
1072
  if (v?.cert && loadVaultDevice()) return // con papel, ya lo comprueba el camino normal
1037
1073
  const r = await remoteCheck({
1038
- master: acta.sealer,
1074
+ master: Acta.sealersOf(acta)[0] || null,
1039
1075
  proxy: v?.proxy || 'wss://proxy.dotrino.com',
1040
1076
  device: { publickey: publickeyJwkStr, privateKey: keypair.privateKey },
1041
1077
  onRevoked: wipeVaultLink
@@ -1493,7 +1529,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1493
1529
 
1494
1530
  async profileMembers () {
1495
1531
  const acta = loadActa()
1496
- if (!acta) return { members: [], profileId: null, seq: 0, sealer: null }
1532
+ if (!acta) return { members: [], profileId: null, seq: 0, sealers: [] }
1497
1533
  const pend = loadRenounces()
1498
1534
  const members = await Promise.all(acta.members.map(async (m) => ({
1499
1535
  pub: m.pub,
@@ -1504,7 +1540,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1504
1540
  caps: Acta.effectiveCaps(acta, m.pub, pend),
1505
1541
  addedAt: m.addedAt || null,
1506
1542
  isMe: m.pub === publickeyJwkStr,
1507
- isMaster: m.pub === acta.sealer,
1543
+ isMaster: Acta.canSeal(acta, m.pub),
1508
1544
  // La llave de CIFRADO del miembro y, derivado, si se le puede envolver algo.
1509
1545
  // Sale en la proyección porque sin ella ninguna interfaz (CLI, TUI, consola)
1510
1546
  // puede decir «a este aparato no se le puede escribir», y ese silencio es
@@ -1512,7 +1548,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1512
1548
  encPub: m.encPub || null,
1513
1549
  canSeal: !!m.encPub
1514
1550
  })))
1515
- return { members, profileId: acta.profileId, seq: acta.seq, sealer: acta.sealer, updatedAt: acta.updatedAt }
1551
+ return { members, profileId: acta.profileId, seq: acta.seq, sealers: Acta.sealersOf(acta), updatedAt: acta.updatedAt }
1516
1552
  },
1517
1553
 
1518
1554
  async myMembership () {
@@ -1523,7 +1559,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1523
1559
  inProfile: !!m,
1524
1560
  profileId: acta.profileId,
1525
1561
  seq: acta.seq,
1526
- isMaster: acta.sealer === publickeyJwkStr,
1562
+ isMaster: Acta.canSeal(acta, publickeyJwkStr),
1527
1563
  caps: Acta.effectiveCaps(acta, publickeyJwkStr, loadRenounces()),
1528
1564
  id: m ? await Acta.memberId(m.pub) : null
1529
1565
  }
@@ -1590,12 +1626,24 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1590
1626
  * sellador tiene que ser miembro para poder serlo, y así no hay ventana intermedia.
1591
1627
  * Cubre igual dispositivo → bóveda y bóveda → bóveda (mudarse de PC).
1592
1628
  */
1629
+ /**
1630
+ * CEDER EL MANDO = CONCEDER `sella`. Ya no hay traspaso: sellar es un permiso, así que
1631
+ * «pasarle el mando a otro» es dárselo, y punto. Lo que ANTES hacía esto —dejar de
1632
+ * poder sellar uno mismo, en el mismo acto— ya no se puede: nadie se quita el sello a
1633
+ * sí mismo (dejaría un acta que él firma sin poder firmarla). Si quieres salir, se lo
1634
+ * pides al otro cuando ya pueda sellar.
1635
+ */
1593
1636
  async handoverMaster ({ to, member = null } = {}) {
1594
1637
  const changes = []
1595
- if (member) changes.push({ op: 'admit', member: { ...member, pub: to } })
1596
- changes.push({ op: 'handover', to })
1638
+ const acta0 = loadActa()
1639
+ if (member) changes.push({ op: 'admit', member: { ...member, pub: to, caps: [...(member.caps || []), 'sealer'] } })
1640
+ else {
1641
+ const m = (acta0?.members || []).find((x) => x.pub === to)
1642
+ if (!m) throw new Error('handover: the new sealer must be a member (admit them in the same change)')
1643
+ changes.push({ op: 'caps', pub: to, caps: [...new Set([...(m.caps || []), 'sealer'])] })
1644
+ }
1597
1645
  const acta = await sealChanges(changes)
1598
- return { ok: true, seq: acta.seq, sealer: acta.sealer }
1646
+ return { ok: true, seq: acta.seq, sealers: Acta.sealersOf(acta) }
1599
1647
  },
1600
1648
 
1601
1649
  /**
@@ -1606,6 +1654,13 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1606
1654
  async renounceCaps ({ caps } = {}) {
1607
1655
  const acta = loadActa()
1608
1656
  if (!acta) throw new Error('this profile has no record yet')
1657
+ // SELLAR NO SE RENUNCIA. Es la misma auto-amputación que el acta ya impide, por otra
1658
+ // puerta: la renuncia es unilateral y no pasa por nadie, así que sin esto un
1659
+ // sellador podría dejarse —o dejar a la cuenta— sin nadie que pueda volver a sellar,
1660
+ // y eso no tiene marcha atrás. Que te lo quite otro sellador, que sí puede.
1661
+ if ((Array.isArray(caps) ? caps : [caps]).includes('sealer')) {
1662
+ throw new Error('sealing cannot be renounced: ask another sealer to take it from you')
1663
+ }
1609
1664
  const record = await Acta.makeRenounce({ member: publickeyJwkStr, caps, privateKey: keypair.privateKey })
1610
1665
  const pend = loadRenounces().filter((r) => r.member !== publickeyJwkStr)
1611
1666
  pend.push(record)
@@ -1735,7 +1790,11 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1735
1790
  // hasta ella: en los dos casos, mandarla es tirar cientos de KB por el transporte.
1736
1791
  if (!mine) return { chain: [], window: ACTA_WINDOW }
1737
1792
  // Puede dar el salto él solo: el sellador no cambió.
1738
- if (cur.sealedBy === mine.sealer) return { chain: [], window: ACTA_WINDOW }
1793
+ // Si quien selló la última YA podía sellar en el acta que tiene el otro, el salto lo
1794
+ // da él solo: no hace falta mandarle los eslabones. Antes esto comparaba contra el
1795
+ // campo `sealer`; ahora se le pregunta al permiso, que es lo mismo con un solo
1796
+ // sellador y lo correcto con varios.
1797
+ if (Acta.canSeal(mine, cur.sealedBy)) return { chain: [], window: ACTA_WINDOW }
1739
1798
  const all = [...hist.filter((a) => a.seq > sinceSeq), cur]
1740
1799
  return { chain: all.sort((a, b) => a.seq - b.seq), window: ACTA_WINDOW }
1741
1800
  },