@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.
- package/package.json +1 -1
- package/vault/acta.js +196 -6
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,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
|
-
|
|
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
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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
|
|