@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.
- package/package.json +1 -1
- package/vault/acta.js +164 -5
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 = 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
|
-
|
|
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
|
-
|
|
359
|
-
|
|
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
|
-
|
|
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
|
|