@dotrino/vault 0.10.0 → 0.11.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/vault",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Usa ESTE dispositivo (navegador) como bóveda/CA del ecosistema Dotrino: atiende enrolamientos por el proxy y firma certificados de delegación a tus máquinas. Incluye el cliente de SERVICIO (Node): un proyecto se enrola una vez y jala sus credenciales del vault en vez del .env (`import '@dotrino/vault/config'`).",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/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
  }
package/src/protocol.js CHANGED
@@ -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 }