@dotrino/identity 0.72.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.72.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)
@@ -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 (_) {} }