@dotrino/identity 0.70.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 +196 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/identity",
3
- "version": "0.70.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,11 +57,27 @@ 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. */
64
68
  const conCadena = (acta) => Number(acta?.v) >= V_CON_CADENA
69
+ /**
70
+ * Solo `https`, y sin excepción para localhost: esta dirección la va a abrir un TERCERO
71
+ * que no te conoce, y mandarlo por texto plano es dejar que cualquiera en el camino le
72
+ * conteste otra cosa. Que no lleve `#` ni credenciales: no es un enlace para una persona.
73
+ */
74
+ const isChainUrl = (u) => {
75
+ if (typeof u !== 'string' || u.length > 300) return false
76
+ try {
77
+ const x = new URL(u)
78
+ return x.protocol === 'https:' && !x.hash && !x.username && !x.password
79
+ } catch (_) { return false }
80
+ }
65
81
  /** Desde esta versión, sellar es solo un permiso y no hay campo `sealer`. */
66
82
  const V_SIN_CAMPO_SELLADOR = 3
67
83
  /** ¿Este acta es de las viejas, con el campo? */
@@ -175,6 +191,124 @@ export async function actaHash (acta) {
175
191
  return hex(await crypto.subtle.digest('SHA-256', enc(canonicalStringify(actaBody(acta)))))
176
192
  }
177
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
+
178
312
  /** Id legible de un miembro (mismo formato que el deviceId del emparejamiento). */
179
313
  export async function memberId (pub) {
180
314
  const id = (await pubkeyId(pub)).slice(0, 8).toUpperCase()
@@ -221,12 +355,30 @@ export const canSeal = (acta, pub, renounces = []) =>
221
355
  * sellador, con todas las capacidades. `profileId` = su pubkey → el nombre del perfil es
222
356
  * estable para siempre y coincide con la identidad que el usuario ya tenía (cero migración).
223
357
  */
224
- export function genesisActa ({ pub, encPub = null, sealPub = null, label = '', now = Date.now() }) {
358
+ export function genesisActa ({ pub, encPub = null, sealPub = null, label = '', chainUrl = null, now = Date.now() }) {
225
359
  if (!isPub(pub)) throw new Error('genesisActa: missing genesis pubkey')
360
+ if (chainUrl != null && !isChainUrl(chainUrl)) throw new Error('genesisActa: chainUrl must be https')
226
361
  return {
227
362
  v: ACTA_V,
228
363
  profileId: pub,
229
364
  sealedBy: pub,
365
+ /**
366
+ * DÓNDE PREGUNTAR SI ESTA CADENA SIGUE VIGENTE. Va en el GÉNESIS y en ningún otro
367
+ * sitio, y esa es toda la idea.
368
+ *
369
+ * La cadena que te llega prueba quién puede sellar, pero no que no haya algo más
370
+ * nuevo — si retiraste una bóveda, quien tenga la cadena vieja la sigue aceptando.
371
+ * Para enterarse hay que poder mirar a algún lado.
372
+ *
373
+ * Si la dirección viajara en la parte cambiable, el sellador expulsado la cambiaría
374
+ * a la suya y te mandaría a mirar su rama. En el génesis no puede: está autofirmada
375
+ * por la llave que da nombre al perfil, y cambiarla exige esa llave.
376
+ *
377
+ * `null` es lo normal y no es un defecto: una cuenta de una sola bóveda no tiene nada
378
+ * que pueda quedar obsoleto —su conjunto de selladores no puede cambiar— así que no
379
+ * hay a dónde preguntar ni falta que hace.
380
+ */
381
+ chainUrl: chainUrl || null,
230
382
  seq: 1,
231
383
  prev: null,
232
384
  // LA CADENA DE SELLADORES. El génesis es el ancla: está autofirmado por la llave que
@@ -235,6 +387,9 @@ export function genesisActa ({ pub, encPub = null, sealPub = null, label = '', n
235
387
  // `true` porque el génesis ESTABLECE el primer conjunto de selladores: es el eslabón
236
388
  // 1 de la cadena, y por eso la siguiente acta tiene que apuntarle a él.
237
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 },
238
393
  members: [{ pub, encPub, label: String(label || '').slice(0, 60), cn: null, caps: [...PAIRED_CAPS, 'sealer'], addedAt: now, cert: null }],
239
394
  revoked: [],
240
395
  renounced: [],
@@ -258,6 +413,7 @@ export function checkShape (acta) {
258
413
  if (!acta || typeof acta !== 'object') return 'no-acta'
259
414
  if (!ACTA_LEIBLES.includes(acta.v)) return 'version'
260
415
  if (!isPub(acta.profileId) || !isPub(acta.sealedBy)) return 'shape'
416
+ if (acta.chainUrl != null && !isChainUrl(acta.chainUrl)) return 'chainurl'
261
417
  // El campo solo existe —y solo se exige— en las viejas.
262
418
  if (conCampoSellador(acta) && !isPub(acta.sealer)) return 'shape'
263
419
  if (!conCampoSellador(acta) && acta.sealer != null) return 'sealer-no-va-en-v3'
@@ -324,8 +480,20 @@ export function checkShape (acta) {
324
480
  export async function sealActa ({ acta, privateKey, privateJwk }) {
325
481
  const shape = checkShape(acta)
326
482
  if (shape) throw new Error('invalid record: ' + shape)
327
- const { signature } = await signWithDevice({ privateKey, privateJwk, publickey: acta.sealedBy, data: actaBody(acta) })
328
- 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 }
329
497
  // La TARJETA se firma en el mismo gesto y viaja con el acta: así cualquier miembro puede
330
498
  // entregársela a un contacto sin ser el master y sin contarle nada de más.
331
499
  sealed.card = await makeProfileCard({ acta: sealed, privateKey, privateJwk })
@@ -426,7 +594,11 @@ export async function verifyActa ({ acta, expectedProfileId } = {}) {
426
594
  if (typeof acta.sig !== 'string') return { ok: false, reason: 'sin-firma' }
427
595
  if (expectedProfileId != null && acta.profileId !== expectedProfileId) return { ok: false, reason: 'otro-perfil' }
428
596
  const ok = await verifyDeviceSig({ publickey: acta.sealedBy, data: actaBody(acta), signature: acta.sig })
429
- 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 }
430
602
  }
431
603
 
432
604
  /**
@@ -661,6 +833,24 @@ export async function applyChanges (acta, changes, { by, now = Date.now(), sealP
661
833
  if (!canSeal(next, by)) {
662
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')
663
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
+ }
664
854
  return next
665
855
  }
666
856