@dotrino/identity 0.53.0 → 0.54.1

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.53.0",
3
+ "version": "0.54.1",
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",
@@ -39,7 +39,8 @@
39
39
  "LICENSE"
40
40
  ],
41
41
  "scripts": {
42
- "test": "node --test test/*.test.js"
42
+ "test": "node --test test/*.test.js",
43
+ "type-check": "tsc --noEmit"
43
44
  },
44
45
  "keywords": [
45
46
  "identity",
@@ -55,9 +56,11 @@
55
56
  "url": "git+https://github.com/imdotrino/dotrino-identity.git"
56
57
  },
57
58
  "dependencies": {
58
- "@dotrino/proxy-client": "0.10.0"
59
+ "@dotrino/proxy-client": "0.10.1"
59
60
  },
60
61
  "devDependencies": {
61
- "fake-indexeddb": "^6.2.5"
62
+ "fake-indexeddb": "^6.2.5",
63
+ "typescript": "5.9.3",
64
+ "@types/node": "^22.0.0"
62
65
  }
63
66
  }
package/vault/acta.js CHANGED
@@ -30,7 +30,16 @@
30
30
  import { canonicalStringify } from './core.js'
31
31
  import { signWithDevice, verifyDeviceSig, pubkeyId } from './capabilities.js'
32
32
 
33
- export const ACTA_V = 1
33
+ export const ACTA_V = 2
34
+
35
+ /**
36
+ * Versiones de acta que se ACEPTAN al leer. La 1 sigue entrando porque una v1 en el disco
37
+ * es un perfil con sus aparatos dentro: rechazarla dejaría al vault sin poder verificar a
38
+ * nadie —y a los servicios sin configuración— por un campo que ni siquiera existía. Se
39
+ * asciende sola: el acta siguiente que selle la maestra ya sale v2 (ver `applyChanges`).
40
+ * La única diferencia es la llave de sellado (§8.9), que en una v1 simplemente no hay.
41
+ */
42
+ const ACTA_LEIBLES = Object.freeze([1, 2])
34
43
 
35
44
  /**
36
45
  * Lista CERRADA de capacidades. Sellar y admitir no están: eso es ser el master.
@@ -126,7 +135,7 @@ export const isHandover = (acta) => !!acta && acta.sealer !== acta.sealedBy
126
135
  * sellador, con todas las capacidades. `profileId` = su pubkey → el nombre del perfil es
127
136
  * estable para siempre y coincide con la identidad que el usuario ya tenía (cero migración).
128
137
  */
129
- export function genesisActa ({ pub, encPub = null, label = '', now = Date.now() }) {
138
+ export function genesisActa ({ pub, encPub = null, sealPub = null, label = '', now = Date.now() }) {
130
139
  if (!isPub(pub)) throw new Error('genesisActa: missing genesis pubkey')
131
140
  return {
132
141
  v: ACTA_V,
@@ -141,6 +150,14 @@ export function genesisActa ({ pub, encPub = null, label = '', now = Date.now()
141
150
  // Llavero del contenido: una entrada por generación, con la clave del perfil ENVUELTA
142
151
  // a cada miembro (ver content.js). Envuelto es público: solo lo abre su destinatario.
143
152
  keyring: [],
153
+ // LLAVE DE SELLADO (§8.9 de dotrino-vault/docs/secretos-sellados.md): con ella la
154
+ // bóveda FIRMA los sobres de los secretos, para que se sepa que salieron de ella.
155
+ // Vive aquí y no en un certificado aparte porque rota con el acta: cada acta nueva
156
+ // puede nombrar una llave nueva, y quien selle el acta es —siempre— la maestra.
157
+ // Nace en null: un perfil no tiene por qué sellar secretos.
158
+ sealPub: sealPub || null,
159
+ sealSince: sealPub ? 1 : 0,
160
+ sealKeys: [],
144
161
  updatedAt: now
145
162
  }
146
163
  }
@@ -148,7 +165,7 @@ export function genesisActa ({ pub, encPub = null, label = '', now = Date.now()
148
165
  /** Comprobaciones de FORMA (sin cripto): que el acta sea un acta. */
149
166
  export function checkShape (acta) {
150
167
  if (!acta || typeof acta !== 'object') return 'no-acta'
151
- if (acta.v !== ACTA_V) return 'version'
168
+ if (!ACTA_LEIBLES.includes(acta.v)) return 'version'
152
169
  if (!isPub(acta.profileId) || !isPub(acta.sealer) || !isPub(acta.sealedBy)) return 'shape'
153
170
  if (!Number.isInteger(acta.seq) || acta.seq < 1) return 'seq'
154
171
  if (acta.seq > 1 && typeof acta.prev !== 'string') return 'prev'
@@ -175,6 +192,20 @@ export function checkShape (acta) {
175
192
  // `encpub`), y cuando no quede ninguno sin ella se aprieta aquí.
176
193
  if (m.encPub != null && !isEncPub(m.encPub)) return 'encpub-invalido'
177
194
  }
195
+ // LA LLAVE DE SELLADO Y SU REGISTRO. `sealPub` puede no estar (un perfil que no sella
196
+ // secretos), pero si está tiene que decir DESDE QUÉ acta manda: sin `sealSince` no se
197
+ // puede decidir con qué llave verificar un sobre viejo.
198
+ if (acta.v >= 2) {
199
+ if (acta.sealPub != null) {
200
+ if (!isPub(acta.sealPub)) return 'sealpub-invalido'
201
+ if (!Number.isInteger(acta.sealSince) || acta.sealSince < 1 || acta.sealSince > acta.seq) return 'sealsince'
202
+ } else if (acta.sealSince) return 'sealsince'
203
+ if (!Array.isArray(acta.sealKeys)) return 'sealkeys'
204
+ for (const k of acta.sealKeys) {
205
+ if (!isPub(k?.pub)) return 'sealkey-invalida'
206
+ if (!Number.isInteger(k.from) || !Number.isInteger(k.to) || k.from < 1 || k.to < k.from) return 'sealkey-rango'
207
+ }
208
+ }
178
209
  if (new Set(acta.members.map((m) => m.pub)).size !== acta.members.length) return 'miembro-duplicado'
179
210
  if (!acta.members.some((m) => m.pub === acta.sealer)) return 'sealer-no-es-miembro'
180
211
  if (!acta.members.some((m) => m.caps.includes('sign'))) return 'sin-firmante'
@@ -216,17 +247,25 @@ export async function verifyActa ({ acta, expectedProfileId } = {}) {
216
247
  * Van en ARRAY porque hay combinaciones que deben ser atómicas: admitir al nuevo sellador y
217
248
  * traspasarle el master ocurre en el MISMO `seq` (§2.1.3).
218
249
  */
219
- export async function applyChanges (acta, changes, { by, now = Date.now() } = {}) {
250
+ export async function applyChanges (acta, changes, { by, now = Date.now(), sealPub = null } = {}) {
220
251
  const shape = checkShape(acta)
221
252
  if (shape) throw new Error('invalid record: ' + shape)
222
253
  if (!by) throw new Error('applyChanges: missing `by` (who seals)')
223
254
  if (by !== acta.sealer) throw new Error('only the master can change the record; this device is not the master')
224
255
 
225
- const list = Array.isArray(changes) ? changes : [changes]
226
- if (list.length === 0) throw new Error('applyChanges: no hay cambios')
256
+ const list = (Array.isArray(changes) ? changes : [changes]).filter(Boolean)
257
+ // Estrenar la llave de sellado ES un cambio del acta, aunque no toque a ningún miembro:
258
+ // rota con el acta (§8.9) y el acta es lo que le da autoridad. Por eso una lista vacía
259
+ // se acepta si viene con llave nueva, y solo entonces.
260
+ if (list.length === 0 && !sealPub) throw new Error('applyChanges: no hay cambios')
227
261
 
228
262
  const next = {
229
263
  ...acta,
264
+ // Asciende de v1 a v2 sin ceremonia: los campos de sellado nacen vacíos y la llave
265
+ // entra en cuanto quien sella pase una (`sealPub`).
266
+ v: ACTA_V,
267
+ sealPub: acta.sealPub || null,
268
+ sealSince: acta.sealPub ? acta.sealSince : 0,
230
269
  sealedBy: by,
231
270
  seq: acta.seq + 1,
232
271
  prev: await actaHash(acta),
@@ -234,10 +273,24 @@ export async function applyChanges (acta, changes, { by, now = Date.now() } = {}
234
273
  revoked: [...(acta.revoked || [])],
235
274
  renounced: [...(acta.renounced || [])],
236
275
  keyring: (acta.keyring || []).map((g) => ({ ...g, wraps: { ...g.wraps } })),
276
+ sealKeys: (acta.sealKeys || []).map((k) => ({ ...k })),
237
277
  updatedAt: now
238
278
  }
239
279
  delete next.sig
240
280
 
281
+ // LA LLAVE DE SELLADO ROTA CON EL ACTA (§8.9 de `secretos-sellados.md`). Quien sella
282
+ // pasa la llave nueva; si no pasa ninguna, la de antes sigue mandando —un master que no
283
+ // sella secretos (el navegador) no tiene por qué inventarse una—.
284
+ //
285
+ // La anterior NO se tira: se guarda con el tramo de `seq` en el que estuvo en vigor,
286
+ // porque los sobres que firmó tienen que seguir verificando. Re-firmarlos al rotar
287
+ // sería recorrer todos los secretos en cada cambio de membresía.
288
+ if (sealPub && sealPub !== next.sealPub) {
289
+ if (next.sealPub) next.sealKeys.push({ pub: next.sealPub, from: next.sealSince, to: acta.seq })
290
+ next.sealPub = sealPub
291
+ next.sealSince = next.seq
292
+ }
293
+
241
294
  const find = (pub) => next.members.find((m) => m.pub === pub)
242
295
 
243
296
  for (const ch of list) {
@@ -368,6 +421,19 @@ export async function applyChanges (acta, changes, { by, now = Date.now() } = {}
368
421
  return next
369
422
  }
370
423
 
424
+ /**
425
+ * La llave que firmaba los sobres cuando el acta iba por `seq`. Es lo que necesita quien
426
+ * VERIFICA un sobre: el sobre dice con qué acta se selló, y esto dice con qué llave hay
427
+ * que comprobarlo. `null` si no había ninguna (perfil que no sellaba) o si el `seq` viene
428
+ * del futuro, que es un sobre que no puede ser bueno.
429
+ */
430
+ export function sealKeyAt (acta, seq) {
431
+ if (!acta || !Number.isInteger(seq) || seq < 1 || seq > acta.seq) return null
432
+ if (acta.sealPub && seq >= acta.sealSince) return acta.sealPub
433
+ for (const k of acta.sealKeys || []) if (seq >= k.from && seq <= k.to) return k.pub
434
+ return null
435
+ }
436
+
371
437
  // ----- renuncia (§2.2): el único cambio que no pasa por el master -----
372
438
 
373
439
  /** Crea el registro firmado con el que un miembro se QUITA capacidades a sí mismo. */
package/vault/core.js CHANGED
@@ -542,6 +542,13 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
542
542
  /** ¿Es ESTE dispositivo el master (el único que puede sellar)? */
543
543
  const amMaster = () => loadActa()?.sealer === publickeyJwkStr
544
544
 
545
+ /**
546
+ * Quien sella SOBRES (la bóveda) pone aquí una función que estrena una llave de sellado
547
+ * y devuelve su pública. Se llama en cada acta, porque esa llave rota con el acta.
548
+ * Un navegador no sella secretos: se queda en `null` y el acta no lleva llave.
549
+ */
550
+ let sealKeyProvider = null
551
+
545
552
  /** Sella con la llave del perfil (CryptoKey, puede ser no extractable). */
546
553
  const seal = (acta) => Acta.sealActa({ acta, privateKey: keypair.privateKey })
547
554
 
@@ -552,7 +559,15 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
552
559
  async function sealChanges (changes) {
553
560
  const acta = loadActa()
554
561
  if (!acta) throw new Error('this profile has no record yet')
555
- const next = await Acta.applyChanges(acta, changes, { by: publickeyJwkStr })
562
+ // LA LLAVE DE SELLADO ROTA CON EL ACTA (§8.9 de `secretos-sellados.md`): si quien usa
563
+ // esta identidad sella secretos —la bóveda—, le pedimos una llave nueva para nombrarla
564
+ // aquí. Si no hay proveedor, o falla, el acta sale igual con la llave de antes: no
565
+ // admitir un aparato porque no se pudo estrenar una llave sería el peor de los canjes.
566
+ let sealPub = null
567
+ if (sealKeyProvider) {
568
+ try { sealPub = await sealKeyProvider() } catch (_) { sealPub = null }
569
+ }
570
+ const next = await Acta.applyChanges(acta, changes, { by: publickeyJwkStr, sealPub })
556
571
  const sealed = await seal(next)
557
572
  pushHistory(acta) // la que deja de ser vigente entra en la ventana de retención
558
573
  saveActa(sealed)
@@ -1447,6 +1462,34 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1447
1462
  return { acta, isMaster: amMaster(), myCaps: Acta.effectiveCaps(acta, publickeyJwkStr, loadRenounces()) }
1448
1463
  },
1449
1464
 
1465
+ /**
1466
+ * Quien sella sobres de secretos (la bóveda) registra aquí cómo estrenar su LLAVE DE
1467
+ * SELLADO: una función que crea el par, se guarda la privada y devuelve la pública. Se
1468
+ * llama al sellar cada acta, que es cuando esa llave rota (§8.9).
1469
+ *
1470
+ * Es opt-in a propósito: una identidad de navegador no sella nada, y un acta sin llave
1471
+ * de sellado es perfectamente válida.
1472
+ */
1473
+ setSealKeyProvider (fn) {
1474
+ sealKeyProvider = typeof fn === 'function' ? fn : null
1475
+ },
1476
+
1477
+ /**
1478
+ * Estrena la llave de sellado sin tocar a nadie más: pide una al proveedor y la nombra
1479
+ * en un acta nueva. Es lo que hace la bóveda al arrancar si el acta no tiene llave, o
1480
+ * si la que nombra no es suya (el disco se restauró, otro master la puso…).
1481
+ */
1482
+ async rotateSealKey () {
1483
+ if (!sealKeyProvider) throw new Error('this identity does not seal envelopes')
1484
+ const acta = await sealChanges([])
1485
+ return { ok: true, seq: acta.seq, sealPub: acta.sealPub }
1486
+ },
1487
+
1488
+ /** La llave con la que se firmaron los sobres de un acta dada. Para VERIFICARLOS. */
1489
+ sealKeyAt (seq) {
1490
+ return Acta.sealKeyAt(loadActa(), seq)
1491
+ },
1492
+
1450
1493
  async profileMembers () {
1451
1494
  const acta = loadActa()
1452
1495
  if (!acta) return { members: [], profileId: null, seq: 0, sealer: null }
package/src/types.d.ts DELETED
@@ -1,5 +0,0 @@
1
- interface Element { [key: string]: any; }
2
- interface EventTarget { [key: string]: any; }
3
- interface HTMLElement { [key: string]: any; }
4
- interface Event { [key: string]: any; }
5
- interface Window { [key: string]: any; }