@dotrino/identity 0.37.0 → 0.38.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.
@@ -1,5 +1,5 @@
1
1
  import { buildSignedChannel, getPublicKeyJwk, signData } from './signature.js'
2
- import { WebRTCManager, RTC_TAG } from './webrtc.js'
2
+ import { WebRTCManager, RTC_TAG, DEFAULT_ICE_SERVERS } from './webrtc.js'
3
3
 
4
4
  /**
5
5
  * Dotrino WebSocket proxy client.
@@ -76,6 +76,10 @@ export class WebSocketProxyClient {
76
76
  clearTimeout(this._reconnectTimer)
77
77
  this._reconnectTimer = null
78
78
  }
79
+ if (this._turnTimer) {
80
+ clearTimeout(this._turnTimer)
81
+ this._turnTimer = null
82
+ }
79
83
  if (this._rtc) this._rtc.closeAll()
80
84
  if (this.ws) {
81
85
  try { this.ws.close(1000) } catch (_) {}
@@ -219,12 +223,57 @@ export class WebSocketProxyClient {
219
223
  * @param {string|string[]} toPubkeys publickey JWK string o array
220
224
  * @param {any} payload
221
225
  */
222
- sendByPubkey (toPubkeys, payload) {
226
+ /**
227
+ * Enviar a una o varias pubkeys.
228
+ *
229
+ * `opts.ephemeral` marca el mensaje como de TIEMPO REAL: si el destinatario no
230
+ * está conectado en ese momento, se descarta en vez de guardarse en la cola
231
+ * offline de 24 h. Úsalo para lo que caduca —jugadas de una partida,
232
+ * señalización WebRTC, presencia—: entregar eso mañana no es tarde, es
233
+ * incorrecto (reinicia negociaciones imposibles y muestra movimientos fuera de
234
+ * contexto). NO lo uses para mensajes de chat, que sí quieren esperar.
235
+ */
236
+ sendByPubkey (toPubkeys, payload, opts = {}) {
223
237
  const list = Array.isArray(toPubkeys) ? toPubkeys : [toPubkeys]
224
- this._sendRaw({
238
+ const msg = {
225
239
  to_publickey: list,
226
240
  message: typeof payload === 'string' ? payload : JSON.stringify(payload)
227
- })
241
+ }
242
+ if (opts.ephemeral) msg.ephemeral = true
243
+ this._sendRaw(msg)
244
+ }
245
+
246
+ /**
247
+ * Pedir una CITA: el código corto que una persona lee, dicta o escanea para
248
+ * emparejarse con esta conexión.
249
+ *
250
+ * Es un código de 6 caracteres (los 2 primeros dicen qué proxio lo emitió),
251
+ * que **caduca en minutos y se quema al usarse**. Esa es la diferencia con el
252
+ * token de 4 caracteres de antes: aquel era permanente mientras durara la
253
+ * conexión Y era la dirección de ruteo, así que tenía que ser adivinable-seguro
254
+ * y corto a la vez, dos cosas incompatibles.
255
+ *
256
+ * @param {{ttlMs?:number}} [opts] vida del código (30s–30min, por defecto 5min)
257
+ * @returns {Promise<{code:string, expiresAt:number, node:string}>}
258
+ */
259
+ requestPairingCode (opts = {}) {
260
+ const msg = { type: 'pair-code' }
261
+ if (opts.ttlMs) msg.ttlMs = opts.ttlMs
262
+ return this._request(msg, 'pair-code')
263
+ }
264
+
265
+ /**
266
+ * Canjear una cita ajena: devuelve a qué conexión (y a qué identidad) apunta.
267
+ * Acepta el código en minúsculas y con espacios o guiones.
268
+ *
269
+ * Si el código lo emitió otro proxio, este le pregunta a ESE proxio — no a
270
+ * toda la malla: un pregón se lo queda el primero que conteste, y así es como
271
+ * un nodo hostil se mete en emparejamientos ajenos.
272
+ *
273
+ * @returns {Promise<{ok:boolean, instance?:string, publickey?:string, error?:string}>}
274
+ */
275
+ redeemPairingCode (code) {
276
+ return this._request({ type: 'pair-redeem', code }, 'pair-redeem')
228
277
  }
229
278
 
230
279
  /**
@@ -233,13 +282,79 @@ export class WebSocketProxyClient {
233
282
  * (típicamente por el identity vault). Devuelve la respuesta del proxy con
234
283
  * `queued_delivered` (mensajes offline despachados al instante).
235
284
  */
236
- identify ({ data, signature, cert }) {
285
+ identify ({ data, signature, cert, acta }) {
237
286
  if (!data || !signature) throw new Error('identify requires {data, signature}')
238
287
  const msg = { type: 'identify', data, signature }
239
288
  if (cert) msg.cert = cert // "una identidad": el proxy bindea este token también bajo tu maestra M
289
+ // Acta de perfil: el proxy la verifica (va firmada) y bindea también el `profileId`, así
290
+ // escribirle a la PERSONA llega a cualquiera de sus dispositivos. Ver acta-de-perfil.md.
291
+ if (acta) msg.acta = acta
240
292
  return this._request(msg, 'identified')
241
293
  }
242
294
 
295
+ /**
296
+ * Pedir al proxy credenciales TURN temporales (Cloudflare) para WebRTC.
297
+ * Requiere haber llamado antes a `identify` en ESTA conexión con la misma
298
+ * pubkey: el proxy solo emite a conexiones identificadas (así el TURN se
299
+ * usa solo desde apps Dotrino y no como relay abierto). Las credenciales
300
+ * expiran solas (TTL corto) y hay cuota por pubkey/hora.
301
+ *
302
+ * @param {Object} opts
303
+ * @param {string} opts.publicKey Pubkey JWK string del vault (la de identify).
304
+ * @param {(data:any)=>Promise<string|{signature:string}>} opts.sign Firma del vault (id.signData).
305
+ * @returns {Promise<{enabled:boolean, iceServers:any[]|null, expiresAt:number|null}>}
306
+ */
307
+ async getTurnCredentials ({ publicKey, sign } = {}) {
308
+ if (!publicKey || typeof sign !== 'function') {
309
+ throw new Error('getTurnCredentials requires { publicKey, sign }')
310
+ }
311
+ const data = { op: 'turn-credentials', publickey: publicKey, ts: Date.now() }
312
+ const signature = await normalizeSignature(sign, data)
313
+ const res = await this._request({ type: 'turn-credentials', data, signature }, 'turn-credentials')
314
+ return {
315
+ enabled: !!res.enabled,
316
+ iceServers: res.iceServers || null,
317
+ expiresAt: res.expiresAt || null
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Activar TURN para WebRTC: obtiene credenciales temporales del proxy, las
323
+ * inyecta como ICE servers (junto a los STUN por defecto) y las renueva
324
+ * sola antes de expirar. Si el proxy no tiene TURN configurado, no cambia
325
+ * nada (los peers siguen STUN-only con fallback al proxy).
326
+ *
327
+ * Afecta a las conexiones P2P NUEVAS (los peers ya negociados conservan su
328
+ * configuración). Llamalo después de `identify`.
329
+ *
330
+ * @param {Object} opts Igual que getTurnCredentials ({ publicKey, sign }).
331
+ * @returns {Promise<boolean>} true si TURN quedó activo.
332
+ */
333
+ async enableTurn ({ publicKey, sign } = {}) {
334
+ if (!this._rtc) throw new Error('WebRTC está deshabilitado en este cliente')
335
+ if (this._turnTimer) {
336
+ clearTimeout(this._turnTimer)
337
+ this._turnTimer = null
338
+ }
339
+ const res = await this.getTurnCredentials({ publicKey, sign })
340
+ if (!res.enabled || !res.iceServers) return false
341
+ this._rtc.setIceServers([...res.iceServers, ...DEFAULT_ICE_SERVERS])
342
+ // Renovar con margen antes de expirar (mínimo 30 s entre renovaciones)
343
+ const refreshIn = Math.max(30000, (res.expiresAt || 0) - Date.now() - 60000)
344
+ this._turnTimer = setTimeout(() => {
345
+ this._turnTimer = null
346
+ this.enableTurn({ publicKey, sign }).catch(() => {
347
+ // Sin conexión o cuota: reintento suave en 60 s; mientras tanto los
348
+ // peers nuevos usan los STUN por defecto.
349
+ this._turnTimer = setTimeout(() => {
350
+ this._turnTimer = null
351
+ this.enableTurn({ publicKey, sign }).catch(() => {})
352
+ }, 60000)
353
+ })
354
+ }, refreshIn)
355
+ return true
356
+ }
357
+
243
358
  /**
244
359
  * Consultar la config de Web Push del proxy.
245
360
  * @returns {Promise<{enabled:boolean, vapidPublicKey:string|null}>}
@@ -451,7 +566,12 @@ export class WebSocketProxyClient {
451
566
  const wasConnected = this._connected
452
567
  this._connected = false
453
568
  this._emit('disconnect', { code: ev.code, reason: ev.reason })
454
- if (wasConnected && this.autoReconnect && ev.code !== 1000) {
569
+ // Reconectar ante cualquier cierre del servidor (incluido code 1000 de un
570
+ // restart limpio del proxy): los cierres INTENCIONALES del cliente ya
571
+ // tienen autoReconnect=false (puesto por close()), así que este guard no
572
+ // filtra desconexiones pedidas por la app. Sin esto, un restart del proxy
573
+ // deja a clientes de larga duración (bots, apps abiertas) zombis para siempre.
574
+ if (wasConnected && this.autoReconnect) {
455
575
  this._scheduleReconnect()
456
576
  }
457
577
  })
@@ -516,7 +636,20 @@ export class WebSocketProxyClient {
516
636
  const { type } = data
517
637
  switch (type) {
518
638
  case 'connected':
519
- this.token = data.token
639
+ // `instance` es el identificador de esta conexión, ya cualificado por
640
+ // nodo: se le puede escribir desde cualquier proxio de la malla. Es el
641
+ // mismo valor que `token` (el nombre histórico), pero ya NO es un código
642
+ // de 4 caracteres para dictar — para eso está `requestPairingCode()`.
643
+ this.instance = data.instance || data.token
644
+ this.node = data.node || null
645
+ // Los nodos que conoce este proxio. Sirve para los descubrimientos que
646
+ // son de TODO el ecosistema y no tienen dueño natural (la lista pública
647
+ // de salas): se pregunta en cada nodo y se mezcla, en vez de designar a
648
+ // uno como árbitro. Son públicos: van en cada instancia y en /peers.
649
+ this.peers = Array.isArray(data.peers) ? data.peers : []
650
+ /** Este nodo + los que conoce, sin repetidos. */
651
+ this.knownNodes = [this.node, ...this.peers].filter((n, i, a) => n && a.indexOf(n) === i)
652
+ this.token = this.instance
520
653
  this._emit('token', this.token)
521
654
  if (this._connectResolve) {
522
655
  this._connectResolve(this.token)
@@ -569,6 +702,9 @@ export class WebSocketProxyClient {
569
702
  case 'push-scheduled':
570
703
  case 'push-canceled':
571
704
  case 'push-list':
705
+ case 'turn-credentials':
706
+ case 'pair-code':
707
+ case 'pair-redeem':
572
708
  this._resolvePending(data, type)
573
709
  break
574
710
  case 'error':
@@ -12,7 +12,7 @@
12
12
  * the "polite" one (rolls back on collision).
13
13
  */
14
14
 
15
- const DEFAULT_ICE_SERVERS = [
15
+ export const DEFAULT_ICE_SERVERS = [
16
16
  { urls: 'stun:stun.l.google.com:19302' },
17
17
  { urls: 'stun:stun1.l.google.com:19302' },
18
18
  { urls: 'stun:global.stun.twilio.com:3478' }
@@ -93,6 +93,15 @@ export class WebRTCManager {
93
93
  for (const t of Array.from(this.peers.keys())) this.closePeer(t)
94
94
  }
95
95
 
96
+ /**
97
+ * Reemplaza la lista de ICE servers para las PRÓXIMAS conexiones (los peers
98
+ * ya negociados conservan la suya). Lo usa `client.enableTurn()` para
99
+ * inyectar las credenciales TURN temporales del proxy.
100
+ */
101
+ setIceServers (list) {
102
+ if (Array.isArray(list) && list.length) this.iceServers = list
103
+ }
104
+
96
105
  isOpen (to) {
97
106
  const p = this.peers.get(to)
98
107
  return !!(p && p.dc && p.dc.readyState === 'open')
@@ -1,6 +1,6 @@
1
- Copia vendorizada de @dotrino/vault@0.7.0 (lib/src/{index,enroll}.js, sin dependencias).
1
+ Copia vendorizada de @dotrino/vault@0.18.0 (lib/src/{index,enroll,protocol}.js, sin dependencias).
2
2
  El iframe de identity se sirve estatico (vanilla, sin build); asi startDeviceVault
3
- resuelve en el navegador sin bundler. index.js importa ./enroll.js (relativo, se
4
- vendoriza tambien) y @dotrino/identity/capabilities (=../../capabilities.js) y
5
- @dotrino/proxy-client (=../proxy-client/), ambos via el import map de index.html.
6
- Re-vendorizar AMBOS archivos al subir @dotrino/vault.
3
+ resuelve en el navegador sin bundler. index.js importa ./enroll.js y ./protocol.js
4
+ (relativos, se vendorizan tambien) y @dotrino/identity/capabilities (=../../capabilities.js)
5
+ y @dotrino/proxy-client (=../proxy-client/), ambos via el import map de index.html.
6
+ Re-vendorizar LOS TRES archivos al subir @dotrino/vault.
@@ -39,14 +39,20 @@ 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
 
48
54
  /** Los scopes del cert se corresponden 1:1 con las capacidades del acta (§D7). */
49
- const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read' }
55
+ const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read', 'vault:admin': 'admin' }
50
56
  export const scopeToCaps = (scope) =>
51
57
  (Array.isArray(scope) ? scope : [scope]).map((s) => SCOPE_TO_CAP[s]).filter(Boolean)
52
58
 
@@ -63,12 +69,22 @@ export function scopeToCn (scope) {
63
69
  return null
64
70
  }
65
71
 
66
- /** Token aleatorio de 128 bits en hex. */
67
- export function randToken () {
68
- const b = crypto.getRandomValues(new Uint8Array(16))
72
+ /**
73
+ * Token aleatorio en hex (16 bytes = 128 bits por defecto).
74
+ *
75
+ * El emparejamiento pide 12 (96 bits): son de un solo uso, valen 5 minutos y hay
76
+ * UNA sesión viva a la vez, así que adivinarlo es 2^95 intentos contra una bóveda
77
+ * que además exige el código de 6 dígitos. A cambio, cada byte de menos son ~1,4
78
+ * caracteres menos en el QR — y el QR se mide en filas de terminal.
79
+ */
80
+ export function randToken (bytes = 16) {
81
+ const b = crypto.getRandomValues(new Uint8Array(bytes))
69
82
  return [...b].map((x) => x.toString(16).padStart(2, '0')).join('')
70
83
  }
71
84
 
85
+ /** Tamaño del token/nonce de una sesión de emparejamiento (ver `randToken`). */
86
+ const PAIR_TOKEN_BYTES = 12
87
+
72
88
  /** deviceId legible (p. ej. `C440-AC0E`) a partir de una pubkey JWK. */
73
89
  export async function deviceIdOf (pub) {
74
90
  const id = (await pubkeyId(pub)).slice(0, 8).toUpperCase()
@@ -94,27 +110,65 @@ export async function deviceIdOf (pub) {
94
110
  export function createEnrollDesk ({
95
111
  identity, iss, proxy, send, sendByPubkey,
96
112
  audit = () => {}, log = () => {},
97
- onChallenge = () => {}, onPendingChange = () => {},
98
- 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
99
121
  } = {}) {
100
- if (!identity) throw new Error('createEnrollDesk: falta identity')
101
- if (!iss) throw new Error('createEnrollDesk: falta iss (pubkey de la maestra)')
122
+ if (!identity) throw new Error('createEnrollDesk: missing identity')
123
+ if (!iss) throw new Error('createEnrollDesk: missing iss (master pubkey)')
102
124
 
103
125
  // token -> { token, exp, scope, ttlMs, label, sn, state, dpub?, deviceId?, commit?, from? }
104
126
  // state: 'AWAITING_ENROLL' -> 'PENDING_CONFIRM'
105
127
  const pending = new Map()
106
128
 
107
129
  const fire = (fn, arg) => { try { fn(arg) } catch (_) {} }
108
- const reply = (to, obj) => { try { send(to, obj) } catch (e) { log('[vault] no se pudo responder:', e.message) } }
130
+ const reply = (to, obj) => { try { send(to, obj) } catch (e) { log('[vault] could not reply:', e.message) } }
109
131
  const isFresh = (d) => typeof d?.ts === 'number' && Math.abs(Date.now() - d.ts) <= FRESH_WINDOW_MS
110
132
 
111
- /** Inicia un emparejamiento: token + nonce de sesión. NO firma nada todavía. */
112
- function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '' } = {}) {
133
+ /**
134
+ * Inicia un emparejamiento: token + nonce de sesión. NO firma nada todavía.
135
+ *
136
+ * `mode` y `account` son LO QUE LA BÓVEDA DECLARA que va a pasar, y viajan en el QR
137
+ * para que el aparato pueda **decirlo antes de hacerlo** en vez de emparejar a
138
+ * ciegas (decisión V9 de `docs/vinculacion-de-cuentas.md`: pregunta el vault, el
139
+ * dispositivo muestra el proceso y sus consecuencias):
140
+ *
141
+ * · `mode: 'join'` → el dispositivo estrena una cuenta suya y entra a la de la
142
+ * bóveda. Es lo único que existe hoy.
143
+ * · `mode: 'adopt'` → la bóveda se quedaría con la cuenta que trae el aparato
144
+ * (camino A). Reservado: todavía no hay protocolo.
145
+ * · `account` → cómo se llama la cuenta de la bóveda, para nombrarla en el
146
+ * aviso. Es ORIENTATIVO (un nombre que puso su dueño); la
147
+ * identidad de verdad de la cuenta es `iss`.
148
+ */
149
+ async function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '', mode = 'join', account = '' } = {}) {
113
150
  pending.clear() // uno a la vez: una sesión nueva supersede a la anterior
114
- const token = randToken()
115
- const sn = randToken()
116
- pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, state: 'AWAITING_ENROLL' })
117
- return { token, qr: { v: 2, iss, proxy, token, sn }, expiresInMs: PAIRING_TTL_MS }
151
+ const acct = String(account || '').slice(0, 40)
152
+ // INVITACIÓN CORTA: si sabemos cómo alcanzarnos, el QR lleva solo eso y el
153
+ // nonce de la sesión. La llave, el proxy y el nombre de la cuenta los pide el
154
+ // aparato por la red presentando el `sn`. El nonce hace de identificador de
155
+ // sesión: no hace falta un token de emparejamiento aparte.
156
+ //
157
+ // `conn` es una CITA del proxio (6 caracteres, un solo uso, caduca en
158
+ // minutos), no la dirección de la conexión: esa pasó a ser una instancia de
159
+ // 24 caracteres, que ni entra cómoda en un QR ni tiene por qué quedar impresa
160
+ // en algo que circula. Por eso se pide una nueva por emparejamiento, y por
161
+ // eso esto es asíncrono.
162
+ const conn = typeof connToken === 'function' ? await connToken() : connToken
163
+ if (conn) {
164
+ const sn = randToken(8)
165
+ pending.set(sn, { token: sn, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
166
+ return { token: sn, qr: { v: 2, conn, sn, m: mode, proxy }, expiresInMs: PAIRING_TTL_MS }
167
+ }
168
+ const token = randToken(PAIR_TOKEN_BYTES)
169
+ const sn = randToken(PAIR_TOKEN_BYTES)
170
+ pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
171
+ return { token, qr: { v: 2, iss, proxy, token, sn, m: mode, ...(acct ? { acct } : {}) }, expiresInMs: PAIRING_TTL_MS }
118
172
  }
119
173
 
120
174
  function stopPairing (token) { pending.delete(token) }
@@ -132,6 +186,28 @@ export function createEnrollDesk ({
132
186
  return null
133
187
  }
134
188
 
189
+ /**
190
+ * «¿Quién eres?» — la respuesta al QR corto. Solo se contesta a quien presente el
191
+ * `sn` de una sesión VIVA: el token de conexión son 4 caracteres y se puede acertar
192
+ * a ciegas, el `sn` no. Fuera de un emparejamiento no hay ninguna sesión y por lo
193
+ * tanto no hay respuesta: la puerta solo está abierta mientras dura el `pair`.
194
+ */
195
+ async function handleHello (from, p) {
196
+ const pend = pending.get(String(p?.sn || ''))
197
+ if (!pend || Date.now() > pend.exp) {
198
+ audit('rejected', { what: 'hello', reason: 'sin-sesion' })
199
+ return reply(from, { type: MSG_ERROR, error: 'no pairing session open for that code' })
200
+ }
201
+ // La respuesta va FIRMADA por la maestra y el `sn` va dentro de lo firmado. Eso ata
202
+ // la respuesta a ESTA sesión: no se puede reutilizar la de otro emparejamiento ni la
203
+ // de otra bóveda. Lo que NO hace es demostrar que sea TU bóveda —cualquiera puede
204
+ // firmar con una llave suya—; eso solo lo demuestra el código de 6 dígitos.
205
+ const body = { op: 'hello', sn: pend.sn, iss, proxy, acct: pend.account || '', m: pend.mode || 'join', ts: Date.now() }
206
+ const { signature } = await identity.signData(body)
207
+ reply(from, { type: MSG_HELLO_OK, body, signature })
208
+ return { ok: true }
209
+ }
210
+
135
211
  /**
136
212
  * ENROLL: el dispositivo prueba posesión de `D` firmando el sobre y deja el
137
213
  * COMPROMISO de su código. Todavía NO se firma ningún cert.
@@ -139,31 +215,42 @@ export function createEnrollDesk ({
139
215
  async function handleEnroll (from, p) {
140
216
  const d = p?.data
141
217
  if (!d || typeof d.dpub !== 'string' || typeof p.signature !== 'string') {
142
- return reply(from, { type: MSG_ERROR, error: 'enroll inválido' })
218
+ return reply(from, { type: MSG_ERROR, error: 'invalid enroll' })
143
219
  }
144
220
  const pend = pending.get(d.token)
145
221
  if (!pend || pend.state === 'DONE' || Date.now() > pend.exp) {
146
- return reply(from, { type: MSG_ERROR, error: 'token de emparejamiento inválido o expirado' })
222
+ return reply(from, { type: MSG_ERROR, error: 'invalid or expired pairing token' })
223
+ }
224
+ if (d.sn !== pend.sn) return reply(from, { type: MSG_ERROR, error: 'invalid session' })
225
+ // V7 · la INTENCIÓN viaja firmada y tiene que coincidir con el modo con el que ESTA
226
+ // bóveda abrió el emparejamiento. Es lo que garantiza que lo que pasa es lo que el
227
+ // humano vio anunciado en las dos pantallas, y no algo que se decidió a mitad de camino.
228
+ const intent = d.intent || 'join'
229
+ if (intent !== 'join' && intent !== 'adopt') {
230
+ return reply(from, { type: MSG_ERROR, error: 'unknown intent: ' + intent })
231
+ }
232
+ if (intent !== (pend.mode || 'join')) {
233
+ audit('rejected', { what: 'enroll', reason: 'intent-mismatch' })
234
+ return reply(from, { type: MSG_ERROR, error: `este emparejamiento se abrió para «${pend.mode || 'join'}» y el dispositivo pidió «${intent}»` })
147
235
  }
148
- if (d.sn !== pend.sn) return reply(from, { type: MSG_ERROR, error: 'sesión inválida' })
149
236
  if (!isFresh(d)) {
150
237
  audit('rejected', { what: 'enroll', reason: 'stale' })
151
- 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)' })
238
+ return reply(from, { type: MSG_ERROR, error: 'stale request: ts outside the ±5 min window (possible replay, or the device clock is off)' })
152
239
  }
153
240
  // PRUEBA DE POSESIÓN: la firma de `data` debe verificar contra `dpub`.
154
241
  if (!(await verifyDeviceSig({ publickey: d.dpub, data: d, signature: p.signature }))) {
155
242
  audit('rejected', { what: 'enroll', reason: 'bad-device-signature' })
156
- return reply(from, { type: MSG_ERROR, error: 'firma de dispositivo inválida' })
243
+ return reply(from, { type: MSG_ERROR, error: 'invalid device signature' })
157
244
  }
158
245
  // El COMPROMISO del código es obligatorio: sin él no se puede comprobar al aprobar
159
246
  // y volveríamos a emitir certs a ciegas. Un cliente viejo cae acá con un mensaje claro.
160
247
  if (typeof d.commit !== 'string' || !/^[0-9a-f]{64}$/.test(d.commit)) {
161
248
  audit('rejected', { what: 'enroll', reason: 'no-commit' })
162
- return reply(from, { type: MSG_ERROR, error: 'este dispositivo usa una versión antigua del emparejamiento (no envía el compromiso del código). Actualízalo y vuelve a intentarlo.' })
249
+ return reply(from, { type: MSG_ERROR, error: 'this device speaks an old pairing version (no code commitment). Update it and try again.' })
163
250
  }
164
251
  // Un solo dispositivo a la vez esperando su código (así aprobar no es ambiguo).
165
252
  if (pend.state === 'PENDING_CONFIRM' && pend.dpub && pend.dpub !== d.dpub) {
166
- return reply(from, { type: MSG_ERROR, error: 'ya hay un dispositivo usando este emparejamiento' })
253
+ return reply(from, { type: MSG_ERROR, error: 'another device is already using this pairing session' })
167
254
  }
168
255
 
169
256
  const deviceId = await deviceIdOf(d.dpub)
@@ -182,9 +269,12 @@ export function createEnrollDesk ({
182
269
  }
183
270
  pend.from = from // la bóveda NO conoce el código: lo aprende cuando lo tipeas
184
271
  if (d.label) pend.label = String(d.label).slice(0, 60)
272
+ // Camino A: de qué cuenta estamos hablando. Se guarda para poder comprobar, cuando
273
+ // llegue el acta sellada, que es la que este dispositivo dijo que iba a entregar.
274
+ if (intent === 'adopt' && typeof d.profileId === 'string') pend.profileId = d.profileId
185
275
 
186
276
  reply(from, { type: MSG_ENROLL_CHALLENGE, deviceId })
187
- fire(onChallenge, { deviceId, scope: pend.scope, label: pend.label || '' })
277
+ fire(onChallenge, { deviceId, scope: pend.scope, label: pend.label || '', mode: pend.mode || 'join' })
188
278
  fire(onPendingChange)
189
279
  return { deviceId }
190
280
  }
@@ -199,16 +289,16 @@ export function createEnrollDesk ({
199
289
  */
200
290
  async function approve (code, { deviceId } = {}) {
201
291
  code = String(code || '').trim()
202
- if (!code) throw new Error('falta el código (los dígitos que muestra el dispositivo)')
292
+ if (!code) throw new Error('missing code (the digits shown by the device)')
203
293
 
204
294
  let pend
205
295
  if (deviceId) {
206
296
  pend = findPending(deviceId)
207
- if (!pend) throw new Error('no hay ninguna máquina esperando aprobación con ese identificador')
297
+ if (!pend) throw new Error('no device awaiting approval with that id')
208
298
  } else {
209
299
  const waiting = [...pending.values()].filter((p) => p.state === 'PENDING_CONFIRM' && p.dpub)
210
- if (waiting.length === 0) throw new Error('no hay ningún dispositivo esperando aprobación')
211
- if (waiting.length > 1) throw new Error('hay más de un emparejamiento en curso; reinícialo con dotrino-vault pair')
300
+ if (waiting.length === 0) throw new Error('no device awaiting approval')
301
+ if (waiting.length > 1) throw new Error('more than one pairing in flight; restart it with dotrino-vault pair')
212
302
  pend = waiting[0]
213
303
  }
214
304
 
@@ -216,8 +306,22 @@ export function createEnrollDesk ({
216
306
  const expected = await commitCode({ code, dpub: pend.dpub, sn: pend.sn })
217
307
  if (expected !== pend.commit) {
218
308
  audit('rejected', { what: 'approve', device: pend.deviceId, reason: 'bad-code' })
219
- log('[vault] código incorrecto para %s: no se emitió ningún certificado', pend.deviceId)
220
- 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.')
309
+ log('[vault] wrong code for %s: no certificate was issued', pend.deviceId)
310
+ throw new Error('code does not match the one shown by the device: no certificate was issued. Check it and try again.')
311
+ }
312
+
313
+ // CAMINO A · aquí la bóveda no entrega un cert: entrega SU IDENTIDAD para que el
314
+ // aparato la meta en el acta de la cuenta que le está pasando. El código de vuelta es
315
+ // la misma defensa de siempre, en el otro sentido: el aparato solo hace caso a una
316
+ // bóveda que demuestre que un humano la aprobó.
317
+ if ((pend.mode || 'join') === 'adopt') {
318
+ audit('adopt-approve', { device: pend.deviceId, profile: pend.profileId || null })
319
+ pend.state = 'AWAITING_ACTA'
320
+ pend.approvedAt = Date.now()
321
+ reply(pend.from, { type: MSG_ENROLL_ADOPT, code, pub: iss, encPub: encPub || null, label: vaultLabel || '' })
322
+ log('[vault] adoption approved for %s: waiting for the sealed record', pend.deviceId)
323
+ fire(onPendingChange)
324
+ return { ok: true, deviceId: pend.deviceId, adopting: true }
221
325
  }
222
326
 
223
327
  const { cert } = await identity.signDelegation(pend.dpub, pend.scope, { ttlMs: pend.ttlMs, label: pend.label })
@@ -247,13 +351,67 @@ export function createEnrollDesk ({
247
351
  return { ok: true, deviceId: pend.deviceId, cert }
248
352
  }
249
353
 
354
+ /**
355
+ * CAMINO A · paso 6: llega el acta que el aparato acaba de sellar, con la bóveda dentro
356
+ * como miembro, la clave de contenido envuelta para ella y el mando ya traspasado.
357
+ *
358
+ * Lo que se comprueba antes de guardar nada (y por qué):
359
+ * · que el sellador sea ESTA bóveda — si no, no es un traspaso, es un acta ajena;
360
+ * · que la selle el aparato que estaba en este emparejamiento — cierra que un tercero
361
+ * que vea pasar el mensaje cuele la suya;
362
+ * · que sea la cuenta que ese aparato declaró al enrolarse (`profileId`) — cierra el
363
+ * cambiazo de cuenta entre el anuncio que leyó el humano y lo que llega después.
364
+ *
365
+ * Adoptar la cuenta de otro solo procede sobre un perfil que **nació para eso** (la marca
366
+ * de `prepareForAdoption`). Es la misma regla del navegador: sin la marca, adoptar sería
367
+ * pisar una cuenta con datos, y eso no puede pasar por accidente.
368
+ */
369
+ async function handleActaSealed (from, p) {
370
+ const acta = p?.acta
371
+ const pend = [...pending.values()].find((x) => x.state === 'AWAITING_ACTA' && (x.from === from || x.dpub))
372
+ if (!pend) return reply(from, { type: MSG_ERROR, error: 'no adoption awaiting a record' })
373
+ if (!acta || typeof acta !== 'object') return reply(from, { type: MSG_ERROR, error: 'record missing or unreadable' })
374
+ if (acta.sealer !== iss) {
375
+ audit('rejected', { what: 'adopt', reason: 'not-sealer' })
376
+ return reply(from, { type: MSG_ERROR, error: 'that record does not name this vault as the sealer' })
377
+ }
378
+ if (acta.sealedBy !== pend.dpub) {
379
+ audit('rejected', { what: 'adopt', reason: 'sealed-by-other' })
380
+ return reply(from, { type: MSG_ERROR, error: 'that record was not sealed by the device of this pairing' })
381
+ }
382
+ if (pend.profileId && acta.profileId !== pend.profileId) {
383
+ audit('rejected', { what: 'adopt', reason: 'other-profile' })
384
+ return reply(from, { type: MSG_ERROR, error: 'that record belongs to an account other than the one the device announced' })
385
+ }
386
+
387
+ try {
388
+ const r = await identity.joinProfile(acta)
389
+ if (!r?.joined) throw new Error(r?.reason || 'could not adopt')
390
+ audit('adopt', { device: pend.deviceId, profile: acta.profileId, seq: acta.seq })
391
+ // El acta que vuelve es la que la bóveda tiene guardada: el aparato la adopta y los
392
+ // dos quedan en la misma versión.
393
+ const mia = (await identity.profileActa?.())?.acta || acta
394
+ reply(pend.from, { type: MSG_ACTA_ADOPTED, code: p.code, acta: mia })
395
+ pend.state = 'DONE'
396
+ pending.delete(pend.token)
397
+ fire(onPendingChange)
398
+ fire(onAdopted, { deviceId: pend.deviceId, profileId: acta.profileId, seq: mia.seq })
399
+ log('[vault] cuenta adoptada del dispositivo %s (perfil %s)', pend.deviceId, acta.profileId?.slice(0, 12))
400
+ return { ok: true, adopted: true, profileId: acta.profileId, seq: mia.seq }
401
+ } catch (e) {
402
+ log('[vault] no se pudo adoptar la cuenta: %s', e.message)
403
+ reply(pend.from, { type: MSG_ERROR, error: 'the vault could not adopt the account: ' + e.message })
404
+ return { ok: false, error: e.message }
405
+ }
406
+ }
407
+
250
408
  /** Rechaza un enrolamiento pendiente. */
251
409
  function reject (deviceId) {
252
410
  const pend = deviceId
253
411
  ? findPending(deviceId)
254
412
  : [...pending.values()].find((p) => p.state === 'PENDING_CONFIRM')
255
413
  if (!pend) return { ok: false }
256
- reply(pend.from, { type: MSG_ERROR, error: 'emparejamiento rechazado' })
414
+ reply(pend.from, { type: MSG_ERROR, error: 'pairing rejected' })
257
415
  pending.delete(pend.token)
258
416
  audit('reject', { device: pend.deviceId })
259
417
  fire(onPendingChange)
@@ -270,7 +428,7 @@ export function createEnrollDesk ({
270
428
  const body = { op: 'revoke', sub: dpub, nonce, iat: Date.now(), exp: Date.now() + DEVICE_TTL_MS }
271
429
  const { signature } = await identity.signData(body)
272
430
  try { sendByPubkey(dpub, { type: MSG_REVOKED, body, signature }) }
273
- catch (e) { log('[vault] no se pudo emitir revoke:', e.message) }
431
+ catch (e) { log('[vault] could not emit revoke:', e.message) }
274
432
  }
275
433
 
276
434
  /** Revoca una delegación por `nonce` y avisa al dispositivo para que se autoborre. */
@@ -284,7 +442,7 @@ export function createEnrollDesk ({
284
442
  }
285
443
 
286
444
  return {
287
- startPairing, stopPairing, handleEnroll, approve, reject,
445
+ startPairing, stopPairing, handleEnroll, handleActaSealed, handleHello, approve, reject,
288
446
  listPending, findPending, emitRevoke, revoke,
289
447
  get pendingCount () { return pending.size }
290
448
  }