@dotrino/identity 0.25.1 → 0.27.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 +4 -1
- package/src/index.js +8 -0
- package/src/node.js +8 -0
- package/vault/acta.js +25 -1
- package/vault/content.js +132 -0
- package/vault/core.js +115 -10
- package/vault/remote.js +13 -8
- package/vault/vendor/vault/VERSION.txt +1 -1
- package/vault/vendor/vault/enroll.js +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/identity",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.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",
|
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
"./vault/remote.js": "./vault/remote.js",
|
|
25
25
|
"./acta": {
|
|
26
26
|
"import": "./vault/acta.js"
|
|
27
|
+
},
|
|
28
|
+
"./content": {
|
|
29
|
+
"import": "./vault/content.js"
|
|
27
30
|
}
|
|
28
31
|
},
|
|
29
32
|
"files": [
|
package/src/index.js
CHANGED
|
@@ -299,6 +299,14 @@ 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
|
+
/** Cifra con la clave de contenido del perfil (la privada de cifrado no sale del vault). */
|
|
305
|
+
async sealContent (plaintext) { return this._call('sealContent', { plaintext }) }
|
|
306
|
+
/** Abre un sobre de contenido con el llavero del perfil. */
|
|
307
|
+
async openContent (envelope) { return this._call('openContent', { envelope }) }
|
|
308
|
+
/** Rota la clave de contenido (corta el acceso al contenido FUTURO de quien ya no está). */
|
|
309
|
+
async rotateContentKey () { return this._call('rotateContentKey') }
|
|
302
310
|
|
|
303
311
|
// ----- Emparejar ESTE navegador/dispositivo con el vault del usuario (Fase 1) -----
|
|
304
312
|
|
package/src/node.js
CHANGED
|
@@ -162,6 +162,14 @@ 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
|
+
/** Cifra con la clave de contenido del perfil (la privada de cifrado no sale del vault). */
|
|
168
|
+
sealContent (plaintext) { return this._h('sealContent', { plaintext }) }
|
|
169
|
+
/** Abre un sobre de contenido con el llavero del perfil. */
|
|
170
|
+
openContent (envelope) { return this._h('openContent', { envelope }) }
|
|
171
|
+
/** Rota la clave de contenido (corta el acceso al contenido FUTURO de quien ya no está). */
|
|
172
|
+
rotateContentKey () { return this._h('rotateContentKey') }
|
|
165
173
|
// Emparejar ESTE dispositivo con el vault del usuario (Fase 1)
|
|
166
174
|
enrollDevice (qr) { return this._h('vaultPair', { qr }) }
|
|
167
175
|
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) })
|
package/vault/content.js
ADDED
|
@@ -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
|
|
378
|
+
const base = Acta.genesisActa({
|
|
378
379
|
pub: publickeyJwkStr, encPub: encPublickeyJwkStr, label: label || me?.nickname || ''
|
|
379
|
-
})
|
|
380
|
-
|
|
381
|
-
|
|
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
|
/**
|
|
@@ -1033,7 +1071,11 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1033
1071
|
*/
|
|
1034
1072
|
async admitMember ({ pub, encPub = null, label = '', caps = ['store', 'read'], cert = null, continuity = null } = {}) {
|
|
1035
1073
|
const acta = await sealChanges([{ op: 'admit', member: { pub, encPub, label, caps, cert, continuity } }])
|
|
1036
|
-
|
|
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 }
|
|
1037
1079
|
},
|
|
1038
1080
|
|
|
1039
1081
|
async setCaps ({ pub, caps } = {}) {
|
|
@@ -1043,7 +1085,11 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1043
1085
|
|
|
1044
1086
|
async removeMember ({ pub } = {}) {
|
|
1045
1087
|
const acta = await sealChanges([{ op: 'remove', pub }])
|
|
1046
|
-
|
|
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 }
|
|
1047
1093
|
},
|
|
1048
1094
|
|
|
1049
1095
|
/**
|
|
@@ -1084,6 +1130,38 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1084
1130
|
return { ok: true, seq: acta.seq }
|
|
1085
1131
|
},
|
|
1086
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
|
+
* Cifra algo con la clave de contenido del perfil. Devuelve el sobre `{gen,iv,ct}`.
|
|
1141
|
+
* La llave privada de cifrado NUNCA sale de aquí: se cifra y descifra dentro.
|
|
1142
|
+
*/
|
|
1143
|
+
async sealContent ({ plaintext } = {}) {
|
|
1144
|
+
const mine = await myCek()
|
|
1145
|
+
if (!mine) throw new Error('este dispositivo todavía no tiene la clave de contenido del perfil')
|
|
1146
|
+
return Content.encryptWithCek({ cek: mine.cek, gen: mine.gen, plaintext: String(plaintext) })
|
|
1147
|
+
},
|
|
1148
|
+
|
|
1149
|
+
/** Abre un sobre de contenido con el llavero del perfil (todas las generaciones). */
|
|
1150
|
+
async openContent ({ envelope } = {}) {
|
|
1151
|
+
return Content.decryptWithKeyring({
|
|
1152
|
+
envelope, keyring: loadActa()?.keyring, myPub: publickeyJwkStr, myEncPrivateKey: encKeypair.privateKey
|
|
1153
|
+
})
|
|
1154
|
+
},
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* Rota la clave de contenido: generación nueva envuelta solo a los miembros de ahora.
|
|
1158
|
+
* Corta el acceso al contenido FUTURO de quien ya no está; lo que ya leyó, ya lo leyó.
|
|
1159
|
+
*/
|
|
1160
|
+
async rotateContentKey () {
|
|
1161
|
+
const r = await rotateCek()
|
|
1162
|
+
return { ok: true, ...r }
|
|
1163
|
+
},
|
|
1164
|
+
|
|
1087
1165
|
/** Adopta un acta que llega de otro miembro (gana el seq mayor; a igual seq, el traspaso). */
|
|
1088
1166
|
async adoptActa ({ acta } = {}) { return adoptActa(acta) },
|
|
1089
1167
|
|
|
@@ -1105,7 +1183,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1105
1183
|
const continuity = (mio && mio.members.length === 1)
|
|
1106
1184
|
? await Acta.makeContinuity({ member: publickeyJwkStr, from: mio.profileId, privateKey: keypair.privateKey })
|
|
1107
1185
|
: null
|
|
1108
|
-
const res = await remoteEnroll({ qr, device, continuity, onChallenge: (c) => emitVault({ phase: 'challenge', deviceId: c.deviceId, code: c.code }) })
|
|
1186
|
+
const res = await remoteEnroll({ qr, device, continuity, encPub: encPublickeyJwkStr, onChallenge: (c) => emitVault({ phase: 'challenge', deviceId: c.deviceId, code: c.code }) })
|
|
1109
1187
|
kv.setItem(VAULT_DEVICE_STORAGE, JSON.stringify({ useIdentityKey: true, publickey: publickeyJwkStr }))
|
|
1110
1188
|
kv.setItem(VAULT_CERT_STORAGE, JSON.stringify({ cert: res.cert, master: res.master, proxy: res.proxy, deviceId: res.deviceId, pairedAt: Date.now() }))
|
|
1111
1189
|
// Conectarse a una bóveda es ENTRAR A SU PERFIL: el acta viene con el cert.
|
|
@@ -1142,12 +1220,31 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1142
1220
|
|
|
1143
1221
|
// Store DELEGADO: lee/escribe el store de hilos+aperturas EN tu vault (con el cert).
|
|
1144
1222
|
// Reusa el MISMO emparejamiento (no hay un pairing aparte para el store).
|
|
1223
|
+
/**
|
|
1224
|
+
* Store DELEGADO, CIFRADO de punta a punta. Los argumentos y el resultado viajan
|
|
1225
|
+
* cifrados con la clave de contenido del perfil: el proxy transporta pero no ve nada
|
|
1226
|
+
* de lo que guardas. Si todavía no tengo la clave (nadie me la ha envuelto), va en
|
|
1227
|
+
* claro como antes — y se dice en el resultado en vez de fallar en silencio.
|
|
1228
|
+
*/
|
|
1145
1229
|
async vaultStore ({ method, args }) {
|
|
1146
1230
|
const v = loadVaultCert(); const device = loadVaultDevice()
|
|
1147
1231
|
if (!v?.cert || !device) throw new Error('este dispositivo no está emparejado con un vault')
|
|
1148
1232
|
maybeRenewVaultCert()
|
|
1149
|
-
|
|
1150
|
-
|
|
1233
|
+
const mine = await myCek().catch(() => null)
|
|
1234
|
+
let payload = { method, args }
|
|
1235
|
+
if (mine) {
|
|
1236
|
+
payload = { method, enc: await Content.encryptWithCek({ cek: mine.cek, gen: mine.gen, plaintext: JSON.stringify(args ?? {}) }) }
|
|
1237
|
+
}
|
|
1238
|
+
try {
|
|
1239
|
+
const res = await remoteStore({ master: v.master, proxy: v.proxy, device, cert: v.cert, method: payload.method, args: payload.args, enc: payload.enc, onRevoked: wipeVaultLink })
|
|
1240
|
+
// La respuesta vuelve cifrada con la misma clave si la bóveda pudo.
|
|
1241
|
+
if (res && typeof res === 'object' && res.__enc && mine) {
|
|
1242
|
+
return JSON.parse(await Content.decryptWithKeyring({
|
|
1243
|
+
envelope: res.__enc, keyring: loadActa()?.keyring, myPub: publickeyJwkStr, myEncPrivateKey: encKeypair.privateKey
|
|
1244
|
+
}))
|
|
1245
|
+
}
|
|
1246
|
+
return res
|
|
1247
|
+
} catch (e) { return handleVaultError(e) }
|
|
1151
1248
|
},
|
|
1152
1249
|
|
|
1153
1250
|
// Lista (solo lectura) de dispositivos enrolados en tu vault.
|
|
@@ -1165,9 +1262,17 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1165
1262
|
|
|
1166
1263
|
// El cert de delegación de este dispositivo (para presentarlo al proxy en `identify`
|
|
1167
1264
|
// → "una identidad": el proxy bindea tu pubkey también bajo tu maestra M). Sin secretos.
|
|
1265
|
+
/**
|
|
1266
|
+
* Lo que este dispositivo presenta al identificarse ante el proxy: su cert de
|
|
1267
|
+
* delegación y su ACTA de perfil. Con el cert, el proxy enruta lo dirigido a la
|
|
1268
|
+
* maestra; con el acta, lo dirigido a la PERSONA (cualquiera de sus dispositivos).
|
|
1269
|
+
* Sin secretos: las dos cosas son públicas y auto-verificables.
|
|
1270
|
+
*/
|
|
1168
1271
|
async getVaultCert () {
|
|
1169
1272
|
const v = loadVaultCert()
|
|
1170
|
-
|
|
1273
|
+
const acta = loadActa()
|
|
1274
|
+
if (!v?.cert) return acta ? { cert: null, master: null, acta } : null
|
|
1275
|
+
return { cert: v.cert, master: v.master, acta }
|
|
1171
1276
|
},
|
|
1172
1277
|
|
|
1173
1278
|
async listContacts () {
|
package/vault/remote.js
CHANGED
|
@@ -41,11 +41,12 @@ export async function isAuthenticRevoke ({ body, signature, master, devicePubkey
|
|
|
41
41
|
* direccionable, es lo que hace que el proxy le entregue lo que tenía ENCOLADO (24 h) —
|
|
42
42
|
* entre otras cosas, un `vault.revoked` emitido mientras estaba apagado.
|
|
43
43
|
*/
|
|
44
|
-
async function identifyAsDevice (client, device) {
|
|
44
|
+
async function identifyAsDevice (client, device, { cert = null, acta = null } = {}) {
|
|
45
45
|
if (!client.token) return
|
|
46
46
|
const data = { op: 'identify', publickey: device.publickey, token: client.token, ts: Date.now() }
|
|
47
47
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, privateKey: device.privateKey, publickey: device.publickey, data })
|
|
48
|
-
|
|
48
|
+
// cert → el proxy enruta lo dirigido a la maestra; acta → lo dirigido a la PERSONA.
|
|
49
|
+
await client.identify({ data, signature, cert, acta })
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
/**
|
|
@@ -56,7 +57,7 @@ async function identifyAsDevice (client, device) {
|
|
|
56
57
|
* @param {number} [opts.approveTimeoutMs] Espera de la aprobación humana (def 3 min).
|
|
57
58
|
* @returns {Promise<{device, cert, master:string, proxy:string, deviceId:string}>}
|
|
58
59
|
*/
|
|
59
|
-
export async function enrollDevice ({ qr, device, onChallenge, label = '', continuity = null, approveTimeoutMs = 180000 } = {}) {
|
|
60
|
+
export async function enrollDevice ({ qr, device, onChallenge, label = '', continuity = null, encPub = null, approveTimeoutMs = 180000 } = {}) {
|
|
60
61
|
if (!qr?.iss || !qr?.proxy || !qr?.token || !qr?.sn) throw new Error('qr inválido (v2): faltan iss/proxy/token/sn')
|
|
61
62
|
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
62
63
|
const client = new WebSocketProxyClient({ url: qr.proxy, enableWebRTC: false, autoReconnect: false })
|
|
@@ -77,7 +78,9 @@ export async function enrollDevice ({ qr, device, onChallenge, label = '', conti
|
|
|
77
78
|
const commit = await commitCode({ code, dpub: dev.publickey, sn: qr.sn })
|
|
78
79
|
// `continuity`: si esta identidad ya existía, va firmada por ella misma para que lo
|
|
79
80
|
// que hizo antes se pueda seguir atribuyendo a la misma persona (ver acta.js).
|
|
80
|
-
|
|
81
|
+
// `encPub`: la llave de CIFRADO de este dispositivo. Sin ella la bóveda no puede
|
|
82
|
+
// envolverle la clave de contenido del perfil, y entraría sin poder leer nada.
|
|
83
|
+
const data = { op: 'enroll', dpub: dev.publickey, token: qr.token, sn: qr.sn, commit, label, ts: Date.now(), ...(continuity ? { continuity } : {}), ...(encPub ? { encPub } : {}) }
|
|
81
84
|
const { signature } = await signWithDevice({ privateJwk: dev.privateJwk, privateKey: dev.privateKey, publickey: dev.publickey, data })
|
|
82
85
|
|
|
83
86
|
const enrolled = new Promise((resolve, reject) => {
|
|
@@ -125,13 +128,13 @@ export async function requestSign ({ master, proxy, device, cert, payload, onRev
|
|
|
125
128
|
* dispositivo estaba apagado la bóveda emitió un `vault.revoked` firmado, llega aquí y se
|
|
126
129
|
* ejecuta el autoborrado (`onRevoked`) tras verificar la firma contra la maestra pineada.
|
|
127
130
|
*/
|
|
128
|
-
async function vaultRpc ({ master, proxy, device, cert, sendType, okType, data, onRevoked, timeoutMs = 15000 }) {
|
|
131
|
+
async function vaultRpc ({ master, proxy, device, cert, acta = null, sendType, okType, data, onRevoked, timeoutMs = 15000 }) {
|
|
129
132
|
if (!master || !proxy || !(device?.privateJwk || device?.privateKey) || !cert) throw new Error('faltan datos de emparejamiento')
|
|
130
133
|
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
131
134
|
const client = new WebSocketProxyClient({ url: proxy, enableWebRTC: false, autoReconnect: false })
|
|
132
135
|
await client.connect()
|
|
133
136
|
try {
|
|
134
|
-
try { await identifyAsDevice(client, device) } catch (_) { /* sin identify seguimos: solo perdemos la cola */ }
|
|
137
|
+
try { await identifyAsDevice(client, device, { cert, acta }) } catch (_) { /* sin identify seguimos: solo perdemos la cola */ }
|
|
135
138
|
const signed = { ...data, publickey: device.publickey, ts: Date.now() }
|
|
136
139
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, privateKey: device.privateKey, publickey: device.publickey, data: signed })
|
|
137
140
|
const pending = new Promise((resolve, reject) => {
|
|
@@ -155,8 +158,10 @@ async function vaultRpc ({ master, proxy, device, cert, sendType, okType, data,
|
|
|
155
158
|
}
|
|
156
159
|
|
|
157
160
|
/** Lee/escribe el store de hilos+aperturas EN el vault (con el cert del dispositivo). */
|
|
158
|
-
export async function requestStore ({ master, proxy, device, cert, method, args, onRevoked } = {}) {
|
|
159
|
-
|
|
161
|
+
export async function requestStore ({ master, proxy, device, cert, method, args, enc, onRevoked } = {}) {
|
|
162
|
+
// `enc`: argumentos cifrados con la clave de contenido del perfil (el proxy no los ve).
|
|
163
|
+
const data = enc ? { op: 'store', method, enc } : { op: 'store', method, args: args || {} }
|
|
164
|
+
const res = await vaultRpc({ master, proxy, device, cert, onRevoked, sendType: 'vault.store', okType: 'vault.store.result', data })
|
|
160
165
|
return res.result
|
|
161
166
|
}
|
|
162
167
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Copia vendorizada de @dotrino/vault@0.
|
|
1
|
+
Copia vendorizada de @dotrino/vault@0.7.0 (lib/src/{index,enroll}.js, sin dependencias).
|
|
2
2
|
El iframe de identity se sirve estatico (vanilla, sin build); asi startDeviceVault
|
|
3
3
|
resuelve en el navegador sin bundler. index.js importa ./enroll.js (relativo, se
|
|
4
4
|
vendoriza tambien) y @dotrino/identity/capabilities (=../../capabilities.js) y
|
|
@@ -158,6 +158,9 @@ export function createEnrollDesk ({
|
|
|
158
158
|
pend.dpub = d.dpub
|
|
159
159
|
pend.deviceId = deviceId
|
|
160
160
|
pend.commit = d.commit
|
|
161
|
+
// Llave de CIFRADO del dispositivo: con ella se le envuelve la clave de contenido del
|
|
162
|
+
// perfil al admitirlo. Sin ella entra, pero no podrá leer lo que haya guardado.
|
|
163
|
+
if (typeof d.encPub === 'string') pend.encPub = d.encPub
|
|
161
164
|
// Certificado de continuidad (opcional): lo firma la identidad que se une, con su
|
|
162
165
|
// propia llave. Se comprueba aquí y se guarda con el miembro al aprobar.
|
|
163
166
|
if (d.continuity) {
|
|
@@ -213,7 +216,7 @@ export function createEnrollDesk ({
|
|
|
213
216
|
try {
|
|
214
217
|
if (typeof identity.admitMember === 'function') {
|
|
215
218
|
const caps = scopeToCaps(pend.scope)
|
|
216
|
-
if (caps.length) await identity.admitMember({ pub: pend.dpub, label: pend.label || '', caps, cert, continuity: pend.continuity || null })
|
|
219
|
+
if (caps.length) await identity.admitMember({ pub: pend.dpub, encPub: pend.encPub || null, label: pend.label || '', caps, cert, continuity: pend.continuity || null })
|
|
217
220
|
}
|
|
218
221
|
acta = (await identity.profileActa?.())?.acta || null
|
|
219
222
|
} catch (e) { log('[vault] no se pudo admitir en el acta:', e.message) }
|