@dotrino/identity 0.34.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/identity",
3
- "version": "0.34.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",
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
- const isPendingJoin = (pid = currentPid) => !!loadProfiles().find((p) => p.id === pid)?.pendingJoin
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 sellador​es 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
@@ -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
- const data = { op: 'enroll', dpub: dev.publickey, token: qr.token, sn: qr.sn, commit, label, ts: Date.now(), ...(continuity ? { continuity } : {}), ...(encPub ? { encPub } : {}) }
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)