@dotrino/identity 0.34.0 → 0.36.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 +1 -1
- package/src/index.js +2 -0
- package/src/node.js +2 -0
- package/vault/core.js +85 -1
- package/vault/remote.js +80 -4
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -306,6 +306,8 @@ export class Identity {
|
|
|
306
306
|
* `{ joined: false, reason: 'perfil-con-datos' }` y no escribe nada.
|
|
307
307
|
*/
|
|
308
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') }
|
|
309
311
|
/** MI tarjeta de perfil: lo mínimo que un contacto necesita para cifrarme a todos mis
|
|
310
312
|
* dispositivos (perfil, versión y llaves). Sin etiquetas ni permisos. */
|
|
311
313
|
async profileCard () { return this._call('profileCard') }
|
package/src/node.js
CHANGED
|
@@ -175,6 +175,8 @@ export class Identity {
|
|
|
175
175
|
adoptActa (acta) { return this._h('adoptActa', { acta }) }
|
|
176
176
|
/** Une ESTE perfil a la cuenta de otro (solo si nació para eso: `createProfile(n, { forVault: true })`). */
|
|
177
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') }
|
|
178
180
|
/** MI tarjeta de perfil: lo mínimo que un contacto necesita para cifrarme a todos mis
|
|
179
181
|
* dispositivos (perfil, versión y llaves). Sin etiquetas ni permisos. */
|
|
180
182
|
profileCard () { return this._h('profileCard') }
|
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.).
|
|
@@ -185,8 +186,15 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
185
186
|
// Además identity NO PUEDE ver el contenido del store (vive en otro origen), así que
|
|
186
187
|
// «vacío» no es algo que pueda comprobar por su cuenta.
|
|
187
188
|
// Ver `dotrino-vault/docs/vinculacion-de-cuentas.md` §5.1.
|
|
188
|
-
|
|
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
|
|
189
196
|
const clearPendingJoin = (pid = currentPid) => {
|
|
197
|
+
try { kv.removeItem(PENDING_JOIN_STORAGE) } catch (_) {}
|
|
190
198
|
const list = loadProfiles(); const e = list.find((p) => p.id === pid)
|
|
191
199
|
if (e?.pendingJoin) { delete e.pendingJoin; saveProfiles(list) }
|
|
192
200
|
}
|
|
@@ -1318,8 +1326,14 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1318
1326
|
* (`forVault`) o si ya está emparejada con ESA misma bóveda
|
|
1319
1327
|
* (re-emparejar). En cualquier otro caso falla **antes de tocar la
|
|
1320
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.
|
|
1321
1334
|
*/
|
|
1322
1335
|
async vaultPair ({ qr, label = '', join = 'current' }) {
|
|
1336
|
+
if (join === 'adopt') return handlers.vaultAdopt({ qr, label })
|
|
1323
1337
|
if (join === 'new') {
|
|
1324
1338
|
await handlers.createProfile({ name: label || me?.nickname || '', forVault: true })
|
|
1325
1339
|
} else {
|
|
@@ -1351,6 +1365,76 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1351
1365
|
return { ok: true, deviceId: res.deviceId, master: res.master, exp: res.cert.exp, scope: res.cert.scope, join: unido }
|
|
1352
1366
|
},
|
|
1353
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 }
|
|
1436
|
+
},
|
|
1437
|
+
|
|
1354
1438
|
async vaultStatus () {
|
|
1355
1439
|
const v = loadVaultCert()
|
|
1356
1440
|
if (!v?.cert) return { paired: false }
|
package/vault/remote.js
CHANGED
|
@@ -16,12 +16,21 @@
|
|
|
16
16
|
import { makeDeviceKey, signWithDevice, verifyDelegation, verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from './capabilities.js'
|
|
17
17
|
|
|
18
18
|
const MSG = {
|
|
19
|
+
HELLO: 'vault.hello',
|
|
20
|
+
HELLO_OK: 'vault.hello.ok',
|
|
19
21
|
ENROLL: 'vault.enroll',
|
|
20
22
|
ENROLL_CHALLENGE: 'vault.enroll.challenge',
|
|
21
23
|
ENROLLED: 'vault.enrolled',
|
|
24
|
+
// --- camino A (la cuenta del aparato pasa a vivir en la bóveda) ---
|
|
25
|
+
// La bóveda, en vez del cert, manda QUIÉN es ella para que el aparato la meta en su
|
|
26
|
+
// acta; el aparato responde con el acta sellada y la bóveda devuelve la definitiva.
|
|
27
|
+
ENROLL_ADOPT: 'vault.enroll.adopt',
|
|
28
|
+
ACTA_SEALED: 'vault.acta.sealed',
|
|
29
|
+
ACTA_ADOPTED: 'vault.acta.adopted',
|
|
22
30
|
REVOKED: 'vault.revoked',
|
|
23
31
|
ERROR: 'vault.error'
|
|
24
32
|
}
|
|
33
|
+
export { MSG as VAULT_MSG }
|
|
25
34
|
|
|
26
35
|
/**
|
|
27
36
|
* ¿Es AUTÉNTICO este `vault.revoked`? Solo lo es si va firmado por la maestra PINEADA al
|
|
@@ -49,6 +58,36 @@ async function identifyAsDevice (client, device, { cert = null, acta = null } =
|
|
|
49
58
|
await client.identify({ data, signature, cert, acta })
|
|
50
59
|
}
|
|
51
60
|
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* La respuesta al `hello` va firmada y con el `sn` DENTRO de lo firmado. Comprobarlo
|
|
64
|
+
* ata la respuesta a ESTA sesión: no vale la de otro emparejamiento ni la de otra
|
|
65
|
+
* bóveda. Ojo con lo que NO prueba: cualquiera puede firmar con una llave suya, así
|
|
66
|
+
* que esto no dice que sea TU bóveda — eso lo dice el código de 6 dígitos, que solo
|
|
67
|
+
* aprende la bóveda donde tú lo tecleas.
|
|
68
|
+
*/
|
|
69
|
+
async function verificarHola (p, sn) {
|
|
70
|
+
const b = p?.body
|
|
71
|
+
if (!b?.iss || b.sn !== sn) throw new Error('la bóveda contestó a otro emparejamiento')
|
|
72
|
+
if (!(await verifyDeviceSig({ publickey: b.iss, data: b, signature: p.signature }))) {
|
|
73
|
+
throw new Error('la respuesta de la bóveda no está bien firmada')
|
|
74
|
+
}
|
|
75
|
+
return b
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** «¿Quién eres?» del QR corto: devuelve `{ iss, proxy, acct, m }` de la bóveda. */
|
|
79
|
+
async function askVault (client, qr) {
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
const off = client.on('message', (_from, p) => {
|
|
82
|
+
if (p?.type === MSG.HELLO_OK) { fin(); verificarHola(p, qr.sn).then((b) => resolve({ iss: b.iss, proxy: b.proxy || qr.proxy, acct: b.acct || '', m: b.m || qr.m }), reject) }
|
|
83
|
+
else if (p?.type === MSG.ERROR) { fin(); reject(new Error(p.error)) }
|
|
84
|
+
})
|
|
85
|
+
const t = setTimeout(() => { fin(); reject(new Error('la bóveda no contestó: ese código pudo caducar')) }, 15000)
|
|
86
|
+
const fin = () => { off(); clearTimeout(t) }
|
|
87
|
+
try { client.send(qr.conn, { type: MSG.HELLO, sn: qr.sn }) } catch (e) { fin(); reject(e) }
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
52
91
|
/**
|
|
53
92
|
* @param {Object} opts
|
|
54
93
|
* @param {{v:number, iss:string, proxy:string, token:string, sn:string}} opts.qr QR v2 del vault.
|
|
@@ -57,12 +96,17 @@ async function identifyAsDevice (client, device, { cert = null, acta = null } =
|
|
|
57
96
|
* @param {number} [opts.approveTimeoutMs] Espera de la aprobación humana (def 3 min).
|
|
58
97
|
* @returns {Promise<{device, cert, master:string, proxy:string, deviceId:string}>}
|
|
59
98
|
*/
|
|
60
|
-
export async function enrollDevice ({ qr, device, onChallenge, label = '', continuity = null, encPub = null, approveTimeoutMs = 180000 } = {}) {
|
|
61
|
-
if (!qr?.
|
|
99
|
+
export async function enrollDevice ({ qr, device, onChallenge, label = '', continuity = null, encPub = null, approveTimeoutMs = 180000, intent = 'join', profileId = null, onAdopt = null } = {}) {
|
|
100
|
+
if (!qr?.sn || !(qr.iss || qr.conn)) throw new Error('qr inválido: falta la bóveda o el nonce')
|
|
62
101
|
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
63
|
-
const client = new WebSocketProxyClient({ url: qr.proxy, enableWebRTC: false, autoReconnect: false })
|
|
102
|
+
const client = new WebSocketProxyClient({ url: qr.proxy || 'wss://proxy.dotrino.com', enableWebRTC: false, autoReconnect: false })
|
|
64
103
|
await client.connect()
|
|
65
104
|
try {
|
|
105
|
+
// QR CORTO: no trae la llave, solo la dirección de la bóveda en el proxy. Se le
|
|
106
|
+
// pregunta quién es, punto a punto, presentando el `sn`; solo contesta si esa sesión
|
|
107
|
+
// de emparejamiento sigue abierta. Lo que acredita la llave no es el QR: es la firma
|
|
108
|
+
// del certificado y el código de 6 dígitos, que solo aprende la bóveda donde lo tecleas.
|
|
109
|
+
if (!qr.iss) qr = { ...qr, ...(await askVault(client, qr)) }
|
|
66
110
|
// Por defecto genera una sub-clave nueva; pero el iframe pasa SU PROPIA llave de
|
|
67
111
|
// identidad (P) como `device` → el cert delega tu identidad y hay UNA sola (signData/
|
|
68
112
|
// identify/cert son la misma P).
|
|
@@ -80,16 +124,40 @@ export async function enrollDevice ({ qr, device, onChallenge, label = '', conti
|
|
|
80
124
|
// que hizo antes se pueda seguir atribuyendo a la misma persona (ver acta.js).
|
|
81
125
|
// `encPub`: la llave de CIFRADO de este dispositivo. Sin ella la bóveda no puede
|
|
82
126
|
// envolverle la clave de contenido del perfil, y entraría sin poder leer nada.
|
|
83
|
-
|
|
127
|
+
// `intent` (V7 de `vinculacion-de-cuentas.md`): va DENTRO de lo firmado, y la bóveda
|
|
128
|
+
// rechaza el que no coincida con el modo con el que ella abrió el emparejamiento. Así
|
|
129
|
+
// ninguno de los dos puede hacer, a mitad de camino, algo distinto de lo que el humano
|
|
130
|
+
// vio anunciado en las dos pantallas.
|
|
131
|
+
const adoptar = intent === 'adopt'
|
|
132
|
+
if (adoptar && typeof onAdopt !== 'function') throw new Error('enrollDevice(adopt): falta onAdopt')
|
|
133
|
+
const data = {
|
|
134
|
+
op: 'enroll', dpub: dev.publickey, token: qr.token || qr.sn, sn: qr.sn, commit, label, ts: Date.now(), intent,
|
|
135
|
+
...(adoptar && profileId ? { profileId } : {}),
|
|
136
|
+
...(continuity ? { continuity } : {}), ...(encPub ? { encPub } : {})
|
|
137
|
+
}
|
|
84
138
|
const { signature } = await signWithDevice({ privateJwk: dev.privateJwk, privateKey: dev.privateKey, publickey: dev.publickey, data })
|
|
85
139
|
|
|
86
140
|
const enrolled = new Promise((resolve, reject) => {
|
|
141
|
+
let sellando = false
|
|
87
142
|
const off = client.on('message', (_from, p) => {
|
|
88
143
|
if (!p || typeof p !== 'object') return
|
|
89
144
|
if (p.type === MSG.ENROLL_CHALLENGE) { try { onChallenge?.({ deviceId, code }) } catch (_) {} }
|
|
90
145
|
// El vault ECHA el código que tipeaste; aceptamos SOLO si coincide con el que generamos.
|
|
91
146
|
// (Un código distinto = un vault que no lo conoce → lo ignoramos y seguimos esperando.)
|
|
92
147
|
else if (p.type === MSG.ENROLLED) { if (p.code === code) { cleanup(); resolve(p) } }
|
|
148
|
+
// CAMINO A · la bóveda dice quién es (con el código de vuelta, misma defensa que el
|
|
149
|
+
// ENROLLED): este aparato la admite en SU acta, le envuelve la clave de contenido y
|
|
150
|
+
// le traspasa el mando, todo en un solo `seq`, y le manda el acta sellada.
|
|
151
|
+
else if (p.type === MSG.ENROLL_ADOPT && adoptar) {
|
|
152
|
+
if (p.code !== code || sellando) return
|
|
153
|
+
sellando = true
|
|
154
|
+
Promise.resolve(onAdopt({ pub: p.pub, encPub: p.encPub || null, label: p.label || '' }))
|
|
155
|
+
.then((acta) => { client.sendByPubkey(qr.iss, { type: MSG.ACTA_SEALED, acta, code }) })
|
|
156
|
+
.catch((e) => { cleanup(); reject(e) })
|
|
157
|
+
}
|
|
158
|
+
// La bóveda ya se vio como sellador y devuelve el acta definitiva (con los certs
|
|
159
|
+
// re-emitidos, §D9). Es la que este aparato adopta.
|
|
160
|
+
else if (p.type === MSG.ACTA_ADOPTED && adoptar) { cleanup(); resolve(p) }
|
|
93
161
|
else if (p.type === MSG.ERROR) { cleanup(); reject(new Error(p.error)) }
|
|
94
162
|
})
|
|
95
163
|
const t = setTimeout(() => { cleanup(); reject(new Error('timeout esperando la aprobación en el vault')) }, approveTimeoutMs)
|
|
@@ -98,6 +166,14 @@ export async function enrollDevice ({ qr, device, onChallenge, label = '', conti
|
|
|
98
166
|
client.sendByPubkey(qr.iss, { type: MSG.ENROLL, data, signature })
|
|
99
167
|
const res = await enrolled
|
|
100
168
|
|
|
169
|
+
// Camino A: aquí no hay cert que validar — este aparato NO delega su identidad, sigue
|
|
170
|
+
// siendo la cuenta. Lo que vuelve es el acta ya sellada por la bóveda.
|
|
171
|
+
if (adoptar) {
|
|
172
|
+
if (!res.acta) throw new Error('la bóveda no devolvió el acta adoptada')
|
|
173
|
+
if (res.acta.sealer !== qr.iss) throw new Error('el acta la sella una bóveda distinta a la que viste')
|
|
174
|
+
return { device: dev, cert: null, master: qr.iss, proxy: qr.proxy, deviceId, acta: res.acta, adopted: true }
|
|
175
|
+
}
|
|
176
|
+
|
|
101
177
|
// Validación estricta antes de guardar (cierra inyección de cert / sustitución de maestra).
|
|
102
178
|
const v = await verifyDelegation({ cert: res.cert, expectedSub: dev.publickey })
|
|
103
179
|
if (!v.ok) throw new Error('cert inválido: ' + v.reason)
|