@dotrino/identity 0.33.0 → 0.35.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 +2 -2
- package/src/index.js +25 -4
- package/src/node.js +6 -2
- package/vault/core.js +172 -18
- package/vault/remote.js +41 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/identity",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.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",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"url": "git+https://github.com/imdotrino/dotrino-identity.git"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@dotrino/proxy-client": "0.
|
|
58
|
+
"@dotrino/proxy-client": "0.8.0"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"fake-indexeddb": "^6.2.5"
|
package/src/index.js
CHANGED
|
@@ -299,6 +299,15 @@ 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
|
+
/**
|
|
303
|
+
* Une ESTE perfil a la cuenta de otro (la de una bóveda). No es adoptar una versión nueva
|
|
304
|
+
* de la tuya: esta llave pasa a ser de OTRA cuenta y deja de tener la suya, así que solo
|
|
305
|
+
* procede sobre un perfil creado con `{ forVault: true }`. Si no, devuelve
|
|
306
|
+
* `{ joined: false, reason: 'perfil-con-datos' }` y no escribe nada.
|
|
307
|
+
*/
|
|
308
|
+
async joinProfile (acta) { return this._call('joinProfile', { acta }) }
|
|
309
|
+
/** Marca este perfil como nacido para ADOPTAR la cuenta de otro (camino A). */
|
|
310
|
+
async prepareForAdoption () { return this._call('prepareForAdoption') }
|
|
302
311
|
/** MI tarjeta de perfil: lo mínimo que un contacto necesita para cifrarme a todos mis
|
|
303
312
|
* dispositivos (perfil, versión y llaves). Sin etiquetas ni permisos. */
|
|
304
313
|
async profileCard () { return this._call('profileCard') }
|
|
@@ -321,10 +330,16 @@ export class Identity {
|
|
|
321
330
|
* nunca sale), hace el emparejamiento endurecido por el proxy y guarda el cert.
|
|
322
331
|
* Emite un evento 'vault' { phase:'challenge', deviceId, sas } para que muestres el
|
|
323
332
|
* código a comparar; resuelve cuando el dueño aprueba en su PC (espera hasta 3 min).
|
|
333
|
+
*
|
|
334
|
+
* `join` dice **de qué cuenta se está hablando** (`vinculacion-de-cuentas.md` §3):
|
|
335
|
+
* · `'new'` → crea aquí una cuenta MÁS, con llave nueva, y es esa la que entra en la
|
|
336
|
+
* bóveda. La que estabas usando no se toca. **Es lo normal.**
|
|
337
|
+
* · `'current'` → sigue con la cuenta abierta; solo vale si nació para adoptar o si ya
|
|
338
|
+
* está emparejada con esa misma bóveda. Si no, falla antes de tocar la red.
|
|
324
339
|
* @returns {Promise<{ ok:boolean, deviceId:string, master:string, exp:number, scope:string[] }>}
|
|
325
340
|
*/
|
|
326
|
-
async enrollDevice (qr, { label = '' } = {}) {
|
|
327
|
-
return this._call('vaultPair', { qr, label }, 200000)
|
|
341
|
+
async enrollDevice (qr, { label = '', join = 'current' } = {}) {
|
|
342
|
+
return this._call('vaultPair', { qr, label, join }, 200000)
|
|
328
343
|
}
|
|
329
344
|
|
|
330
345
|
/** Estado de emparejamiento: { paired, deviceId?, master?, scope?, exp?, pairedAt? }. */
|
|
@@ -410,8 +425,14 @@ export class Identity {
|
|
|
410
425
|
async listProfiles () { return this._call('listProfiles') }
|
|
411
426
|
/** El perfil activo: { id, name, pubkey }. */
|
|
412
427
|
async currentProfile () { return this._call('currentProfile') }
|
|
413
|
-
/**
|
|
414
|
-
|
|
428
|
+
/**
|
|
429
|
+
* Crea un perfil nuevo (identidad fresca) y lo deja activo. La app debe recargar.
|
|
430
|
+
*
|
|
431
|
+
* `forVault: true` lo marca como **nacido para adoptar** la cuenta de una bóveda (camino B
|
|
432
|
+
* de `vinculacion-de-cuentas.md`): es el único permiso que acepta `joinProfile`, y se
|
|
433
|
+
* consume al unirse. Sin esa marca, la cuenta es de este dispositivo y nadie se la lleva.
|
|
434
|
+
*/
|
|
435
|
+
async createProfile (name, { forVault = false } = {}) { return this._call('createProfile', { name, forVault }) }
|
|
415
436
|
/** Cambia el perfil activo. La app debe recargar la página. */
|
|
416
437
|
async switchProfile (id) { return this._call('switchProfile', { id }) }
|
|
417
438
|
/** Renombra un perfil (o el activo si no se pasa id). */
|
package/src/node.js
CHANGED
|
@@ -173,6 +173,10 @@ export class Identity {
|
|
|
173
173
|
renounceCaps (caps) { return this._h('renounceCaps', { caps }) }
|
|
174
174
|
absorbRenounce (record) { return this._h('absorbRenounce', { record }) }
|
|
175
175
|
adoptActa (acta) { return this._h('adoptActa', { acta }) }
|
|
176
|
+
/** Une ESTE perfil a la cuenta de otro (solo si nació para eso: `createProfile(n, { forVault: true })`). */
|
|
177
|
+
joinProfile (acta) { return this._h('joinProfile', { acta }) }
|
|
178
|
+
/** Marca este perfil como nacido para ADOPTAR la cuenta de otro (camino A: lo usa la bóveda). */
|
|
179
|
+
prepareForAdoption () { return this._h('prepareForAdoption') }
|
|
176
180
|
/** MI tarjeta de perfil: lo mínimo que un contacto necesita para cifrarme a todos mis
|
|
177
181
|
* dispositivos (perfil, versión y llaves). Sin etiquetas ni permisos. */
|
|
178
182
|
profileCard () { return this._h('profileCard') }
|
|
@@ -187,7 +191,7 @@ export class Identity {
|
|
|
187
191
|
/** Rota la clave de contenido (corta el acceso al contenido FUTURO de quien ya no está). */
|
|
188
192
|
rotateContentKey () { return this._h('rotateContentKey') }
|
|
189
193
|
// Emparejar ESTE dispositivo con el vault del usuario (Fase 1)
|
|
190
|
-
enrollDevice (qr, { label = '' } = {}) { return this._h('vaultPair', { qr, label }) }
|
|
194
|
+
enrollDevice (qr, { label = '', join = 'current' } = {}) { return this._h('vaultPair', { qr, label, join }) }
|
|
191
195
|
vaultStatus () { return this._h('vaultStatus') }
|
|
192
196
|
unpairDevice () { return this._h('vaultUnpair') }
|
|
193
197
|
vaultSign (payload) { return this._h('vaultSign', { payload }) }
|
|
@@ -198,7 +202,7 @@ export class Identity {
|
|
|
198
202
|
// Multi-perfil por dispositivo (crear/cambiar reinicializa con el nuevo perfil activo).
|
|
199
203
|
listProfiles () { return this._h('listProfiles') }
|
|
200
204
|
currentProfile () { return this._h('currentProfile') }
|
|
201
|
-
createProfile (name) { return this._h('createProfile', { name }) }
|
|
205
|
+
createProfile (name, { forVault = false } = {}) { return this._h('createProfile', { name, forVault }) }
|
|
202
206
|
switchProfile (id) { return this._h('switchProfile', { id }) }
|
|
203
207
|
renameProfile (id, name) { return this._h('renameProfile', { id, name }) }
|
|
204
208
|
deleteProfile (id) { return this._h('deleteProfile', { id }) }
|
package/vault/core.js
CHANGED
|
@@ -34,6 +34,7 @@ export const VAULT_DEVICE_STORAGE = 'dotrino.identity.vault.device' // sub-clave
|
|
|
34
34
|
export const VAULT_CERT_STORAGE = 'dotrino.identity.vault.cert' // { cert, master, proxy, deviceId, pairedAt }
|
|
35
35
|
export const ACTA_STORAGE = 'dotrino.identity.acta' // acta de perfil vigente (quién es del perfil y qué puede)
|
|
36
36
|
export const ACTA_HISTORY_STORAGE = 'dotrino.identity.acta.history' // últimas actas selladas (§1.3)
|
|
37
|
+
export const PENDING_JOIN_STORAGE = 'dotrino.identity.pendingJoin' // «nací para adoptar la cuenta de otro»
|
|
37
38
|
export const RENOUNCE_STORAGE = 'dotrino.identity.renounced' // renuncias propias aún no absorbidas por el master
|
|
38
39
|
// Multi-perfil por dispositivo: lista de perfiles + el activo. Cada perfil tiene su propio
|
|
39
40
|
// namespace `dotrino.identity.p.<id>.<suffix>` para TODAS las claves de arriba (keypair, me, etc.).
|
|
@@ -178,6 +179,26 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
178
179
|
const loadProfiles = () => { try { return JSON.parse(rawKv.getItem(PROFILES_STORAGE) || '[]') || [] } catch { return [] } }
|
|
179
180
|
const saveProfiles = (list) => rawKv.setItem(PROFILES_STORAGE, JSON.stringify(list))
|
|
180
181
|
|
|
182
|
+
// ----- la MARCA de «este perfil nació para adoptar la cuenta de una bóveda» -----
|
|
183
|
+
// Unirse a otra cuenta borra la que este perfil tenía, así que no puede pasar por
|
|
184
|
+
// accidente ni deducirse de heurísticas tipo «parece vacío»: se pide a propósito con
|
|
185
|
+
// `createProfile({ forVault: true })` y esa marca es el único permiso que vale.
|
|
186
|
+
// Además identity NO PUEDE ver el contenido del store (vive en otro origen), así que
|
|
187
|
+
// «vacío» no es algo que pueda comprobar por su cuenta.
|
|
188
|
+
// Ver `dotrino-vault/docs/vinculacion-de-cuentas.md` §5.1.
|
|
189
|
+
// La marca vive en DOS sitios porque hay dos formas de tener perfiles: en el
|
|
190
|
+
// navegador son entradas de una lista dentro del mismo almacén (`pendingJoin` en la
|
|
191
|
+
// entrada), y en la bóveda es UN directorio por perfil, donde esa lista está vacía y
|
|
192
|
+
// no hay ninguna entrada que marcar. El kv es lo único que existe siempre y ya está
|
|
193
|
+
// acotado al perfil abierto, así que ahí va la marca de la bóveda.
|
|
194
|
+
const isPendingJoin = (pid = currentPid) =>
|
|
195
|
+
kv.getItem(PENDING_JOIN_STORAGE) === '1' || !!loadProfiles().find((p) => p.id === pid)?.pendingJoin
|
|
196
|
+
const clearPendingJoin = (pid = currentPid) => {
|
|
197
|
+
try { kv.removeItem(PENDING_JOIN_STORAGE) } catch (_) {}
|
|
198
|
+
const list = loadProfiles(); const e = list.find((p) => p.id === pid)
|
|
199
|
+
if (e?.pendingJoin) { delete e.pendingJoin; saveProfiles(list) }
|
|
200
|
+
}
|
|
201
|
+
|
|
181
202
|
// ----- keypair loaders -----
|
|
182
203
|
// Con `keyStore` (IndexedDB del navegador): la privada vive como CryptoKey
|
|
183
204
|
// NO EXTRACTABLE — puede FIRMAR/DERIVAR pero nadie (ni este código, ni un XSS)
|
|
@@ -440,13 +461,18 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
440
461
|
}
|
|
441
462
|
|
|
442
463
|
/**
|
|
443
|
-
* UNIRSE a
|
|
444
|
-
*
|
|
445
|
-
*
|
|
464
|
+
* UNIRSE a la cuenta de la bóveda con la que te acabas de emparejar (camino B de
|
|
465
|
+
* `dotrino-vault/docs/vinculacion-de-cuentas.md`). NO es adoptar una versión nueva de TU
|
|
466
|
+
* acta: es que ESTA llave pase a ser de OTRA cuenta, y por lo tanto **deja de tener la
|
|
467
|
+
* suya**.
|
|
468
|
+
*
|
|
469
|
+
* Por eso solo procede sobre un perfil que **nació para esto** (`createProfile({ forVault:
|
|
470
|
+
* true })`). Sin la marca no se une: se devuelve el conflicto para que la consola ofrezca
|
|
471
|
+
* crear una cuenta nueva, y **no se escribe nada**.
|
|
446
472
|
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
473
|
+
* Antes bastaba con ser el único miembro del acta propia, y entonces se sobrescribía el
|
|
474
|
+
* acta sin preguntar: una cuenta con su contenido pasaba a colgar de otra en silencio —
|
|
475
|
+
* justo la fusión de cuentas que el modelo prohíbe (§4). Eso se acabó.
|
|
450
476
|
*/
|
|
451
477
|
async function joinProfile (candidate) {
|
|
452
478
|
const v = await Acta.verifyActa({ acta: candidate })
|
|
@@ -455,10 +481,29 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
455
481
|
return { joined: false, reason: 'no-soy-miembro' }
|
|
456
482
|
}
|
|
457
483
|
const current = loadActa()
|
|
458
|
-
|
|
459
|
-
|
|
484
|
+
|
|
485
|
+
// Misma cuenta: no es unirse, es ponerse al día. Va por las reglas de adopción (§2.4.1).
|
|
486
|
+
if (current && current.profileId === candidate.profileId) {
|
|
487
|
+
const r = await adoptActa(candidate)
|
|
488
|
+
return { joined: r.adopted, reason: r.reason, profileId: candidate.profileId, seq: r.seq }
|
|
460
489
|
}
|
|
490
|
+
|
|
491
|
+
if (current && !isPendingJoin()) {
|
|
492
|
+
return {
|
|
493
|
+
joined: false,
|
|
494
|
+
reason: 'perfil-con-datos',
|
|
495
|
+
profileId: current.profileId,
|
|
496
|
+
seq: current.seq,
|
|
497
|
+
members: current.members.length
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
461
501
|
saveActa(candidate)
|
|
502
|
+
// El historial era de la cuenta anterior y no encadena con esta. La que se abandona es
|
|
503
|
+
// siempre una génesis recién nacida (lo garantiza la marca), así que no hay nada que
|
|
504
|
+
// retener; guardarla aquí solo mezclaría dos cuentas en la misma ventana (§1.3).
|
|
505
|
+
kv.setItem(ACTA_HISTORY_STORAGE, '[]')
|
|
506
|
+
clearPendingJoin()
|
|
462
507
|
emitVault({ phase: 'acta', seq: candidate.seq, sealer: candidate.sealer, joined: true })
|
|
463
508
|
return { joined: true, profileId: candidate.profileId, seq: candidate.seq }
|
|
464
509
|
}
|
|
@@ -1018,14 +1063,23 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1018
1063
|
const m = raw ? JSON.parse(raw) : null
|
|
1019
1064
|
if (m && typeof m.avatar === 'string') avatar = m.avatar
|
|
1020
1065
|
} catch (_) {}
|
|
1021
|
-
|
|
1066
|
+
// `pendingJoin`: nació para adoptar la cuenta de una bóveda y todavía no se unió.
|
|
1067
|
+
// La consola lo usa para no ofrecerlo como una cuenta normal a medio hacer.
|
|
1068
|
+
return { id: p.id, name: p.name || '', pubkey: p.pubkey || null, avatar, current: p.id === currentPid, pendingJoin: !!p.pendingJoin }
|
|
1022
1069
|
})
|
|
1023
1070
|
},
|
|
1024
1071
|
async currentProfile () {
|
|
1025
1072
|
const e = loadProfiles().find((p) => p.id === currentPid) || {}
|
|
1026
1073
|
return { id: currentPid, name: e.name || me?.nickname || '', pubkey: publickeyJwkStr }
|
|
1027
1074
|
},
|
|
1028
|
-
|
|
1075
|
+
/**
|
|
1076
|
+
* Crea una cuenta más en este dispositivo, con su propia llave.
|
|
1077
|
+
*
|
|
1078
|
+
* `forVault: true` la marca como **nacida para adoptar** la cuenta de una bóveda
|
|
1079
|
+
* (camino B): es el único permiso que acepta `joinProfile`, y se consume al unirse.
|
|
1080
|
+
* Sin la marca, la cuenta es de este dispositivo y nadie se la puede llevar.
|
|
1081
|
+
*/
|
|
1082
|
+
async createProfile ({ name, forVault = false } = {}) {
|
|
1029
1083
|
const pid = 'p' + crypto.randomUUID().slice(0, 8)
|
|
1030
1084
|
currentPid = pid
|
|
1031
1085
|
rawKv.setItem(CURRENT_STORAGE, pid)
|
|
@@ -1035,9 +1089,11 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1035
1089
|
encKeypair = await loadOrCreateEncKeypair(); encPublickeyJwkStr = JSON.stringify(encKeypair.publicJwk)
|
|
1036
1090
|
me = { publickey: publickeyJwkStr, encryptionPubkey: encPublickeyJwkStr, nickname: String(name || '').slice(0, 40) }
|
|
1037
1091
|
saveMe(me)
|
|
1038
|
-
const list = loadProfiles()
|
|
1092
|
+
const list = loadProfiles()
|
|
1093
|
+
list.push({ id: pid, name: me.nickname, pubkey: publickeyJwkStr, ...(forVault ? { pendingJoin: true } : {}) })
|
|
1094
|
+
saveProfiles(list)
|
|
1039
1095
|
await ensureActa(me.nickname) // el perfil nuevo nace con su acta (él mismo es el master)
|
|
1040
|
-
return { id: pid, name: me.nickname, pubkey: publickeyJwkStr }
|
|
1096
|
+
return { id: pid, name: me.nickname, pubkey: publickeyJwkStr, pendingJoin: !!forVault }
|
|
1041
1097
|
},
|
|
1042
1098
|
async switchProfile ({ id } = {}) {
|
|
1043
1099
|
if (!loadProfiles().find((p) => p.id === id)) throw new Error('perfil no existe')
|
|
@@ -1259,7 +1315,33 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1259
1315
|
// ----- emparejar ESTE dispositivo con el vault del usuario (Fase 1) -----
|
|
1260
1316
|
// Genera D aquí dentro (su privada NUNCA sale de la identidad), hace el enroll
|
|
1261
1317
|
// endurecido por el proxy y guarda el cert. NO cambia signData todavía (Fase 2).
|
|
1262
|
-
|
|
1318
|
+
/**
|
|
1319
|
+
* Empareja este dispositivo con una bóveda.
|
|
1320
|
+
*
|
|
1321
|
+
* `join` dice **de qué cuenta estamos hablando** (V7: la intención es explícita, nunca
|
|
1322
|
+
* se adivina):
|
|
1323
|
+
* · `'new'` → camino B: crea aquí una cuenta más, con llave nueva, y ES ESA la que
|
|
1324
|
+
* entra al acta de la bóveda. La que estabas usando **no se toca**.
|
|
1325
|
+
* · `'current'` → sigue con la cuenta abierta. Solo vale si nació para adoptar
|
|
1326
|
+
* (`forVault`) o si ya está emparejada con ESA misma bóveda
|
|
1327
|
+
* (re-emparejar). En cualquier otro caso falla **antes de tocar la
|
|
1328
|
+
* red**, en vez de traerse un acta ajena y pisar la tuya.
|
|
1329
|
+
* · `'adopt'` → camino A: la cuenta que YA vive en este aparato pasa a vivir en la
|
|
1330
|
+
* bóveda. Sigue siendo la misma cuenta para todo el mundo (mismo
|
|
1331
|
+
* `profileId`); lo que cambia es quién sella. Solo puede hacerlo el
|
|
1332
|
+
* master: si esta cuenta ya la manda otra bóveda, no hay nada que
|
|
1333
|
+
* regalar y falla en voz alta.
|
|
1334
|
+
*/
|
|
1335
|
+
async vaultPair ({ qr, label = '', join = 'current' }) {
|
|
1336
|
+
if (join === 'adopt') return handlers.vaultAdopt({ qr, label })
|
|
1337
|
+
if (join === 'new') {
|
|
1338
|
+
await handlers.createProfile({ name: label || me?.nickname || '', forVault: true })
|
|
1339
|
+
} else {
|
|
1340
|
+
const yaConEsta = loadVaultCert()?.master === qr?.iss
|
|
1341
|
+
if (loadActa() && !isPendingJoin() && !yaConEsta) {
|
|
1342
|
+
throw new Error('este aparato ya está usando una cuenta: para usar también la de tu bóveda, crea una cuenta nueva aquí (la que tienes abierta no se toca)')
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1263
1345
|
// Usa la PROPIA llave de identidad de este navegador como dispositivo: el cert delega
|
|
1264
1346
|
// TU identidad (P) desde la maestra M → una sola identidad (signData/identify/cert = P).
|
|
1265
1347
|
// La privada es la CryptoKey del perfil (no extractable): se pasa como `privateKey`
|
|
@@ -1267,18 +1349,90 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1267
1349
|
const device = { publickey: publickeyJwkStr, privateKey: keypair.privateKey }
|
|
1268
1350
|
// Si esta identidad ya existía por su cuenta, se lleva un certificado de continuidad
|
|
1269
1351
|
// firmado por ella misma: es el puente para que su reputación previa siga contando.
|
|
1352
|
+
// Solo si esta llave tenía vida propia. Una recién creada para adoptar (camino B) no
|
|
1353
|
+
// tiene pasado que salvar: mandarle un puente de continuidad sería puro ruido.
|
|
1270
1354
|
const mio = loadActa()
|
|
1271
|
-
const continuity = (mio && mio.members.length === 1)
|
|
1355
|
+
const continuity = (mio && mio.members.length === 1 && !isPendingJoin())
|
|
1272
1356
|
? await Acta.makeContinuity({ member: publickeyJwkStr, from: mio.profileId, privateKey: keypair.privateKey })
|
|
1273
1357
|
: null
|
|
1274
1358
|
const res = await remoteEnroll({ qr, device, continuity, encPub: encPublickeyJwkStr, label: label || me?.nickname || '', onChallenge: (c) => emitVault({ phase: 'challenge', deviceId: c.deviceId, code: c.code }) })
|
|
1275
1359
|
kv.setItem(VAULT_DEVICE_STORAGE, JSON.stringify({ useIdentityKey: true, publickey: publickeyJwkStr }))
|
|
1276
1360
|
kv.setItem(VAULT_CERT_STORAGE, JSON.stringify({ cert: res.cert, master: res.master, proxy: res.proxy, deviceId: res.deviceId, pairedAt: Date.now() }))
|
|
1277
|
-
// Conectarse a una bóveda es ENTRAR A SU
|
|
1278
|
-
const
|
|
1279
|
-
emitVault({ phase: 'paired', deviceId: res.deviceId, master: res.master, join })
|
|
1361
|
+
// Conectarse a una bóveda es ENTRAR A SU CUENTA: el acta viene con el cert.
|
|
1362
|
+
const unido = res.acta ? await joinProfile(res.acta) : { joined: false, reason: 'sin-acta' }
|
|
1363
|
+
emitVault({ phase: 'paired', deviceId: res.deviceId, master: res.master, join: unido })
|
|
1280
1364
|
pullProfileFromVault() // adoptar el perfil que ya viva en el vault (si hay)
|
|
1281
|
-
return { ok: true, deviceId: res.deviceId, master: res.master, exp: res.cert.exp, scope: res.cert.scope, join }
|
|
1365
|
+
return { ok: true, deviceId: res.deviceId, master: res.master, exp: res.cert.exp, scope: res.cert.scope, join: unido }
|
|
1366
|
+
},
|
|
1367
|
+
|
|
1368
|
+
/**
|
|
1369
|
+
* CAMINO A — «esta cuenta que tengo aquí, que la guarde mi computadora».
|
|
1370
|
+
*
|
|
1371
|
+
* La cuenta no se muda ni se copia: sigue siendo la misma (mismo `profileId`, misma
|
|
1372
|
+
* reputación, lo mismo firmado). Lo único que cambia es **quién sella el acta**. Este
|
|
1373
|
+
* aparato admite a la bóveda como miembro, le envuelve la clave de contenido para que
|
|
1374
|
+
* pueda leer lo que ya hay, y le traspasa el mando — los tres cambios en un **único
|
|
1375
|
+
* `seq`**, que es la regla que existe justamente para que no haya un momento raro en
|
|
1376
|
+
* el que la cuenta tenga dos selladores o ninguno.
|
|
1377
|
+
*
|
|
1378
|
+
* Requisito (§2 del doc): solo puede hacerlo el master. Si esta cuenta ya la manda otra
|
|
1379
|
+
* bóveda, este aparato no puede regalar lo que no tiene; el traspaso se hace desde la
|
|
1380
|
+
* que manda hoy.
|
|
1381
|
+
*/
|
|
1382
|
+
async vaultAdopt ({ qr, label = '' } = {}) {
|
|
1383
|
+
const mio = loadActa()
|
|
1384
|
+
if (!mio) throw new Error('este aparato todavía no tiene ninguna cuenta que entregar')
|
|
1385
|
+
if (!amMaster()) throw new Error('no-eres-el-master: esta cuenta ya la manda otro dispositivo o bóveda; el traspaso se hace desde ahí')
|
|
1386
|
+
|
|
1387
|
+
const device = { publickey: publickeyJwkStr, privateKey: keypair.privateKey }
|
|
1388
|
+
const res = await remoteEnroll({
|
|
1389
|
+
qr,
|
|
1390
|
+
device,
|
|
1391
|
+
intent: 'adopt',
|
|
1392
|
+
profileId: mio.profileId,
|
|
1393
|
+
encPub: encPublickeyJwkStr,
|
|
1394
|
+
label: label || me?.nickname || '',
|
|
1395
|
+
onChallenge: (c) => emitVault({ phase: 'challenge', deviceId: c.deviceId, code: c.code }),
|
|
1396
|
+
// Admitir + envolver + traspasar, en UN solo sello. Se ejecuta cuando la bóveda ya
|
|
1397
|
+
// fue aprobada por un humano (el código de 6 dígitos volvió correcto).
|
|
1398
|
+
onAdopt: async ({ pub, encPub, label: vlabel }) => {
|
|
1399
|
+
const cambios = [{ op: 'admit', member: { pub, encPub, label: vlabel || 'bóveda', caps: ['sign', 'store', 'read'] } }]
|
|
1400
|
+
// Sin la clave de contenido envuelta, la bóveda entraría mandando pero sin poder
|
|
1401
|
+
// leer nada de lo que guarda la cuenta que acaba de recibir.
|
|
1402
|
+
const mine = await myCek()
|
|
1403
|
+
if (mine && encPub) {
|
|
1404
|
+
const wrap = await Content.wrapForMember({ cek: mine.cek, memberEncPub: encPub })
|
|
1405
|
+
cambios.push({ op: 'wrap', gen: mine.gen, pub, wrap })
|
|
1406
|
+
}
|
|
1407
|
+
cambios.push({ op: 'handover', to: pub })
|
|
1408
|
+
return sealChanges(cambios)
|
|
1409
|
+
}
|
|
1410
|
+
})
|
|
1411
|
+
// La bóveda devuelve el acta ya sellada por ella (y con los certs re-emitidos): se
|
|
1412
|
+
// adopta por las reglas de siempre (§2.4.1) — encaja porque el sellador es el que
|
|
1413
|
+
// este mismo aparato nombró hace un momento.
|
|
1414
|
+
// `misma-acta` = la bóveda guardó exactamente la que este aparato acababa de sellar
|
|
1415
|
+
// y no la cambió. No hay nada que adoptar, y es justo lo que se esperaba: el
|
|
1416
|
+
// traspaso ya iba dentro de esa acta.
|
|
1417
|
+
const r = await adoptActa(res.acta)
|
|
1418
|
+
const ok = r.adopted || r.reason === 'misma-acta'
|
|
1419
|
+
emitVault({ phase: 'adopted', master: res.master, seq: res.acta?.seq, ok })
|
|
1420
|
+
if (!ok) throw new Error('la bóveda devolvió un acta que no encaja: ' + r.reason)
|
|
1421
|
+
return { ok: true, adopted: true, profileId: mio.profileId, seq: r.seq ?? res.acta?.seq, master: res.master, deviceId: res.deviceId }
|
|
1422
|
+
},
|
|
1423
|
+
|
|
1424
|
+
/**
|
|
1425
|
+
* Marca este perfil como **nacido para adoptar** la cuenta de otro (la marca que
|
|
1426
|
+
* `joinProfile` exige, §5.1). Lo usa la BÓVEDA al abrir un perfil vacío para el camino
|
|
1427
|
+
* A: sin la marca, adoptar la cuenta del aparato se leería como pisar una cuenta con
|
|
1428
|
+
* datos y se rechazaría, que es exactamente lo que tiene que pasar cuando nadie lo pidió.
|
|
1429
|
+
*/
|
|
1430
|
+
async prepareForAdoption () {
|
|
1431
|
+
kv.setItem(PENDING_JOIN_STORAGE, '1')
|
|
1432
|
+
const list = loadProfiles()
|
|
1433
|
+
const e = list.find((p) => p.id === currentPid)
|
|
1434
|
+
if (e) { e.pendingJoin = true; saveProfiles(list) }
|
|
1435
|
+
return { ok: true, pending: true }
|
|
1282
1436
|
},
|
|
1283
1437
|
|
|
1284
1438
|
async vaultStatus () {
|
package/vault/remote.js
CHANGED
|
@@ -19,9 +19,16 @@ const MSG = {
|
|
|
19
19
|
ENROLL: 'vault.enroll',
|
|
20
20
|
ENROLL_CHALLENGE: 'vault.enroll.challenge',
|
|
21
21
|
ENROLLED: 'vault.enrolled',
|
|
22
|
+
// --- camino A (la cuenta del aparato pasa a vivir en la bóveda) ---
|
|
23
|
+
// La bóveda, en vez del cert, manda QUIÉN es ella para que el aparato la meta en su
|
|
24
|
+
// acta; el aparato responde con el acta sellada y la bóveda devuelve la definitiva.
|
|
25
|
+
ENROLL_ADOPT: 'vault.enroll.adopt',
|
|
26
|
+
ACTA_SEALED: 'vault.acta.sealed',
|
|
27
|
+
ACTA_ADOPTED: 'vault.acta.adopted',
|
|
22
28
|
REVOKED: 'vault.revoked',
|
|
23
29
|
ERROR: 'vault.error'
|
|
24
30
|
}
|
|
31
|
+
export { MSG as VAULT_MSG }
|
|
25
32
|
|
|
26
33
|
/**
|
|
27
34
|
* ¿Es AUTÉNTICO este `vault.revoked`? Solo lo es si va firmado por la maestra PINEADA al
|
|
@@ -57,7 +64,7 @@ async function identifyAsDevice (client, device, { cert = null, acta = null } =
|
|
|
57
64
|
* @param {number} [opts.approveTimeoutMs] Espera de la aprobación humana (def 3 min).
|
|
58
65
|
* @returns {Promise<{device, cert, master:string, proxy:string, deviceId:string}>}
|
|
59
66
|
*/
|
|
60
|
-
export async function enrollDevice ({ qr, device, onChallenge, label = '', continuity = null, encPub = null, approveTimeoutMs = 180000 } = {}) {
|
|
67
|
+
export async function enrollDevice ({ qr, device, onChallenge, label = '', continuity = null, encPub = null, approveTimeoutMs = 180000, intent = 'join', profileId = null, onAdopt = null } = {}) {
|
|
61
68
|
if (!qr?.iss || !qr?.proxy || !qr?.token || !qr?.sn) throw new Error('qr inválido (v2): faltan iss/proxy/token/sn')
|
|
62
69
|
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
63
70
|
const client = new WebSocketProxyClient({ url: qr.proxy, enableWebRTC: false, autoReconnect: false })
|
|
@@ -80,16 +87,40 @@ export async function enrollDevice ({ qr, device, onChallenge, label = '', conti
|
|
|
80
87
|
// que hizo antes se pueda seguir atribuyendo a la misma persona (ver acta.js).
|
|
81
88
|
// `encPub`: la llave de CIFRADO de este dispositivo. Sin ella la bóveda no puede
|
|
82
89
|
// envolverle la clave de contenido del perfil, y entraría sin poder leer nada.
|
|
83
|
-
|
|
90
|
+
// `intent` (V7 de `vinculacion-de-cuentas.md`): va DENTRO de lo firmado, y la bóveda
|
|
91
|
+
// rechaza el que no coincida con el modo con el que ella abrió el emparejamiento. Así
|
|
92
|
+
// ninguno de los dos puede hacer, a mitad de camino, algo distinto de lo que el humano
|
|
93
|
+
// vio anunciado en las dos pantallas.
|
|
94
|
+
const adoptar = intent === 'adopt'
|
|
95
|
+
if (adoptar && typeof onAdopt !== 'function') throw new Error('enrollDevice(adopt): falta onAdopt')
|
|
96
|
+
const data = {
|
|
97
|
+
op: 'enroll', dpub: dev.publickey, token: qr.token, sn: qr.sn, commit, label, ts: Date.now(), intent,
|
|
98
|
+
...(adoptar && profileId ? { profileId } : {}),
|
|
99
|
+
...(continuity ? { continuity } : {}), ...(encPub ? { encPub } : {})
|
|
100
|
+
}
|
|
84
101
|
const { signature } = await signWithDevice({ privateJwk: dev.privateJwk, privateKey: dev.privateKey, publickey: dev.publickey, data })
|
|
85
102
|
|
|
86
103
|
const enrolled = new Promise((resolve, reject) => {
|
|
104
|
+
let sellando = false
|
|
87
105
|
const off = client.on('message', (_from, p) => {
|
|
88
106
|
if (!p || typeof p !== 'object') return
|
|
89
107
|
if (p.type === MSG.ENROLL_CHALLENGE) { try { onChallenge?.({ deviceId, code }) } catch (_) {} }
|
|
90
108
|
// El vault ECHA el código que tipeaste; aceptamos SOLO si coincide con el que generamos.
|
|
91
109
|
// (Un código distinto = un vault que no lo conoce → lo ignoramos y seguimos esperando.)
|
|
92
110
|
else if (p.type === MSG.ENROLLED) { if (p.code === code) { cleanup(); resolve(p) } }
|
|
111
|
+
// CAMINO A · la bóveda dice quién es (con el código de vuelta, misma defensa que el
|
|
112
|
+
// ENROLLED): este aparato la admite en SU acta, le envuelve la clave de contenido y
|
|
113
|
+
// le traspasa el mando, todo en un solo `seq`, y le manda el acta sellada.
|
|
114
|
+
else if (p.type === MSG.ENROLL_ADOPT && adoptar) {
|
|
115
|
+
if (p.code !== code || sellando) return
|
|
116
|
+
sellando = true
|
|
117
|
+
Promise.resolve(onAdopt({ pub: p.pub, encPub: p.encPub || null, label: p.label || '' }))
|
|
118
|
+
.then((acta) => { client.sendByPubkey(qr.iss, { type: MSG.ACTA_SEALED, acta, code }) })
|
|
119
|
+
.catch((e) => { cleanup(); reject(e) })
|
|
120
|
+
}
|
|
121
|
+
// La bóveda ya se vio como sellador y devuelve el acta definitiva (con los certs
|
|
122
|
+
// re-emitidos, §D9). Es la que este aparato adopta.
|
|
123
|
+
else if (p.type === MSG.ACTA_ADOPTED && adoptar) { cleanup(); resolve(p) }
|
|
93
124
|
else if (p.type === MSG.ERROR) { cleanup(); reject(new Error(p.error)) }
|
|
94
125
|
})
|
|
95
126
|
const t = setTimeout(() => { cleanup(); reject(new Error('timeout esperando la aprobación en el vault')) }, approveTimeoutMs)
|
|
@@ -98,6 +129,14 @@ export async function enrollDevice ({ qr, device, onChallenge, label = '', conti
|
|
|
98
129
|
client.sendByPubkey(qr.iss, { type: MSG.ENROLL, data, signature })
|
|
99
130
|
const res = await enrolled
|
|
100
131
|
|
|
132
|
+
// Camino A: aquí no hay cert que validar — este aparato NO delega su identidad, sigue
|
|
133
|
+
// siendo la cuenta. Lo que vuelve es el acta ya sellada por la bóveda.
|
|
134
|
+
if (adoptar) {
|
|
135
|
+
if (!res.acta) throw new Error('la bóveda no devolvió el acta adoptada')
|
|
136
|
+
if (res.acta.sealer !== qr.iss) throw new Error('el acta la sella una bóveda distinta a la que viste')
|
|
137
|
+
return { device: dev, cert: null, master: qr.iss, proxy: qr.proxy, deviceId, acta: res.acta, adopted: true }
|
|
138
|
+
}
|
|
139
|
+
|
|
101
140
|
// Validación estricta antes de guardar (cierra inyección de cert / sustitución de maestra).
|
|
102
141
|
const v = await verifyDelegation({ cert: res.cert, expectedSub: dev.publickey })
|
|
103
142
|
if (!v.ok) throw new Error('cert inválido: ' + v.reason)
|