@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/identity",
3
- "version": "0.33.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.6.3"
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
- /** Crea un perfil nuevo (identidad fresca) y lo deja activo. La app debe recargar. */
414
- async createProfile (name) { return this._call('createProfile', { name }) }
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 otro perfil (el de la bóveda a la que te acabas de conectar). No es adoptar
444
- * una versión nueva de TU acta: es cambiar de perfil, así que solo procede si aquí no
445
- * hay nada que perder es decir, si este dispositivo es el único miembro del suyo.
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
- * Si ya tienes otros dispositivos, los dos lados tienen master y hay que ELEGIR cuál
448
- * manda (§2.4.3): eso es una decisión del dueño, no un efecto colateral de escanear un
449
- * código, así que se devuelve el conflicto para que lo resuelva la consola.
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
- if (current && current.profileId !== candidate.profileId && current.members.length > 1) {
459
- return { joined: false, reason: 'ya-tienes-perfil-propio', members: current.members.length }
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
- return { id: p.id, name: p.name || '', pubkey: p.pubkey || null, avatar, current: p.id === currentPid }
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
- async createProfile ({ name } = {}) {
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(); list.push({ id: pid, name: me.nickname, pubkey: publickeyJwkStr }); saveProfiles(list)
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
- async vaultPair ({ qr, label = '' }) {
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 PERFIL: el acta viene con el cert.
1278
- const join = res.acta ? await joinProfile(res.acta) : { joined: false, reason: 'sin-acta' }
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 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 }
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
- 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)