@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/identity",
3
- "version": "0.71.0",
3
+ "version": "0.73.0",
4
4
  "description": "Identidad y rating de usuarios compartidos entre apps de Dotrino (vault iframe + postMessage)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/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 = 4
40
+ export const ACTA_V = 5
41
41
 
42
42
  /**
43
43
  * Versiones de acta que se ACEPTAN al leer. La 1 sigue entrando porque una v1 en el disco
@@ -57,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
- const ACTA_LEIBLES = Object.freeze([1, 2, 3, 4])
60
+ // v5 mete DENTRO del acta el eslabón publicable de la cadena de selladores, firmado
61
+ // aparte (ver `sealerLinkOf`): así se publica solo eso y no el acta entera, que llevaba
62
+ // los aparatos con sus nombres. Una v4 se sigue leyendo, pero no tiene eslabón y por tanto
63
+ // no puede publicar: su cuenta no aparece en el registro hasta que selle una v5 nueva.
64
+ const ACTA_LEIBLES = Object.freeze([1, 2, 3, 4, 5])
61
65
  /** Desde esta versión, el acta lleva su eslabón de la cadena de selladores. */
62
66
  const V_CON_CADENA = 4
63
67
  /** ¿Esta acta lleva el eslabón? Las anteriores se leen igual; no pueden encadenar. */
@@ -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
- const { signature } = await signWithDevice({ privateKey, privateJwk, publickey: acta.sealedBy, data: actaBody(acta) })
359
- const sealed = { ...acta, sig: signature }
483
+ // PRIMERO EL ESLABÓN, DESPUÉS EL ACTA. Este es el orden que hace que no puedan discrepar:
484
+ // se firma el eslabón, se mete en el acta, y la firma del acta lo cubre. Quien tiene el
485
+ // acta lo da por bueno con verificarla; quien solo tiene el registro verifica su firma.
486
+ // Si ya viene firmado no se vuelve a firmar: se está arrastrando el de un acta anterior.
487
+ let conEslabon = acta
488
+ if (acta.sealerLink && !acta.sealerLink.sig) {
489
+ if (acta.sealerLink.by !== acta.sealedBy) throw new Error('invalid record: the chain link names a sealer other than the one signing')
490
+ const { signature: firmaEslabon } = await signWithDevice({
491
+ privateKey, privateJwk, publickey: acta.sealedBy, data: sealerLinkBody(acta.sealerLink)
492
+ })
493
+ conEslabon = { ...acta, sealerLink: { ...acta.sealerLink, sig: firmaEslabon } }
494
+ }
495
+ const { signature } = await signWithDevice({ privateKey, privateJwk, publickey: conEslabon.sealedBy, data: actaBody(conEslabon) })
496
+ const sealed = { ...conEslabon, sig: signature }
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
- return ok ? { ok: true } : { ok: false, reason: 'firma-invalida' }
597
+ if (!ok) return { ok: false, reason: 'firma-invalida' }
598
+ // El eslabón va DENTRO y la firma de arriba lo cubre, pero eso solo prueba que está ahí.
599
+ // Que diga lo mismo que el acta es lo que hace verdad «ya está validado dentro».
600
+ const mal = checkSealerLink(acta)
601
+ return mal ? { ok: false, reason: mal } : { ok: true }
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
 
@@ -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
- * hasta `exp`», con un `nonce` que es el mango de revocación.
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), hasta `exp`, y se puede revocar. La clave maestra queda intacta.
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, exp: cert.exp, nonce: cert.nonce }
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, exp, nonce, sig }`.
177
+ * completo `{ v, iss, sub, scope, iat, seq, nonce, sig }`.
166
178
  */
167
- export async function signDelegationWith (privateKey, iss, { sub, scope, iat, exp, nonce }) {
168
- const body = { v: 1, iss, sub, scope, iat, exp, nonce }
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) ventana temporal `iat now exp`,
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?, exp?, nonce?}}
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, now = Date.now(), skewMs = 0, revoked } = {}) {
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, exp, nonce, sig } = cert
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 exp !== 'number' || (typeof scope !== 'string' && !Array.isArray(scope))) return { ok: false, reason: 'shape' }
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
- // `skewMs` tolera la diferencia de reloj entre el EMISOR (vault) y el VERIFICADOR
225
- // (p.ej. el bridge de geo, otra máquina). Default 0 = estricto.
226
- const sk = Math.max(0, skewMs)
227
- if (now < iat - sk) return { ok: false, reason: 'not-yet-valid' }
228
- if (now > exp + sk) return { ok: false, reason: 'expired' }
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 `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, exp, nonce }
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 `P`, scope, exp, revocación),
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, expectedIssuer, trustedIssuer, now = Date.now(), skewMs = 0, revoked } = {}) {
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
- const d = await verifyDelegation({ cert, expectedScope, now, skewMs, revoked })
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
- const issuer = trustedIssuer != null ? trustedIssuer : expectedIssuer
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, MAX_DELEGATION_MS, DEFAULT_DELEGATION_MS } from './capabilities.js'
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 { privateJwk, publicJwk } = JSON.parse(raw)
244
- const privateKey = await crypto.subtle.importKey('jwk', privateJwk, algo, true, privUses)
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
- kv.setItem(storageKey, JSON.stringify({ privateJwk, publicJwk }))
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 (los dos registros crecían para siempre): la renovación automática firma un cert
281
- // nuevo cada 30 días, así que sin podar cada dispositivo dejaba 12 entradas muertas al año.
282
- // Se tira lo que YA NO PUEDE SERVIR, nunca lo vivo:
283
- // · delegación cuando su `exp` ya pasó (un cert vencido no autoriza nada).
284
- // OJO: no se poda «la anterior del mismo dispositivo» al renovar, porque el cert
285
- // viejo SIGUE VIGENTE hasta su exp y hay que poder revocarlo si te roban el aparato.
286
- // · revocación 30 días después de revocar: para entonces el cert al que apunta está
287
- // vencido seguro (el tope duro de vida es `MAX_DELEGATION_MS`, y exp ≤ iat + 30 días
288
- // revokedAt + 30 días), y un cert vencido ya falla por `expired` sin mirar la lista.
289
- const DELEGATION_MAX_LIFE_MS = 30 * 24 * 60 * 60 * 1000 // espejo de MAX_DELEGATION_MS (capabilities.js)
290
-
291
- function loadDelegations () {
292
- const o = loadJson(DELEGATIONS_STORAGE)
293
- const now = Date.now()
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
- const o = loadJson(REVOCATIONS_STORAGE)
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: keypair.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: keypair.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: keypair.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: keypair.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: keypair.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, exp: res.cert.exp, scope: res.cert.scope, join: unido }
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
- const RENEW_WINDOW_MS = 15 * 24 * 60 * 60 * 1000
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
- // Se renueva por dos motivos: porque el cert se acerca a su fin, o porque el acta
856
- // dice que este aparato puede algo distinto de lo que lleva escrito el cert.
857
- const porCaducar = v.cert.exp > now && v.cert.exp - now <= RENEW_WINDOW_MS
858
- if (v.cert.exp <= now || (!porCaducar && !certDesfasadoDelActa())) return
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', exp: cert.exp })
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 || v.cert.exp <= Date.now()) return
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: keypair.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(keypair.privateKey, bytes)
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(keypair.privateKey, sigBytes)
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(keypair.privateKey, bytes),
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
- async signDelegation ({ sub, scope, ttlMs, exp, nonce, label, supersede }) {
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
- const want = typeof exp === 'number' ? exp : iat + (Number(ttlMs) || DEFAULT_DELEGATION_MS)
1399
- const cappedExp = Math.min(want, iat + MAX_DELEGATION_MS) // tope duro de vida
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(keypair.privateKey, publickeyJwkStr, { sub, scope, iat, exp: cappedExp, nonce: nonce || crypto.randomUUID() })
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, exp: cappedExp, label: typeof label === 'string' ? label.slice(0, 60) : '' }
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: keypair.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: keypair.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, exp: v.cert.exp, pairedAt: v.pairedAt }
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, PEER_SKEW_MS } from './capabilities.js'
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
- // `PEER_SKEW_MS`: el cert lo acaba de sellar la bóveda con SU reloj y lo valida ESTE
212
- // aparato con el suyo. Sin margen, ir 850 ms por detrás bastaba para no poder enrolarse.
213
- const v = await verifyDelegation({ cert: res.cert, expectedSub: dev.publickey, skewMs: PEER_SKEW_MS })
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 (_) {} }