@dotrino/identity 0.71.0 → 0.73.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/src/node.js +13 -0
- package/vault/acta.js +164 -5
- package/vault/capabilities.js +60 -23
- package/vault/core.js +134 -62
- package/vault/remote.js +17 -5
package/package.json
CHANGED
package/src/node.js
CHANGED
|
@@ -102,6 +102,10 @@ export class Identity {
|
|
|
102
102
|
constructor (options = {}) {
|
|
103
103
|
this._dir = options.dir || DEFAULT_DIR
|
|
104
104
|
this._atRest = options.atRest || null
|
|
105
|
+
// CANDADO DE LA MAESTRA: `{ seal(jwkStr), open(blob) }`. Quien lo provee (el daemon
|
|
106
|
+
// del vault) es el único que tiene la llave que sale de la contraseña. Sin candado,
|
|
107
|
+
// la maestra se guarda como siempre — un perfil sin contraseña no cambia.
|
|
108
|
+
this._keyLock = options.keyLock || null
|
|
105
109
|
this._core = null
|
|
106
110
|
this._listeners = new Map()
|
|
107
111
|
}
|
|
@@ -121,6 +125,7 @@ export class Identity {
|
|
|
121
125
|
if (this._core) return this
|
|
122
126
|
this._core = await createIdentityCore({
|
|
123
127
|
kv: fileKv(path.join(this._dir, 'identity.json'), this._atRest),
|
|
128
|
+
keyLock: this._keyLock,
|
|
124
129
|
peers: filePeers(path.join(this._dir, 'peers.json')),
|
|
125
130
|
makeSync: null,
|
|
126
131
|
// Aquí las cuentas las lleva quien hospeda (el daemon del vault tiene su propio
|
|
@@ -137,6 +142,14 @@ export class Identity {
|
|
|
137
142
|
|
|
138
143
|
get me () { return this._core?.me || null }
|
|
139
144
|
|
|
145
|
+
// ----- candado de la maestra -----
|
|
146
|
+
/** `true` si la maestra está sellada: esta identidad NO puede firmar nada. */
|
|
147
|
+
get masterLocked () { return !!this._core?.masterLocked }
|
|
148
|
+
/** Sella la maestra que ya existe (se llama al abrir el perfil, con la llave en la mano). */
|
|
149
|
+
sealMasterKey () { return this._core?.sealMasterKey?.() }
|
|
150
|
+
/** Vuelve a cargar el par tras abrir el candado, sin reabrir la identidad. */
|
|
151
|
+
reloadMasterKey () { return this._core?.reloadMasterKey?.() }
|
|
152
|
+
|
|
140
153
|
_h (method, params = {}) {
|
|
141
154
|
if (!this._core) throw new Error('Identity not ready — call ready()/connect() first')
|
|
142
155
|
return this._core.handlers[method](params)
|
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
|
|
package/vault/capabilities.js
CHANGED
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
* Solución (subkeys / capabilities, estilo certs SSH / OAuth device tokens):
|
|
9
9
|
* - El dispositivo genera SU PROPIA clave `D` (la maestra nunca la ve).
|
|
10
10
|
* - El vault firma un CERTIFICADO: «la clave D puede `scope` para la identidad P,
|
|
11
|
-
*
|
|
11
|
+
* mientras el ACTA lo diga», con un `nonce` que es el mango de revocación.
|
|
12
12
|
* - El dispositivo firma cada acción con `D` y adjunta el cert. Cualquiera
|
|
13
13
|
* verifica la CADENA `D ← P` + scope + expiración + revocación, offline.
|
|
14
14
|
*
|
|
15
15
|
* Garantía: robar el dispositivo solo permite lo del `scope` (p.ej. publicar
|
|
16
|
-
* ubicación)
|
|
16
|
+
* ubicación) mientras el acta lo diga, y se puede revocar. La clave maestra queda intacta.
|
|
17
17
|
*
|
|
18
18
|
* Cripto IDÉNTICA al resto del ecosistema: ECDSA P-256 + SHA-256 sobre
|
|
19
19
|
* `canonicalStringify`, firma en base64 de los 64 bytes crudos (r||s). Módulo
|
|
@@ -105,8 +105,20 @@ export async function commitCode ({ code, dpub, sn }) {
|
|
|
105
105
|
export { avatarSvg, avatarDataUri } from './avatar.js'
|
|
106
106
|
|
|
107
107
|
/** Cuerpo canónico del certificado (lo que se firma): el cert SIN la firma. */
|
|
108
|
+
/**
|
|
109
|
+
* Lo que se firma de un certificado. `seq` en vez de `exp` (dueño, 2026-08-31).
|
|
110
|
+
*
|
|
111
|
+
* EL PAPEL YA NO CADUCA POR RELOJ: caduca cuando cambia el acta. Antes vencía a los 30
|
|
112
|
+
* días, y eso obligaba a que alguien con la maestra estuviera disponible cada mes o los
|
|
113
|
+
* aparatos se quedaban fuera — con una bóveda que pasa casi todo el tiempo cerrada, eso
|
|
114
|
+
* no iba a pasar nunca.
|
|
115
|
+
*
|
|
116
|
+
* Atarlo al `seq` hace las dos cosas de golpe: quitarle un permiso a un aparato surte
|
|
117
|
+
* efecto AL INSTANTE (el acta sube de `seq` y su papel deja de valer, sin esperar a
|
|
118
|
+
* ninguna renovación), y nadie tiene que abrir nada por calendario.
|
|
119
|
+
*/
|
|
108
120
|
export function delegationBody (cert) {
|
|
109
|
-
return { v: cert.v, iss: cert.iss, sub: cert.sub, scope: cert.scope, iat: cert.iat,
|
|
121
|
+
return { v: cert.v, iss: cert.iss, sub: cert.sub, scope: cert.scope, iat: cert.iat, seq: cert.seq, nonce: cert.nonce }
|
|
110
122
|
}
|
|
111
123
|
|
|
112
124
|
/**
|
|
@@ -162,10 +174,10 @@ export async function importDeviceEncKey (encPrivateJwk) {
|
|
|
162
174
|
/**
|
|
163
175
|
* Firma un certificado de delegación con una `privateKey` (CryptoKey) cuyo pubkey
|
|
164
176
|
* es `iss`. Lo usa el handler del vault (con la clave maestra). Devuelve el cert
|
|
165
|
-
* completo `{ v, iss, sub, scope, iat,
|
|
177
|
+
* completo `{ v, iss, sub, scope, iat, seq, nonce, sig }`.
|
|
166
178
|
*/
|
|
167
|
-
export async function signDelegationWith (privateKey, iss, { sub, scope, iat,
|
|
168
|
-
const body = { v: 1, iss, sub, scope, iat,
|
|
179
|
+
export async function signDelegationWith (privateKey, iss, { sub, scope, iat, seq, nonce }) {
|
|
180
|
+
const body = { v: 1, iss, sub, scope, iat, seq, nonce }
|
|
169
181
|
const sig = await rawSign(privateKey, enc(canonicalStringify(body)))
|
|
170
182
|
return { ...body, sig }
|
|
171
183
|
}
|
|
@@ -190,11 +202,11 @@ export async function signWithDevice ({ privateJwk, privateKey, publickey, data
|
|
|
190
202
|
/**
|
|
191
203
|
* Verifica un CERTIFICADO de delegación (offline; no requiere la clave maestra):
|
|
192
204
|
* 1) firma de la maestra (`iss`) sobre el cuerpo canónico,
|
|
193
|
-
* 2)
|
|
205
|
+
* 2) el acta: quien lo emitió puede sellar, y el papel no es de un acta más nueva que la mía,
|
|
194
206
|
* 3) `scope` incluye `expectedScope` (si se pide),
|
|
195
207
|
* 4) `sub` === `expectedSub` (si se pide),
|
|
196
208
|
* 5) `nonce` no revocado (`revoked`: fn(nonce)→bool, Set o mapa).
|
|
197
|
-
* @returns {{ok:boolean, reason?:string, iss?, sub?, scope?, iat?,
|
|
209
|
+
* @returns {{ok:boolean, reason?:string, iss?, sub?, scope?, iat?, seq?, nonce?}}
|
|
198
210
|
*/
|
|
199
211
|
/**
|
|
200
212
|
* MARGEN DE RELOJ ENTRE DOS APARATOS. Sin esto, emparejar es una lotería.
|
|
@@ -215,17 +227,41 @@ export async function signWithDevice ({ privateJwk, privateKey, publickey, data
|
|
|
215
227
|
*/
|
|
216
228
|
export const PEER_SKEW_MS = 120_000
|
|
217
229
|
|
|
218
|
-
export async function verifyDelegation ({ cert, expectedScope, expectedSub,
|
|
230
|
+
export async function verifyDelegation ({ cert, expectedScope, expectedSub, actaSeq = null, sealers = null, revoked } = {}) {
|
|
219
231
|
if (!cert || typeof cert !== 'object') return { ok: false, reason: 'no-cert' }
|
|
220
|
-
const { v, iss, sub, scope, iat,
|
|
232
|
+
const { v, iss, sub, scope, iat, seq, nonce, sig } = cert
|
|
221
233
|
if (v !== 1 || typeof iss !== 'string' || typeof sub !== 'string' || typeof sig !== 'string') return { ok: false, reason: 'shape' }
|
|
222
|
-
if (typeof iat !== 'number' || typeof
|
|
234
|
+
if (typeof iat !== 'number' || typeof seq !== 'number' || (typeof scope !== 'string' && !Array.isArray(scope))) return { ok: false, reason: 'shape' }
|
|
223
235
|
if (!(await rawVerify(iss, enc(canonicalStringify(delegationBody(cert))), sig))) return { ok: false, reason: 'bad-signature' }
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
236
|
+
// EL ACTA MANDA, Y NO HAY RELOJ (dueño, 2026-08-31). El papel no vence: dice «una
|
|
237
|
+
// selladora de este perfil, mirando el acta nº `seq`, avaló esta llave». Lo que puede
|
|
238
|
+
// hacer HOY lo dice el acta de hoy, y eso lo cruza cada mostrador (`memberCanScope`).
|
|
239
|
+
//
|
|
240
|
+
// Llegan el `seq` y la LISTA DE SELLADORES, no el acta entera, y no por capricho: este
|
|
241
|
+
// módulo no sabe de actas —`acta.js` importa de aquí, así que mirar para allá sería un
|
|
242
|
+
// ciclo— y la regla de quién sella vive en el acta, en un solo sitio. Quien verifica saca
|
|
243
|
+
// la lista con `sealersOf` y la pasa.
|
|
244
|
+
//
|
|
245
|
+
// Sin esos datos no se puede juzgar, y se dice en vez de contestar «vale» a solas:
|
|
246
|
+
// devolver `ok` sin haber comprobado nada es exactamente cómo un papel viejo seguía
|
|
247
|
+
// entrando.
|
|
248
|
+
if (typeof actaSeq !== 'number' || !Array.isArray(sealers)) return { ok: false, reason: 'no-acta' }
|
|
249
|
+
// SOLO SE RECHAZA EL PAPEL DEL FUTURO. Si el cert nombra un acta MÁS NUEVA que la que
|
|
250
|
+
// tengo, no puedo juzgarlo: mi política está atrasada y decir que sí sería fiarme de algo
|
|
251
|
+
// que no he visto. Al revés no: un papel viejo es normal —el aparato estuvo apagado— y lo
|
|
252
|
+
// que puede hacer ya lo decide mi acta, que es más nueva.
|
|
253
|
+
//
|
|
254
|
+
// La alternativa era exigir `seq === actaSeq`, o sea que cada cambio del acta invalidara
|
|
255
|
+
// TODOS los papeles a la vez. Consigue lo mismo (quitar un permiso surte efecto al
|
|
256
|
+
// instante, porque eso lo hace el cruce con el acta) y además deja tirado al aparato que
|
|
257
|
+
// estaba apagado: vuelve, su papel ya no vale, y renovarlo exige una selladora ABIERTA.
|
|
258
|
+
// O sea que un cambio de acta te obligaría a abrir la bóveda para que tus aparatos
|
|
259
|
+
// volvieran — justo lo que se acaba de quitar de en medio.
|
|
260
|
+
if (seq > actaSeq) return { ok: false, reason: 'acta-vieja' }
|
|
261
|
+
// Y QUIEN LO EMITIÓ TIENE QUE PODER SELLAR. No se compara contra «la maestra»: cualquiera
|
|
262
|
+
// que el acta nombre sellador emite papeles válidos — si no, la segunda bóveda podría
|
|
263
|
+
// invalidar todos los certificados al sellar y luego no poder dar los nuevos.
|
|
264
|
+
if (!sealers.includes(iss)) return { ok: false, reason: 'untrusted-issuer' }
|
|
229
265
|
if (expectedScope != null && !scopeAllows(scope, expectedScope)) return { ok: false, reason: 'scope' }
|
|
230
266
|
if (expectedSub != null && sub !== expectedSub) return { ok: false, reason: 'sub' }
|
|
231
267
|
if (nonce && revoked) {
|
|
@@ -233,26 +269,27 @@ export async function verifyDelegation ({ cert, expectedScope, expectedSub, now
|
|
|
233
269
|
: (revoked instanceof Set ? revoked.has(nonce) : !!revoked[nonce])
|
|
234
270
|
if (isRev) return { ok: false, reason: 'revoked' }
|
|
235
271
|
}
|
|
236
|
-
return { ok: true, iss, sub, scope, iat,
|
|
272
|
+
return { ok: true, iss, sub, scope, iat, seq, nonce }
|
|
237
273
|
}
|
|
238
274
|
|
|
239
275
|
/**
|
|
240
276
|
* Verificación de CADENA de una acción/pin delegado (lo único que llama el bridge):
|
|
241
277
|
* 1) el dispositivo `D` (= `data.publickey`) firmó `data`,
|
|
242
278
|
* 2) el cert delega a ESTE dispositivo (`cert.sub === data.publickey`),
|
|
243
|
-
* 3) el cert es válido (firma de
|
|
279
|
+
* 3) el cert es válido (firma de una selladora, scope, revocación),
|
|
244
280
|
* 4) opcional: `cert.iss === trustedIssuer` (fija la identidad maestra esperada).
|
|
245
281
|
* @returns {{ok:boolean, reason?:string, issuer?:string, device?:string}}
|
|
246
282
|
*/
|
|
247
|
-
export async function verifyChain ({ data, signature, cert, expectedScope,
|
|
283
|
+
export async function verifyChain ({ data, signature, cert, expectedScope, actaSeq = null, sealers = null, revoked } = {}) {
|
|
248
284
|
if (!data || typeof data !== 'object' || typeof signature !== 'string') return { ok: false, reason: 'shape' }
|
|
249
285
|
const device = data.publickey
|
|
250
286
|
if (typeof device !== 'string') return { ok: false, reason: 'no-device-pubkey' }
|
|
251
287
|
if (!(await rawVerify(device, enc(canonicalStringify(data)), signature))) return { ok: false, reason: 'bad-action-signature' }
|
|
252
288
|
if (!cert || cert.sub !== device) return { ok: false, reason: 'cert-device-mismatch' }
|
|
253
|
-
|
|
289
|
+
// `actaSeq` + `sealers` sustituyen a `trustedIssuer`: ya no se compara contra UNA llave
|
|
290
|
+
// (la maestra), sino contra lo que el acta dice — quién puede sellar, y cuál es el acta
|
|
291
|
+
// vigente. Ver `verifyDelegation`.
|
|
292
|
+
const d = await verifyDelegation({ cert, expectedScope, actaSeq, sealers, revoked })
|
|
254
293
|
if (!d.ok) return { ok: false, reason: d.reason }
|
|
255
|
-
|
|
256
|
-
if (issuer != null && cert.iss !== issuer) return { ok: false, reason: 'untrusted-issuer' }
|
|
257
|
-
return { ok: true, issuer: cert.iss, device }
|
|
294
|
+
return { ok: true, issuer: cert.iss, device, scope: cert.scope }
|
|
258
295
|
}
|
package/vault/core.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* vault, compartida por todos los runtimes.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { signDelegationWith
|
|
21
|
+
import { signDelegationWith } from './capabilities.js'
|
|
22
22
|
import * as Acta from './acta.js'
|
|
23
23
|
import * as Content from './content.js'
|
|
24
24
|
import { pubkeyId as pubkeyIdOf, signWithDevice } from './capabilities.js'
|
|
@@ -158,7 +158,7 @@ function sanitizeProfilePatch (patch = {}) {
|
|
|
158
158
|
return out
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, keyStore = null, sessionKv = null, removeAccountOnExpulsion = true }) {
|
|
161
|
+
export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, keyStore = null, sessionKv = null, removeAccountOnExpulsion = true, keyLock = null }) {
|
|
162
162
|
const {
|
|
163
163
|
initPeerStorage, loadPeers, savePeers, setPeersDirect, upsertPeer, onDirty
|
|
164
164
|
} = peers
|
|
@@ -240,18 +240,77 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
240
240
|
const raw = kv.getItem(storageKey)
|
|
241
241
|
if (raw) {
|
|
242
242
|
try {
|
|
243
|
-
const
|
|
244
|
-
const
|
|
243
|
+
const guardado = JSON.parse(raw)
|
|
244
|
+
const { publicJwk } = guardado
|
|
245
|
+
// BAJO CANDADO. La mitad PRIVADA viaja sellada con una llave que no está en este
|
|
246
|
+
// disco (la deriva la contraseña del dueño); la PÚBLICA se queda en claro, que es
|
|
247
|
+
// lo que es. Cerrado se devuelve la identidad SIN con qué firmar: se sabe quién
|
|
248
|
+
// eres, no se puede hablar por ti.
|
|
249
|
+
//
|
|
250
|
+
// Y lo que NO se hace, que es el fallo que se paga caro: **no se genera otra**.
|
|
251
|
+
// Un `catch` que cae a `generateKey` con la llave delante, sellada, le cambiaría
|
|
252
|
+
// la identidad a la cuenta y la dejaría fuera de su propio perfil para siempre.
|
|
253
|
+
if (guardado.sealed) {
|
|
254
|
+
const abierto = keyLock?.open ? await keyLock.open(guardado.sealed) : null
|
|
255
|
+
if (!abierto) return { privateKey: null, publicKey: await importPub(publicJwk), publicJwk, locked: true }
|
|
256
|
+
const privateKey = await crypto.subtle.importKey('jwk', JSON.parse(abierto), algo, true, privUses)
|
|
257
|
+
return { privateKey, publicKey: await importPub(publicJwk), publicJwk }
|
|
258
|
+
}
|
|
259
|
+
const privateKey = await crypto.subtle.importKey('jwk', guardado.privateJwk, algo, true, privUses)
|
|
245
260
|
return { privateKey, publicKey: await importPub(publicJwk), publicJwk }
|
|
246
|
-
} catch (
|
|
261
|
+
} catch (e) {
|
|
262
|
+
// Solo se sigue de largo si NO había nada que abrir. Con una llave sellada
|
|
263
|
+
// delante, un error es un error: se propaga en vez de fabricar otra identidad.
|
|
264
|
+
if (String(raw).includes('"sealed"')) throw e
|
|
265
|
+
}
|
|
247
266
|
}
|
|
248
267
|
const pair = await crypto.subtle.generateKey(algo, true, pairUses)
|
|
249
268
|
const privateJwk = await crypto.subtle.exportKey('jwk', pair.privateKey)
|
|
250
269
|
const publicJwk = await crypto.subtle.exportKey('jwk', pair.publicKey)
|
|
251
|
-
|
|
270
|
+
await guardarPar(storageKey, privateJwk, publicJwk)
|
|
252
271
|
return { privateKey: pair.privateKey, publicKey: pair.publicKey, publicJwk }
|
|
253
272
|
}
|
|
254
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Escribe un par. Si hay candado ABIERTO, la privada va sellada; si no, en claro bajo el
|
|
276
|
+
* cifrado en reposo de siempre. Un perfil sin contraseña se queda como estaba.
|
|
277
|
+
*/
|
|
278
|
+
async function guardarPar (storageKey, privateJwk, publicJwk) {
|
|
279
|
+
if (keyLock?.seal) {
|
|
280
|
+
const sealed = await keyLock.seal(JSON.stringify(privateJwk))
|
|
281
|
+
if (sealed) return kv.setItem(storageKey, JSON.stringify({ sealed, publicJwk }))
|
|
282
|
+
}
|
|
283
|
+
kv.setItem(storageKey, JSON.stringify({ privateJwk, publicJwk }))
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* ECHAR EL CANDADO A LA MAESTRA QUE YA EXISTE: se llama al abrir el perfil, cuando la
|
|
288
|
+
* llave de la contraseña está en la mano. Idempotente.
|
|
289
|
+
*/
|
|
290
|
+
async function sealMasterKey () {
|
|
291
|
+
const raw = kv.getItem(KEY_STORAGE)
|
|
292
|
+
if (!raw || !keyLock?.seal) return { ok: false, reason: 'sin-candado' }
|
|
293
|
+
const guardado = JSON.parse(raw)
|
|
294
|
+
if (guardado.sealed) return { ok: true, already: true }
|
|
295
|
+
await guardarPar(KEY_STORAGE, guardado.privateJwk, guardado.publicJwk)
|
|
296
|
+
return { ok: true, sealed: true }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* LA LLAVE PARA FIRMAR, o un error con nombre.
|
|
301
|
+
*
|
|
302
|
+
* Todo lo que firme con la maestra pasa por aquí. Cerrada, `keypair.privateKey` es
|
|
303
|
+
* `null` y sin esto reventaría con un `TypeError` a diez marcos de profundidad —
|
|
304
|
+
* ilegible para quien lo recibe e indistinguible de un fallo de red. El código va
|
|
305
|
+
* aparte del texto porque el texto se traduce (ver el contrato de errores).
|
|
306
|
+
*/
|
|
307
|
+
function masterKey () {
|
|
308
|
+
if (!keypair?.privateKey) {
|
|
309
|
+
throw Object.assign(new Error('vault locked: the master key is sealed; unlock the profile to sign'), { code: 'vault-locked' })
|
|
310
|
+
}
|
|
311
|
+
return keypair.privateKey
|
|
312
|
+
}
|
|
313
|
+
|
|
255
314
|
const loadOrCreateKeypair = () => loadOrCreatePair('sign', KEY_STORAGE)
|
|
256
315
|
const loadOrCreateEncKeypair = () => loadOrCreatePair('enc', ENC_KEY_STORAGE)
|
|
257
316
|
|
|
@@ -277,40 +336,30 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
277
336
|
|
|
278
337
|
function loadJson (key) { try { return JSON.parse(kv.getItem(key) || '{}') || {} } catch (_) { return {} } }
|
|
279
338
|
|
|
280
|
-
// PODA
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
let changed = false
|
|
295
|
-
for (const k of Object.keys(o)) {
|
|
296
|
-
const exp = o[k]?.exp
|
|
297
|
-
if (typeof exp === 'number' && exp < now) { delete o[k]; changed = true }
|
|
298
|
-
}
|
|
299
|
-
if (changed) kv.setItem(DELEGATIONS_STORAGE, JSON.stringify(o))
|
|
300
|
-
return o
|
|
301
|
-
}
|
|
339
|
+
// YA NO SE PODA NADA, y el motivo es que la poda existía por el reloj.
|
|
340
|
+
//
|
|
341
|
+
// Antes se tiraba lo VENCIDO, porque la renovación automática firmaba un papel nuevo cada
|
|
342
|
+
// 30 días y sin podar cada aparato dejaba doce entradas muertas al año. Con el papel atado
|
|
343
|
+
// al acta esa renovación desaparece: solo se emite uno nuevo cuando el acta cambia lo que
|
|
344
|
+
// ese aparato puede, y ahí `revokePriorCertsFor` ya retira el anterior. O sea que el
|
|
345
|
+
// registro crece con los CAMBIOS DE POLÍTICA, no con el calendario.
|
|
346
|
+
//
|
|
347
|
+
// Se probó podar «lo que el acta ya no nombra» y se descartó: borra en silencio, y un
|
|
348
|
+
// registro que se borra solo es justo lo que no quieres tener delante cuando estás
|
|
349
|
+
// averiguando qué pasó. Las revocaciones, por lo mismo, son PARA SIEMPRE: se podaban a
|
|
350
|
+
// los 30 días porque para entonces el papel estaba vencido seguro, y sin vencimiento
|
|
351
|
+
// olvidar una revocación lo resucita.
|
|
352
|
+
const loadDelegations = () => loadJson(DELEGATIONS_STORAGE)
|
|
302
353
|
const saveDelegations = (o) => kv.setItem(DELEGATIONS_STORAGE, JSON.stringify(o))
|
|
303
354
|
|
|
355
|
+
/**
|
|
356
|
+
* Las revocaciones NO se podan por tiempo. Se podaban a los 30 días porque para entonces
|
|
357
|
+
* el papel al que apuntaban estaba vencido seguro; sin vencimiento ese razonamiento se
|
|
358
|
+
* cae, y olvidar una revocación **resucita el papel**. Se quedan mientras el aparato siga
|
|
359
|
+
* en el acta; cuando se le echa, se van con él (`loadDelegations` hace lo mismo).
|
|
360
|
+
*/
|
|
304
361
|
function loadRevocations () {
|
|
305
|
-
|
|
306
|
-
const now = Date.now()
|
|
307
|
-
let changed = false
|
|
308
|
-
for (const k of Object.keys(o)) {
|
|
309
|
-
const at = o[k]
|
|
310
|
-
if (typeof at === 'number' && now - at > DELEGATION_MAX_LIFE_MS) { delete o[k]; changed = true }
|
|
311
|
-
}
|
|
312
|
-
if (changed) kv.setItem(REVOCATIONS_STORAGE, JSON.stringify(o))
|
|
313
|
-
return o
|
|
362
|
+
return loadJson(REVOCATIONS_STORAGE)
|
|
314
363
|
}
|
|
315
364
|
const saveRevocations = (o) => kv.setItem(REVOCATIONS_STORAGE, JSON.stringify(o))
|
|
316
365
|
|
|
@@ -503,14 +552,14 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
503
552
|
// Marcador nuevo (o JWK legado que ES la llave del perfil): usar la CryptoKey
|
|
504
553
|
// no extractable del perfil para firmar; nada de privadas en claro.
|
|
505
554
|
if (d.useIdentityKey || (d.publickey === publickeyJwkStr && !d.privateJwk)) {
|
|
506
|
-
return { publickey: publickeyJwkStr, privateKey:
|
|
555
|
+
return { publickey: publickeyJwkStr, privateKey: masterKey() }
|
|
507
556
|
}
|
|
508
557
|
// MIGRACIÓN: el emparejamiento viejo persistía la privada del perfil en
|
|
509
558
|
// claro aquí. Si es la misma llave del perfil, reemplazar por el marcador
|
|
510
559
|
// (borra el último JWK plano) y firmar con la CryptoKey.
|
|
511
560
|
if (d.privateJwk && d.publickey === publickeyJwkStr) {
|
|
512
561
|
kv.setItem(VAULT_DEVICE_STORAGE, JSON.stringify({ useIdentityKey: true, publickey: publickeyJwkStr }))
|
|
513
|
-
return { publickey: publickeyJwkStr, privateKey:
|
|
562
|
+
return { publickey: publickeyJwkStr, privateKey: masterKey() }
|
|
514
563
|
}
|
|
515
564
|
return d // legado real (dispositivo con llave propia distinta)
|
|
516
565
|
} catch (_) { return null }
|
|
@@ -591,7 +640,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
591
640
|
let sealKeyProvider = null
|
|
592
641
|
|
|
593
642
|
/** Sella con la llave del perfil (CryptoKey, puede ser no extractable). */
|
|
594
|
-
const seal = (acta) => Acta.sealActa({ acta, privateKey:
|
|
643
|
+
const seal = (acta) => Acta.sealActa({ acta, privateKey: masterKey() })
|
|
595
644
|
|
|
596
645
|
/**
|
|
597
646
|
* Aplica cambios, sella y guarda. Solo funciona si este dispositivo es el master: es la
|
|
@@ -759,14 +808,14 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
759
808
|
// TU identidad (P) desde la maestra M → una sola identidad (signData/identify/cert = P).
|
|
760
809
|
// La privada es la CryptoKey del perfil (no extractable): se pasa como `privateKey`
|
|
761
810
|
// y NO se persiste ningún JWK del dispositivo (marcador useIdentityKey).
|
|
762
|
-
const device = { publickey: publickeyJwkStr, privateKey:
|
|
811
|
+
const device = { publickey: publickeyJwkStr, privateKey: masterKey() }
|
|
763
812
|
// Si esta identidad ya existía por su cuenta, se lleva un certificado de continuidad
|
|
764
813
|
// firmado por ella misma: es el puente para que su reputación previa siga contando.
|
|
765
814
|
// Solo si esta llave tenía vida propia. Una recién creada para adoptar (camino B) no
|
|
766
815
|
// tiene pasado que salvar: mandarle un puente de continuidad sería puro ruido.
|
|
767
816
|
const mine = loadActa()
|
|
768
817
|
const continuity = (mine && mine.members.length === 1 && !isPendingJoin())
|
|
769
|
-
? await Acta.makeContinuity({ member: publickeyJwkStr, from: mine.profileId, privateKey:
|
|
818
|
+
? await Acta.makeContinuity({ member: publickeyJwkStr, from: mine.profileId, privateKey: masterKey() })
|
|
770
819
|
: null
|
|
771
820
|
const res = await remoteEnroll({ qr, device, continuity, encPub: encPublickeyJwkStr, label: label || me?.nickname || '', onChallenge: (c) => emitVault({ phase: 'challenge', deviceId: c.deviceId, code: c.code }) })
|
|
772
821
|
kv.setItem(VAULT_DEVICE_STORAGE, JSON.stringify({ useIdentityKey: true, publickey: publickeyJwkStr }))
|
|
@@ -775,7 +824,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
775
824
|
const unido = res.acta ? await joinProfile(res.acta) : { joined: false, reason: 'sin-acta' }
|
|
776
825
|
emitVault({ phase: 'paired', deviceId: res.deviceId, master: res.master, join: unido })
|
|
777
826
|
pullProfileFromVault() // adoptar el perfil que ya viva en el vault (si hay)
|
|
778
|
-
return { ok: true, deviceId: res.deviceId, master: res.master,
|
|
827
|
+
return { ok: true, deviceId: res.deviceId, master: res.master, seq: res.cert.seq, scope: res.cert.scope, join: unido }
|
|
779
828
|
}
|
|
780
829
|
|
|
781
830
|
/**
|
|
@@ -826,7 +875,8 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
826
875
|
// segundo plano un `vault.renew`: el vault firma un cert fresco (30 días) para la
|
|
827
876
|
// misma sub-clave y scope. Mientras uses el ecosistema ~1 vez al mes, nunca vence.
|
|
828
877
|
// Un cert YA vencido o revocado no puede renovarse (ahí sí, re-emparejar).
|
|
829
|
-
|
|
878
|
+
// Ya no hay ventana de caducidad: el papel no vence. Lo único que obliga a pedir uno
|
|
879
|
+
// nuevo es que el ACTA diga algo distinto de lo que lleva escrito.
|
|
830
880
|
const RENEW_RETRY_MS = 60 * 60 * 1000 // si falla (vault apagado), no insistir >1 vez/hora
|
|
831
881
|
let renewLastTry = 0
|
|
832
882
|
/**
|
|
@@ -852,10 +902,12 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
852
902
|
const v = loadVaultCert(); const device = loadVaultDevice()
|
|
853
903
|
if (!v?.cert || !device) return
|
|
854
904
|
const now = Date.now()
|
|
855
|
-
//
|
|
856
|
-
//
|
|
857
|
-
|
|
858
|
-
|
|
905
|
+
// UN SOLO MOTIVO: que el acta diga algo distinto de lo que lleva el papel. El otro
|
|
906
|
+
// —«se acerca su fin»— era el que obligaba a la bóveda a firmar sola cada mes, y con
|
|
907
|
+
// él se va la última razón por la que la maestra tenía que estar disponible sin nadie
|
|
908
|
+
// delante. Renovar pasa a ocurrir justo cuando ya hay una selladora abierta, porque
|
|
909
|
+
// cambiar el acta ES tenerla abierta.
|
|
910
|
+
if (!certDesfasadoDelActa()) return
|
|
859
911
|
if (now - renewLastTry < RENEW_RETRY_MS) return
|
|
860
912
|
renovarCert().catch(() => {}) // best-effort: el cert vigente sigue sirviendo mientras tanto
|
|
861
913
|
} catch (_) {}
|
|
@@ -868,7 +920,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
868
920
|
renewLastTry = Date.now()
|
|
869
921
|
const { cert } = await remoteRenew({ master: v.master, proxy: v.proxy, device, cert: v.cert, onRevoked: wipeVaultLink })
|
|
870
922
|
kv.setItem(VAULT_CERT_STORAGE, JSON.stringify({ ...v, cert, renewedAt: Date.now() }))
|
|
871
|
-
emitVault({ phase: 'renewed',
|
|
923
|
+
emitVault({ phase: 'renewed', seq: cert.seq })
|
|
872
924
|
return cert
|
|
873
925
|
}
|
|
874
926
|
|
|
@@ -1050,7 +1102,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1050
1102
|
let profilePushTimer = null
|
|
1051
1103
|
function pushProfileToVault () {
|
|
1052
1104
|
const v = loadVaultCert(); const device = loadVaultDevice()
|
|
1053
|
-
if (!v?.cert || !device
|
|
1105
|
+
if (!v?.cert || !device) return
|
|
1054
1106
|
clearTimeout(profilePushTimer)
|
|
1055
1107
|
profilePushTimer = setTimeout(() => {
|
|
1056
1108
|
const { publickey, encryptionPubkey, ...content } = me || {}
|
|
@@ -1085,7 +1137,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1085
1137
|
const r = await remoteCheck({
|
|
1086
1138
|
master: Acta.sealersOf(acta)[0] || null,
|
|
1087
1139
|
proxy: v?.proxy || 'wss://proxy.dotrino.com',
|
|
1088
|
-
device: { publickey: publickeyJwkStr, privateKey:
|
|
1140
|
+
device: { publickey: publickeyJwkStr, privateKey: masterKey() },
|
|
1089
1141
|
onRevoked: wipeVaultLink
|
|
1090
1142
|
})
|
|
1091
1143
|
if (r?.error) console.warn('[identity] could not confirm membership with the vault:', r.error)
|
|
@@ -1209,7 +1261,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1209
1261
|
async signChallenge ({ nonce }) {
|
|
1210
1262
|
if (!nonce || typeof nonce !== 'string') throw new Error('nonce required')
|
|
1211
1263
|
const bytes = new TextEncoder().encode(nonce)
|
|
1212
|
-
const signature = await signBytes(
|
|
1264
|
+
const signature = await signBytes(masterKey(), bytes)
|
|
1213
1265
|
return { nonce, publickey: publickeyJwkStr, encryptionPubkey: encPublickeyJwkStr, signature }
|
|
1214
1266
|
},
|
|
1215
1267
|
|
|
@@ -1240,7 +1292,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1240
1292
|
const issuedAt = Date.now()
|
|
1241
1293
|
const envelope = { subject: publickey, rating: r, notes: safeNotes, ratedBy: publickeyJwkStr, issuedAt }
|
|
1242
1294
|
const sigBytes = new TextEncoder().encode(canonicalStringify(envelope))
|
|
1243
|
-
const signature = await signBytes(
|
|
1295
|
+
const signature = await signBytes(masterKey(), sigBytes)
|
|
1244
1296
|
const myRating = { ...envelope, signature }
|
|
1245
1297
|
return upsertPeer(publickey, { myRating, rating: r, notes: safeNotes })
|
|
1246
1298
|
},
|
|
@@ -1359,7 +1411,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1359
1411
|
const bytes = new TextEncoder().encode(canonicalStringify(data))
|
|
1360
1412
|
const acta = loadActa()
|
|
1361
1413
|
return {
|
|
1362
|
-
signature: await signBytes(
|
|
1414
|
+
signature: await signBytes(masterKey(), bytes),
|
|
1363
1415
|
publickey: publickeyJwkStr,
|
|
1364
1416
|
// A NOMBRE DE QUIÉN VA. `publickey` es la llave de ESTE aparato, y las apps la
|
|
1365
1417
|
// venían guardando como si fuera la identidad: publicar desde el teléfono y
|
|
@@ -1391,16 +1443,26 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1391
1443
|
// de dispositivo `sub`, acotado por `scope` y `exp`, revocable por `nonce`.
|
|
1392
1444
|
// Es la ÚNICA forma en que la autoridad sale de la clave maestra, y va limitada.
|
|
1393
1445
|
|
|
1394
|
-
|
|
1446
|
+
/**
|
|
1447
|
+
* EL PAPEL NO CADUCA POR RELOJ: lleva el `seq` del acta con el que se emitió.
|
|
1448
|
+
*
|
|
1449
|
+
* `ttlMs`/`exp` se aceptan y se IGNORAN a propósito, para no romper a quien todavía los
|
|
1450
|
+
* pasa (el daemon, `enroll.js`). Reventar ahí dejaría sin emparejar a media cadena por
|
|
1451
|
+
* un parámetro que ya no significa nada.
|
|
1452
|
+
*/
|
|
1453
|
+
async signDelegation ({ sub, scope, nonce, label, supersede }) {
|
|
1395
1454
|
if (!sub || typeof sub !== 'string') throw new Error('sub (device pubkey) required')
|
|
1396
1455
|
if (!scope || (typeof scope !== 'string' && !Array.isArray(scope))) throw new Error('scope required')
|
|
1397
1456
|
const iat = Date.now()
|
|
1398
|
-
|
|
1399
|
-
|
|
1457
|
+
// El acta con la que se emite. Sin acta no hay papel: el certificado dice «una
|
|
1458
|
+
// selladora de ESTE perfil, mirando ESTA acta, avaló esta llave», y sin acta no se
|
|
1459
|
+
// puede decir ninguna de las dos cosas.
|
|
1460
|
+
const acta = loadActa()
|
|
1461
|
+
if (!acta) throw Object.assign(new Error('this profile has no record to issue against'), { code: 'sin-acta' })
|
|
1400
1462
|
// `iss` se FUERZA a la propia maestra: el usuario no puede emitir cert para otro emisor.
|
|
1401
|
-
const cert = await signDelegationWith(
|
|
1463
|
+
const cert = await signDelegationWith(masterKey(), publickeyJwkStr, { sub, scope, iat, seq: acta.seq, nonce: nonce || crypto.randomUUID() })
|
|
1402
1464
|
const store = loadDelegations()
|
|
1403
|
-
store[cert.nonce] = { nonce: cert.nonce, sub, scope, iat,
|
|
1465
|
+
store[cert.nonce] = { nonce: cert.nonce, sub, scope, iat, seq: acta.seq, label: typeof label === 'string' ? label.slice(0, 60) : '' }
|
|
1404
1466
|
saveDelegations(store)
|
|
1405
1467
|
// UNA LLAVE, UN CERTIFICADO VIGENTE. Renovar emitía uno nuevo y dejaba vivo el
|
|
1406
1468
|
// anterior: el mismo aparato salía dos veces en la lista (parecían dos máquinas) y,
|
|
@@ -1702,7 +1764,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1702
1764
|
if ((Array.isArray(caps) ? caps : [caps]).includes('sealer')) {
|
|
1703
1765
|
throw new Error('sealing cannot be renounced: ask another sealer to take it from you')
|
|
1704
1766
|
}
|
|
1705
|
-
const record = await Acta.makeRenounce({ member: publickeyJwkStr, caps, privateKey:
|
|
1767
|
+
const record = await Acta.makeRenounce({ member: publickeyJwkStr, caps, privateKey: masterKey() })
|
|
1706
1768
|
const pend = loadRenounces().filter((r) => r.member !== publickeyJwkStr)
|
|
1707
1769
|
pend.push(record)
|
|
1708
1770
|
saveRenounces(pend)
|
|
@@ -1956,7 +2018,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1956
2018
|
if (!mine) throw new Error('this device has no account to hand over yet')
|
|
1957
2019
|
if (!amMaster()) throw new Error('not-the-master: another device or vault is in charge of this account; the handover is done from there')
|
|
1958
2020
|
|
|
1959
|
-
const device = { publickey: publickeyJwkStr, privateKey:
|
|
2021
|
+
const device = { publickey: publickeyJwkStr, privateKey: masterKey() }
|
|
1960
2022
|
const res = await remoteEnroll({
|
|
1961
2023
|
qr,
|
|
1962
2024
|
device,
|
|
@@ -2011,7 +2073,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
2011
2073
|
const v = loadVaultCert()
|
|
2012
2074
|
if (!v?.cert) return { paired: false }
|
|
2013
2075
|
maybeRenewVaultCert()
|
|
2014
|
-
return { paired: true, deviceId: v.deviceId, master: v.master, proxy: v.proxy, scope: v.cert.scope,
|
|
2076
|
+
return { paired: true, deviceId: v.deviceId, master: v.master, proxy: v.proxy, scope: v.cert.scope, seq: v.cert.seq, pairedAt: v.pairedAt }
|
|
2015
2077
|
},
|
|
2016
2078
|
|
|
2017
2079
|
async vaultUnpair () {
|
|
@@ -2456,6 +2518,16 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
2456
2518
|
return {
|
|
2457
2519
|
handlers,
|
|
2458
2520
|
get me () { return me },
|
|
2521
|
+
/** ¿Está la maestra bajo llave? Cerrada, esta identidad NO puede firmar nada. */
|
|
2522
|
+
get masterLocked () { return !keypair?.privateKey },
|
|
2523
|
+
/** Echa el candado a la maestra que ya existía (al abrir el perfil). Idempotente. */
|
|
2524
|
+
sealMasterKey,
|
|
2525
|
+
/** Recarga el par tras abrir el candado, sin reabrir la identidad entera. */
|
|
2526
|
+
async reloadMasterKey () {
|
|
2527
|
+
keypair = await loadOrCreateKeypair()
|
|
2528
|
+
publickeyJwkStr = JSON.stringify(keypair.publicJwk)
|
|
2529
|
+
return { locked: !keypair?.privateKey }
|
|
2530
|
+
},
|
|
2459
2531
|
sync,
|
|
2460
2532
|
onSyncStatus (fn) { if (sync) sync.onStatus(fn) },
|
|
2461
2533
|
onVaultEvent (fn) { vaultListeners.add(fn); return () => vaultListeners.delete(fn) }
|
package/vault/remote.js
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* No reimplementa cripto: usa `@dotrino/identity/capabilities`. Transporte:
|
|
14
14
|
* `@dotrino/proxy-client` (importado perezosamente; solo se carga al emparejar).
|
|
15
15
|
*/
|
|
16
|
-
import { makeDeviceKey, signWithDevice, verifyDelegation, verifyDeviceSig, makePairingCode, commitCode, pubkeyId
|
|
16
|
+
import { makeDeviceKey, signWithDevice, verifyDelegation, verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from './capabilities.js'
|
|
17
|
+
import { sealersOf } from './acta.js'
|
|
17
18
|
|
|
18
19
|
const MSG = {
|
|
19
20
|
HELLO: 'vault.hello',
|
|
@@ -208,11 +209,22 @@ export async function enrollDevice ({ qr, device, onChallenge, label = '', conti
|
|
|
208
209
|
}
|
|
209
210
|
|
|
210
211
|
// Validación estricta antes de guardar (cierra inyección de cert / sustitución de maestra).
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
|
|
212
|
+
//
|
|
213
|
+
// EL ACTA VIAJA CON EL PAPEL y hace falta para juzgarlo: el cert lleva el `seq` del acta
|
|
214
|
+
// con el que se emitió, y quien lo emitió tiene que ser SELLADORA de ese acta. Lo que se
|
|
215
|
+
// fija ya no es la LLAVE que firma —con varias selladoras puede ser otra del mismo
|
|
216
|
+
// perfil— sino el PERFIL: el del QR, que es el que el usuario vio.
|
|
217
|
+
//
|
|
218
|
+
// (Aquí vivía el margen de reloj: el cert lo sellaba la bóveda con SU reloj y lo validaba
|
|
219
|
+
// este aparato con el suyo, y 850 ms de diferencia bastaban para no poder enrolarse. Sin
|
|
220
|
+
// vencimiento no hay ventana que ajustar y el problema no puede volver.)
|
|
221
|
+
if (!res.acta) throw new Error('the vault did not send its record: cannot check who signed this cert')
|
|
222
|
+
if (res.acta.profileId !== qr.iss) throw new Error('the record is from a profile other than the one you saw')
|
|
223
|
+
const v = await verifyDelegation({
|
|
224
|
+
cert: res.cert, expectedSub: dev.publickey,
|
|
225
|
+
actaSeq: res.acta.seq, sealers: sealersOf(res.acta)
|
|
226
|
+
})
|
|
214
227
|
if (!v.ok) throw new Error('invalid cert: ' + v.reason)
|
|
215
|
-
if (res.cert.iss !== qr.iss) throw new Error('cert signed by a master key different from the one you saw')
|
|
216
228
|
if (res.cert.sub !== dev.publickey) throw new Error('cert issued for a different device')
|
|
217
229
|
return { device: dev, cert: res.cert, master: qr.iss, proxy: qr.proxy, deviceId, acta: res.acta || null }
|
|
218
230
|
} finally { try { client.close() } catch (_) {} }
|