@dotrino/identity 0.121.3 → 0.121.4
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/src/index.js +1 -1
- package/vault/acta.js +69 -69
- package/vault/assertion.js +15 -15
- package/vault/capabilities.js +3 -3
- package/vault/core.js +11 -11
- package/vault/session.js +13 -13
- package/vault/sessionFlow.js +7 -7
- package/vault/vendor/roadmap/VERSION.txt +1 -1
- package/vault/vendor/roadmap/manifests.js +7 -7
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -387,7 +387,7 @@ export class Identity {
|
|
|
387
387
|
* Une ESTE perfil a la cuenta de otro (la de una bóveda). No es adoptar una versión nueva
|
|
388
388
|
* de la tuya: esta llave pasa a ser de OTRA cuenta y deja de tener la suya, así que solo
|
|
389
389
|
* procede sobre un perfil creado con `{ forVault: true }`. Si no, devuelve
|
|
390
|
-
* `{ joined: false, reason: '
|
|
390
|
+
* `{ joined: false, reason: 'profile-has-data' }` y no escribe nada.
|
|
391
391
|
*/
|
|
392
392
|
async joinProfile (acta) { return this._call('joinProfile', { acta }) }
|
|
393
393
|
/** Marca este perfil como nacido para ADOPTAR la cuenta de otro (camino A). */
|
package/vault/acta.js
CHANGED
|
@@ -289,38 +289,38 @@ export const sealerLinkOf = (acta) => (acta?.sealerChanged && acta?.sealerLink)
|
|
|
289
289
|
*/
|
|
290
290
|
export function checkSealerLink (acta) {
|
|
291
291
|
const l = acta?.sealerLink
|
|
292
|
-
if (!l) return acta?.sealerChanged && acta?.v >= 5 ? '
|
|
293
|
-
if (l.v !== SEALER_LINK_V) return '
|
|
294
|
-
if (typeof l.sig !== 'string') return '
|
|
295
|
-
if (l.profileId !== acta.profileId) return '
|
|
292
|
+
if (!l) return acta?.sealerChanged && acta?.v >= 5 ? 'link-missing' : null
|
|
293
|
+
if (l.v !== SEALER_LINK_V) return 'link-version'
|
|
294
|
+
if (typeof l.sig !== 'string') return 'link-unsigned'
|
|
295
|
+
if (l.profileId !== acta.profileId) return 'link-other-profile'
|
|
296
296
|
// EL ACTA VIGENTE ARRASTRA EL ÚLTIMO ESLABÓN, que casi nunca es de ella: los selladores
|
|
297
297
|
// cambian poquísimo y las actas cambian con cada emparejamiento. Así que solo cuando
|
|
298
298
|
// ESTA acta es el eslabón se le exige que coincida en `seq` y en quién lo firmó; si lo
|
|
299
299
|
// hereda, basta con que venga de antes.
|
|
300
300
|
if (acta.sealerChanged) {
|
|
301
|
-
if (l.seq !== acta.seq) return '
|
|
302
|
-
if (l.by !== acta.sealedBy) return '
|
|
301
|
+
if (l.seq !== acta.seq) return 'link-other-seq'
|
|
302
|
+
if (l.by !== acta.sealedBy) return 'link-other-sealer'
|
|
303
303
|
} else if (!(l.seq < acta.seq)) {
|
|
304
|
-
return '
|
|
304
|
+
return 'link-from-future'
|
|
305
305
|
}
|
|
306
306
|
// Y en los dos casos tiene que DECIR LO MISMO que el acta sobre quién sella: si no
|
|
307
307
|
// cambiaron, el heredado los sigue describiendo. Esto es lo que impide que el acta y su
|
|
308
308
|
// eslabón cuenten cosas distintas — que es todo el punto de meterlo dentro.
|
|
309
309
|
const dice = [...(l.sealers || [])].slice().sort().join('|')
|
|
310
310
|
const segunElActa = sealersOf(acta).slice().sort().join('|')
|
|
311
|
-
if (dice !== segunElActa) return '
|
|
311
|
+
if (dice !== segunElActa) return 'link-mismatch'
|
|
312
312
|
return null
|
|
313
313
|
}
|
|
314
314
|
|
|
315
315
|
/** Firma del eslabón por quien sella. Se hace ANTES de firmar el acta que lo lleva. */
|
|
316
316
|
export async function verifySealerLink (link) {
|
|
317
|
-
if (!link || link.v !== SEALER_LINK_V) return { ok: false, reason: '
|
|
318
|
-
if (!isPub(link.profileId) || !isPub(link.by)) return { ok: false, reason: '
|
|
319
|
-
if (!Array.isArray(link.sealers) || !link.sealers.length || !link.sealers.every(isPub)) return { ok: false, reason: '
|
|
320
|
-
if (typeof link.seq !== 'number' || link.seq < 1) return { ok: false, reason: '
|
|
321
|
-
if (typeof link.sig !== 'string') return { ok: false, reason: '
|
|
317
|
+
if (!link || link.v !== SEALER_LINK_V) return { ok: false, reason: 'link-version' }
|
|
318
|
+
if (!isPub(link.profileId) || !isPub(link.by)) return { ok: false, reason: 'link-shape' }
|
|
319
|
+
if (!Array.isArray(link.sealers) || !link.sealers.length || !link.sealers.every(isPub)) return { ok: false, reason: 'link-sealers' }
|
|
320
|
+
if (typeof link.seq !== 'number' || link.seq < 1) return { ok: false, reason: 'link-seq' }
|
|
321
|
+
if (typeof link.sig !== 'string') return { ok: false, reason: 'link-unsigned' }
|
|
322
322
|
const ok = await verifyDeviceSig({ publickey: link.by, data: sealerLinkBody(link), signature: link.sig })
|
|
323
|
-
return ok ? { ok: true } : { ok: false, reason: '
|
|
323
|
+
return ok ? { ok: true } : { ok: false, reason: 'link-invalid-signature' }
|
|
324
324
|
}
|
|
325
325
|
|
|
326
326
|
/**
|
|
@@ -338,29 +338,29 @@ export async function verifySealerLink (link) {
|
|
|
338
338
|
* aceptando a un sellador retirado; para eso está mirar el registro, no más firmas.
|
|
339
339
|
*/
|
|
340
340
|
export async function verifySealerLinkChain (chain, { expectedProfileId = null } = {}) {
|
|
341
|
-
if (!Array.isArray(chain) || !chain.length) return { ok: false, reason: '
|
|
341
|
+
if (!Array.isArray(chain) || !chain.length) return { ok: false, reason: 'empty-chain' }
|
|
342
342
|
const [raiz] = chain
|
|
343
343
|
// La misma regla que en `verifySealerChain`: la raíz la ancla la llave del perfil, no el
|
|
344
344
|
// número 1 (una cuenta puede haber perdido su génesis; ver allí).
|
|
345
|
-
if (!Number.isInteger(raiz?.seq) || raiz.seq < 1) return { ok: false, reason: 'no-
|
|
346
|
-
if (raiz.by !== raiz.profileId) return { ok: false, reason: 'genesis-
|
|
347
|
-
if (expectedProfileId != null && raiz.profileId !== expectedProfileId) return { ok: false, reason: '
|
|
345
|
+
if (!Number.isInteger(raiz?.seq) || raiz.seq < 1) return { ok: false, reason: 'no-genesis' }
|
|
346
|
+
if (raiz.by !== raiz.profileId) return { ok: false, reason: 'genesis-not-self-signed' }
|
|
347
|
+
if (expectedProfileId != null && raiz.profileId !== expectedProfileId) return { ok: false, reason: 'other-profile' }
|
|
348
348
|
const vr = await verifySealerLink(raiz)
|
|
349
349
|
if (!vr.ok) return { ok: false, reason: 'genesis:' + vr.reason }
|
|
350
350
|
|
|
351
351
|
for (let i = 1; i < chain.length; i++) {
|
|
352
352
|
const previo = chain[i - 1]
|
|
353
353
|
const actual = chain[i]
|
|
354
|
-
if (actual?.profileId !== raiz.profileId) return { ok: false, reason: `
|
|
355
|
-
if (!(actual.seq > previo.seq)) return { ok: false, reason: `
|
|
354
|
+
if (actual?.profileId !== raiz.profileId) return { ok: false, reason: `link-${i}:other-profile` }
|
|
355
|
+
if (!(actual.seq > previo.seq)) return { ok: false, reason: `link-${i}:seq-not-increasing` }
|
|
356
356
|
const v = await verifySealerLink(actual)
|
|
357
|
-
if (!v.ok) return { ok: false, reason: `
|
|
357
|
+
if (!v.ok) return { ok: false, reason: `link-${i}:` + v.reason }
|
|
358
358
|
const a = actual.prev
|
|
359
359
|
if (!a || a.seq !== previo.seq || a.hash !== await sealerLinkHash(previo)) {
|
|
360
|
-
return { ok: false, reason: `
|
|
360
|
+
return { ok: false, reason: `link-${i}:not-chained` }
|
|
361
361
|
}
|
|
362
362
|
// Y lo que da la autoridad: quien lo firmó podía sellar SEGÚN EL ESLABÓN ANTERIOR.
|
|
363
|
-
if (!previo.sealers.includes(actual.by)) return { ok: false, reason: `
|
|
363
|
+
if (!previo.sealers.includes(actual.by)) return { ok: false, reason: `link-${i}:unauthorized-sealer` }
|
|
364
364
|
}
|
|
365
365
|
const ultimo = chain[chain.length - 1]
|
|
366
366
|
return { ok: true, profileId: raiz.profileId, seq: ultimo.seq, sealers: [...ultimo.sealers] }
|
|
@@ -473,7 +473,7 @@ export function checkShape (acta) {
|
|
|
473
473
|
if (acta.chainUrl != null && !isChainUrl(acta.chainUrl)) return 'chainurl'
|
|
474
474
|
// El campo solo existe —y solo se exige— en las viejas.
|
|
475
475
|
if (conCampoSellador(acta) && !isPub(acta.sealer)) return 'shape'
|
|
476
|
-
if (!conCampoSellador(acta) && acta.sealer != null) return 'sealer-
|
|
476
|
+
if (!conCampoSellador(acta) && acta.sealer != null) return 'sealer-not-in-v3'
|
|
477
477
|
if (!Number.isInteger(acta.seq) || acta.seq < 1) return 'seq'
|
|
478
478
|
if (acta.seq > 1 && typeof acta.prev !== 'string') return 'prev'
|
|
479
479
|
if (!Array.isArray(acta.members) || acta.members.length === 0) return 'members'
|
|
@@ -507,7 +507,7 @@ export function checkShape (acta) {
|
|
|
507
507
|
// El CN es la frontera: un SERVICIO solo puede abrir su propio cajón, y un
|
|
508
508
|
// DISPOSITIVO no tiene cajón que abrir. Que no se pueda escribir un acta que
|
|
509
509
|
// mezcle las dos cosas es lo que hace que el límite sea real y no una costumbre.
|
|
510
|
-
if (m.cn != null && !isValidCn(m.cn)) return 'cn
|
|
510
|
+
if (m.cn != null && !isValidCn(m.cn)) return 'invalid-cn'
|
|
511
511
|
// La llave de cifrado: por ahora se valida la FORMA y solo si viene. NO se exige
|
|
512
512
|
// todavía, aunque el destino sea exigírsela a quien recibe secretos.
|
|
513
513
|
//
|
|
@@ -516,27 +516,27 @@ export function checkShape (acta) {
|
|
|
516
516
|
// entró sin ella —que son todas las anteriores a esto— y el vault dejaría de
|
|
517
517
|
// arrancar en vez de avisar. Primero los servicios registran su llave (op
|
|
518
518
|
// `encpub`), y cuando no quede ninguno sin ella se aprieta aquí.
|
|
519
|
-
if (m.encPub != null && !isEncPub(m.encPub)) return 'encpub
|
|
519
|
+
if (m.encPub != null && !isEncPub(m.encPub)) return 'invalid-encpub'
|
|
520
520
|
}
|
|
521
521
|
// LA LLAVE DE SELLADO Y SU REGISTRO. `sealPub` puede no estar (un perfil que no sella
|
|
522
522
|
// secretos), pero si está tiene que decir DESDE QUÉ acta manda: sin `sealSince` no se
|
|
523
523
|
// puede decidir con qué llave verificar un sobre viejo.
|
|
524
524
|
if (acta.v >= 2) {
|
|
525
525
|
if (acta.sealPub != null) {
|
|
526
|
-
if (!isPub(acta.sealPub)) return 'sealpub
|
|
526
|
+
if (!isPub(acta.sealPub)) return 'invalid-sealpub'
|
|
527
527
|
if (!Number.isInteger(acta.sealSince) || acta.sealSince < 1 || acta.sealSince > acta.seq) return 'sealsince'
|
|
528
528
|
} else if (acta.sealSince) return 'sealsince'
|
|
529
529
|
if (!Array.isArray(acta.sealKeys)) return 'sealkeys'
|
|
530
530
|
for (const k of acta.sealKeys) {
|
|
531
|
-
if (!isPub(k?.pub)) return 'sealkey
|
|
532
|
-
if (!Number.isInteger(k.from) || !Number.isInteger(k.to) || k.from < 1 || k.to < k.from) return 'sealkey-
|
|
531
|
+
if (!isPub(k?.pub)) return 'invalid-sealkey'
|
|
532
|
+
if (!Number.isInteger(k.from) || !Number.isInteger(k.to) || k.from < 1 || k.to < k.from) return 'sealkey-range'
|
|
533
533
|
}
|
|
534
534
|
}
|
|
535
|
-
if (new Set(acta.members.map((m) => m.pub)).size !== acta.members.length) return '
|
|
535
|
+
if (new Set(acta.members.map((m) => m.pub)).size !== acta.members.length) return 'duplicate-member'
|
|
536
536
|
// SIN NADIE QUE SELLE, el acta está muerta: no habría forma de volver a cambiarla nunca.
|
|
537
537
|
// Es el hermano del cierre de `sign`, y sustituye a «el sellador tiene que ser miembro»
|
|
538
538
|
// ahora que sellar es solo un permiso y los permisos ya son de miembros por definición.
|
|
539
|
-
if (!sealersOf(acta).length) return '
|
|
539
|
+
if (!sealersOf(acta).length) return 'no-sealer'
|
|
540
540
|
// La cadena de selladores: el génesis es el ancla (`null`); cualquier otra apunta a un
|
|
541
541
|
// eslabón anterior. Sin `hash` no se puede encadenar nada, así que se exige la forma.
|
|
542
542
|
if (acta.sealerAnchor != null) {
|
|
@@ -549,7 +549,7 @@ export function checkShape (acta) {
|
|
|
549
549
|
// inválida. Lo que sostiene la cadena no es el ancla sino que **cada eslabón lo selle
|
|
550
550
|
// alguien a quien el anterior autorizaba**; el ancla añade que no se pueda empalmar un
|
|
551
551
|
// eslabón de otra rama, y se comprueba cuando viene (ver `verifySealerChain`).
|
|
552
|
-
if (!acta.members.some((m) => m.caps.includes('sign'))) return '
|
|
552
|
+
if (!acta.members.some((m) => m.caps.includes('sign'))) return 'no-signer'
|
|
553
553
|
return null
|
|
554
554
|
}
|
|
555
555
|
|
|
@@ -627,20 +627,20 @@ export async function verifySignedBy (args = {}) {
|
|
|
627
627
|
return { ok: false, reason: 'shape' }
|
|
628
628
|
}
|
|
629
629
|
const c = await verifySealerChain(chain, { expectedProfileId })
|
|
630
|
-
if (!c.ok) return { ok: false, reason: '
|
|
630
|
+
if (!c.ok) return { ok: false, reason: 'chain:' + c.reason }
|
|
631
631
|
const cabeza = chain[chain.length - 1]
|
|
632
632
|
// Quien firmó tiene que ser miembro CON `sign` en el acta vigente de esa cadena. Si le
|
|
633
633
|
// quitaste el permiso, sus firmas dejan de contar en cuanto se ve un acta más nueva.
|
|
634
634
|
// Un SERVICIO (miembro con `cn`) solo firma dentro de su cajón: quien verifica dice cuál
|
|
635
635
|
// con `ns` (el directorio de nombres acepta lo firmado por su aparato `names`, no por
|
|
636
636
|
// cualquier servicio de la cuenta). Sin `ns`, un servicio nunca pasa, como siempre.
|
|
637
|
-
if (!memberCanSign(cabeza, publickey, ns)) return { ok: false, reason: '
|
|
638
|
-
if (!(await verifyDeviceSig({ publickey, data, signature }))) return { ok: false, reason: '
|
|
637
|
+
if (!memberCanSign(cabeza, publickey, ns)) return { ok: false, reason: 'unauthorized-signer' }
|
|
638
|
+
if (!(await verifyDeviceSig({ publickey, data, signature }))) return { ok: false, reason: 'invalid-signature' }
|
|
639
639
|
return { ok: true, profileId: c.profileId, seq: c.seq, signer: publickey }
|
|
640
640
|
}
|
|
641
641
|
|
|
642
642
|
export async function verifySealerChain (chain, { expectedProfileId = null } = {}) {
|
|
643
|
-
if (!Array.isArray(chain) || !chain.length) return { ok: false, reason: '
|
|
643
|
+
if (!Array.isArray(chain) || !chain.length) return { ok: false, reason: 'empty-chain' }
|
|
644
644
|
const [raiz] = chain
|
|
645
645
|
|
|
646
646
|
// EL ANCLA ES LA LLAVE DEL PERFIL, NO EL NÚMERO 1 (dueño, 2026-10-14). La raíz vale si
|
|
@@ -651,18 +651,18 @@ export async function verifySealerChain (chain, { expectedProfileId = null } = {
|
|
|
651
651
|
// perfil fabrica un génesis alterno igual que un acta posterior, y contra eso la capa es
|
|
652
652
|
// el registro público. Lo que sí sostiene la cadena —que cada eslabón lo firmó alguien a
|
|
653
653
|
// quien el anterior autorizaba— sigue intacto.
|
|
654
|
-
if (!Number.isInteger(raiz.seq) || raiz.seq < 1) return { ok: false, reason: 'no-
|
|
655
|
-
if (raiz.sealedBy !== raiz.profileId) return { ok: false, reason: 'genesis-
|
|
656
|
-
if (expectedProfileId != null && raiz.profileId !== expectedProfileId) return { ok: false, reason: '
|
|
654
|
+
if (!Number.isInteger(raiz.seq) || raiz.seq < 1) return { ok: false, reason: 'no-genesis' }
|
|
655
|
+
if (raiz.sealedBy !== raiz.profileId) return { ok: false, reason: 'genesis-not-self-signed' }
|
|
656
|
+
if (expectedProfileId != null && raiz.profileId !== expectedProfileId) return { ok: false, reason: 'other-profile' }
|
|
657
657
|
const vr = await verifyActa({ acta: raiz })
|
|
658
658
|
if (!vr.ok) return { ok: false, reason: 'genesis:' + vr.reason }
|
|
659
659
|
|
|
660
660
|
for (let i = 1; i < chain.length; i++) {
|
|
661
661
|
const previo = chain[i - 1]
|
|
662
662
|
const actual = chain[i]
|
|
663
|
-
if (actual.profileId !== raiz.profileId) return { ok: false, reason: '
|
|
663
|
+
if (actual.profileId !== raiz.profileId) return { ok: false, reason: 'other-profile' }
|
|
664
664
|
const v = await verifyActa({ acta: actual })
|
|
665
|
-
if (!v.ok) return { ok: false, reason: `
|
|
665
|
+
if (!v.ok) return { ok: false, reason: `link-${i}:` + v.reason }
|
|
666
666
|
// Encadena con el anterior: mismo `seq` y mismo hash. Sin el hash bastaría con
|
|
667
667
|
// acertar un número para colar un eslabón de otra rama.
|
|
668
668
|
// El ancla, SI viene: fija que este eslabón sale del anterior y no de otra rama. Una
|
|
@@ -670,10 +670,10 @@ export async function verifySealerChain (chain, { expectedProfileId = null } = {
|
|
|
670
670
|
// abajo — que es lo que de verdad impide falsificarla.
|
|
671
671
|
const a = actual.sealerAnchor
|
|
672
672
|
if (a && (a.seq !== previo.seq || a.hash !== await actaHash(previo))) {
|
|
673
|
-
return { ok: false, reason: `
|
|
673
|
+
return { ok: false, reason: `link-${i}:not-chained` }
|
|
674
674
|
}
|
|
675
675
|
// Y lo que da la autoridad: quien la selló tenía permiso SEGÚN EL ESLABÓN ANTERIOR.
|
|
676
|
-
if (!canSeal(previo, actual.sealedBy)) return { ok: false, reason: `
|
|
676
|
+
if (!canSeal(previo, actual.sealedBy)) return { ok: false, reason: `link-${i}:unauthorized-sealer` }
|
|
677
677
|
}
|
|
678
678
|
const ultima = chain[chain.length - 1]
|
|
679
679
|
return { ok: true, profileId: raiz.profileId, seq: ultima.seq, sealers: sealersOf(ultima) }
|
|
@@ -682,10 +682,10 @@ export async function verifySealerChain (chain, { expectedProfileId = null } = {
|
|
|
682
682
|
export async function verifyActa ({ acta, expectedProfileId } = {}) {
|
|
683
683
|
const shape = checkShape(acta)
|
|
684
684
|
if (shape) return { ok: false, reason: shape }
|
|
685
|
-
if (typeof acta.sig !== 'string') return { ok: false, reason: '
|
|
686
|
-
if (expectedProfileId != null && acta.profileId !== expectedProfileId) return { ok: false, reason: '
|
|
685
|
+
if (typeof acta.sig !== 'string') return { ok: false, reason: 'unsigned' }
|
|
686
|
+
if (expectedProfileId != null && acta.profileId !== expectedProfileId) return { ok: false, reason: 'other-profile' }
|
|
687
687
|
const ok = await verifyDeviceSig({ publickey: acta.sealedBy, data: actaBody(acta), signature: acta.sig })
|
|
688
|
-
if (!ok) return { ok: false, reason: '
|
|
688
|
+
if (!ok) return { ok: false, reason: 'invalid-signature' }
|
|
689
689
|
// El eslabón va DENTRO y la firma de arriba lo cubre, pero eso solo prueba que está ahí.
|
|
690
690
|
// Que diga lo mismo que el acta es lo que hace verdad «ya está validado dentro».
|
|
691
691
|
const mal = checkSealerLink(acta)
|
|
@@ -720,21 +720,21 @@ export async function verifyActa ({ acta, expectedProfileId } = {}) {
|
|
|
720
720
|
* @returns {Promise<{ok:true}|{ok:false, reason:string}>}
|
|
721
721
|
*/
|
|
722
722
|
export async function checkVaultReply ({ acta, cert, vault, sub, scope = null, justSealed = false } = {}) {
|
|
723
|
-
if (!acta) return { ok: false, reason: '
|
|
724
|
-
if (!isPub(vault)) return { ok: false, reason: '
|
|
723
|
+
if (!acta) return { ok: false, reason: 'no-record' }
|
|
724
|
+
if (!isPub(vault)) return { ok: false, reason: 'no-vault-key' }
|
|
725
725
|
// Para QUÉ llave es el papel no es opcional: sin ella `verifyDelegation` no lo mira, y un
|
|
726
726
|
// papel emitido para otra llave pasaría por bueno.
|
|
727
|
-
if (!isPub(sub)) return { ok: false, reason: '
|
|
727
|
+
if (!isPub(sub)) return { ok: false, reason: 'no-device-key' }
|
|
728
728
|
const v = await verifyActa({ acta })
|
|
729
|
-
if (!v.ok) return { ok: false, reason: '
|
|
730
|
-
if (!canSeal(acta, vault)) return { ok: false, reason: '
|
|
731
|
-
if (justSealed && !samePubkey(acta.sealedBy, vault)) return { ok: false, reason: '
|
|
732
|
-
if (!cert || !samePubkey(cert.iss, vault)) return { ok: false, reason: '
|
|
729
|
+
if (!v.ok) return { ok: false, reason: 'record:' + v.reason }
|
|
730
|
+
if (!canSeal(acta, vault)) return { ok: false, reason: 'vault-not-sealer' }
|
|
731
|
+
if (justSealed && !samePubkey(acta.sealedBy, vault)) return { ok: false, reason: 'record-from-other-sealer' }
|
|
732
|
+
if (!cert || !samePubkey(cert.iss, vault)) return { ok: false, reason: 'cert-from-other-key' }
|
|
733
733
|
const d = await verifyDelegation({
|
|
734
734
|
cert, expectedSub: sub, actaSeq: acta.seq, sealers: sealersOf(acta),
|
|
735
735
|
...(scope ? { expectedScope: scope } : {})
|
|
736
736
|
})
|
|
737
|
-
if (!d.ok) return { ok: false, reason: '
|
|
737
|
+
if (!d.ok) return { ok: false, reason: 'cert:' + d.reason }
|
|
738
738
|
return { ok: true }
|
|
739
739
|
}
|
|
740
740
|
|
|
@@ -1213,16 +1213,16 @@ export async function verifyProfileCard (card) {
|
|
|
1213
1213
|
* anterior — así nadie te cuela dispositivos ajenos en el perfil de tu contacto,
|
|
1214
1214
|
* y nunca se retrocede a una versión vieja.
|
|
1215
1215
|
* Si el master cambió legítimamente (traspaso), la tarjeta nueva la firma el entrante y
|
|
1216
|
-
* hay que re-confirmarla: se devuelve `master-
|
|
1216
|
+
* hay que re-confirmarla: se devuelve `master-changed` para que la app lo diga en vez de
|
|
1217
1217
|
* aceptarlo en silencio.
|
|
1218
1218
|
*/
|
|
1219
1219
|
export async function canAdoptCard ({ card, current }) {
|
|
1220
|
-
if (!(await verifyProfileCard(card))) return { adopt: false, reason: '
|
|
1221
|
-
if (current && current.profileId !== card.profileId) return { adopt: false, reason: '
|
|
1222
|
-
if (!current) return { adopt: true, reason: '
|
|
1223
|
-
if (card.seq < current.seq) return { adopt: false, reason: 'seq
|
|
1224
|
-
if (card.sealedBy !== current.sealedBy) return { adopt: false, reason: 'master-
|
|
1225
|
-
return { adopt: true, reason: card.seq > current.seq ? 'seq
|
|
1220
|
+
if (!(await verifyProfileCard(card))) return { adopt: false, reason: 'invalid-signature' }
|
|
1221
|
+
if (current && current.profileId !== card.profileId) return { adopt: false, reason: 'other-profile' }
|
|
1222
|
+
if (!current) return { adopt: true, reason: 'first-time' }
|
|
1223
|
+
if (card.seq < current.seq) return { adopt: false, reason: 'lower-seq' }
|
|
1224
|
+
if (card.sealedBy !== current.sealedBy) return { adopt: false, reason: 'master-changed' }
|
|
1225
|
+
return { adopt: true, reason: card.seq > current.seq ? 'higher-seq' : 'same' }
|
|
1226
1226
|
}
|
|
1227
1227
|
|
|
1228
1228
|
// ----- continuidad: unir una identidad que ya existía -----
|
|
@@ -1270,19 +1270,19 @@ export async function verifyContinuity (record) {
|
|
|
1270
1270
|
export async function canAdopt ({ candidate, current }) {
|
|
1271
1271
|
const v = await verifyActa({ acta: candidate, expectedProfileId: current?.profileId })
|
|
1272
1272
|
if (!v.ok) return { adopt: false, reason: v.reason }
|
|
1273
|
-
if (!current) return { adopt: true, reason: '
|
|
1273
|
+
if (!current) return { adopt: true, reason: 'no-previous-record' }
|
|
1274
1274
|
|
|
1275
1275
|
if (candidate.seq > current.seq) {
|
|
1276
|
-
if (!canSeal(current, candidate.sealedBy)) return { adopt: false, reason: '
|
|
1276
|
+
if (!canSeal(current, candidate.sealedBy)) return { adopt: false, reason: 'unauthorized-sealer' }
|
|
1277
1277
|
if (candidate.seq === current.seq + 1 && candidate.prev !== await actaHash(current)) {
|
|
1278
|
-
return { adopt: false, reason: '
|
|
1278
|
+
return { adopt: false, reason: 'not-chained' }
|
|
1279
1279
|
}
|
|
1280
|
-
return { adopt: true, reason: 'seq
|
|
1280
|
+
return { adopt: true, reason: 'higher-seq' }
|
|
1281
1281
|
}
|
|
1282
1282
|
|
|
1283
1283
|
if (candidate.seq === current.seq) {
|
|
1284
1284
|
const [hCan, hCur] = [await actaHash(candidate), await actaHash(current)]
|
|
1285
|
-
if (hCan === hCur) return { adopt: false, reason: '
|
|
1285
|
+
if (hCan === hCur) return { adopt: false, reason: 'same-record' }
|
|
1286
1286
|
// A igual `seq`, con multivault, las dos ramas pueden venir de selladores DISTINTOS y
|
|
1287
1287
|
// ser las dos legítimas. Antes era imposible y se rechazaba de plano; ahora se
|
|
1288
1288
|
// desempata con las reglas de abajo, que ya estaban escritas.
|
|
@@ -1294,16 +1294,16 @@ export async function canAdopt ({ candidate, current }) {
|
|
|
1294
1294
|
// correcta es por el acta PADRE, que aquí no se tiene; tener el mismo `sealedBy` es
|
|
1295
1295
|
// exactamente lo que dice que las dos salieron de ella.
|
|
1296
1296
|
const mismoFirmante = candidate.sealedBy === current.sealedBy
|
|
1297
|
-
if (!mismoFirmante && !canSeal(current, candidate.sealedBy)) return { adopt: false, reason: '
|
|
1297
|
+
if (!mismoFirmante && !canSeal(current, candidate.sealedBy)) return { adopt: false, reason: 'other-sealer' }
|
|
1298
1298
|
// SOLO QUEDA EL HASH. Antes había una regla más —«gana la que traspasa»— que existía
|
|
1299
1299
|
// para arbitrar el campo `sealer`: dos ramas que cedían el mando a destinos distintos.
|
|
1300
1300
|
// Ese campo ya no existe (dueño, 2026-08-31: «SEAL es el nuevo master»), y con él se
|
|
1301
1301
|
// va la regla y la ambigüedad que arrastraba: `sealer !== sealedBy` significaba
|
|
1302
1302
|
// «traspaso» con un solo master y dejó de significarlo con varios selladores.
|
|
1303
|
-
return hCan < hCur ? { adopt: true, reason: '
|
|
1303
|
+
return hCan < hCur ? { adopt: true, reason: 'hash-tiebreak' } : { adopt: false, reason: 'hash-tiebreak' }
|
|
1304
1304
|
}
|
|
1305
1305
|
|
|
1306
|
-
return { adopt: false, reason: 'seq
|
|
1306
|
+
return { adopt: false, reason: 'lower-seq' }
|
|
1307
1307
|
}
|
|
1308
1308
|
|
|
1309
1309
|
export default {
|
package/vault/assertion.js
CHANGED
|
@@ -145,31 +145,31 @@ export async function verifyAssertion (assertion, opts = {}) {
|
|
|
145
145
|
if (!Array.isArray(a.scopes) || (a.claims != null && typeof a.claims !== 'object')) return { ok: false, reason: 'shape' }
|
|
146
146
|
if (typeof a.signature !== 'string' || typeof a.publickey !== 'string') return { ok: false, reason: 'shape' }
|
|
147
147
|
|
|
148
|
-
if (a.aud !== audience.trim()) return { ok: false, reason: '
|
|
149
|
-
if (a.nonce !== nonce) return { ok: false, reason: '
|
|
148
|
+
if (a.aud !== audience.trim()) return { ok: false, reason: 'other-audience' }
|
|
149
|
+
if (a.nonce !== nonce) return { ok: false, reason: 'other-nonce' }
|
|
150
150
|
|
|
151
|
-
if (a.exp <= a.iat) return { ok: false, reason: '
|
|
151
|
+
if (a.exp <= a.iat) return { ok: false, reason: 'invalid-lifetime' }
|
|
152
152
|
// El TOPE lo comprueba quien recibe. Si no, el emisor decide solo cuánto dura su
|
|
153
153
|
// credencial y el tope no es un tope.
|
|
154
|
-
if (a.exp - a.iat > ASSERTION_MAX_TTL_MS) return { ok: false, reason: '
|
|
154
|
+
if (a.exp - a.iat > ASSERTION_MAX_TTL_MS) return { ok: false, reason: 'lifetime-too-long' }
|
|
155
155
|
// Y el vencimiento se juzga SIN margen: el margen es para el arranque (relojes que
|
|
156
156
|
// difieren), no para seguir aceptando lo que ya venció.
|
|
157
|
-
if (a.exp <= now) return { ok: false, reason: '
|
|
158
|
-
if (a.iat > now + maxSkewMs) return { ok: false, reason: '
|
|
157
|
+
if (a.exp <= now) return { ok: false, reason: 'expired' }
|
|
158
|
+
if (a.iat > now + maxSkewMs) return { ok: false, reason: 'from-future' }
|
|
159
159
|
|
|
160
|
-
if (a.scopes.some((s) => !SCOPES.includes(s))) return { ok: false, reason: '
|
|
160
|
+
if (a.scopes.some((s) => !SCOPES.includes(s))) return { ok: false, reason: 'unknown-scope' }
|
|
161
161
|
const permitido = claimsAllowed(a.scopes)
|
|
162
|
-
if (Object.keys(a.claims || {}).some((k) => !permitido.has(k))) return { ok: false, reason: 'claim-
|
|
162
|
+
if (Object.keys(a.claims || {}).some((k) => !permitido.has(k))) return { ok: false, reason: 'claim-without-scope' }
|
|
163
163
|
|
|
164
164
|
// Y lo de siempre: que la firma sea de alguien a quien el acta de esa cadena autoriza a
|
|
165
165
|
// firmar por esta identidad. Es la misma comprobación que para cualquier contenido
|
|
166
166
|
// firmado; aquí no se inventa una cripto aparte.
|
|
167
167
|
const v = await verifySignedBy({ data: bodyOf(a), signature: a.signature, publickey: a.publickey, chain: a.chain, expectedProfileId })
|
|
168
|
-
if (!v.ok) return { ok: false, reason: '
|
|
168
|
+
if (!v.ok) return { ok: false, reason: 'signature:' + v.reason }
|
|
169
169
|
// A NOMBRE DE QUIÉN dice ir, contra a nombre de quién va de verdad. Sin esto una prueba
|
|
170
170
|
// podría afirmar ser de otro perfil y la firma seguiría cuadrando: diría la verdad sobre
|
|
171
171
|
// quién la firmó y una mentira sobre de quién es.
|
|
172
|
-
if (a.sub !== v.profileId) return { ok: false, reason: '
|
|
172
|
+
if (a.sub !== v.profileId) return { ok: false, reason: 'other-subject' }
|
|
173
173
|
|
|
174
174
|
return { ok: true, profileId: v.profileId, signer: v.signer, seq: v.seq, scopes: [...a.scopes], claims: { ...(a.claims || {}) }, aud: a.aud, exp: a.exp }
|
|
175
175
|
}
|
|
@@ -200,18 +200,18 @@ export async function verifySignedFor (args = {}) {
|
|
|
200
200
|
const { data, signature, publickey, chain, audience, expectedProfileId = null, ns = null, now = Date.now(), maxSkewMs = ASSERTION_MAX_SKEW_MS } = args
|
|
201
201
|
if (typeof audience !== 'string' || !audience.trim()) return { ok: false, reason: 'no-audience' }
|
|
202
202
|
if (!data || typeof data !== 'object') return { ok: false, reason: 'shape' }
|
|
203
|
-
if (typeof data.aud !== 'string' || !data.aud) return { ok: false, reason: '
|
|
204
|
-
if (data.aud !== audience.trim()) return { ok: false, reason: '
|
|
203
|
+
if (typeof data.aud !== 'string' || !data.aud) return { ok: false, reason: 'no-aud' }
|
|
204
|
+
if (data.aud !== audience.trim()) return { ok: false, reason: 'other-audience' }
|
|
205
205
|
if (data.exp != null) {
|
|
206
206
|
if (!Number.isFinite(data.exp)) return { ok: false, reason: 'shape' }
|
|
207
|
-
if (data.exp <= now) return { ok: false, reason: '
|
|
207
|
+
if (data.exp <= now) return { ok: false, reason: 'expired' }
|
|
208
208
|
}
|
|
209
209
|
if (data.iat != null) {
|
|
210
210
|
if (!Number.isFinite(data.iat)) return { ok: false, reason: 'shape' }
|
|
211
|
-
if (data.iat > now + maxSkewMs) return { ok: false, reason: '
|
|
211
|
+
if (data.iat > now + maxSkewMs) return { ok: false, reason: 'from-future' }
|
|
212
212
|
}
|
|
213
213
|
const v = await verifySignedBy({ data, signature, publickey, chain, expectedProfileId, ns })
|
|
214
|
-
if (!v.ok) return { ok: false, reason: '
|
|
214
|
+
if (!v.ok) return { ok: false, reason: 'signature:' + v.reason }
|
|
215
215
|
return { ok: true, profileId: v.profileId, signer: v.signer, seq: v.seq, aud: data.aud }
|
|
216
216
|
}
|
|
217
217
|
|
package/vault/capabilities.js
CHANGED
|
@@ -30,7 +30,7 @@ const SIGN = { name: 'ECDSA', hash: { name: 'SHA-256' } }
|
|
|
30
30
|
/*
|
|
31
31
|
* EL MODELO VIEJO YA NO ENTRA. Los papeles de antes de 0.73 llevaban `exp` y no `seq`; se
|
|
32
32
|
* aceptaron hasta el 2026-10-01 por un repliegue de migración declarado y con fecha, y esa
|
|
33
|
-
* fecha pasó: se quitó (2026-10-05). Un papel sin `seq` se rechaza con `legacy-cert-
|
|
33
|
+
* fecha pasó: se quitó (2026-10-05). Un papel sin `seq` se rechaza con `legacy-cert-retired`,
|
|
34
34
|
* el mismo código que daba el repliegue al caducar, así que quien lo comprueba no cambia.
|
|
35
35
|
*/
|
|
36
36
|
|
|
@@ -238,7 +238,7 @@ export async function verifyDelegation ({ cert, expectedScope, expectedSub, acta
|
|
|
238
238
|
if (v !== 1 || typeof iss !== 'string' || typeof sub !== 'string' || typeof sig !== 'string') return { ok: false, reason: 'shape' }
|
|
239
239
|
if (typeof iat !== 'number' || (typeof scope !== 'string' && !Array.isArray(scope))) return { ok: false, reason: 'shape' }
|
|
240
240
|
// UN PAPEL DEL MODELO VIEJO (con `exp`, sin `seq`) ya no vale: el repliegue se retiró.
|
|
241
|
-
if (typeof seq !== 'number') return { ok: false, reason: typeof cert.exp === 'number' ? 'legacy-cert-
|
|
241
|
+
if (typeof seq !== 'number') return { ok: false, reason: typeof cert.exp === 'number' ? 'legacy-cert-retired' : 'shape' }
|
|
242
242
|
if (!(await rawVerify(iss, enc(canonicalStringify(delegationBody(cert))), sig))) return { ok: false, reason: 'bad-signature' }
|
|
243
243
|
// EL ACTA MANDA, Y NO HAY RELOJ (dueño, 2026-08-31). El papel no vence: dice «una
|
|
244
244
|
// selladora de este perfil, mirando el acta nº `seq`, avaló esta llave». Lo que puede
|
|
@@ -264,7 +264,7 @@ export async function verifyDelegation ({ cert, expectedScope, expectedSub, acta
|
|
|
264
264
|
// estaba apagado: vuelve, su papel ya no vale, y renovarlo exige una selladora ABIERTA.
|
|
265
265
|
// O sea que un cambio de acta te obligaría a abrir la bóveda para que tus aparatos
|
|
266
266
|
// volvieran — justo lo que se acaba de quitar de en medio.
|
|
267
|
-
if (seq > actaSeq) return { ok: false, reason: '
|
|
267
|
+
if (seq > actaSeq) return { ok: false, reason: 'stale-record' }
|
|
268
268
|
// Y QUIEN LO EMITIÓ TIENE QUE PODER SELLAR. No se compara contra «la maestra»: cualquiera
|
|
269
269
|
// que el acta nombre sellador emite papeles válidos — si no, la segunda bóveda podría
|
|
270
270
|
// invalidar todos los certificados al sellar y luego no poder dar los nuevos.
|
package/vault/core.js
CHANGED
|
@@ -408,7 +408,7 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
408
408
|
*/
|
|
409
409
|
async function sealMasterKey () {
|
|
410
410
|
const raw = kv.getItem(KEY_STORAGE)
|
|
411
|
-
if (!raw || !keyLock?.seal) return { ok: false, reason: '
|
|
411
|
+
if (!raw || !keyLock?.seal) return { ok: false, reason: 'no-lock' }
|
|
412
412
|
const guardado = JSON.parse(raw)
|
|
413
413
|
if (guardado.sealed) return { ok: true, already: true }
|
|
414
414
|
await guardarPar(KEY_STORAGE, guardado.privateJwk, guardado.publicJwk)
|
|
@@ -1253,7 +1253,7 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
1253
1253
|
* La plaza que quede abierta allí se cierra desde la consola, y así está dicho en el diseño.
|
|
1254
1254
|
*/
|
|
1255
1255
|
async function closeLoginOn (meta) {
|
|
1256
|
-
if (!meta?.user || !meta?.sid) return { ok: false, reason: '
|
|
1256
|
+
if (!meta?.user || !meta?.sid) return { ok: false, reason: 'no-login' }
|
|
1257
1257
|
let client = null
|
|
1258
1258
|
try {
|
|
1259
1259
|
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
@@ -1292,9 +1292,9 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
1292
1292
|
*/
|
|
1293
1293
|
async function joinProfile (candidate) {
|
|
1294
1294
|
const v = await Acta.verifyActa({ acta: candidate })
|
|
1295
|
-
if (!v.ok) return { joined: false, reason: '
|
|
1295
|
+
if (!v.ok) return { joined: false, reason: 'invalid-record:' + v.reason }
|
|
1296
1296
|
if (!candidate.members.some((m) => m.pub === publickeyJwkStr)) {
|
|
1297
|
-
return { joined: false, reason: '
|
|
1297
|
+
return { joined: false, reason: 'not-a-member' }
|
|
1298
1298
|
}
|
|
1299
1299
|
const current = loadActa()
|
|
1300
1300
|
|
|
@@ -1307,7 +1307,7 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
1307
1307
|
if (current && !isPendingJoin()) {
|
|
1308
1308
|
return {
|
|
1309
1309
|
joined: false,
|
|
1310
|
-
reason: '
|
|
1310
|
+
reason: 'profile-has-data',
|
|
1311
1311
|
profileId: current.profileId,
|
|
1312
1312
|
seq: current.seq,
|
|
1313
1313
|
members: current.members.length
|
|
@@ -1347,7 +1347,7 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
1347
1347
|
kv.setItem(VAULT_DEVICE_STORAGE, JSON.stringify({ useIdentityKey: true, publickey: publickeyJwkStr }))
|
|
1348
1348
|
kv.setItem(VAULT_CERT_STORAGE, JSON.stringify({ cert: res.cert, master: res.master, proxy: res.proxy, deviceId: res.deviceId, pairedAt: Date.now() }))
|
|
1349
1349
|
// Conectarse a una bóveda es ENTRAR A SU CUENTA: el acta viene con el cert.
|
|
1350
|
-
const unido = res.acta ? await joinProfile(res.acta) : { joined: false, reason: '
|
|
1350
|
+
const unido = res.acta ? await joinProfile(res.acta) : { joined: false, reason: 'no-record' }
|
|
1351
1351
|
emitVault({ phase: 'paired', deviceId: res.deviceId, master: res.master, join: unido })
|
|
1352
1352
|
// LA LLAVE ES DEL CHIP DEL TELÉFONO (app de Android): la app guarda la cuenta para su
|
|
1353
1353
|
// pantalla nativa de Pedidos, con el MISMO papel. Así el teléfono es un solo aparato en
|
|
@@ -1380,7 +1380,7 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
1380
1380
|
const r = await adoptActa(a)
|
|
1381
1381
|
if (r.adopted) last = r
|
|
1382
1382
|
}
|
|
1383
|
-
return last || { adopted: false, reason: '
|
|
1383
|
+
return last || { adopted: false, reason: 'nothing-to-adopt', seq: loadActa()?.seq ?? null }
|
|
1384
1384
|
}
|
|
1385
1385
|
|
|
1386
1386
|
/**
|
|
@@ -1416,7 +1416,7 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
1416
1416
|
// Ya no hay ventana de caducidad: el papel no vence. Lo único que obliga a pedir uno
|
|
1417
1417
|
// nuevo es que el ACTA diga algo distinto de lo que lleva escrito.
|
|
1418
1418
|
/** Rechazos de la bóveda que se arreglan con un papel nuevo (el mismo juego que la app nativa). */
|
|
1419
|
-
const PAPER_REASONS = new Set(['scope', '
|
|
1419
|
+
const PAPER_REASONS = new Set(['scope', 'stale-record', 'untrusted-issuer', 'expired', 'legacy-cert-retired', 'no-acta'])
|
|
1420
1420
|
const RENEW_RETRY_MS = 60 * 60 * 1000 // si falla (vault apagado), no insistir >1 vez/hora
|
|
1421
1421
|
let renewLastTry = 0
|
|
1422
1422
|
/**
|
|
@@ -2328,7 +2328,7 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
2328
2328
|
// selladora de ESTE perfil, mirando ESTA acta, avaló esta llave», y sin acta no se
|
|
2329
2329
|
// puede decir ninguna de las dos cosas.
|
|
2330
2330
|
const acta = loadActa()
|
|
2331
|
-
if (!acta) throw Object.assign(new Error('this profile has no record to issue against'), { code: '
|
|
2331
|
+
if (!acta) throw Object.assign(new Error('this profile has no record to issue against'), { code: 'no-record' })
|
|
2332
2332
|
// `iss` se FUERZA a la propia maestra: el usuario no puede emitir cert para otro emisor.
|
|
2333
2333
|
const cert = await signDelegationWith(masterKey(), publickeyJwkStr, { sub, scope, iat, seq: acta.seq, nonce: nonce || crypto.randomUUID() })
|
|
2334
2334
|
const store = loadDelegations()
|
|
@@ -3024,11 +3024,11 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
3024
3024
|
// La bóveda devuelve el acta ya sellada por ella (y con los certs re-emitidos): se
|
|
3025
3025
|
// adopta por las reglas de siempre (§2.4.1) — encaja porque el sellador es el que
|
|
3026
3026
|
// este mismo aparato nombró hace un momento.
|
|
3027
|
-
// `
|
|
3027
|
+
// `same-record` = la bóveda guardó exactamente la que este aparato acababa de sellar
|
|
3028
3028
|
// y no la cambió. No hay nada que adoptar, y es justo lo que se esperaba: el
|
|
3029
3029
|
// traspaso ya iba dentro de esa acta.
|
|
3030
3030
|
const r = await adoptActa(res.acta)
|
|
3031
|
-
const ok = r.adopted || r.reason === '
|
|
3031
|
+
const ok = r.adopted || r.reason === 'same-record'
|
|
3032
3032
|
emitVault({ phase: 'adopted', master: res.master, seq: res.acta?.seq, ok })
|
|
3033
3033
|
if (!ok) throw new Error('the vault returned a record that does not fit: ' + r.reason)
|
|
3034
3034
|
return { ok: true, adopted: true, profileId: mine.profileId, seq: r.seq ?? res.acta?.seq, master: res.master, deviceId: res.deviceId }
|
package/vault/session.js
CHANGED
|
@@ -117,39 +117,39 @@ export async function verifySession (paper, { chain, expectedProfileId = null, o
|
|
|
117
117
|
if (!isStr(p.sid) || !isStr(p.s) || !isStr(p.by) || !isStr(p.origin) || !isStr(p.sig)) return { ok: false, reason: 'shape' }
|
|
118
118
|
if (!Number.isFinite(p.iat) || !Number.isFinite(p.exp) || !Array.isArray(p.scopes)) return { ok: false, reason: 'shape' }
|
|
119
119
|
|
|
120
|
-
if (p.exp <= p.iat) return { ok: false, reason: '
|
|
121
|
-
if (p.exp - p.iat > SESSION_MAX_TTL_MS) return { ok: false, reason: '
|
|
122
|
-
if (p.exp <= now) return { ok: false, reason: '
|
|
123
|
-
if (p.iat > now + maxSkewMs) return { ok: false, reason: '
|
|
120
|
+
if (p.exp <= p.iat) return { ok: false, reason: 'invalid-lifetime' }
|
|
121
|
+
if (p.exp - p.iat > SESSION_MAX_TTL_MS) return { ok: false, reason: 'lifetime-too-long' }
|
|
122
|
+
if (p.exp <= now) return { ok: false, reason: 'expired' }
|
|
123
|
+
if (p.iat > now + maxSkewMs) return { ok: false, reason: 'from-future' }
|
|
124
124
|
|
|
125
125
|
// DÓNDE vale. Un papel para una aplicación no vale en otra: el origen va firmado dentro.
|
|
126
|
-
if (origin != null && p.origin !== String(origin).trim()) return { ok: false, reason: '
|
|
126
|
+
if (origin != null && p.origin !== String(origin).trim()) return { ok: false, reason: 'other-origin' }
|
|
127
127
|
|
|
128
|
-
if (p.scopes.some((s) => !SESSION_SCOPES.includes(s))) return { ok: false, reason: '
|
|
129
|
-
if (p.scopes.some((s) => SESSION_FORBIDDEN.includes(s))) return { ok: false, reason: '
|
|
128
|
+
if (p.scopes.some((s) => !SESSION_SCOPES.includes(s))) return { ok: false, reason: 'unknown-scope' }
|
|
129
|
+
if (p.scopes.some((s) => SESSION_FORBIDDEN.includes(s))) return { ok: false, reason: 'forbidden-scope' }
|
|
130
130
|
|
|
131
131
|
// EL ACTA MANDA, y por eso hace falta: dice si el aparato que respalda sigue siendo del
|
|
132
132
|
// perfil y qué puede. Sin ella no se juzga, en vez de dar por bueno lo que diga el papel.
|
|
133
133
|
const c = await verifySealerChain(chain, { expectedProfileId })
|
|
134
|
-
if (!c.ok) return { ok: false, reason: '
|
|
134
|
+
if (!c.ok) return { ok: false, reason: 'chain:' + c.reason }
|
|
135
135
|
const acta = chain[chain.length - 1]
|
|
136
136
|
|
|
137
137
|
// PRIMERO, ¿ES DE LA CASA? Y en este orden a propósito: si al aparato lo quitaron, el
|
|
138
138
|
// motivo tiene que decir eso y no «le falta un permiso», que manda a mirar al sitio
|
|
139
139
|
// equivocado. Es además el invariante que hace barata la revocación — quitar un aparato
|
|
140
140
|
// se lleva sus sesiones sin avisar a nadie ni perseguir papeles.
|
|
141
|
-
if (!(acta.members || []).some((m) => m?.pub === p.by)) return { ok: false, reason: '
|
|
141
|
+
if (!(acta.members || []).some((m) => m?.pub === p.by)) return { ok: false, reason: 'device-not-in-profile' }
|
|
142
142
|
|
|
143
143
|
// Y DESPUÉS, NUNCA AMPLÍA: cada alcance exige que el aparato que firmó lo tenga HOY. Se
|
|
144
144
|
// comprueba aquí y no solo al emitir, porque quien emite es precisamente quien podría
|
|
145
145
|
// mentir — y porque el acta de hoy puede haberle quitado lo que tenía ayer.
|
|
146
146
|
for (const s of p.scopes) {
|
|
147
147
|
const cap = SCOPE_NEEDS[s]
|
|
148
|
-
if (cap && !memberCan(acta, p.by, cap)) return { ok: false, reason: '
|
|
148
|
+
if (cap && !memberCan(acta, p.by, cap)) return { ok: false, reason: 'device-lacks-' + cap }
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
const { sig, ...body } = p
|
|
152
|
-
if (!(await verifyDeviceSig({ publickey: p.by, data: body, signature: sig }))) return { ok: false, reason: '
|
|
152
|
+
if (!(await verifyDeviceSig({ publickey: p.by, data: body, signature: sig }))) return { ok: false, reason: 'invalid-signature' }
|
|
153
153
|
|
|
154
154
|
return { ok: true, profileId: c.profileId, seq: c.seq, sid: p.sid, s: p.s, by: p.by, origin: p.origin, scopes: [...p.scopes], exp: p.exp }
|
|
155
155
|
}
|
|
@@ -164,8 +164,8 @@ export async function verifySessionSigned ({ data, signature, session, chain, sc
|
|
|
164
164
|
if (!data || !isStr(signature)) return { ok: false, reason: 'shape' }
|
|
165
165
|
const v = await verifySession(session, { chain, expectedProfileId, origin, now })
|
|
166
166
|
if (!v.ok) return v
|
|
167
|
-
if (scope && !v.scopes.includes(scope)) return { ok: false, reason: '
|
|
168
|
-
if (!(await verifyDeviceSig({ publickey: v.s, data, signature }))) return { ok: false, reason: '
|
|
167
|
+
if (scope && !v.scopes.includes(scope)) return { ok: false, reason: 'out-of-scope' }
|
|
168
|
+
if (!(await verifyDeviceSig({ publickey: v.s, data, signature }))) return { ok: false, reason: 'invalid-signature' }
|
|
169
169
|
return { ok: true, profileId: v.profileId, sid: v.sid, scopes: v.scopes, exp: v.exp }
|
|
170
170
|
}
|
|
171
171
|
|
package/vault/sessionFlow.js
CHANGED
|
@@ -81,18 +81,18 @@ export async function openSession ({ transport, sessionPubkey, encPub, origin, s
|
|
|
81
81
|
return await new Promise((resolve, reject) => {
|
|
82
82
|
let listo = false
|
|
83
83
|
const fin = (fn, arg) => { if (!listo) { listo = true; clearTimeout(reloj); off?.(); fn(arg) } }
|
|
84
|
-
const reloj = setTimeout(() => fin(reject, Object.assign(new Error('
|
|
84
|
+
const reloj = setTimeout(() => fin(reject, Object.assign(new Error('openSession: nobody granted the session in time'), { code: 'session-timeout' })), timeoutMs)
|
|
85
85
|
const off = transport.on('message', async (_from, payload, meta) => {
|
|
86
86
|
const p = typeof payload === 'string' ? (() => { try { return JSON.parse(payload) } catch (_) { return null } })() : payload
|
|
87
87
|
if (!p || p.sid !== sid) return
|
|
88
|
-
if (p.op === SESSION_OP.DENY) return fin(reject, Object.assign(new Error('
|
|
88
|
+
if (p.op === SESSION_OP.DENY) return fin(reject, Object.assign(new Error('openSession: the session was denied'), { code: 'session-denied' }))
|
|
89
89
|
if (p.op !== SESSION_OP.GRANT) return
|
|
90
90
|
// NO SE ACEPTA LO QUE VENGA EN CLARO. El sellado es del pilar del transporte; aquí
|
|
91
91
|
// solo se comprueba que llegó sellado, que es lo que la app puede saber.
|
|
92
92
|
if (meta && meta.sealed === false) return
|
|
93
93
|
const v = await verifySession(p.paper, { chain: p.chain, origin: origin.trim() })
|
|
94
|
-
if (!v.ok) return fin(reject, Object.assign(new Error('
|
|
95
|
-
if (p.paper.s !== sessionPubkey) return fin(reject, Object.assign(new Error('
|
|
94
|
+
if (!v.ok) return fin(reject, Object.assign(new Error('openSession: the session paper does not verify: ' + v.reason), { code: 'session-invalid' }))
|
|
95
|
+
if (p.paper.s !== sessionPubkey) return fin(reject, Object.assign(new Error('openSession: the session paper is for another key'), { code: 'session-invalid' }))
|
|
96
96
|
fin(resolve, { paper: p.paper, chain: p.chain, profileId: v.profileId, sid, scopes: v.scopes, exp: v.exp })
|
|
97
97
|
})
|
|
98
98
|
})
|
|
@@ -109,7 +109,7 @@ export async function openSession ({ transport, sessionPubkey, encPub, origin, s
|
|
|
109
109
|
*/
|
|
110
110
|
export async function grantSession ({ transport, invite, by, sign, chain, scopes, ttlMs = SESSION_DEFAULT_TTL_MS, now = Date.now() } = {}) {
|
|
111
111
|
const inv = parseInvite(invite)
|
|
112
|
-
if (!inv) throw new Error('grantSession:
|
|
112
|
+
if (!inv) throw Object.assign(new Error('grantSession: unreadable invite'), { code: 'bad-invite' })
|
|
113
113
|
if (typeof by !== 'string' || !by) throw new Error('grantSession: by (this device pubkey) required')
|
|
114
114
|
if (typeof sign !== 'function') throw new Error('grantSession: sign(body) required')
|
|
115
115
|
if (!Array.isArray(chain) || !chain.length) throw new Error('grantSession: chain required')
|
|
@@ -123,7 +123,7 @@ export async function grantSession ({ transport, invite, by, sign, chain, scopes
|
|
|
123
123
|
/** Decir que no, en vez de dejar al otro mirando una pantalla que no avanza. */
|
|
124
124
|
export async function denySession ({ transport, invite } = {}) {
|
|
125
125
|
const inv = parseInvite(invite)
|
|
126
|
-
if (!inv) throw new Error('denySession:
|
|
126
|
+
if (!inv) throw Object.assign(new Error('denySession: unreadable invite'), { code: 'bad-invite' })
|
|
127
127
|
await enviar(transport, inv, { op: SESSION_OP.DENY, sid: inv.sid })
|
|
128
128
|
}
|
|
129
129
|
|
|
@@ -144,7 +144,7 @@ async function enviar (transport, inv, payload) {
|
|
|
144
144
|
}
|
|
145
145
|
// Sin llave de cifrado del otro lado no se puede sellar. Se dice en vez de mandarlo en
|
|
146
146
|
// claro por su cuenta: quien llama decide si eso le vale.
|
|
147
|
-
if (!inv.encPub) throw Object.assign(new Error('sessionFlow:
|
|
147
|
+
if (!inv.encPub) throw Object.assign(new Error('sessionFlow: the invite carries no encPub; cannot seal'), { code: 'unsealed' })
|
|
148
148
|
return transport.sendByPubkey(inv.s, payload)
|
|
149
149
|
}
|
|
150
150
|
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
Copia vendorizada de @dotrino/roadmap@0.4.
|
|
1
|
+
Copia vendorizada de @dotrino/roadmap@0.4.35 (dotrino-roadmap/src/{index,manifests}.js).
|
|
2
2
|
NO se edita a mano: la escribe `node vendor.mjs` y la vigila test/vendor-up-to-date.test.mjs.
|
|
3
3
|
El registro de versiones rotas que viaja con cada versión. Importa @dotrino/compat/ranges.
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
export const MANIFESTS = Object.freeze({
|
|
9
9
|
"dotrino": {
|
|
10
10
|
"manifest": "dotrino",
|
|
11
|
-
"seq":
|
|
12
|
-
"issued": "2026-10-
|
|
11
|
+
"seq": 32,
|
|
12
|
+
"issued": "2026-10-18",
|
|
13
13
|
"note": "Registro comun de compatibilidad del ecosistema. Se edita a mano en este repo y se consulta con @dotrino/roadmap. Rangos: ver @dotrino/compat/ranges (x.y.z, >= > <= <, «a - b», «*», o una lista que es un O).",
|
|
14
14
|
"products": {
|
|
15
15
|
"compat": {
|
|
@@ -31,7 +31,7 @@ export const MANIFESTS = Object.freeze({
|
|
|
31
31
|
"identity": {
|
|
32
32
|
"repo": "imdotrino/dotrino-identity",
|
|
33
33
|
"npm": "@dotrino/identity",
|
|
34
|
-
"current": "0.121.
|
|
34
|
+
"current": "0.121.3",
|
|
35
35
|
"protocol": 1,
|
|
36
36
|
"requires": {
|
|
37
37
|
"proxy-client": ">=0.13.1"
|
|
@@ -101,7 +101,7 @@ export const MANIFESTS = Object.freeze({
|
|
|
101
101
|
"profile": {
|
|
102
102
|
"repo": "imdotrino/dotrino-profile",
|
|
103
103
|
"npm": "@dotrino/profile",
|
|
104
|
-
"current": "0.20.
|
|
104
|
+
"current": "0.20.3",
|
|
105
105
|
"protocol": 1,
|
|
106
106
|
"requires": {
|
|
107
107
|
"identity": ">=0.33.0"
|
|
@@ -131,7 +131,7 @@ export const MANIFESTS = Object.freeze({
|
|
|
131
131
|
"remote-agent": {
|
|
132
132
|
"repo": "imdotrino/dotrino-remote-agent",
|
|
133
133
|
"npm": "@dotrino/remote-agent",
|
|
134
|
-
"current": "0.22.
|
|
134
|
+
"current": "0.22.2",
|
|
135
135
|
"protocol": 1,
|
|
136
136
|
"requires": {
|
|
137
137
|
"identity": ">=0.120.0",
|
|
@@ -193,10 +193,10 @@ export const MANIFESTS = Object.freeze({
|
|
|
193
193
|
"content": {
|
|
194
194
|
"repo": "imdotrino/dotrino-content",
|
|
195
195
|
"npm": "@dotrino/content",
|
|
196
|
-
"current": "0.6.
|
|
196
|
+
"current": "0.6.7",
|
|
197
197
|
"protocol": 1,
|
|
198
198
|
"requires": {
|
|
199
|
-
"remote-agent": ">=0.22.
|
|
199
|
+
"remote-agent": ">=0.22.2",
|
|
200
200
|
"identity": ">=0.120.0",
|
|
201
201
|
"vault": ">=0.87.0",
|
|
202
202
|
"proxy-client": ">=0.36.1"
|