@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.
- package/package.json +1 -1
- package/vault/acta.js +171 -45
- package/vault/core.js +74 -15
package/package.json
CHANGED
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 =
|
|
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
|
-
|
|
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
|
|
186
|
-
.filter((m) =>
|
|
204
|
+
const porPermiso = (acta.members || [])
|
|
205
|
+
.filter((m) => memberCan(acta, m.pub, 'sealer', renounces))
|
|
187
206
|
.map((m) => m.pub)
|
|
188
|
-
|
|
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 && (
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
497
|
-
|
|
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
|
-
//
|
|
765
|
-
//
|
|
766
|
-
//
|
|
767
|
-
//
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
1596
|
-
changes.push({ op: '
|
|
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,
|
|
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
|
-
|
|
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
|
},
|