@dotrino/identity 0.26.0 → 0.28.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 +4 -0
- package/src/node.js +20 -5
- package/vault/core.js +48 -4
- 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.28.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
|
@@ -301,6 +301,10 @@ export class Identity {
|
|
|
301
301
|
async adoptActa (acta) { return this._call('adoptActa', { acta }) }
|
|
302
302
|
/** La clave de contenido del perfil, abierta con la llave de cifrado de este dispositivo. */
|
|
303
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 }) }
|
|
304
308
|
/** Rota la clave de contenido (corta el acceso al contenido FUTURO de quien ya no está). */
|
|
305
309
|
async rotateContentKey () { return this._call('rotateContentKey') }
|
|
306
310
|
|
package/src/node.js
CHANGED
|
@@ -20,15 +20,25 @@ import path from 'node:path'
|
|
|
20
20
|
import os from 'node:os'
|
|
21
21
|
import { createIdentityCore } from '../vault/core.js'
|
|
22
22
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
23
|
+
/**
|
|
24
|
+
* kv síncrono respaldado por un archivo JSON (estilo localStorage).
|
|
25
|
+
*
|
|
26
|
+
* `atRest` (opcional) cifra el archivo entero en disco: quien lo provee decide con qué
|
|
27
|
+
* (el vault lo liga a la máquina, ver `dotrino-vault/src/atrest.js`). Si el archivo estaba
|
|
28
|
+
* en claro se lee igual y queda cifrado en la primera escritura, sin pedir nada.
|
|
29
|
+
*/
|
|
30
|
+
function fileKv (filePath, atRest = null) {
|
|
25
31
|
let data = {}
|
|
26
32
|
try {
|
|
27
|
-
if (fs.existsSync(filePath))
|
|
33
|
+
if (fs.existsSync(filePath)) {
|
|
34
|
+
const raw = fs.readFileSync(filePath, 'utf8')
|
|
35
|
+
data = JSON.parse(atRest ? atRest.decrypt(raw) : raw) || {}
|
|
36
|
+
}
|
|
28
37
|
} catch (_) { data = {} }
|
|
29
38
|
const flush = () => {
|
|
30
39
|
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
31
|
-
|
|
40
|
+
const text = JSON.stringify(data)
|
|
41
|
+
fs.writeFileSync(filePath, atRest ? atRest.encrypt(text) : text, { mode: 0o600 })
|
|
32
42
|
}
|
|
33
43
|
return {
|
|
34
44
|
getItem: (k) => (k in data ? data[k] : null),
|
|
@@ -91,6 +101,7 @@ export class Identity {
|
|
|
91
101
|
*/
|
|
92
102
|
constructor (options = {}) {
|
|
93
103
|
this._dir = options.dir || DEFAULT_DIR
|
|
104
|
+
this._atRest = options.atRest || null
|
|
94
105
|
this._core = null
|
|
95
106
|
this._listeners = new Map()
|
|
96
107
|
}
|
|
@@ -109,7 +120,7 @@ export class Identity {
|
|
|
109
120
|
async ready () {
|
|
110
121
|
if (this._core) return this
|
|
111
122
|
this._core = await createIdentityCore({
|
|
112
|
-
kv: fileKv(path.join(this._dir, 'identity.json')),
|
|
123
|
+
kv: fileKv(path.join(this._dir, 'identity.json'), this._atRest),
|
|
113
124
|
peers: filePeers(path.join(this._dir, 'peers.json')),
|
|
114
125
|
makeSync: null
|
|
115
126
|
})
|
|
@@ -164,6 +175,10 @@ export class Identity {
|
|
|
164
175
|
adoptActa (acta) { return this._h('adoptActa', { acta }) }
|
|
165
176
|
/** La clave de contenido del perfil, abierta con la llave de cifrado de este dispositivo. */
|
|
166
177
|
contentKey () { return this._h('contentKey') }
|
|
178
|
+
/** Cifra con la clave de contenido del perfil (la privada de cifrado no sale del vault). */
|
|
179
|
+
sealContent (plaintext) { return this._h('sealContent', { plaintext }) }
|
|
180
|
+
/** Abre un sobre de contenido con el llavero del perfil. */
|
|
181
|
+
openContent (envelope) { return this._h('openContent', { envelope }) }
|
|
167
182
|
/** Rota la clave de contenido (corta el acceso al contenido FUTURO de quien ya no está). */
|
|
168
183
|
rotateContentKey () { return this._h('rotateContentKey') }
|
|
169
184
|
// Emparejar ESTE dispositivo con el vault del usuario (Fase 1)
|
package/vault/core.js
CHANGED
|
@@ -1136,6 +1136,23 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1136
1136
|
*/
|
|
1137
1137
|
async contentKey () { return myCek() },
|
|
1138
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
|
+
|
|
1139
1156
|
/**
|
|
1140
1157
|
* Rota la clave de contenido: generación nueva envuelta solo a los miembros de ahora.
|
|
1141
1158
|
* Corta el acceso al contenido FUTURO de quien ya no está; lo que ya leyó, ya lo leyó.
|
|
@@ -1166,7 +1183,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1166
1183
|
const continuity = (mio && mio.members.length === 1)
|
|
1167
1184
|
? await Acta.makeContinuity({ member: publickeyJwkStr, from: mio.profileId, privateKey: keypair.privateKey })
|
|
1168
1185
|
: null
|
|
1169
|
-
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 }) })
|
|
1170
1187
|
kv.setItem(VAULT_DEVICE_STORAGE, JSON.stringify({ useIdentityKey: true, publickey: publickeyJwkStr }))
|
|
1171
1188
|
kv.setItem(VAULT_CERT_STORAGE, JSON.stringify({ cert: res.cert, master: res.master, proxy: res.proxy, deviceId: res.deviceId, pairedAt: Date.now() }))
|
|
1172
1189
|
// Conectarse a una bóveda es ENTRAR A SU PERFIL: el acta viene con el cert.
|
|
@@ -1203,12 +1220,31 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1203
1220
|
|
|
1204
1221
|
// Store DELEGADO: lee/escribe el store de hilos+aperturas EN tu vault (con el cert).
|
|
1205
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
|
+
*/
|
|
1206
1229
|
async vaultStore ({ method, args }) {
|
|
1207
1230
|
const v = loadVaultCert(); const device = loadVaultDevice()
|
|
1208
1231
|
if (!v?.cert || !device) throw new Error('este dispositivo no está emparejado con un vault')
|
|
1209
1232
|
maybeRenewVaultCert()
|
|
1210
|
-
|
|
1211
|
-
|
|
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) }
|
|
1212
1248
|
},
|
|
1213
1249
|
|
|
1214
1250
|
// Lista (solo lectura) de dispositivos enrolados en tu vault.
|
|
@@ -1226,9 +1262,17 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1226
1262
|
|
|
1227
1263
|
// El cert de delegación de este dispositivo (para presentarlo al proxy en `identify`
|
|
1228
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
|
+
*/
|
|
1229
1271
|
async getVaultCert () {
|
|
1230
1272
|
const v = loadVaultCert()
|
|
1231
|
-
|
|
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 }
|
|
1232
1276
|
},
|
|
1233
1277
|
|
|
1234
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) }
|