@dotrino/vaultd 0.9.0 → 0.10.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/lib/src/enroll.js CHANGED
@@ -42,6 +42,10 @@ export const DEVICE_TTL_MS = 30 * 24 * 60 * 60 * 1000
42
42
  export const MSG_ENROLL = 'vault.enroll'
43
43
  export const MSG_ENROLL_CHALLENGE = 'vault.enroll.challenge'
44
44
  export const MSG_ENROLLED = 'vault.enrolled'
45
+ // --- camino A: la cuenta del aparato pasa a vivir en la bóveda ---
46
+ export const MSG_ENROLL_ADOPT = 'vault.enroll.adopt'
47
+ export const MSG_ACTA_SEALED = 'vault.acta.sealed'
48
+ export const MSG_ACTA_ADOPTED = 'vault.acta.adopted'
45
49
  export const MSG_REVOKED = 'vault.revoked'
46
50
  export const MSG_ERROR = 'vault.error'
47
51
 
@@ -104,8 +108,11 @@ export async function deviceIdOf (pub) {
104
108
  export function createEnrollDesk ({
105
109
  identity, iss, proxy, send, sendByPubkey,
106
110
  audit = () => {}, log = () => {},
107
- onChallenge = () => {}, onPendingChange = () => {},
108
- defaultScope = ['vault:sign'], defaultTtlMs = DEVICE_TTL_MS
111
+ onChallenge = () => {}, onPendingChange = () => {}, onAdopted = () => {},
112
+ defaultScope = ['vault:sign'], defaultTtlMs = DEVICE_TTL_MS,
113
+ // Camino A: lo que ESTA bóveda le manda al aparato para que la meta en su acta. `encPub`
114
+ // es su llave de CIFRADO — sin ella entra mandando pero sin poder leer el contenido.
115
+ encPub = null, vaultLabel = ''
109
116
  } = {}) {
110
117
  if (!identity) throw new Error('createEnrollDesk: falta identity')
111
118
  if (!iss) throw new Error('createEnrollDesk: falta iss (pubkey de la maestra)')
@@ -172,6 +179,17 @@ export function createEnrollDesk ({
172
179
  return reply(from, { type: MSG_ERROR, error: 'token de emparejamiento inválido o expirado' })
173
180
  }
174
181
  if (d.sn !== pend.sn) return reply(from, { type: MSG_ERROR, error: 'sesión inválida' })
182
+ // V7 · la INTENCIÓN viaja firmada y tiene que coincidir con el modo con el que ESTA
183
+ // bóveda abrió el emparejamiento. Es lo que garantiza que lo que pasa es lo que el
184
+ // humano vio anunciado en las dos pantallas, y no algo que se decidió a mitad de camino.
185
+ const intent = d.intent || 'join'
186
+ if (intent !== 'join' && intent !== 'adopt') {
187
+ return reply(from, { type: MSG_ERROR, error: 'intención desconocida: ' + intent })
188
+ }
189
+ if (intent !== (pend.mode || 'join')) {
190
+ audit('rejected', { what: 'enroll', reason: 'intent-mismatch' })
191
+ return reply(from, { type: MSG_ERROR, error: `este emparejamiento se abrió para «${pend.mode || 'join'}» y el dispositivo pidió «${intent}»` })
192
+ }
175
193
  if (!isFresh(d)) {
176
194
  audit('rejected', { what: 'enroll', reason: 'stale' })
177
195
  return reply(from, { type: MSG_ERROR, error: 'petición vencida: ts fuera de la ventana ±5 min (posible replay, o el reloj del dispositivo está desfasado)' })
@@ -208,9 +226,12 @@ export function createEnrollDesk ({
208
226
  }
209
227
  pend.from = from // la bóveda NO conoce el código: lo aprende cuando lo tipeas
210
228
  if (d.label) pend.label = String(d.label).slice(0, 60)
229
+ // Camino A: de qué cuenta estamos hablando. Se guarda para poder comprobar, cuando
230
+ // llegue el acta sellada, que es la que este dispositivo dijo que iba a entregar.
231
+ if (intent === 'adopt' && typeof d.profileId === 'string') pend.profileId = d.profileId
211
232
 
212
233
  reply(from, { type: MSG_ENROLL_CHALLENGE, deviceId })
213
- fire(onChallenge, { deviceId, scope: pend.scope, label: pend.label || '' })
234
+ fire(onChallenge, { deviceId, scope: pend.scope, label: pend.label || '', mode: pend.mode || 'join' })
214
235
  fire(onPendingChange)
215
236
  return { deviceId }
216
237
  }
@@ -246,6 +267,20 @@ export function createEnrollDesk ({
246
267
  throw new Error('el código no coincide con el que muestra el dispositivo: no se emitió ningún certificado. Vuelve a mirarlo y prueba otra vez.')
247
268
  }
248
269
 
270
+ // CAMINO A · aquí la bóveda no entrega un cert: entrega SU IDENTIDAD para que el
271
+ // aparato la meta en el acta de la cuenta que le está pasando. El código de vuelta es
272
+ // la misma defensa de siempre, en el otro sentido: el aparato solo hace caso a una
273
+ // bóveda que demuestre que un humano la aprobó.
274
+ if ((pend.mode || 'join') === 'adopt') {
275
+ audit('adopt-approve', { device: pend.deviceId, profile: pend.profileId || null })
276
+ pend.state = 'AWAITING_ACTA'
277
+ pend.approvedAt = Date.now()
278
+ reply(pend.from, { type: MSG_ENROLL_ADOPT, code, pub: iss, encPub: encPub || null, label: vaultLabel || '' })
279
+ log('[vault] adopción aprobada para %s: esperando el acta sellada', pend.deviceId)
280
+ fire(onPendingChange)
281
+ return { ok: true, deviceId: pend.deviceId, adopting: true }
282
+ }
283
+
249
284
  const { cert } = await identity.signDelegation(pend.dpub, pend.scope, { ttlMs: pend.ttlMs, label: pend.label })
250
285
 
251
286
  // Aprobar un emparejamiento ES admitir al dispositivo en el perfil: el cert es la
@@ -273,6 +308,60 @@ export function createEnrollDesk ({
273
308
  return { ok: true, deviceId: pend.deviceId, cert }
274
309
  }
275
310
 
311
+ /**
312
+ * CAMINO A · paso 6: llega el acta que el aparato acaba de sellar, con la bóveda dentro
313
+ * como miembro, la clave de contenido envuelta para ella y el mando ya traspasado.
314
+ *
315
+ * Lo que se comprueba antes de guardar nada (y por qué):
316
+ * · que el sellador sea ESTA bóveda — si no, no es un traspaso, es un acta ajena;
317
+ * · que la selle el aparato que estaba en este emparejamiento — cierra que un tercero
318
+ * que vea pasar el mensaje cuele la suya;
319
+ * · que sea la cuenta que ese aparato declaró al enrolarse (`profileId`) — cierra el
320
+ * cambiazo de cuenta entre el anuncio que leyó el humano y lo que llega después.
321
+ *
322
+ * Adoptar la cuenta de otro solo procede sobre un perfil que **nació para eso** (la marca
323
+ * de `prepareForAdoption`). Es la misma regla del navegador: sin la marca, adoptar sería
324
+ * pisar una cuenta con datos, y eso no puede pasar por accidente.
325
+ */
326
+ async function handleActaSealed (from, p) {
327
+ const acta = p?.acta
328
+ const pend = [...pending.values()].find((x) => x.state === 'AWAITING_ACTA' && (x.from === from || x.dpub))
329
+ if (!pend) return reply(from, { type: MSG_ERROR, error: 'no hay ninguna adopción esperando un acta' })
330
+ if (!acta || typeof acta !== 'object') return reply(from, { type: MSG_ERROR, error: 'acta ausente o ilegible' })
331
+ if (acta.sealer !== iss) {
332
+ audit('rejected', { what: 'adopt', reason: 'not-sealer' })
333
+ return reply(from, { type: MSG_ERROR, error: 'esa acta no nombra a esta bóveda como quien manda' })
334
+ }
335
+ if (acta.sealedBy !== pend.dpub) {
336
+ audit('rejected', { what: 'adopt', reason: 'sealed-by-other' })
337
+ return reply(from, { type: MSG_ERROR, error: 'esa acta no la selló el dispositivo de este emparejamiento' })
338
+ }
339
+ if (pend.profileId && acta.profileId !== pend.profileId) {
340
+ audit('rejected', { what: 'adopt', reason: 'other-profile' })
341
+ return reply(from, { type: MSG_ERROR, error: 'esa acta es de otra cuenta distinta a la que anunció el dispositivo' })
342
+ }
343
+
344
+ try {
345
+ const r = await identity.joinProfile(acta)
346
+ if (!r?.joined) throw new Error(r?.reason || 'no se pudo adoptar')
347
+ audit('adopt', { device: pend.deviceId, profile: acta.profileId, seq: acta.seq })
348
+ // El acta que vuelve es la que la bóveda tiene guardada: el aparato la adopta y los
349
+ // dos quedan en la misma versión.
350
+ const mia = (await identity.profileActa?.())?.acta || acta
351
+ reply(pend.from, { type: MSG_ACTA_ADOPTED, code: p.code, acta: mia })
352
+ pend.state = 'DONE'
353
+ pending.delete(pend.token)
354
+ fire(onPendingChange)
355
+ fire(onAdopted, { deviceId: pend.deviceId, profileId: acta.profileId, seq: mia.seq })
356
+ log('[vault] cuenta adoptada del dispositivo %s (perfil %s)', pend.deviceId, acta.profileId?.slice(0, 12))
357
+ return { ok: true, adopted: true, profileId: acta.profileId, seq: mia.seq }
358
+ } catch (e) {
359
+ log('[vault] no se pudo adoptar la cuenta: %s', e.message)
360
+ reply(pend.from, { type: MSG_ERROR, error: 'la bóveda no pudo adoptar la cuenta: ' + e.message })
361
+ return { ok: false, error: e.message }
362
+ }
363
+ }
364
+
276
365
  /** Rechaza un enrolamiento pendiente. */
277
366
  function reject (deviceId) {
278
367
  const pend = deviceId
@@ -310,7 +399,7 @@ export function createEnrollDesk ({
310
399
  }
311
400
 
312
401
  return {
313
- startPairing, stopPairing, handleEnroll, approve, reject,
402
+ startPairing, stopPairing, handleEnroll, handleActaSealed, approve, reject,
314
403
  listPending, findPending, emitRevoke, revoke,
315
404
  get pendingCount () { return pending.size }
316
405
  }
@@ -20,6 +20,13 @@ export const MSG = Object.freeze({
20
20
  ENROLL: 'vault.enroll', // dispositivo → vault: { data, signature }
21
21
  ENROLL_CHALLENGE: 'vault.enroll.challenge', // vault → dispositivo: { deviceId, sas }
22
22
  ENROLLED: 'vault.enrolled', // vault → dispositivo (tras aprobar): { cert, iss, sas }
23
+ // Camino A (la cuenta del aparato pasa a vivir en la bóveda): en vez de un cert, la
24
+ // bóveda manda QUIÉN es para que el aparato la admita, le envuelva la clave de
25
+ // contenido y le traspase el mando; el aparato devuelve el acta sellada y la bóveda
26
+ // responde con la definitiva. Ver docs/vinculacion-de-cuentas.md §2.
27
+ ENROLL_ADOPT: 'vault.enroll.adopt', // vault → dispositivo: { code, pub, encPub, label }
28
+ ACTA_SEALED: 'vault.acta.sealed', // dispositivo → vault: { acta, code }
29
+ ACTA_ADOPTED: 'vault.acta.adopted', // vault → dispositivo: { acta }
23
30
  REVOKED: 'vault.revoked', // vault → dispositivo: { body:{op,sub,nonce,iat,exp}, signature }
24
31
  SIGN: 'vault.sign', // dispositivo → vault: { data, signature, cert }
25
32
  SIGNED: 'vault.signed', // vault → dispositivo: { signature, publickey, device }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
6
6
  "bin": {
@@ -19,7 +19,7 @@
19
19
  "node": ">=20"
20
20
  },
21
21
  "dependencies": {
22
- "@dotrino/identity": "^0.34.0",
22
+ "@dotrino/identity": "^0.35.0",
23
23
  "@dotrino/proxy-client": "0.8.0",
24
24
  "ws": "^8.18.0"
25
25
  },
package/src/ctl.js CHANGED
@@ -166,9 +166,24 @@ async function cmdPair (args = []) {
166
166
  PROFILE = d.id // el emparejamiento y los comandos siguientes apuntan a ELLA
167
167
  console.log('Cuenta nueva: %s (%s)', name, d.id)
168
168
  }
169
+ // `--adopt [nombre]`: la TERCERA respuesta a «¿de qué cuenta hablamos?» — el camino A.
170
+ // Aquí la cuenta NO sale de esta bóveda: la trae el aparato y esta bóveda pasa a
171
+ // guardarla y a mandarla. Por eso se crea un perfil VACÍO, que nace a la espera de
172
+ // adoptarla; sin ese sitio no habría dónde meterla.
173
+ const adIdx = args.findIndex((a) => a === '--adopt')
174
+ const adopt = adIdx >= 0
175
+ if (adopt) {
176
+ const next = args[adIdx + 1]
177
+ const name = (next && !next.startsWith('-')) ? next : `cuenta del dispositivo`
178
+ const d = await profileRequest('add', { name, adopt: true })
179
+ if (d.error) { console.error('%s', d.error); process.exit(1) }
180
+ if (!d.id) { console.error('El daemon no dijo qué cuenta creó.'); process.exit(1) }
181
+ PROFILE = d.id
182
+ console.log('Cuenta a la espera de adoptar la del dispositivo: %s (%s)', name, d.id)
183
+ }
169
184
  // La petición se escribe SIEMPRE (aunque no haya --service): lleva a qué perfil
170
185
  // se empareja el dispositivo.
171
- writeReq('pair-request.json', service ? { service } : {})
186
+ writeReq('pair-request.json', { ...(service ? { service } : {}), ...(adopt ? { mode: 'adopt' } : {}) })
172
187
  avisar(s.pid, 'SIGUSR1')
173
188
 
174
189
  let pair = null
package/src/daemon.js CHANGED
@@ -123,7 +123,14 @@ export async function runDaemon () {
123
123
  // TAMBIÉN dentro del QR (`acct`) para que el dispositivo pueda anunciar qué va
124
124
  // a pasar antes de hacerlo (V9 de docs/vinculacion-de-cuentas.md).
125
125
  const profileName = mgr.profiles.get(profileId)?.name || ''
126
- const { qr, expiresInMs } = vault.startPairing({ scope, label, ttlMs: DEVICE_TTL_MS, mode: 'join', account: profileName })
126
+ // MODO (V9): lo decide la bóveda y viaja en el QR para que el aparato pueda decir
127
+ // qué va a pasar antes de hacerlo.
128
+ // · `join` → el dispositivo entra a esta cuenta de la bóveda.
129
+ // · `adopt` → la bóveda se queda con la cuenta que trae el aparato (camino A). El
130
+ // perfil de esta bóveda tiene que haber nacido para eso (`--adopt`
131
+ // crea uno vacío), o no habría dónde meterla.
132
+ const mode = pairReq?.mode === 'adopt' ? 'adopt' : 'join'
133
+ const { qr, expiresInMs } = vault.startPairing({ scope, label, ttlMs: DEVICE_TTL_MS, mode, account: profileName })
127
134
  writeJson(pairFile, { v: 2, qr, expiresAt: Date.now() + expiresInMs, profile: profileId, profileName })
128
135
  // El token es un secreto efímero: no debe quedar en disco más allá de su
129
136
  // vida. Se borra al VENCER (aquí) y al APROBARSE (abajo, consumido).
@@ -160,7 +167,7 @@ export async function runDaemon () {
160
167
  case 'list': return {} // el volcado de perfiles ya se hace abajo
161
168
  // `id`: quien la crea necesita saber CUÁL quedó, no adivinar por nombre (dos
162
169
  // cuentas pueden llamarse igual). Lo usa «emparejar en una cuenta nueva».
163
- case 'add': { const p = await mgr.add(req.name); return { done: `perfil creado: ${p.name || p.id}`, id: p.id } }
170
+ case 'add': { const p = await mgr.add(req.name, { adopt: !!req.adopt }); return { done: `perfil creado: ${p.name || p.id}`, id: p.id, adopt: !!req.adopt } }
164
171
  case 'rm': { const r = await mgr.remove(req.profile); return { done: `perfil borrado: ${r.name || r.id}` } }
165
172
  case 'rename': { const p = mgr.profiles.rename(ref(), req.name); return { done: `perfil renombrado: ${p.name}` } }
166
173
  case 'use': { const p = mgr.profiles.setCurrent(ref()); return { done: `perfil activo: ${p.name || p.id}` } }
package/src/manager.js CHANGED
@@ -56,6 +56,10 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
56
56
  proxyUrl,
57
57
  log: (...a) => log(`[${tag}]`, ...a),
58
58
  isLocked: () => profiles.isLocked(id),
59
+ // Camino A: nació para adoptar la cuenta de un aparato (ver profiles.add).
60
+ forAdoption: !!p?.adopt,
61
+ // Ya adoptó: la marca se consume (no vuelve a estar «a la espera»).
62
+ onAdopted: () => { try { profiles.clearAdopt(id) } catch (_) {} },
59
63
  onEnrollChallenge: (info) => onEnrollChallenge?.({ ...info, profile: id, profileName: p?.name || '' })
60
64
  })
61
65
  running.set(id, v)
@@ -88,10 +92,15 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
88
92
  currentId: () => profiles.current(),
89
93
  resolve: (ref) => profiles.resolve(ref),
90
94
 
91
- async add (name) {
92
- const p = profiles.add(name)
95
+ /**
96
+ * Crea un perfil. Con `adopt: true` nace **vacío, esperando la cuenta de un aparato**
97
+ * (camino A): la bóveda pone el sitio y la llave que entrará como miembro, pero la
98
+ * cuenta la trae el dispositivo y se adopta al emparejar.
99
+ */
100
+ async add (name, { adopt = false } = {}) {
101
+ const p = profiles.add(name, { adopt })
93
102
  await open(p.id)
94
- log('[vault] perfil creado: %s (%s)', p.name || '(sin nombre)', p.id)
103
+ log('[vault] perfil creado%s: %s (%s)', adopt ? ' (a la espera de adoptar una cuenta)' : '', p.name || '(sin nombre)', p.id)
95
104
  return profiles.get(p.id)
96
105
  },
97
106
 
package/src/profiles.js CHANGED
@@ -63,7 +63,9 @@ export function openProfiles (root = dataDir()) {
63
63
  createdAt: p.createdAt || null,
64
64
  protected: !!p.pwd,
65
65
  locked: !!p.pwd && !unlocked.has(p.id),
66
- current: p.id === data.current
66
+ current: p.id === data.current,
67
+ // Nació para adoptar la cuenta de un aparato (camino A) y todavía no lo ha hecho.
68
+ ...(p.adopt ? { adopt: true } : {})
67
69
  })
68
70
 
69
71
  function assertExists (id) {
@@ -123,15 +125,30 @@ export function openProfiles (root = dataDir()) {
123
125
  return { id, migrated: legacy }
124
126
  },
125
127
 
126
- add (name) {
128
+ /**
129
+ * Crea un perfil. Con `adopt: true` nace **para adoptar la cuenta de un aparato**
130
+ * (camino A): la bóveda le hace sitio, pero la cuenta —el `profileId`, la reputación,
131
+ * lo ya firmado— la trae el dispositivo. La marca la consume `startVault`, que se la
132
+ * pasa a la identidad (`prepareForAdoption`); sin ella, adoptar una cuenta ajena se
133
+ * leería como pisar una cuenta con datos y se rechazaría, que es lo que tiene que
134
+ * pasar cuando nadie lo pidió.
135
+ */
136
+ add (name, { adopt = false } = {}) {
127
137
  const id = newId()
128
138
  ensureDir(dirOf(id))
129
- data.profiles.push({ id, name: cleanName(name), createdAt: Date.now() })
139
+ data.profiles.push({ id, name: cleanName(name), createdAt: Date.now(), ...(adopt ? { adopt: true } : {}) })
130
140
  if (!data.current) data.current = id
131
141
  save()
132
142
  return entry(find(id))
133
143
  },
134
144
 
145
+ /** Quita la marca de «nació para adoptar» (ya adoptó, o se canceló). */
146
+ clearAdopt (id) {
147
+ const p = find(id)
148
+ if (p?.adopt) { delete p.adopt; save() }
149
+ return !!p
150
+ },
151
+
135
152
  rename (id, name) {
136
153
  const p = assertExists(id)
137
154
  api.assertUnlocked(id)
package/src/tui/app.js CHANGED
@@ -694,10 +694,8 @@ function pairingBody (st, t, cols, height) {
694
694
  // en la cuenta equivocada sin enterarse.
695
695
  const ap = activeProfile(st)
696
696
  const acct = info.profileName || ap?.name || info.profile || ap?.id || '—'
697
- lines.push(t.bold(i.pairAccount(acct)))
698
697
  const left = Math.max(0, Math.round((info.expiresAt - Date.now()) / 60000))
699
- lines.push(t.muted(i.pairValid(left)))
700
- lines.push('')
698
+ lines.push(t.bold(i.pairAccount(acct, left)))
701
699
  // QR: se dibuja siempre que quepa de ancho; si es más alto que la pantalla se
702
700
  // puede hacer scroll hacia arriba/abajo para verlo completo.
703
701
  let qr = ''
@@ -711,6 +709,7 @@ function pairingBody (st, t, cols, height) {
711
709
  lines.push(t.warn(i.pairQrTooNarrow(cols, qrWidth)))
712
710
  lines.push('')
713
711
  }
712
+ lines.push(t.muted(i.pairScan))
714
713
  lines.push(t.bold(i.pairUrl) + info.url)
715
714
  lines.push('')
716
715
  lines.push(t.muted(i.pairPaste))
package/src/tui/i18n.js CHANGED
@@ -134,8 +134,10 @@ const es = {
134
134
  newAccountLabel: 'Nombre de la cuenta nueva',
135
135
  newAccountHint: 'nace vacía; el dispositivo será su primer invitado',
136
136
  accountCreated: (name) => `Cuenta creada: ${name}`,
137
- pairAccount: (name) => `Cuenta que se comparte: ${name}`,
138
- pairValid: (min) => `Válido ~${min} min. Escanéalo o abre la URL en el dispositivo.`,
137
+ // Cuenta + vigencia en UNA línea: cada línea de cabecera es una fila menos de QR
138
+ // visible antes de tener que hacer scroll.
139
+ pairAccount: (name, min) => `Cuenta que se comparte: ${name} · válido ~${min} min`,
140
+ pairScan: 'Escanéalo, o abre esta dirección en el dispositivo:',
139
141
  pairUrl: 'URL: ',
140
142
  pairPaste: 'O pega este código en vault.dotrino.com/dispositivos:',
141
143
  pairWarning: '⚠ Este código deja LEER tus datos y FIRMAR con tu identidad. No lo compartas.',
@@ -297,8 +299,8 @@ const en = {
297
299
  newAccountLabel: 'Name of the new account',
298
300
  newAccountHint: 'born empty; the device will be its first guest',
299
301
  accountCreated: (name) => `Account created: ${name}`,
300
- pairAccount: (name) => `Account being shared: ${name}`,
301
- pairValid: (min) => `Valid ~${min} min. Scan it or open the URL on the device.`,
302
+ pairAccount: (name, min) => `Account being shared: ${name} · valid ~${min} min`,
303
+ pairScan: 'Scan it, or open this address on the device:',
302
304
  pairUrl: 'URL: ',
303
305
  pairPaste: 'Or paste this code into vault.dotrino.com/dispositivos:',
304
306
  pairWarning: '⚠ This code lets someone READ your data and SIGN as you. Do not share it.',
package/src/vault.js CHANGED
@@ -35,7 +35,7 @@ import { MSG, SCOPE, secretsScope, isValidSecretsNs } from './protocol.js'
35
35
  * Solo bloquea EDITAR el perfil (`profileSet`): firmar/leer y el resto del store
36
36
  * siguen sirviendo a los dispositivos enrolados aunque esté bloqueado.
37
37
  */
38
- export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log, onEnrollChallenge, isLocked = () => false } = {}) {
38
+ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log, onEnrollChallenge, isLocked = () => false, forAdoption = false, onAdopted } = {}) {
39
39
  ensureDir(dir)
40
40
  // CIFRADO EN REPOSO ligado a esta máquina: `identity.json` deja de estar en claro, así
41
41
  // que copiarlo a otro equipo no sirve de nada. No protege contra quien ya tiene ESTA
@@ -47,6 +47,13 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
47
47
  } catch (e) { log('[vault] no se pudo cifrar la identidad en reposo:', e.message) }
48
48
  const identity = await Identity.connect({ dir, atRest: atRestFor(dir) })
49
49
  if (!identity.me?.publickey) await identity.setMyNickname('')
50
+ // CAMINO A: este perfil nació para adoptar la cuenta de un aparato. La identidad se crea
51
+ // igual (su llave es la que entrará como miembro), pero se marca para que `joinProfile`
52
+ // acepte cambiar su acta recién nacida por la que traiga el dispositivo. Sin la marca,
53
+ // adoptar sería pisar una cuenta con datos y se rechaza — que es lo correcto por defecto.
54
+ if (forAdoption) {
55
+ try { await identity.prepareForAdoption() } catch (e) { log('[vault] no se pudo preparar el perfil para adoptar:', e.message) }
56
+ }
50
57
 
51
58
  const store = openStore(dir)
52
59
  const threads = openThreadStore(dir)
@@ -101,6 +108,12 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
101
108
  sendByPubkey: (pub, obj) => client.sendByPubkey(pub, obj),
102
109
  audit,
103
110
  log,
111
+ // Camino A: lo que esta bóveda le manda al aparato para entrar en SU acta. La llave de
112
+ // cifrado es lo que le permite leer el contenido de la cuenta que va a custodiar; sin
113
+ // ella entraría mandando una cuenta que no puede abrir.
114
+ encPub: identity.me?.encryptionPubkey || null,
115
+ vaultLabel: 'bóveda',
116
+ onAdopted: (info) => { try { onAdopted?.(info) } catch (_) {} },
104
117
  defaultScope: [SCOPE.READ],
105
118
  onChallenge ({ deviceId, scope }) {
106
119
  log(`\n[vault] Un dispositivo quiere conectarse:`)
@@ -263,6 +276,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
263
276
  if (!payload || typeof payload !== 'object') return
264
277
  try {
265
278
  if (payload.type === MSG.ENROLL) return await desk.handleEnroll(from, payload)
279
+ if (payload.type === MSG.ACTA_SEALED) return await desk.handleActaSealed(from, payload)
266
280
  if (payload.type === MSG.SIGN) return await handleSign(from, payload)
267
281
  if (payload.type === MSG.GET) return await handleGet(from, payload)
268
282
  if (payload.type === MSG.STORE) return await handleStore(from, payload)