@dotrino/vault 0.10.0 → 0.12.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.12.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
@@ -39,9 +39,15 @@ export const FRESH_WINDOW_MS = 5 * 60 * 1000
39
39
  /** Vida por defecto del cert de un dispositivo (tope duro de `MAX_DELEGATION_MS`). */
40
40
  export const DEVICE_TTL_MS = 30 * 24 * 60 * 60 * 1000
41
41
 
42
+ export const MSG_HELLO = 'vault.hello'
43
+ export const MSG_HELLO_OK = 'vault.hello.ok'
42
44
  export const MSG_ENROLL = 'vault.enroll'
43
45
  export const MSG_ENROLL_CHALLENGE = 'vault.enroll.challenge'
44
46
  export const MSG_ENROLLED = 'vault.enrolled'
47
+ // --- camino A: la cuenta del aparato pasa a vivir en la bóveda ---
48
+ export const MSG_ENROLL_ADOPT = 'vault.enroll.adopt'
49
+ export const MSG_ACTA_SEALED = 'vault.acta.sealed'
50
+ export const MSG_ACTA_ADOPTED = 'vault.acta.adopted'
45
51
  export const MSG_REVOKED = 'vault.revoked'
46
52
  export const MSG_ERROR = 'vault.error'
47
53
 
@@ -104,8 +110,14 @@ export async function deviceIdOf (pub) {
104
110
  export function createEnrollDesk ({
105
111
  identity, iss, proxy, send, sendByPubkey,
106
112
  audit = () => {}, log = () => {},
107
- onChallenge = () => {}, onPendingChange = () => {},
108
- defaultScope = ['vault:sign'], defaultTtlMs = DEVICE_TTL_MS
113
+ onChallenge = () => {}, onPendingChange = () => {}, onAdopted = () => {},
114
+ defaultScope = ['vault:sign'], defaultTtlMs = DEVICE_TTL_MS,
115
+ // Camino A: lo que ESTA bóveda le manda al aparato para que la meta en su acta. `encPub`
116
+ // es su llave de CIFRADO — sin ella entra mandando pero sin poder leer el contenido.
117
+ encPub = null, vaultLabel = '',
118
+ // Token de CONEXIÓN de esta bóveda en el proxy (4 chars): su dirección. Es lo
119
+ // único que necesita el QR corto para que el aparato le hable punto a punto.
120
+ connToken = null
109
121
  } = {}) {
110
122
  if (!identity) throw new Error('createEnrollDesk: falta identity')
111
123
  if (!iss) throw new Error('createEnrollDesk: falta iss (pubkey de la maestra)')
@@ -136,9 +148,19 @@ export function createEnrollDesk ({
136
148
  */
137
149
  function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '', mode = 'join', account = '' } = {}) {
138
150
  pending.clear() // uno a la vez: una sesión nueva supersede a la anterior
151
+ const acct = String(account || '').slice(0, 40)
152
+ // INVITACIÓN CORTA: si sabemos nuestra dirección en el proxy, el QR lleva solo
153
+ // eso y el nonce de la sesión (13 bytes). La llave, el proxy y el nombre de la
154
+ // cuenta los pide el aparato por la red presentando el `sn`. El nonce hace de
155
+ // identificador de sesión: no hace falta un token de emparejamiento aparte.
156
+ const conn = typeof connToken === 'function' ? connToken() : connToken
157
+ if (conn) {
158
+ const sn = randToken(8)
159
+ pending.set(sn, { token: sn, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
160
+ return { token: sn, qr: { v: 2, conn, sn, m: mode, proxy }, expiresInMs: PAIRING_TTL_MS }
161
+ }
139
162
  const token = randToken(PAIR_TOKEN_BYTES)
140
163
  const sn = randToken(PAIR_TOKEN_BYTES)
141
- const acct = String(account || '').slice(0, 40)
142
164
  pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
143
165
  return { token, qr: { v: 2, iss, proxy, token, sn, m: mode, ...(acct ? { acct } : {}) }, expiresInMs: PAIRING_TTL_MS }
144
166
  }
@@ -158,6 +180,28 @@ export function createEnrollDesk ({
158
180
  return null
159
181
  }
160
182
 
183
+ /**
184
+ * «¿Quién eres?» — la respuesta al QR corto. Solo se contesta a quien presente el
185
+ * `sn` de una sesión VIVA: el token de conexión son 4 caracteres y se puede acertar
186
+ * a ciegas, el `sn` no. Fuera de un emparejamiento no hay ninguna sesión y por lo
187
+ * tanto no hay respuesta: la puerta solo está abierta mientras dura el `pair`.
188
+ */
189
+ async function handleHello (from, p) {
190
+ const pend = pending.get(String(p?.sn || ''))
191
+ if (!pend || Date.now() > pend.exp) {
192
+ audit('rejected', { what: 'hello', reason: 'sin-sesion' })
193
+ return reply(from, { type: MSG_ERROR, error: 'no hay ningún emparejamiento abierto con ese código' })
194
+ }
195
+ // La respuesta va FIRMADA por la maestra y el `sn` va dentro de lo firmado. Eso ata
196
+ // la respuesta a ESTA sesión: no se puede reutilizar la de otro emparejamiento ni la
197
+ // de otra bóveda. Lo que NO hace es demostrar que sea TU bóveda —cualquiera puede
198
+ // firmar con una llave suya—; eso solo lo demuestra el código de 6 dígitos.
199
+ const body = { op: 'hello', sn: pend.sn, iss, proxy, acct: pend.account || '', m: pend.mode || 'join', ts: Date.now() }
200
+ const { signature } = await identity.signData(body)
201
+ reply(from, { type: MSG_HELLO_OK, body, signature })
202
+ return { ok: true }
203
+ }
204
+
161
205
  /**
162
206
  * ENROLL: el dispositivo prueba posesión de `D` firmando el sobre y deja el
163
207
  * COMPROMISO de su código. Todavía NO se firma ningún cert.
@@ -172,6 +216,17 @@ export function createEnrollDesk ({
172
216
  return reply(from, { type: MSG_ERROR, error: 'token de emparejamiento inválido o expirado' })
173
217
  }
174
218
  if (d.sn !== pend.sn) return reply(from, { type: MSG_ERROR, error: 'sesión inválida' })
219
+ // V7 · la INTENCIÓN viaja firmada y tiene que coincidir con el modo con el que ESTA
220
+ // bóveda abrió el emparejamiento. Es lo que garantiza que lo que pasa es lo que el
221
+ // humano vio anunciado en las dos pantallas, y no algo que se decidió a mitad de camino.
222
+ const intent = d.intent || 'join'
223
+ if (intent !== 'join' && intent !== 'adopt') {
224
+ return reply(from, { type: MSG_ERROR, error: 'intención desconocida: ' + intent })
225
+ }
226
+ if (intent !== (pend.mode || 'join')) {
227
+ audit('rejected', { what: 'enroll', reason: 'intent-mismatch' })
228
+ return reply(from, { type: MSG_ERROR, error: `este emparejamiento se abrió para «${pend.mode || 'join'}» y el dispositivo pidió «${intent}»` })
229
+ }
175
230
  if (!isFresh(d)) {
176
231
  audit('rejected', { what: 'enroll', reason: 'stale' })
177
232
  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 +263,12 @@ export function createEnrollDesk ({
208
263
  }
209
264
  pend.from = from // la bóveda NO conoce el código: lo aprende cuando lo tipeas
210
265
  if (d.label) pend.label = String(d.label).slice(0, 60)
266
+ // Camino A: de qué cuenta estamos hablando. Se guarda para poder comprobar, cuando
267
+ // llegue el acta sellada, que es la que este dispositivo dijo que iba a entregar.
268
+ if (intent === 'adopt' && typeof d.profileId === 'string') pend.profileId = d.profileId
211
269
 
212
270
  reply(from, { type: MSG_ENROLL_CHALLENGE, deviceId })
213
- fire(onChallenge, { deviceId, scope: pend.scope, label: pend.label || '' })
271
+ fire(onChallenge, { deviceId, scope: pend.scope, label: pend.label || '', mode: pend.mode || 'join' })
214
272
  fire(onPendingChange)
215
273
  return { deviceId }
216
274
  }
@@ -246,6 +304,20 @@ export function createEnrollDesk ({
246
304
  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
305
  }
248
306
 
307
+ // CAMINO A · aquí la bóveda no entrega un cert: entrega SU IDENTIDAD para que el
308
+ // aparato la meta en el acta de la cuenta que le está pasando. El código de vuelta es
309
+ // la misma defensa de siempre, en el otro sentido: el aparato solo hace caso a una
310
+ // bóveda que demuestre que un humano la aprobó.
311
+ if ((pend.mode || 'join') === 'adopt') {
312
+ audit('adopt-approve', { device: pend.deviceId, profile: pend.profileId || null })
313
+ pend.state = 'AWAITING_ACTA'
314
+ pend.approvedAt = Date.now()
315
+ reply(pend.from, { type: MSG_ENROLL_ADOPT, code, pub: iss, encPub: encPub || null, label: vaultLabel || '' })
316
+ log('[vault] adopción aprobada para %s: esperando el acta sellada', pend.deviceId)
317
+ fire(onPendingChange)
318
+ return { ok: true, deviceId: pend.deviceId, adopting: true }
319
+ }
320
+
249
321
  const { cert } = await identity.signDelegation(pend.dpub, pend.scope, { ttlMs: pend.ttlMs, label: pend.label })
250
322
 
251
323
  // Aprobar un emparejamiento ES admitir al dispositivo en el perfil: el cert es la
@@ -273,6 +345,60 @@ export function createEnrollDesk ({
273
345
  return { ok: true, deviceId: pend.deviceId, cert }
274
346
  }
275
347
 
348
+ /**
349
+ * CAMINO A · paso 6: llega el acta que el aparato acaba de sellar, con la bóveda dentro
350
+ * como miembro, la clave de contenido envuelta para ella y el mando ya traspasado.
351
+ *
352
+ * Lo que se comprueba antes de guardar nada (y por qué):
353
+ * · que el sellador sea ESTA bóveda — si no, no es un traspaso, es un acta ajena;
354
+ * · que la selle el aparato que estaba en este emparejamiento — cierra que un tercero
355
+ * que vea pasar el mensaje cuele la suya;
356
+ * · que sea la cuenta que ese aparato declaró al enrolarse (`profileId`) — cierra el
357
+ * cambiazo de cuenta entre el anuncio que leyó el humano y lo que llega después.
358
+ *
359
+ * Adoptar la cuenta de otro solo procede sobre un perfil que **nació para eso** (la marca
360
+ * de `prepareForAdoption`). Es la misma regla del navegador: sin la marca, adoptar sería
361
+ * pisar una cuenta con datos, y eso no puede pasar por accidente.
362
+ */
363
+ async function handleActaSealed (from, p) {
364
+ const acta = p?.acta
365
+ const pend = [...pending.values()].find((x) => x.state === 'AWAITING_ACTA' && (x.from === from || x.dpub))
366
+ if (!pend) return reply(from, { type: MSG_ERROR, error: 'no hay ninguna adopción esperando un acta' })
367
+ if (!acta || typeof acta !== 'object') return reply(from, { type: MSG_ERROR, error: 'acta ausente o ilegible' })
368
+ if (acta.sealer !== iss) {
369
+ audit('rejected', { what: 'adopt', reason: 'not-sealer' })
370
+ return reply(from, { type: MSG_ERROR, error: 'esa acta no nombra a esta bóveda como quien manda' })
371
+ }
372
+ if (acta.sealedBy !== pend.dpub) {
373
+ audit('rejected', { what: 'adopt', reason: 'sealed-by-other' })
374
+ return reply(from, { type: MSG_ERROR, error: 'esa acta no la selló el dispositivo de este emparejamiento' })
375
+ }
376
+ if (pend.profileId && acta.profileId !== pend.profileId) {
377
+ audit('rejected', { what: 'adopt', reason: 'other-profile' })
378
+ return reply(from, { type: MSG_ERROR, error: 'esa acta es de otra cuenta distinta a la que anunció el dispositivo' })
379
+ }
380
+
381
+ try {
382
+ const r = await identity.joinProfile(acta)
383
+ if (!r?.joined) throw new Error(r?.reason || 'no se pudo adoptar')
384
+ audit('adopt', { device: pend.deviceId, profile: acta.profileId, seq: acta.seq })
385
+ // El acta que vuelve es la que la bóveda tiene guardada: el aparato la adopta y los
386
+ // dos quedan en la misma versión.
387
+ const mia = (await identity.profileActa?.())?.acta || acta
388
+ reply(pend.from, { type: MSG_ACTA_ADOPTED, code: p.code, acta: mia })
389
+ pend.state = 'DONE'
390
+ pending.delete(pend.token)
391
+ fire(onPendingChange)
392
+ fire(onAdopted, { deviceId: pend.deviceId, profileId: acta.profileId, seq: mia.seq })
393
+ log('[vault] cuenta adoptada del dispositivo %s (perfil %s)', pend.deviceId, acta.profileId?.slice(0, 12))
394
+ return { ok: true, adopted: true, profileId: acta.profileId, seq: mia.seq }
395
+ } catch (e) {
396
+ log('[vault] no se pudo adoptar la cuenta: %s', e.message)
397
+ reply(pend.from, { type: MSG_ERROR, error: 'la bóveda no pudo adoptar la cuenta: ' + e.message })
398
+ return { ok: false, error: e.message }
399
+ }
400
+ }
401
+
276
402
  /** Rechaza un enrolamiento pendiente. */
277
403
  function reject (deviceId) {
278
404
  const pend = deviceId
@@ -310,7 +436,7 @@ export function createEnrollDesk ({
310
436
  }
311
437
 
312
438
  return {
313
- startPairing, stopPairing, handleEnroll, approve, reject,
439
+ startPairing, stopPairing, handleEnroll, handleActaSealed, handleHello, approve, reject,
314
440
  listPending, findPending, emitRevoke, revoke,
315
441
  get pendingCount () { return pending.size }
316
442
  }
package/src/invite.js CHANGED
@@ -49,6 +49,18 @@
49
49
  export const FMT_JSON = 'j'
50
50
  export const FMT_B64 = 'b'
51
51
  export const FMT_COMPACT = 'c'
52
+ /**
53
+ * `t` — la invitación CORTA: solo la dirección y el nonce de la sesión. La llave
54
+ * maestra ya no viaja; el aparato se la pide a la bóveda por la red presentando el
55
+ * `sn`, y la comprueba con la firma del certificado y con el código de 6 dígitos,
56
+ * que es lo único que de verdad decide. Esconderla no aportaba nada —una pública es
57
+ * pública— y ocupaba 44 de los ~100 caracteres.
58
+ *
59
+ * `token` aquí NO es el de la sesión de emparejamiento: es el **token de conexión**
60
+ * que el proxy le da a la bóveda (4 caracteres), o sea su dirección. Con eso el
61
+ * aparato le habla directo, punto a punto, sin resolver nada.
62
+ */
63
+ export const FMT_SHORT = 't'
52
64
 
53
65
  /** El proxy del ecosistema: si es este, no viaja en la invitación compacta. */
54
66
  export const DEFAULT_PROXY = 'wss://proxy.dotrino.com'
@@ -266,6 +278,57 @@ function compactDecode (text) {
266
278
  // API
267
279
  // ---------------------------------------------------------------------------
268
280
 
281
+ /**
282
+ * Blob de la forma CORTA (`t`): cabecera(1) ‖ token de conexión(4 ASCII) ‖ sn(8)
283
+ * ‖ [len+proxy].
284
+ * bits 0-2 versión · bit 3 modo (join/adopt) · bit 4 lleva proxy propio
285
+ *
286
+ * Sin llave y sin nombre de cuenta: eso llega en la respuesta de la bóveda, así que
287
+ * un nombre largo ya no agranda el QR. 13 bytes → 18 caracteres.
288
+ *
289
+ * El PROXY sí viaja cuando no es el del ecosistema, y no es opcional: el token de
290
+ * conexión solo tiene sentido **en el proxy donde se emitió**. Quitarlo hacía que un
291
+ * aparato se conectara al proxy público y le hablara a un token de otro servidor —
292
+ * el mensaje no llegaba a ninguna parte y el emparejamiento se quedaba esperando.
293
+ * Le pasa a cualquiera con proxy propio (y lo cazó el E2E de secretos, que levanta
294
+ * uno local).
295
+ */
296
+ const CONN_TOKEN_LEN = 4
297
+
298
+ function shortEncode (qr) {
299
+ if (!qr || qr.v !== 2) return null
300
+ const mode = MODES.indexOf(qr.m)
301
+ if (mode < 0) return null
302
+ const conn = String(qr.conn || '')
303
+ if (conn.length !== CONN_TOKEN_LEN || !/^[\x21-\x7e]+$/.test(conn)) return null
304
+ if (!/^[0-9a-f]{16}$/.test(qr.sn || '')) return null // 8 bytes
305
+ const known = new Set(['v', 'conn', 'sn', 'm', 'proxy'])
306
+ if (Object.keys(qr).some((k) => !known.has(k))) return null
307
+ const ownProxy = qr.proxy && qr.proxy !== DEFAULT_PROXY
308
+ const proxy = ownProxy ? utf8(String(qr.proxy)) : null
309
+ if (proxy && proxy.length > 255) return null
310
+ const out = [qr.v | (mode << 3) | (ownProxy ? 0x10 : 0), ...[...conn].map((c) => c.charCodeAt(0)), ...hexToBytes(qr.sn)]
311
+ if (proxy) out.push(proxy.length, ...proxy)
312
+ return bytesToB64url(Uint8Array.from(out))
313
+ }
314
+
315
+ function shortDecode (text) {
316
+ const b = b64urlToBytes(text)
317
+ if (!b || b.length < 1 + CONN_TOKEN_LEN + 8) return null
318
+ const head = b[0]
319
+ if (head & 0xe0) return null
320
+ let i = 1 + CONN_TOKEN_LEN + 8
321
+ let proxy = DEFAULT_PROXY
322
+ if (head & 0x10) {
323
+ if (i >= b.length) return null
324
+ const n = b[i]; i += 1
325
+ if (i + n !== b.length) return null
326
+ proxy = fromUtf8(b.subarray(i, i + n)); i += n
327
+ } else if (i !== b.length) return null
328
+ const conn = String.fromCharCode(...b.subarray(1, 1 + CONN_TOKEN_LEN))
329
+ return { v: head & 7, conn, sn: bytesToHex(b.subarray(1 + CONN_TOKEN_LEN, 1 + CONN_TOKEN_LEN + 8)), m: MODES[(head >> 3) & 1], proxy }
330
+ }
331
+
269
332
  /** Comparación por contenido, sin depender del orden de las claves. */
270
333
  const canon = (o) => JSON.stringify(Object.keys(o).sort().map((k) => [k, o[k]]))
271
334
 
@@ -278,6 +341,11 @@ const canon = (o) => JSON.stringify(Object.keys(o).sort().map((k) => [k, o[k]]))
278
341
  */
279
342
  export function encodeInvite (qr, fmt = FMT_COMPACT) {
280
343
  const json = JSON.stringify(qr)
344
+ // La forma corta se emite cuando el QR trae dirección en vez de llave.
345
+ if (qr && qr.conn) {
346
+ const t = shortEncode(qr)
347
+ if (t) { const back = shortDecode(t); if (back && canon(back) === canon(qr)) return FMT_SHORT + t }
348
+ }
281
349
  if (fmt === FMT_JSON) return FMT_JSON + json
282
350
  if (fmt === FMT_COMPACT) {
283
351
  const c = compactEncode(qr)
@@ -320,6 +388,7 @@ export function parseInvite (text) {
320
388
 
321
389
  const marca = payload[0]
322
390
  const resto = payload.slice(1)
391
+ if (marca === FMT_SHORT) { const o = shortDecode(resto); if (o) return o }
323
392
  if (marca === FMT_COMPACT) { const o = compactDecode(resto); if (o) return o }
324
393
  if (marca === FMT_JSON) { const o = parse(undoUrl(resto)) || parse(resto); if (o) return o }
325
394
  if (marca === FMT_B64) { const s = b64urlDecodeStr(resto); const o = s && parse(s); if (o) return o }
@@ -331,4 +400,4 @@ export function parseInvite (text) {
331
400
  return s ? parse(s) : null
332
401
  }
333
402
 
334
- export default { encodeInvite, inviteUrl, parseInvite, FMT_JSON, FMT_B64, FMT_COMPACT, PAIR_URL, DEFAULT_PROXY }
403
+ export default { encodeInvite, inviteUrl, parseInvite, FMT_JSON, FMT_B64, FMT_COMPACT, FMT_SHORT, PAIR_URL, DEFAULT_PROXY }
package/src/protocol.js CHANGED
@@ -17,9 +17,21 @@
17
17
  * pineada (cierra el wipe-DoS; un ERROR plano jamas borra).
18
18
  */
19
19
  export const MSG = Object.freeze({
20
+ // La invitación corta no lleva la llave: el aparato la pide presentando el `sn` de
21
+ // la sesión. Una pública es pública — esto no la esconde, solo evita abrirle la
22
+ // puerta a quien acertó el token de conexión a ciegas.
23
+ HELLO: 'vault.hello', // dispositivo → vault: { sn }
24
+ HELLO_OK: 'vault.hello.ok', // vault → dispositivo: { iss, acct }
20
25
  ENROLL: 'vault.enroll', // dispositivo → vault: { data, signature }
21
26
  ENROLL_CHALLENGE: 'vault.enroll.challenge', // vault → dispositivo: { deviceId, sas }
22
27
  ENROLLED: 'vault.enrolled', // vault → dispositivo (tras aprobar): { cert, iss, sas }
28
+ // Camino A (la cuenta del aparato pasa a vivir en la bóveda): en vez de un cert, la
29
+ // bóveda manda QUIÉN es para que el aparato la admita, le envuelva la clave de
30
+ // contenido y le traspase el mando; el aparato devuelve el acta sellada y la bóveda
31
+ // responde con la definitiva. Ver docs/vinculacion-de-cuentas.md §2.
32
+ ENROLL_ADOPT: 'vault.enroll.adopt', // vault → dispositivo: { code, pub, encPub, label }
33
+ ACTA_SEALED: 'vault.acta.sealed', // dispositivo → vault: { acta, code }
34
+ ACTA_ADOPTED: 'vault.acta.adopted', // vault → dispositivo: { acta }
23
35
  REVOKED: 'vault.revoked', // vault → dispositivo: { body:{op,sub,nonce,iat,exp}, signature }
24
36
  SIGN: 'vault.sign', // dispositivo → vault: { data, signature, cert }
25
37
  SIGNED: 'vault.signed', // vault → dispositivo: { signature, publickey, device }
package/src/service.js CHANGED
@@ -60,6 +60,23 @@ function installNodeGlobals () {
60
60
  }
61
61
  }
62
62
 
63
+
64
+ /**
65
+ * La respuesta al `hello` va firmada y con el `sn` DENTRO de lo firmado. Comprobarlo
66
+ * ata la respuesta a ESTA sesión: no vale la de otro emparejamiento ni la de otra
67
+ * bóveda. Ojo con lo que NO prueba: cualquiera puede firmar con una llave suya, así
68
+ * que esto no dice que sea TU bóveda — eso lo dice el código de 6 dígitos, que solo
69
+ * aprende la bóveda donde tú lo tecleas.
70
+ */
71
+ async function verificarHola (p, sn) {
72
+ const b = p?.body
73
+ if (!b?.iss || b.sn !== sn) throw new Error('la bóveda contestó a otro emparejamiento')
74
+ if (!(await verifyDeviceSig({ publickey: b.iss, data: b, signature: p.signature }))) {
75
+ throw new Error('la respuesta de la bóveda no está bien firmada')
76
+ }
77
+ return b
78
+ }
79
+
63
80
  async function freshClient (proxyUrl, connectTimeoutMs = 20000) {
64
81
  installNodeGlobals()
65
82
  const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
@@ -132,12 +149,25 @@ function writeServiceIdentity (dir, obj) {
132
149
  */
133
150
  export async function enrollService ({ qr, ns, dir, label, onCode, approveTimeoutMs = 180000 } = {}) {
134
151
  if (typeof qr === 'string') { try { qr = JSON.parse(qr) } catch (_) { throw new Error('qr inválido: no es JSON') } }
135
- if (!qr?.iss || !qr?.proxy || !qr?.token || !qr?.sn) throw new Error('qr inválido (v2): faltan iss/proxy/token/sn')
152
+ if (!qr?.sn || !(qr.iss || qr.conn)) throw new Error('qr inválido: falta la bóveda o el nonce')
136
153
  if (!isValidSecretsNs(ns)) throw new Error('ns inválido (usa [a-z0-9-]{1,32}, p.ej. "proxy")')
137
154
  if (!dir) throw new Error('falta dir (dónde persistir la identidad del servicio)')
138
155
  label = label || 'servicio:' + ns
139
156
 
140
- const client = await freshClient(qr.proxy)
157
+ const client = await freshClient(qr.proxy || 'wss://proxy.dotrino.com')
158
+ // QR CORTO: se le pregunta a la bóveda quién es, punto a punto, presentando el `sn`.
159
+ if (!qr.iss) {
160
+ const hola = await new Promise((resolve, reject) => {
161
+ const off = client.on('message', (_f, p) => {
162
+ if (p?.type === MSG.HELLO_OK) { fin(); verificarHola(p, qr.sn).then(resolve, reject) }
163
+ else if (p?.type === MSG.ERROR) { fin(); reject(new Error(p.error)) }
164
+ })
165
+ const t = setTimeout(() => { fin(); reject(new Error('la bóveda no contestó: ese código pudo caducar')) }, 15000)
166
+ const fin = () => { off(); clearTimeout(t) }
167
+ try { client.send(qr.conn, { type: MSG.HELLO, sn: qr.sn }) } catch (e) { fin(); reject(e) }
168
+ })
169
+ qr = { ...qr, iss: hola.iss, proxy: hola.proxy || qr.proxy }
170
+ }
141
171
  try {
142
172
  const device = await makeDeviceKey({ label })
143
173
  const deviceId = (await pubkeyId(device.publickey)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
@@ -147,7 +177,7 @@ export async function enrollService ({ qr, ns, dir, label, onCode, approveTimeou
147
177
  // El COMPROMISO del código (nunca el código): la bóveda lo recompone con lo que
148
178
  // tipeas y solo entonces firma el cert → aprobar exige haber leído esta pantalla.
149
179
  const commit = await commitCode({ code, dpub: device.publickey, sn: qr.sn })
150
- const data = { op: 'enroll', dpub: device.publickey, token: qr.token, sn: qr.sn, commit, label, ts: Date.now() }
180
+ const data = { op: 'enroll', dpub: device.publickey, token: qr.token || qr.sn, sn: qr.sn, commit, label, ts: Date.now() }
151
181
  const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
152
182
 
153
183
  const enrolled = new Promise((resolve, reject) => {