@dotrino/vaultd 0.8.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/README.md CHANGED
@@ -195,6 +195,15 @@ entre las dos pantallas y corras `approve`. Un código robado ya no alcanza para
195
195
  entrar; la revocación de un dispositivo le ordena **autoborrarse** (con firma de la
196
196
  maestra, no por un mensaje cualquiera).
197
197
 
198
+ La invitación viaja **comprimida** (`lib/src/invite.js`): los datos van en binario y el
199
+ binario en base64url, ~100 caracteres en vez de los ~340 del JSON. Eso baja el QR de 69
200
+ módulos a 41 —de 77×39 a 49×25 en la terminal— y de paso deja un código pegable de una
201
+ sola palabra. El grueso del ahorro es la llave maestra: en el QR va el **punto comprimido**
202
+ de la curva (33 bytes) y el lector rearma la JWK con una plantilla, comprobando que sale
203
+ **byte a byte** igual, porque el proxy direcciona por esa string exacta. Si una llave no
204
+ encaja en ninguna plantilla, la invitación sale en su forma larga: se hace grande, nunca
205
+ incorrecta.
206
+
198
207
  El servicio se gestiona con systemd `--user`
199
208
  (`systemctl --user {start,stop,restart} dotrino-vault`). Tus datos —clave maestra
200
209
  incluida— viven en `~/.local/share/dotrino/vault` (permisos `0600`/`0700`), con un
@@ -323,6 +332,7 @@ sin vault; solo la feature que los necesita (TURN) espera. Primer consumidor:
323
332
  - `src/store.js` — árbol de contenidos (`vault.json`, versionado).
324
333
  - `src/client.js` — helper de **dispositivo** (enrolar / pedir firma / leer).
325
334
  - `src/protocol.js` — tipos de mensaje y scopes. · `src/qr.js` — QR ASCII. · `src/paths.js` — dirs.
335
+ - `lib/src/invite.js` — la invitación de emparejamiento: cómo se comprime y cómo se lee.
326
336
  - `bin/sea-entry.js` — entrypoint del binario único (multicall daemon / `--ctl` / `--tui`).
327
337
  - `bin/dotrino-vaultd.js` — entrypoint de desarrollo (node directo).
328
338
  - `bin/dotrino-vault-tui.js` — entrypoint de desarrollo de la TUI.
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
 
@@ -63,12 +67,22 @@ export function scopeToCn (scope) {
63
67
  return null
64
68
  }
65
69
 
66
- /** Token aleatorio de 128 bits en hex. */
67
- export function randToken () {
68
- const b = crypto.getRandomValues(new Uint8Array(16))
70
+ /**
71
+ * Token aleatorio en hex (16 bytes = 128 bits por defecto).
72
+ *
73
+ * El emparejamiento pide 12 (96 bits): son de un solo uso, valen 5 minutos y hay
74
+ * UNA sesión viva a la vez, así que adivinarlo es 2^95 intentos contra una bóveda
75
+ * que además exige el código de 6 dígitos. A cambio, cada byte de menos son ~1,4
76
+ * caracteres menos en el QR — y el QR se mide en filas de terminal.
77
+ */
78
+ export function randToken (bytes = 16) {
79
+ const b = crypto.getRandomValues(new Uint8Array(bytes))
69
80
  return [...b].map((x) => x.toString(16).padStart(2, '0')).join('')
70
81
  }
71
82
 
83
+ /** Tamaño del token/nonce de una sesión de emparejamiento (ver `randToken`). */
84
+ const PAIR_TOKEN_BYTES = 12
85
+
72
86
  /** deviceId legible (p. ej. `C440-AC0E`) a partir de una pubkey JWK. */
73
87
  export async function deviceIdOf (pub) {
74
88
  const id = (await pubkeyId(pub)).slice(0, 8).toUpperCase()
@@ -94,8 +108,11 @@ export async function deviceIdOf (pub) {
94
108
  export function createEnrollDesk ({
95
109
  identity, iss, proxy, send, sendByPubkey,
96
110
  audit = () => {}, log = () => {},
97
- onChallenge = () => {}, onPendingChange = () => {},
98
- 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 = ''
99
116
  } = {}) {
100
117
  if (!identity) throw new Error('createEnrollDesk: falta identity')
101
118
  if (!iss) throw new Error('createEnrollDesk: falta iss (pubkey de la maestra)')
@@ -126,8 +143,8 @@ export function createEnrollDesk ({
126
143
  */
127
144
  function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '', mode = 'join', account = '' } = {}) {
128
145
  pending.clear() // uno a la vez: una sesión nueva supersede a la anterior
129
- const token = randToken()
130
- const sn = randToken()
146
+ const token = randToken(PAIR_TOKEN_BYTES)
147
+ const sn = randToken(PAIR_TOKEN_BYTES)
131
148
  const acct = String(account || '').slice(0, 40)
132
149
  pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
133
150
  return { token, qr: { v: 2, iss, proxy, token, sn, m: mode, ...(acct ? { acct } : {}) }, expiresInMs: PAIRING_TTL_MS }
@@ -162,6 +179,17 @@ export function createEnrollDesk ({
162
179
  return reply(from, { type: MSG_ERROR, error: 'token de emparejamiento inválido o expirado' })
163
180
  }
164
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
+ }
165
193
  if (!isFresh(d)) {
166
194
  audit('rejected', { what: 'enroll', reason: 'stale' })
167
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)' })
@@ -198,9 +226,12 @@ export function createEnrollDesk ({
198
226
  }
199
227
  pend.from = from // la bóveda NO conoce el código: lo aprende cuando lo tipeas
200
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
201
232
 
202
233
  reply(from, { type: MSG_ENROLL_CHALLENGE, deviceId })
203
- fire(onChallenge, { deviceId, scope: pend.scope, label: pend.label || '' })
234
+ fire(onChallenge, { deviceId, scope: pend.scope, label: pend.label || '', mode: pend.mode || 'join' })
204
235
  fire(onPendingChange)
205
236
  return { deviceId }
206
237
  }
@@ -236,6 +267,20 @@ export function createEnrollDesk ({
236
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.')
237
268
  }
238
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
+
239
284
  const { cert } = await identity.signDelegation(pend.dpub, pend.scope, { ttlMs: pend.ttlMs, label: pend.label })
240
285
 
241
286
  // Aprobar un emparejamiento ES admitir al dispositivo en el perfil: el cert es la
@@ -263,6 +308,60 @@ export function createEnrollDesk ({
263
308
  return { ok: true, deviceId: pend.deviceId, cert }
264
309
  }
265
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
+
266
365
  /** Rechaza un enrolamiento pendiente. */
267
366
  function reject (deviceId) {
268
367
  const pend = deviceId
@@ -300,7 +399,7 @@ export function createEnrollDesk ({
300
399
  }
301
400
 
302
401
  return {
303
- startPairing, stopPairing, handleEnroll, approve, reject,
402
+ startPairing, stopPairing, handleEnroll, handleActaSealed, approve, reject,
304
403
  listPending, findPending, emitRevoke, revoke,
305
404
  get pendingCount () { return pending.size }
306
405
  }
package/lib/src/invite.js CHANGED
@@ -1,65 +1,310 @@
1
1
  /**
2
2
  * invite.js — la invitación de emparejamiento: cómo se escribe y cómo se lee.
3
3
  *
4
- * Un emparejamiento viaja de dos maneras, y cada una quiere una codificación
5
- * distinta:
4
+ * Una invitación viaja de dos maneras —un QR que se escanea y un código que se
5
+ * copia y se pega— y **las dos quieren lo mismo: que sea CORTA**. En un QR cada
6
+ * carácter son módulos, y los módulos son filas y columnas de terminal; en un
7
+ * código pegable, cada carácter es una oportunidad de que alguien lo corte mal.
6
8
  *
7
- * · **El QR / el enlace** JSON CRUDO (`j`). Nadie lo lee con los ojos y cada
8
- * carácter cuenta: el base64 infla el payload un 33 % y eso son módulos de más
9
- * en el QR (~8 columnas y 4 filas de terminal). Va crudo.
10
- * · **El código que se copia y se pega** base64url (`b`). Ahí lo manipula
11
- * una persona: una sola palabra sin comillas, llaves ni espacios, que sobrevive
12
- * a un doble clic, a un chat y a un campo de texto.
9
+ * Por eso el formato vigente es **`c` (compacto)**: los datos van en BINARIO y el
10
+ * binario en base64url. Nada de JSON. Un JSON con la llave maestra dentro pesa
11
+ * ~340 caracteres (la llave es una JWK *serializada como string*, con sus comillas
12
+ * escapadas: 182 de esos 340 son ella sola); el mismo contenido en binario son ~75
13
+ * bytes **~100 caracteres**. Medido en la práctica: el QR pasó de 69 módulos
14
+ * (77×39 en la terminal) a 41 (49×25), y el código pegable de 458 caracteres a 101.
13
15
  *
14
- * Para que el lector no tenga que ADIVINAR cuál de las dos le llegó, el payload
15
- * empieza por una **marca de formato** de un carácter (`j` o `b`). Sin marca se
16
- * asume el formato viejo (base64url, y si no, JSON), solo por compatibilidad con
17
- * los enlaces que ya salieron.
16
+ * De dónde sale el ahorro, en orden de importancia:
17
+ * · **La llave `iss`** (182 44 chars). Una pubkey P-256 son dos coordenadas de
18
+ * 32 bytes; en el QR va el **punto comprimido** de 33 bytes (SEC1: `02`/`03`
19
+ * según la paridad de `y`, seguido de `x`) y el lector recupera `y` resolviendo
20
+ * la curva. La JWK se rearma con una PLANTILLA (el índice va en la cabecera)
21
+ * para que la string vuelva **byte a byte** igual: el proxy direcciona por esa
22
+ * string exacta, así que una coma de más rompe el enrutamiento.
23
+ * · **`token` y `sn`** (34+34 → 16+16 bytes): eran hexadecimal, que gasta dos
24
+ * caracteres por byte.
25
+ * · **`proxy`** (33 → 0): si es el del ecosistema no viaja; se sobreentiende.
26
+ * · **`m` y `v`**: dos campos de JSON → dos grupos de bits de la cabecera.
18
27
  *
19
- * GOTCHA que justifica la mitad de este archivo: el JSON crudo lleva `{`, `}` y
20
- * `"`, que **no son legales en una URI**. Al abrir el enlace, el navegador los
21
- * percent-codifica (`%22`…), así que lo que llega a `location.hash` NO es el JSON
22
- * que se emitió. Medido en un navegador real (2026-07-28). Por eso se
23
- * `decodeURIComponent` antes de parsear: sin eso, el QR daba «ese código no vale».
28
+ * SEGURIDAD DEL AHORRO: `encodeInvite` **comprueba el viaje de vuelta** antes de
29
+ * entregar la forma compacta decodifica lo que acaba de codificar y exige que sea
30
+ * idéntico al original. Si algo no encaja (una JWK con otra forma, un campo nuevo,
31
+ * un `token` que no es hex), devuelve la forma larga en base64. Así una llave rara
32
+ * no rompe un emparejamiento: como mucho lo hace más grande.
33
+ *
34
+ * Formatos que se leen (el primer carácter dice cuál es):
35
+ * · `c` — compacto binario+base64url. **El que se emite hoy**, para el QR y para
36
+ * el código pegable: es a la vez el más corto y una sola palabra sin
37
+ * comillas ni llaves, que sobrevive a un doble clic y a un chat.
38
+ * · `b` — base64url del JSON. Forma larga, de reserva.
39
+ * · `j` — JSON crudo. Solo para leer enlaces ya emitidos.
40
+ * · sin marca — anterior a la marca de formato (base64url, o JSON).
41
+ *
42
+ * GOTCHA que justifica el `decodeURIComponent`: el JSON crudo lleva `{`, `}` y `"`,
43
+ * que **no son legales en una URI**. Al abrir el enlace, el navegador los
44
+ * percent-codifica (`%22`…), así que lo que llega a `location.hash` NO es lo que se
45
+ * emitió. Medido en un navegador real (2026-07-28). El formato compacto no tiene
46
+ * ese problema —base64url ya es seguro en una URL—, pero `j` sigue por ahí.
24
47
  */
25
48
 
26
49
  export const FMT_JSON = 'j'
27
50
  export const FMT_B64 = 'b'
51
+ export const FMT_COMPACT = 'c'
52
+
53
+ /** El proxy del ecosistema: si es este, no viaja en la invitación compacta. */
54
+ export const DEFAULT_PROXY = 'wss://proxy.dotrino.com'
55
+
56
+ /**
57
+ * La base del enlace del QR. Corta a propósito (`/d#v=` en vez de
58
+ * `/dispositivos#vault=`): son 15 caracteres menos dentro del QR, y ahí los
59
+ * caracteres se pagan en módulos. `vault.dotrino.com/dispositivos` sigue
60
+ * funcionando —es la ruta que la gente ya tiene— y `parseInvite` lee las dos.
61
+ */
62
+ export const PAIR_URL = 'https://vault.dotrino.com/d#v='
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Conversiones (sin dependencias: esto corre en el navegador, en Node y dentro
66
+ // del binario SEA)
67
+ // ---------------------------------------------------------------------------
68
+
69
+ const B64_STD = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
28
70
 
29
- const b64urlEncode = (s) => {
30
- const bytes = new TextEncoder().encode(s)
31
- let bin = ''
32
- for (const b of bytes) bin += String.fromCharCode(b)
33
- const raw = typeof btoa === 'function' ? btoa(bin) : Buffer.from(s, 'utf8').toString('base64')
34
- return raw.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
71
+ function bytesToB64url (bytes) {
72
+ let out = ''
73
+ for (let i = 0; i < bytes.length; i += 3) {
74
+ const a = bytes[i]; const b = bytes[i + 1]; const c = bytes[i + 2]
75
+ out += B64_STD[a >> 2]
76
+ out += B64_STD[((a & 3) << 4) | ((b ?? 0) >> 4)]
77
+ if (b === undefined) break
78
+ out += B64_STD[((b & 15) << 2) | ((c ?? 0) >> 6)]
79
+ if (c === undefined) break
80
+ out += B64_STD[c & 63]
81
+ }
82
+ return out.replace(/\+/g, '-').replace(/\//g, '_')
35
83
  }
36
84
 
37
- const b64urlDecode = (s) => {
38
- const b = s.replace(/-/g, '+').replace(/_/g, '/')
39
- const pad = b + '='.repeat((4 - b.length % 4) % 4)
40
- if (typeof atob === 'function') {
41
- const bin = atob(pad)
42
- const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0))
43
- return new TextDecoder().decode(bytes)
85
+ function b64urlToBytes (s) {
86
+ const clean = String(s).replace(/-/g, '+').replace(/_/g, '/').replace(/[^A-Za-z0-9+/]/g, '')
87
+ const out = []
88
+ let acc = 0; let bits = 0
89
+ for (const ch of clean) {
90
+ const v = B64_STD.indexOf(ch)
91
+ if (v < 0) return null
92
+ acc = (acc << 6) | v; bits += 6
93
+ if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 0xff) }
44
94
  }
45
- return Buffer.from(pad, 'base64').toString('utf8')
95
+ return Uint8Array.from(out)
46
96
  }
47
97
 
48
- /** El payload marcado, listo para meter en el `#fragment` o para copiar y pegar. */
49
- export function encodeInvite (qr, fmt = FMT_B64) {
98
+ const utf8 = (s) => new TextEncoder().encode(s)
99
+ const fromUtf8 = (b) => new TextDecoder().decode(b)
100
+
101
+ /**
102
+ * `token` y `sn` son hexadecimal. Caben en la invitación en dos tamaños —12 o 16
103
+ * bytes— porque el hexadecimal gasta dos caracteres por byte y en un QR eso se
104
+ * paga: 12 bytes (96 bits, lo que emite hoy `startPairing`) son 11 caracteres
105
+ * menos que 16, y esos 11 caracteres son la diferencia entre que quepa un nombre
106
+ * de cuenta normal o que el QR suba una versión entera.
107
+ */
108
+ const hexLen = (s) => (typeof s === 'string' && /^[0-9a-f]+$/.test(s) && (s.length === 24 || s.length === 32)) ? s.length / 2 : 0
109
+ const hexToBytes = (s) => Uint8Array.from(s.match(/../g).map((h) => parseInt(h, 16)))
110
+ const bytesToHex = (b) => [...b].map((x) => x.toString(16).padStart(2, '0')).join('')
111
+
112
+ const b64urlEncodeStr = (s) => bytesToB64url(utf8(s))
113
+ const b64urlDecodeStr = (s) => { const b = b64urlToBytes(s); return b ? fromUtf8(b) : null }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // La llave maestra: JWK ⇄ punto comprimido de la curva P-256
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /**
120
+ * Las formas de JWK que existen en el ecosistema. `JSON.stringify` de una JWK
121
+ * conserva el orden en que la exportó cada WebCrypto, y **ese orden es parte de la
122
+ * identidad**: el proxy direcciona por la string exacta. Por eso no se «normaliza»
123
+ * la JWK —eso cambiaría la dirección de la bóveda—, se rearma tal cual con la
124
+ * plantilla que le toca. El índice viaja en la cabecera (2 bits, hasta 4 formas).
125
+ */
126
+ const JWK_TEMPLATES = [
127
+ // 0 — WebCrypto de Node (el daemon del PC): key_ops, ext, kty, x, y, crv
128
+ (x, y) => `{"key_ops":["verify"],"ext":true,"kty":"EC","x":"${x}","y":"${y}","crv":"P-256"}`,
129
+ // 1 — WebCrypto de navegador (Chrome/Firefox/Safari, alfabético): crv, ext, key_ops, kty, x, y
130
+ (x, y) => `{"crv":"P-256","ext":true,"key_ops":["verify"],"kty":"EC","x":"${x}","y":"${y}"}`,
131
+ ]
132
+
133
+ // Parámetros de la curva P-256 (FIPS 186-4). `a` es −3.
134
+ const P256_P = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffffn
135
+ const P256_B = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604bn
136
+
137
+ const modPow = (base, exp, m) => {
138
+ let r = 1n; let b = base % m
139
+ while (exp > 0n) { if (exp & 1n) r = (r * b) % m; b = (b * b) % m; exp >>= 1n }
140
+ return r
141
+ }
142
+
143
+ const bytesToBig = (b) => { let n = 0n; for (const x of b) n = (n << 8n) | BigInt(x); return n }
144
+ const bigToBytes32 = (n) => { const out = new Uint8Array(32); for (let i = 31; i >= 0; i--) { out[i] = Number(n & 0xffn); n >>= 8n } return out }
145
+
146
+ /** Coordenada de una JWK: base64url de 32 bytes exactos, o `null`. */
147
+ function coordBytes (s) {
148
+ if (typeof s !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(s)) return null
149
+ const b = b64urlToBytes(s)
150
+ return b && b.length === 32 ? b : null
151
+ }
152
+
153
+ /**
154
+ * `iss` (JWK serializada) → `{ tpl, point }` con el punto comprimido de 33 bytes, o
155
+ * `null` si la llave no tiene una forma conocida (entonces la invitación va larga).
156
+ */
157
+ function packPubkey (iss) {
158
+ if (typeof iss !== 'string') return null
159
+ let jwk
160
+ try { jwk = JSON.parse(iss) } catch { return null }
161
+ if (!jwk || jwk.kty !== 'EC' || jwk.crv !== 'P-256') return null
162
+ const xb = coordBytes(jwk.x); const yb = coordBytes(jwk.y)
163
+ if (!xb || !yb) return null
164
+
165
+ const tpl = JWK_TEMPLATES.findIndex((f) => f(jwk.x, jwk.y) === iss)
166
+ if (tpl < 0) return null // forma desconocida: no se puede rearmar igual → larga
167
+
168
+ const point = new Uint8Array(33)
169
+ point[0] = 2 + (yb[31] & 1) // 02 = y par, 03 = y impar
170
+ point.set(xb, 1)
171
+ return { tpl, point }
172
+ }
173
+
174
+ /** Punto comprimido + plantilla → la `iss` original (o `null` si el punto no es de la curva). */
175
+ function unpackPubkey (point, tpl) {
176
+ const template = JWK_TEMPLATES[tpl]
177
+ if (!template || point.length !== 33 || (point[0] !== 2 && point[0] !== 3)) return null
178
+
179
+ const x = bytesToBig(point.subarray(1))
180
+ if (x >= P256_P) return null
181
+ // y² = x³ − 3x + b (mod p). Con p ≡ 3 (mod 4) la raíz es y = (y²)^((p+1)/4).
182
+ const y2 = (((x * x) % P256_P) * x - 3n * x + P256_B) % P256_P
183
+ const rhs = (y2 + P256_P) % P256_P
184
+ let y = modPow(rhs, (P256_P + 1n) / 4n, P256_P)
185
+ if ((y * y) % P256_P !== rhs) return null // x no está en la curva
186
+ if ((y & 1n) !== BigInt(point[0] & 1)) y = P256_P - y
187
+
188
+ return template(bytesToB64url(point.subarray(1)), bytesToB64url(bigToBytes32(y)))
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // El formato compacto
193
+ // ---------------------------------------------------------------------------
194
+
195
+ /**
196
+ * Cabecera (1 byte):
197
+ * bits 0-2 versión del protocolo (`qr.v`, hoy 2)
198
+ * bit 3 modo: 0 = `join` · 1 = `adopt`
199
+ * bits 4-5 plantilla de la JWK (índice en `JWK_TEMPLATES`)
200
+ * bit 6 lleva proxy propio (si no, el del ecosistema)
201
+ * bit 7 `token`/`sn` de 12 bytes (si 0, de 16)
202
+ *
203
+ * Cuerpo: punto comprimido (33) ‖ token (n) ‖ sn (n) ‖ len+`acct` ‖ [len+`proxy`]
204
+ */
205
+ const MODES = ['join', 'adopt']
206
+
207
+ function compactEncode (qr) {
208
+ if (!qr || typeof qr !== 'object') return null
209
+ const v = qr.v
210
+ if (!Number.isInteger(v) || v < 0 || v > 7) return null
211
+ const mode = MODES.indexOf(qr.m)
212
+ if (mode < 0) return null
213
+ const n = hexLen(qr.token)
214
+ if (!n || hexLen(qr.sn) !== n) return null
215
+
216
+ const pub = packPubkey(qr.iss)
217
+ if (!pub || pub.tpl > 3) return null
218
+
219
+ const acct = utf8(String(qr.acct || ''))
220
+ if (acct.length > 255) return null
221
+ const ownProxy = qr.proxy && qr.proxy !== DEFAULT_PROXY
222
+ const proxy = ownProxy ? utf8(String(qr.proxy)) : null
223
+ if (proxy && proxy.length > 255) return null
224
+
225
+ // Un campo que no se sepa escribir se perdería en silencio: eso NO se hace.
226
+ const known = new Set(['v', 'iss', 'proxy', 'token', 'sn', 'm', 'acct'])
227
+ if (Object.keys(qr).some((k) => !known.has(k))) return null
228
+
229
+ const head = v | (mode << 3) | (pub.tpl << 4) | (ownProxy ? 0x40 : 0) | (n === 12 ? 0x80 : 0)
230
+ const out = [head, ...pub.point, ...hexToBytes(qr.token), ...hexToBytes(qr.sn), acct.length, ...acct]
231
+ if (proxy) out.push(proxy.length, ...proxy)
232
+ return bytesToB64url(Uint8Array.from(out))
233
+ }
234
+
235
+ function compactDecode (text) {
236
+ const b = b64urlToBytes(text)
237
+ const n = (b && (b[0] & 0x80)) ? 12 : 16
238
+ if (!b || b.length < 35 + n * 2) return null
239
+ const head = b[0]
240
+
241
+ const iss = unpackPubkey(b.subarray(1, 34), (head >> 4) & 3)
242
+ if (!iss) return null
243
+
244
+ let i = 34
245
+ const token = bytesToHex(b.subarray(i, i + n)); i += n
246
+ const sn = bytesToHex(b.subarray(i, i + n)); i += n
247
+ const acctLen = b[i]; i += 1
248
+ if (i + acctLen > b.length) return null
249
+ const acct = acctLen ? fromUtf8(b.subarray(i, i + acctLen)) : ''
250
+ i += acctLen
251
+
252
+ let proxy = DEFAULT_PROXY
253
+ if (head & 0x40) {
254
+ if (i >= b.length) return null
255
+ const n = b[i]; i += 1
256
+ if (i + n > b.length) return null
257
+ proxy = fromUtf8(b.subarray(i, i + n)); i += n
258
+ }
259
+ if (i !== b.length) return null // sobran bytes: no es lo que creemos que es
260
+
261
+ // Mismo orden de campos que `startPairing`, para que las dos formas serialicen igual.
262
+ return { v: head & 7, iss, proxy, token, sn, m: MODES[(head >> 3) & 1], ...(acct ? { acct } : {}) }
263
+ }
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // API
267
+ // ---------------------------------------------------------------------------
268
+
269
+ /** Comparación por contenido, sin depender del orden de las claves. */
270
+ const canon = (o) => JSON.stringify(Object.keys(o).sort().map((k) => [k, o[k]]))
271
+
272
+ /**
273
+ * El payload marcado, listo para meter en el `#fragment` o para copiar y pegar.
274
+ *
275
+ * Por defecto **compacto**, que es lo más corto y a la vez pegable. Se cae a la
276
+ * forma larga (`b`) sola, sin avisar, si el compacto no reproduce el original
277
+ * exactamente: más vale un QR grande que uno que no empareja.
278
+ */
279
+ export function encodeInvite (qr, fmt = FMT_COMPACT) {
50
280
  const json = JSON.stringify(qr)
51
- return fmt === FMT_JSON ? FMT_JSON + json : FMT_B64 + b64urlEncode(json)
281
+ if (fmt === FMT_JSON) return FMT_JSON + json
282
+ if (fmt === FMT_COMPACT) {
283
+ const c = compactEncode(qr)
284
+ // El viaje de vuelta se comprueba SIEMPRE: es barato (una vez por
285
+ // emparejamiento) y es lo que permite comprimir sin jugarse el enrolamiento.
286
+ if (c) { const back = compactDecode(c); if (back && canon(back) === canon(qr)) return FMT_COMPACT + c }
287
+ }
288
+ return FMT_B64 + b64urlEncodeStr(json)
52
289
  }
53
290
 
54
- /** Una URL sin percent-encodear rompe el `#`: el fragmento se corta ahí. */
291
+ /** El enlace del QR: `https://vault.dotrino.com/d#v=<invitación>`. */
292
+ export function inviteUrl (qr) { return PAIR_URL + encodeInvite(qr) }
293
+
294
+ /**
295
+ * Corta el `#fragment` de una URL. `#vault=` es la forma larga histórica y `#v=` la
296
+ * corta de hoy; se prueba la larga primero porque `#vault=` empieza por `#v`.
297
+ */
55
298
  const cutFragment = (text) => {
56
- const i = text.indexOf('#vault=')
57
- return i >= 0 ? text.slice(i + 7) : text
299
+ const long = text.indexOf('#vault=')
300
+ if (long >= 0) return text.slice(long + 7)
301
+ const short = text.indexOf('#v=')
302
+ return short >= 0 ? text.slice(short + 3) : text
58
303
  }
59
304
 
60
305
  /**
61
- * Lee una invitación venga como venga: URL con `#vault=…`, el código suelto, con
62
- * marca de formato o sin ella (formatos viejos). Devuelve el objeto del QR o
306
+ * Lee una invitación venga como venga: URL con `#v=`/`#vault=`, el código suelto,
307
+ * con marca de formato o sin ella (formatos viejos). Devuelve el objeto del QR o
63
308
  * `null` — nunca lanza, porque del otro lado hay alguien pegando texto a mano.
64
309
  */
65
310
  export function parseInvite (text) {
@@ -75,13 +320,15 @@ export function parseInvite (text) {
75
320
 
76
321
  const marca = payload[0]
77
322
  const resto = payload.slice(1)
78
- if (marca === FMT_JSON) return parse(undoUrl(resto)) || parse(resto)
79
- if (marca === FMT_B64) { try { return parse(b64urlDecode(resto)) } catch { return null } }
323
+ if (marca === FMT_COMPACT) { const o = compactDecode(resto); if (o) return o }
324
+ if (marca === FMT_JSON) { const o = parse(undoUrl(resto)) || parse(resto); if (o) return o }
325
+ if (marca === FMT_B64) { const s = b64urlDecodeStr(resto); const o = s && parse(s); if (o) return o }
80
326
 
81
327
  // --- sin marca: formatos anteriores a la marca de formato (compatibilidad) ---
82
328
  const crudo = undoUrl(payload)
83
329
  if (crudo.trimStart().startsWith('{')) return parse(crudo)
84
- try { return parse(b64urlDecode(payload)) } catch { return null }
330
+ const s = b64urlDecodeStr(payload)
331
+ return s ? parse(s) : null
85
332
  }
86
333
 
87
- export default { encodeInvite, parseInvite, FMT_JSON, FMT_B64 }
334
+ export default { encodeInvite, inviteUrl, parseInvite, FMT_JSON, FMT_B64, FMT_COMPACT, PAIR_URL, DEFAULT_PROXY }
@@ -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.8.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
@@ -26,6 +26,7 @@ import { execFileSync } from 'node:child_process'
26
26
  import { pubkeyId } from '@dotrino/identity/capabilities'
27
27
  import { dataDir, readJson } from './paths.js'
28
28
  import { qrToString } from './qr.js'
29
+ import { encodeInvite, inviteUrl } from '../lib/src/invite.js'
29
30
  import { VERSION } from './version.js'
30
31
 
31
32
  const dir = dataDir()
@@ -53,8 +54,6 @@ const writeReq = (name, obj) => fs.writeFileSync(path.join(dir, name), JSON.stri
53
54
  const R = '\x1b[31m', B = '\x1b[1m', Z = '\x1b[0m' // rojo / negrita / reset
54
55
  // La versión se inyecta en build (esbuild --define); en dev cae a 'dev'.
55
56
 
56
- const PROFILE_URL = 'https://vault.dotrino.com/dispositivos#vault='
57
-
58
57
  /**
59
58
  * Cómo se arranca el vault EN ESTA MÁQUINA. El CLI corre en Linux (servicio systemd),
60
59
  * en Windows/macOS (por npm, en primer plano o al inicio de sesión) y dentro de un
@@ -167,18 +166,34 @@ async function cmdPair (args = []) {
167
166
  PROFILE = d.id // el emparejamiento y los comandos siguientes apuntan a ELLA
168
167
  console.log('Cuenta nueva: %s (%s)', name, d.id)
169
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
+ }
170
184
  // La petición se escribe SIEMPRE (aunque no haya --service): lleva a qué perfil
171
185
  // se empareja el dispositivo.
172
- writeReq('pair-request.json', service ? { service } : {})
186
+ writeReq('pair-request.json', { ...(service ? { service } : {}), ...(adopt ? { mode: 'adopt' } : {}) })
173
187
  avisar(s.pid, 'SIGUSR1')
174
188
 
175
189
  let pair = null
176
190
  for (let i = 0; i < 50; i++) { await sleep(100); const p = readJson(pairFile, null); if (p?.expiresAt > Date.now()) { pair = p; break } }
177
191
  if (!pair) { console.error('No se recibió respuesta del daemon para el emparejamiento.'); process.exit(1) }
178
192
 
179
- const payload = JSON.stringify(pair.qr)
180
- const b64 = Buffer.from(payload, 'utf8').toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
181
- const url = PROFILE_URL + b64
193
+ // Una sola forma para las dos cosas: la invitación compacta (base64url de ~100
194
+ // caracteres) va igual de bien dentro del QR que pegada a mano. Ver `lib/src/invite.js`.
195
+ const b64 = encodeInvite(pair.qr)
196
+ const url = inviteUrl(pair.qr)
182
197
  const mins = Math.round((pair.expiresAt - Date.now()) / 60000)
183
198
  // QUÉ CUENTA se comparte: el vault puede tener varias bóvedas y este QR sale de
184
199
  // UNA (la activa, o la de --profile). Decirlo evita enrolar el dispositivo en la
@@ -186,7 +201,7 @@ async function cmdPair (args = []) {
186
201
  const acct = pair.profileName || pair.profile
187
202
  if (acct) console.log('\nCuenta que se comparte: %s%s', acct, pair.profileName && pair.profile ? ` (${pair.profile})` : '')
188
203
  console.log('\nEscanea este QR con el dispositivo que quieres conectar (válido %d min):\n', mins)
189
- console.log(qrToString(url)) // el QR abre vault.dotrino.com/dispositivos y empareja solo
204
+ console.log(qrToString(url)) // el QR abre la consola de dispositivos y empareja solo
190
205
  console.log(`${R}${B}⚠ Este código deja LEER tus datos y FIRMAR con tu identidad.${Z}`)
191
206
  console.log(`${R} NO lo compartas con nadie, ni con "soporte". Solo escanéalo en TU dispositivo.${Z}`)
192
207
  console.log('\nO abre esta dirección en el dispositivo:\n ' + url)
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,10 +134,12 @@ 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
- pairPaste: 'O pega este código en la pestaña #vault de profile.dotrino.com:',
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.',
142
144
  pairConnected: (id) => `⧗ Se conectó: ${id} — pulsa A y escribe el código que muestra.`,
143
145
  pairWaiting: 'Esperando a que el dispositivo se conecte…',
@@ -297,10 +299,10 @@ 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
- pairPaste: 'Or paste this code into the #vault tab of profile.dotrino.com:',
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.',
305
307
  pairConnected: (id) => `⧗ Connected: ${id} — press A and type the code it shows.`,
306
308
  pairWaiting: 'Waiting for the device to connect…',
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)
@@ -20,7 +20,7 @@ import fs from 'node:fs'
20
20
  import path from 'node:path'
21
21
  import { pubkeyId } from '@dotrino/identity/capabilities'
22
22
  import { dataDir, readJson } from './paths.js'
23
- import { encodeInvite, FMT_JSON, FMT_B64 } from '../lib/src/invite.js'
23
+ import { encodeInvite, inviteUrl } from '../lib/src/invite.js'
24
24
 
25
25
  const dir = dataDir()
26
26
 
@@ -256,24 +256,24 @@ export async function deleteScope (ns, profile) {
256
256
  // Emparejamiento de dispositivos (pares)
257
257
  // ---------------------------------------------------------------------------
258
258
 
259
- const PROFILE_URL = 'https://vault.dotrino.com/dispositivos#vault='
260
-
261
259
  /**
262
- * Codifica el QR crudo en sus dos formas (ver `lib/src/invite.js`):
260
+ * Codifica el QR crudo para enseñarlo (ver `lib/src/invite.js`).
263
261
  *
264
- * · `url` → para el QR y el enlace: JSON CRUDO con marca `j`. Es lo más corto
265
- * que hay, y en un QR cada carácter son módulos: con base64 el QR
266
- * pide ~8 columnas y 4 filas más de terminal.
267
- * · `code` para copiar y pegar: base64url con marca `b`. Una sola palabra,
268
- * sin comillas ni llaves, que sobrevive a un doble clic y a un chat.
262
+ * Las dos formas son **la misma invitación compacta**: binario en base64url, ~100
263
+ * caracteres. Sirve igual para el QR (donde cada carácter son módulos, y módulos
264
+ * son filas de terminal) que para copiar y pegar (una sola palabra, sin comillas
265
+ * ni llaves, que sobrevive a un doble clic y a un chat). Antes hacían falta dos
266
+ * codificaciones distintas porque la única forma de achicar el QR era mandar el
267
+ * JSON crudo, ilegible al pegarlo; comprimiendo de verdad, esa disyuntiva
268
+ * desaparece.
269
269
  *
270
- * La MARCA de formato (el primer carácter) existe para que el lector no tenga que
271
- * probar los dos formatos a ver cuál cuela: la lee y sabe.
270
+ * · `url` → el enlace del QR, `…/d#v=<invitación>`.
271
+ * · `code` → la invitación suelta, para pegar en la consola.
272
+ * · `payload` → el JSON crudo. Ya no se emite; se devuelve para diagnóstico.
272
273
  */
273
274
  export function pairUrl (qr) {
274
- const payload = JSON.stringify(qr)
275
- const code = encodeInvite(qr, FMT_B64)
276
- return { url: PROFILE_URL + encodeInvite(qr, FMT_JSON), code, payload, b64: code }
275
+ const code = encodeInvite(qr)
276
+ return { url: inviteUrl(qr), code, payload: JSON.stringify(qr), b64: code }
277
277
  }
278
278
 
279
279
  export async function startPairing ({ profile, service } = {}) {