@dotrino/identity 0.71.0 → 0.72.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 (2) hide show
  1. package/package.json +1 -1
  2. package/vault/acta.js +164 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/identity",
3
- "version": "0.71.0",
3
+ "version": "0.72.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 = 4
40
+ export const ACTA_V = 5
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 = 4
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, 4])
60
+ // v5 mete DENTRO del acta el eslabón publicable de la cadena de selladores, firmado
61
+ // aparte (ver `sealerLinkOf`): así se publica solo eso y no el acta entera, que llevaba
62
+ // los aparatos con sus nombres. Una v4 se sigue leyendo, pero no tiene eslabón y por tanto
63
+ // no puede publicar: su cuenta no aparece en el registro hasta que selle una v5 nueva.
64
+ const ACTA_LEIBLES = Object.freeze([1, 2, 3, 4, 5])
61
65
  /** Desde esta versión, el acta lleva su eslabón de la cadena de selladores. */
62
66
  const V_CON_CADENA = 4
63
67
  /** ¿Esta acta lleva el eslabón? Las anteriores se leen igual; no pueden encadenar. */
@@ -187,6 +191,124 @@ export async function actaHash (acta) {
187
191
  return hex(await crypto.subtle.digest('SHA-256', enc(canonicalStringify(actaBody(acta)))))
188
192
  }
189
193
 
194
+ /**
195
+ * EL ESLABÓN DE LA CADENA DE SELLADORES: lo ÚNICO que se publica.
196
+ *
197
+ * El problema que resuelve (dueño, 2026-08-31). Publicar «los eslabones donde cambia quién
198
+ * sella» sonaba a publicar poco, pero un eslabón ERA un acta entera: se fue a un repo
199
+ * público con los `label` y los `cn` de cada aparato y el llavero. Recortar el acta no
200
+ * vale, porque su firma cubre el acta entera y dejaría de verificar.
201
+ *
202
+ * La salida es del dueño y es mejor que tener dos documentos que puedan discrepar: se
203
+ * construye el eslabón, **se firma**, se mete en el acta, y **se firma el acta encima**.
204
+ * Resultado: uno solo, firmado dos veces.
205
+ *
206
+ * · quien tiene el acta la verifica y con eso el eslabón de dentro queda validado —
207
+ * no hay nada aparte en lo que confiar;
208
+ * · quien solo tiene el registro verifica la firma del eslabón, que es de la misma
209
+ * llave y dice lo mismo. Discrepar es imposible por construcción.
210
+ *
211
+ * ENCADENA CONTRA EL ESLABÓN ANTERIOR, no contra el acta anterior. El `sealerAnchor` del
212
+ * acta apunta al hash del ACTA previa, y quien solo lee el registro no la tiene ni puede
213
+ * calcularla — la cadena publicada tiene que sostenerse sola.
214
+ *
215
+ * Lo que lleva es lo mínimo para responder «¿esta llave sigue pudiendo sellar?»: nada de
216
+ * miembros, ni etiquetas, ni cajones, ni llavero.
217
+ */
218
+ export const SEALER_LINK_V = 1
219
+
220
+ /** El cuerpo firmable del eslabón (todo menos su propia firma). */
221
+ export const sealerLinkBody = (link) => { const { sig, ...body } = link || {}; return body }
222
+
223
+ /** Hash del eslabón. Es a esto a lo que apunta el `prev` del siguiente. */
224
+ export async function sealerLinkHash (link) {
225
+ return hex(await crypto.subtle.digest('SHA-256', enc(canonicalStringify(sealerLinkBody(link)))))
226
+ }
227
+
228
+ /** El eslabón que hay que publicar de esta acta, o `null` si no cambió el sellador. */
229
+ export const sealerLinkOf = (acta) => (acta?.sealerChanged && acta?.sealerLink) || null
230
+
231
+ /**
232
+ * ¿Es este eslabón coherente con el acta que lo lleva? Es lo que hace verdad la frase «ya
233
+ * está validado dentro del acta»: la firma del acta cubre el eslabón, pero eso solo prueba
234
+ * que está ahí — que DIGA lo mismo que el acta hay que comprobarlo.
235
+ */
236
+ export function checkSealerLink (acta) {
237
+ const l = acta?.sealerLink
238
+ if (!l) return acta?.sealerChanged && acta?.v >= 5 ? 'eslabon-ausente' : null
239
+ if (l.v !== SEALER_LINK_V) return 'eslabon-version'
240
+ if (typeof l.sig !== 'string') return 'eslabon-sin-firma'
241
+ if (l.profileId !== acta.profileId) return 'eslabon-otro-perfil'
242
+ // EL ACTA VIGENTE ARRASTRA EL ÚLTIMO ESLABÓN, que casi nunca es de ella: los selladores
243
+ // cambian poquísimo y las actas cambian con cada emparejamiento. Así que solo cuando
244
+ // ESTA acta es el eslabón se le exige que coincida en `seq` y en quién lo firmó; si lo
245
+ // hereda, basta con que venga de antes.
246
+ if (acta.sealerChanged) {
247
+ if (l.seq !== acta.seq) return 'eslabon-otro-seq'
248
+ if (l.by !== acta.sealedBy) return 'eslabon-otro-sellador'
249
+ } else if (!(l.seq < acta.seq)) {
250
+ return 'eslabon-del-futuro'
251
+ }
252
+ // Y en los dos casos tiene que DECIR LO MISMO que el acta sobre quién sella: si no
253
+ // cambiaron, el heredado los sigue describiendo. Esto es lo que impide que el acta y su
254
+ // eslabón cuenten cosas distintas — que es todo el punto de meterlo dentro.
255
+ const dice = [...(l.sealers || [])].slice().sort().join('|')
256
+ const segunElActa = sealersOf(acta).slice().sort().join('|')
257
+ if (dice !== segunElActa) return 'eslabon-no-cuadra'
258
+ return null
259
+ }
260
+
261
+ /** Firma del eslabón por quien sella. Se hace ANTES de firmar el acta que lo lleva. */
262
+ export async function verifySealerLink (link) {
263
+ if (!link || link.v !== SEALER_LINK_V) return { ok: false, reason: 'eslabon-version' }
264
+ if (!isPub(link.profileId) || !isPub(link.by)) return { ok: false, reason: 'eslabon-forma' }
265
+ if (!Array.isArray(link.sealers) || !link.sealers.length || !link.sealers.every(isPub)) return { ok: false, reason: 'eslabon-selladores' }
266
+ if (typeof link.seq !== 'number' || link.seq < 1) return { ok: false, reason: 'eslabon-seq' }
267
+ if (typeof link.sig !== 'string') return { ok: false, reason: 'eslabon-sin-firma' }
268
+ const ok = await verifyDeviceSig({ publickey: link.by, data: sealerLinkBody(link), signature: link.sig })
269
+ return ok ? { ok: true } : { ok: false, reason: 'eslabon-firma-invalida' }
270
+ }
271
+
272
+ /**
273
+ * VERIFICA LA CADENA PUBLICADA — la que vive en el registro, sin actas de por medio.
274
+ *
275
+ * Es la hermana de `verifySealerChain`, que hace lo mismo con actas enteras para quien las
276
+ * tiene. Lo que comprueba, y por qué basta:
277
+ * 1. el primero es el GÉNESIS: `seq 1`, sin `prev`, y firmado por `profileId` — el ancla,
278
+ * que no se puede fabricar sin la llave que da nombre al perfil;
279
+ * 2. cada siguiente lo firmó alguien a quien el ANTERIOR autorizaba;
280
+ * 3. y apunta al anterior por `seq` + hash, así que no se cuela uno de otra rama.
281
+ *
282
+ * Lo que NO resuelve, y no lo esconde: la frescura. Quien guardó una cadena vieja sigue
283
+ * aceptando a un sellador retirado; para eso está mirar el registro, no más firmas.
284
+ */
285
+ export async function verifySealerLinkChain (chain, { expectedProfileId = null } = {}) {
286
+ if (!Array.isArray(chain) || !chain.length) return { ok: false, reason: 'cadena-vacia' }
287
+ const [raiz] = chain
288
+ if (raiz?.seq !== 1 || raiz?.prev != null) return { ok: false, reason: 'no-empieza-en-genesis' }
289
+ if (raiz.by !== raiz.profileId) return { ok: false, reason: 'genesis-no-autofirmado' }
290
+ if (expectedProfileId != null && raiz.profileId !== expectedProfileId) return { ok: false, reason: 'otro-perfil' }
291
+ const vr = await verifySealerLink(raiz)
292
+ if (!vr.ok) return { ok: false, reason: 'genesis:' + vr.reason }
293
+
294
+ for (let i = 1; i < chain.length; i++) {
295
+ const previo = chain[i - 1]
296
+ const actual = chain[i]
297
+ if (actual?.profileId !== raiz.profileId) return { ok: false, reason: `eslabon-${i}:otro-perfil` }
298
+ if (!(actual.seq > previo.seq)) return { ok: false, reason: `eslabon-${i}:seq-no-crece` }
299
+ const v = await verifySealerLink(actual)
300
+ if (!v.ok) return { ok: false, reason: `eslabon-${i}:` + v.reason }
301
+ const a = actual.prev
302
+ if (!a || a.seq !== previo.seq || a.hash !== await sealerLinkHash(previo)) {
303
+ return { ok: false, reason: `eslabon-${i}:no-encadena` }
304
+ }
305
+ // Y lo que da la autoridad: quien lo firmó podía sellar SEGÚN EL ESLABÓN ANTERIOR.
306
+ if (!previo.sealers.includes(actual.by)) return { ok: false, reason: `eslabon-${i}:sellador-no-autorizado` }
307
+ }
308
+ const ultimo = chain[chain.length - 1]
309
+ return { ok: true, profileId: raiz.profileId, seq: ultimo.seq, sealers: [...ultimo.sealers] }
310
+ }
311
+
190
312
  /** Id legible de un miembro (mismo formato que el deviceId del emparejamiento). */
191
313
  export async function memberId (pub) {
192
314
  const id = (await pubkeyId(pub)).slice(0, 8).toUpperCase()
@@ -265,6 +387,9 @@ export function genesisActa ({ pub, encPub = null, sealPub = null, label = '', c
265
387
  // `true` porque el génesis ESTABLECE el primer conjunto de selladores: es el eslabón
266
388
  // 1 de la cadena, y por eso la siguiente acta tiene que apuntarle a él.
267
389
  sealerChanged: true,
390
+ // EL ESLABÓN 1 de la cadena publicable, todavía SIN FIRMAR: lo firma `sealActa` con la
391
+ // misma llave, y después firma el acta que lo lleva. Es lo único que sale al registro.
392
+ sealerLink: { v: SEALER_LINK_V, profileId: pub, seq: 1, by: pub, sealers: [pub], prev: null, iat: now },
268
393
  members: [{ pub, encPub, label: String(label || '').slice(0, 60), cn: null, caps: [...PAIRED_CAPS, 'sealer'], addedAt: now, cert: null }],
269
394
  revoked: [],
270
395
  renounced: [],
@@ -355,8 +480,20 @@ export function checkShape (acta) {
355
480
  export async function sealActa ({ acta, privateKey, privateJwk }) {
356
481
  const shape = checkShape(acta)
357
482
  if (shape) throw new Error('invalid record: ' + shape)
358
- const { signature } = await signWithDevice({ privateKey, privateJwk, publickey: acta.sealedBy, data: actaBody(acta) })
359
- const sealed = { ...acta, sig: signature }
483
+ // PRIMERO EL ESLABÓN, DESPUÉS EL ACTA. Este es el orden que hace que no puedan discrepar:
484
+ // se firma el eslabón, se mete en el acta, y la firma del acta lo cubre. Quien tiene el
485
+ // acta lo da por bueno con verificarla; quien solo tiene el registro verifica su firma.
486
+ // Si ya viene firmado no se vuelve a firmar: se está arrastrando el de un acta anterior.
487
+ let conEslabon = acta
488
+ if (acta.sealerLink && !acta.sealerLink.sig) {
489
+ if (acta.sealerLink.by !== acta.sealedBy) throw new Error('invalid record: the chain link names a sealer other than the one signing')
490
+ const { signature: firmaEslabon } = await signWithDevice({
491
+ privateKey, privateJwk, publickey: acta.sealedBy, data: sealerLinkBody(acta.sealerLink)
492
+ })
493
+ conEslabon = { ...acta, sealerLink: { ...acta.sealerLink, sig: firmaEslabon } }
494
+ }
495
+ const { signature } = await signWithDevice({ privateKey, privateJwk, publickey: conEslabon.sealedBy, data: actaBody(conEslabon) })
496
+ const sealed = { ...conEslabon, sig: signature }
360
497
  // La TARJETA se firma en el mismo gesto y viaja con el acta: así cualquier miembro puede
361
498
  // entregársela a un contacto sin ser el master y sin contarle nada de más.
362
499
  sealed.card = await makeProfileCard({ acta: sealed, privateKey, privateJwk })
@@ -457,7 +594,11 @@ export async function verifyActa ({ acta, expectedProfileId } = {}) {
457
594
  if (typeof acta.sig !== 'string') return { ok: false, reason: 'sin-firma' }
458
595
  if (expectedProfileId != null && acta.profileId !== expectedProfileId) return { ok: false, reason: 'otro-perfil' }
459
596
  const ok = await verifyDeviceSig({ publickey: acta.sealedBy, data: actaBody(acta), signature: acta.sig })
460
- return ok ? { ok: true } : { ok: false, reason: 'firma-invalida' }
597
+ if (!ok) return { ok: false, reason: 'firma-invalida' }
598
+ // El eslabón va DENTRO y la firma de arriba lo cubre, pero eso solo prueba que está ahí.
599
+ // Que diga lo mismo que el acta es lo que hace verdad «ya está validado dentro».
600
+ const mal = checkSealerLink(acta)
601
+ return mal ? { ok: false, reason: mal } : { ok: true }
461
602
  }
462
603
 
463
604
  /**
@@ -692,6 +833,24 @@ export async function applyChanges (acta, changes, { by, now = Date.now(), sealP
692
833
  if (!canSeal(next, by)) {
693
834
  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')
694
835
  }
836
+ // EL ESLABÓN PUBLICABLE. Si cambió quién sella se acuña uno nuevo —sin firmar, lo firma
837
+ // `sealActa`— encadenado al que traía el acta anterior. Si no cambió, se arrastra el
838
+ // mismo: así el acta vigente SIEMPRE lleva el último eslabón, y `prev` apunta al eslabón
839
+ // anterior de la cadena (no al acta anterior, que quien lee el registro no tiene).
840
+ if (next.sealerChanged) {
841
+ const anterior = acta.sealerLink || null
842
+ next.sealerLink = {
843
+ v: SEALER_LINK_V,
844
+ profileId: next.profileId,
845
+ seq: next.seq,
846
+ by,
847
+ sealers: sealersOf(next).slice().sort(),
848
+ prev: anterior ? { seq: anterior.seq, hash: await sealerLinkHash(anterior) } : null,
849
+ iat: now
850
+ }
851
+ } else if (acta.sealerLink) {
852
+ next.sealerLink = acta.sealerLink
853
+ }
695
854
  return next
696
855
  }
697
856