@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 +7 -4
- package/vault/acta.js +72 -6
- package/vault/core.js +44 -1
- package/src/types.d.ts +0 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/identity",
|
|
3
|
-
"version": "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.
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 }
|