@dotrino/identity 0.25.0 → 0.26.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.25.0",
3
+ "version": "0.26.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/index.js CHANGED
@@ -299,6 +299,10 @@ export class Identity {
299
299
 
300
300
  /** Adopta un acta recibida de otro miembro (gana el seq mayor; a igual seq, el traspaso). */
301
301
  async adoptActa (acta) { return this._call('adoptActa', { acta }) }
302
+ /** La clave de contenido del perfil, abierta con la llave de cifrado de este dispositivo. */
303
+ async contentKey () { return this._call('contentKey') }
304
+ /** Rota la clave de contenido (corta el acceso al contenido FUTURO de quien ya no está). */
305
+ async rotateContentKey () { return this._call('rotateContentKey') }
302
306
 
303
307
  // ----- Emparejar ESTE navegador/dispositivo con el vault del usuario (Fase 1) -----
304
308
 
package/src/node.js CHANGED
@@ -162,6 +162,10 @@ export class Identity {
162
162
  renounceCaps (caps) { return this._h('renounceCaps', { caps }) }
163
163
  absorbRenounce (record) { return this._h('absorbRenounce', { record }) }
164
164
  adoptActa (acta) { return this._h('adoptActa', { acta }) }
165
+ /** La clave de contenido del perfil, abierta con la llave de cifrado de este dispositivo. */
166
+ contentKey () { return this._h('contentKey') }
167
+ /** Rota la clave de contenido (corta el acceso al contenido FUTURO de quien ya no está). */
168
+ rotateContentKey () { return this._h('rotateContentKey') }
165
169
  // Emparejar ESTE dispositivo con el vault del usuario (Fase 1)
166
170
  enrollDevice (qr) { return this._h('vaultPair', { qr }) }
167
171
  vaultStatus () { return this._h('vaultStatus') }
package/vault/acta.js CHANGED
@@ -80,6 +80,9 @@ export function genesisActa ({ pub, encPub = null, label = '', now = Date.now()
80
80
  members: [{ pub, encPub, label: String(label || '').slice(0, 60), caps: [...CAPS], addedAt: now, cert: null }],
81
81
  revoked: [],
82
82
  renounced: [],
83
+ // Llavero del contenido: una entrada por generación, con la clave del perfil ENVUELTA
84
+ // a cada miembro (ver content.js). Envuelto es público: solo lo abre su destinatario.
85
+ keyring: [],
83
86
  updatedAt: now
84
87
  }
85
88
  }
@@ -150,6 +153,7 @@ export async function applyChanges (acta, changes, { by, now = Date.now() } = {}
150
153
  members: acta.members.map((m) => ({ ...m, caps: [...m.caps] })),
151
154
  revoked: [...(acta.revoked || [])],
152
155
  renounced: [...(acta.renounced || [])],
156
+ keyring: (acta.keyring || []).map((g) => ({ ...g, wraps: { ...g.wraps } })),
153
157
  updatedAt: now
154
158
  }
155
159
  delete next.sig
@@ -184,7 +188,13 @@ export async function applyChanges (acta, changes, { by, now = Date.now() } = {}
184
188
  const i = next.members.findIndex((m) => m.pub === ch.pub)
185
189
  if (i < 0) throw new Error('remove: ese miembro no está en el acta')
186
190
  if (next.members[i].pub === next.sealer) throw new Error('remove: no puedes expulsar al master; primero traspasa el sellado')
187
- next.members.splice(i, 1)
191
+ const fuera = next.members.splice(i, 1)[0]
192
+ // Sus envolturas se van con él: sin ellas no puede abrir ninguna generación. (El
193
+ // acceso al contenido FUTURO se corta rotando, ver content.js; lo ya leído no vuelve.)
194
+ next.keyring = next.keyring.map((g) => {
195
+ const { [fuera.pub]: _, ...resto } = g.wraps || {}
196
+ return { ...g, wraps: resto }
197
+ })
188
198
  break
189
199
  }
190
200
  case 'handover': {
@@ -192,6 +202,20 @@ export async function applyChanges (acta, changes, { by, now = Date.now() } = {}
192
202
  next.sealer = ch.to
193
203
  break
194
204
  }
205
+ case 'keyring': {
206
+ // Generación NUEVA de la clave de contenido (al rotar: expulsar a alguien).
207
+ const g = ch.generation
208
+ if (!g || !Number.isInteger(g.gen)) throw new Error('keyring: generación inválida')
209
+ next.keyring = [...next.keyring.filter((x) => x.gen !== g.gen), g].sort((a, b) => a.gen - b.gen)
210
+ break
211
+ }
212
+ case 'wrap': {
213
+ // Envolver la clave YA existente para un miembro nuevo (al admitir: no hace falta rotar).
214
+ const g = next.keyring.find((x) => x.gen === ch.gen)
215
+ if (!g) throw new Error('wrap: esa generación no está en el llavero')
216
+ g.wraps = { ...g.wraps, [ch.pub]: ch.wrap }
217
+ break
218
+ }
195
219
  case 'revoke': {
196
220
  if (typeof ch.nonce !== 'string') throw new Error('revoke: falta el nonce')
197
221
  next.revoked.push({ nonce: ch.nonce, until: ch.until || (now + 30 * 24 * 60 * 60 * 1000) })
@@ -0,0 +1,132 @@
1
+ /**
2
+ * content.js — la LLAVE DE CONTENIDO del perfil, para que todos tus dispositivos lean lo
3
+ * mismo sin que ninguna llave de identidad se mueva.
4
+ *
5
+ * La distinción que ordena esto (y que conviene decir en voz alta):
6
+ *
7
+ * · Las llaves de FIRMA son intransferibles. Nacen y mueren en su dispositivo.
8
+ * · La llave de CONTENIDO se comparte por diseño — si no, dos dispositivos tuyos no
9
+ * podrían leer el mismo archivo, que es justo lo que se quiere.
10
+ *
11
+ * Cómo: el perfil tiene una clave simétrica (la CEK) que se ENVUELVE hacia la llave de
12
+ * cifrado de cada miembro (ECDH P-256 efímero + AES-GCM, la misma cripto que ya usa el
13
+ * ecosistema para los sobres sellados). Cada miembro abre su envoltura con su propia
14
+ * privada; nadie más puede. Admitir un miembro = envolverle la CEK. Expulsarlo = ROTAR la
15
+ * CEK y envolver la nueva al resto.
16
+ *
17
+ * Lo que esto protege y lo que no: rotar corta el acceso al contenido FUTURO. Lo que el
18
+ * expulsado ya leyó, ya lo leyó — eso no se puede deshacer y no se promete.
19
+ *
20
+ * Módulo PURO (WebCrypto, sin kv/red/disco).
21
+ */
22
+
23
+ const subtle = globalThis.crypto.subtle
24
+ const ECDH = { name: 'ECDH', namedCurve: 'P-256' }
25
+
26
+ const b64 = (buf) => {
27
+ const bytes = new Uint8Array(buf)
28
+ let s = ''
29
+ for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i])
30
+ return btoa(s)
31
+ }
32
+ const fromB64 = (str) => {
33
+ const bin = atob(str)
34
+ const out = new Uint8Array(bin.length)
35
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
36
+ return out
37
+ }
38
+
39
+ async function sharedKey (privateKey, peerPubJwkStr) {
40
+ const pub = await subtle.importKey('jwk', JSON.parse(peerPubJwkStr), ECDH, false, [])
41
+ const bits = await subtle.deriveBits({ name: 'ECDH', public: pub }, privateKey, 256)
42
+ return subtle.importKey('raw', bits, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'])
43
+ }
44
+
45
+ /** Genera una clave de contenido nueva (AES-256-GCM). Devuelve los bytes en base64. */
46
+ export async function makeContentKey () {
47
+ const k = await subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'])
48
+ return b64(await subtle.exportKey('raw', k))
49
+ }
50
+
51
+ /**
52
+ * Envuelve la CEK hacia la llave de cifrado de un miembro. La envoltura es pública: solo
53
+ * la abre quien tenga la privada de `memberEncPub`, así que puede viajar en el acta.
54
+ * @returns {Promise<{epk:string, iv:string, ct:string}>}
55
+ */
56
+ export async function wrapForMember ({ cek, memberEncPub }) {
57
+ if (typeof cek !== 'string' || !cek) throw new Error('wrapForMember: falta la clave de contenido')
58
+ if (typeof memberEncPub !== 'string' || !memberEncPub) throw new Error('wrapForMember: el miembro no tiene llave de cifrado')
59
+ const eph = await subtle.generateKey(ECDH, false, ['deriveBits'])
60
+ const key = await sharedKey(eph.privateKey, memberEncPub)
61
+ const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
62
+ const ct = await subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(cek))
63
+ const epk = await subtle.exportKey('jwk', eph.publicKey)
64
+ return {
65
+ epk: JSON.stringify({ kty: epk.kty, crv: epk.crv, x: epk.x, y: epk.y }),
66
+ iv: b64(iv),
67
+ ct: b64(ct)
68
+ }
69
+ }
70
+
71
+ /** Abre la envoltura con la llave de cifrado privada de ESTE miembro. */
72
+ export async function openWrap ({ wrap, myEncPrivateKey }) {
73
+ if (!wrap?.epk || !wrap?.iv || !wrap?.ct) throw new Error('envoltura inválida')
74
+ const key = await sharedKey(myEncPrivateKey, wrap.epk)
75
+ const pt = await subtle.decrypt({ name: 'AES-GCM', iv: fromB64(wrap.iv) }, key, fromB64(wrap.ct))
76
+ return new TextDecoder().decode(pt)
77
+ }
78
+
79
+ /**
80
+ * Genera la CEK de una generación y la envuelve a TODOS los miembros que tengan llave de
81
+ * cifrado. Un miembro sin `encPub` simplemente no recibe envoltura (y por tanto no lee el
82
+ * contenido): se devuelve la lista para que la consola lo diga en vez de fallar en silencio.
83
+ */
84
+ export async function makeGeneration ({ members, gen = 1, cek = null, now = Date.now() }) {
85
+ const key = cek || await makeContentKey()
86
+ const wraps = {}
87
+ const sinLlave = []
88
+ for (const m of members || []) {
89
+ if (!m?.encPub) { sinLlave.push(m?.pub); continue }
90
+ wraps[m.pub] = await wrapForMember({ cek: key, memberEncPub: m.encPub })
91
+ }
92
+ return { generation: { gen, createdAt: now, wraps }, cek: key, sinLlave }
93
+ }
94
+
95
+ /** La CEK vigente para mí, sacada del llavero del acta. `null` si no tengo envoltura. */
96
+ export async function myContentKey ({ keyring, myPub, myEncPrivateKey }) {
97
+ const gens = [...(keyring || [])].sort((a, b) => (b.gen || 0) - (a.gen || 0))
98
+ for (const g of gens) {
99
+ const w = g.wraps?.[myPub]
100
+ if (!w) continue
101
+ try { return { gen: g.gen, cek: await openWrap({ wrap: w, myEncPrivateKey }) } } catch (_) {}
102
+ }
103
+ return null
104
+ }
105
+
106
+ /** Cifra con la CEK. Devuelve un sobre `{ gen, iv, ct }` (el `gen` dice con cuál se cifró). */
107
+ export async function encryptWithCek ({ cek, gen, plaintext }) {
108
+ const k = await subtle.importKey('raw', fromB64(cek), { name: 'AES-GCM' }, false, ['encrypt'])
109
+ const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
110
+ const ct = await subtle.encrypt({ name: 'AES-GCM', iv }, k, new TextEncoder().encode(plaintext))
111
+ return { gen, iv: b64(iv), ct: b64(ct) }
112
+ }
113
+
114
+ /**
115
+ * Descifra un sobre. Hay que darle el llavero porque el contenido viejo está cifrado con
116
+ * generaciones anteriores: por eso las CEK antiguas se conservan (32 bytes cada una) en vez
117
+ * de re-cifrarlo todo de golpe al rotar.
118
+ */
119
+ export async function decryptWithKeyring ({ envelope, keyring, myPub, myEncPrivateKey }) {
120
+ const g = (keyring || []).find((x) => x.gen === envelope?.gen)
121
+ const w = g?.wraps?.[myPub]
122
+ if (!w) throw new Error('este dispositivo no tiene la llave de esa generación de contenido')
123
+ const cek = await openWrap({ wrap: w, myEncPrivateKey })
124
+ const k = await subtle.importKey('raw', fromB64(cek), { name: 'AES-GCM' }, false, ['decrypt'])
125
+ const pt = await subtle.decrypt({ name: 'AES-GCM', iv: fromB64(envelope.iv) }, k, fromB64(envelope.ct))
126
+ return new TextDecoder().decode(pt)
127
+ }
128
+
129
+ export default {
130
+ makeContentKey, wrapForMember, openWrap, makeGeneration, myContentKey,
131
+ encryptWithCek, decryptWithKeyring
132
+ }
package/vault/core.js CHANGED
@@ -20,6 +20,7 @@
20
20
 
21
21
  import { signDelegationWith, MAX_DELEGATION_MS, DEFAULT_DELEGATION_MS } from './capabilities.js'
22
22
  import * as Acta from './acta.js'
23
+ import * as Content from './content.js'
23
24
  import { enrollDevice as remoteEnroll, requestSign as remoteSign, requestStore as remoteStore, requestDevices as remoteDevices, requestRenew as remoteRenew } from './remote.js'
24
25
 
25
26
  export const KEY_STORAGE = 'dotrino.identity.keypair'
@@ -374,11 +375,48 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
374
375
  */
375
376
  async function ensureActa (label = '') {
376
377
  if (loadActa()) return loadActa()
377
- const acta = await seal(Acta.genesisActa({
378
+ const base = Acta.genesisActa({
378
379
  pub: publickeyJwkStr, encPub: encPublickeyJwkStr, label: label || me?.nickname || ''
379
- }))
380
- saveActa(acta)
381
- return acta
380
+ })
381
+ // La primera generación de la clave de contenido va DENTRO del acta de génesis, no en
382
+ // un cambio aparte: un perfil recién nacido está en `seq 1`, no en `seq 2`.
383
+ try {
384
+ const { generation } = await Content.makeGeneration({ members: base.members, gen: 1 })
385
+ base.keyring = [generation]
386
+ } catch (e) { console.warn('[identity] no se pudo crear la clave de contenido:', e.message) }
387
+ saveActa(await seal(base))
388
+ return loadActa()
389
+ }
390
+
391
+ // ----- CLAVE DE CONTENIDO del perfil (para que todos tus dispositivos lean lo mismo) -----
392
+ // Las llaves de FIRMA no se mueven nunca; la de CONTENIDO se comparte por diseño,
393
+ // envuelta a la llave de cifrado de cada miembro (ver content.js).
394
+
395
+ /** Mi copia de la clave de contenido vigente, o null si aún no me la han envuelto. */
396
+ const myCek = () => Content.myContentKey({
397
+ keyring: loadActa()?.keyring, myPub: publickeyJwkStr, myEncPrivateKey: encKeypair.privateKey
398
+ })
399
+
400
+ /** Envuelve la clave vigente para un miembro recién admitido (no hace falta rotar). */
401
+ async function wrapForNewMember (pub) {
402
+ const acta = loadActa()
403
+ const gen = (acta?.keyring || []).at(-1)
404
+ const m = acta?.members.find((x) => x.pub === pub)
405
+ if (!gen || !m?.encPub) return false
406
+ const mine = await myCek()
407
+ if (!mine) return false
408
+ const wrap = await Content.wrapForMember({ cek: mine.cek, memberEncPub: m.encPub })
409
+ await sealChanges([{ op: 'wrap', gen: mine.gen, pub, wrap }])
410
+ return true
411
+ }
412
+
413
+ /** Rota la clave: generación nueva envuelta SOLO a los miembros actuales. */
414
+ async function rotateCek () {
415
+ const acta = loadActa()
416
+ const gen = ((acta.keyring || []).at(-1)?.gen || 0) + 1
417
+ const { generation, sinLlave } = await Content.makeGeneration({ members: acta.members, gen })
418
+ await sealChanges([{ op: 'keyring', generation }])
419
+ return { gen, sinLlave }
382
420
  }
383
421
 
384
422
  /**
@@ -1027,10 +1065,17 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1027
1065
 
1028
1066
  async isMaster () { return amMaster() },
1029
1067
 
1030
- /** Admite un miembro (solo el master). El cert lo emite quien llama, antes o después. */
1031
- async admitMember ({ pub, encPub = null, label = '', caps = ['store', 'read'], cert = null } = {}) {
1032
- const acta = await sealChanges([{ op: 'admit', member: { pub, encPub, label, caps, cert } }])
1033
- return { ok: true, seq: acta.seq }
1068
+ /**
1069
+ * Admite un miembro (solo el master). El cert lo emite quien llama, antes o después.
1070
+ * `continuity`: si esa identidad ya existía por su cuenta, su puente firmado (F3).
1071
+ */
1072
+ async admitMember ({ pub, encPub = null, label = '', caps = ['store', 'read'], cert = null, continuity = null } = {}) {
1073
+ const acta = await sealChanges([{ op: 'admit', member: { pub, encPub, label, caps, cert, continuity } }])
1074
+ // Que entre al perfil incluye poder LEER lo que ya hay: se le envuelve la clave
1075
+ // vigente (no hace falta rotar; rotar es para cuando alguien SALE).
1076
+ let wrapped = false
1077
+ try { wrapped = await wrapForNewMember(pub) } catch (_) {}
1078
+ return { ok: true, seq: acta.seq, wrapped }
1034
1079
  },
1035
1080
 
1036
1081
  async setCaps ({ pub, caps } = {}) {
@@ -1040,7 +1085,11 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1040
1085
 
1041
1086
  async removeMember ({ pub } = {}) {
1042
1087
  const acta = await sealChanges([{ op: 'remove', pub }])
1043
- return { ok: true, seq: acta.seq }
1088
+ // Expulsar rota la clave: el que sale no podrá abrir el contenido NUEVO. Lo que ya
1089
+ // leyó no vuelve — eso no se puede deshacer y no se promete.
1090
+ let rotated = null
1091
+ try { rotated = await rotateCek() } catch (_) {}
1092
+ return { ok: true, seq: acta.seq, rotated }
1044
1093
  },
1045
1094
 
1046
1095
  /**
@@ -1081,6 +1130,21 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1081
1130
  return { ok: true, seq: acta.seq }
1082
1131
  },
1083
1132
 
1133
+ /**
1134
+ * La clave de contenido de este perfil, ya abierta con la llave de cifrado de este
1135
+ * dispositivo. `null` si todavía no te la han envuelto (o si te expulsaron).
1136
+ */
1137
+ async contentKey () { return myCek() },
1138
+
1139
+ /**
1140
+ * Rota la clave de contenido: generación nueva envuelta solo a los miembros de ahora.
1141
+ * Corta el acceso al contenido FUTURO de quien ya no está; lo que ya leyó, ya lo leyó.
1142
+ */
1143
+ async rotateContentKey () {
1144
+ const r = await rotateCek()
1145
+ return { ok: true, ...r }
1146
+ },
1147
+
1084
1148
  /** Adopta un acta que llega de otro miembro (gana el seq mayor; a igual seq, el traspaso). */
1085
1149
  async adoptActa ({ acta } = {}) { return adoptActa(acta) },
1086
1150