@dotrino/vaultd 0.7.9 → 0.9.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.
@@ -39,7 +39,7 @@ if (process.argv.includes('--pair')) {
39
39
  const { qr, expiresInMs } = mgr.current().startPairing({ label: 'cli' })
40
40
  const { url, b64 } = pairUrl(qr)
41
41
  console.log(`\nEmparejá un dispositivo (válido ${expiresInMs / 60000} min):\n`)
42
- console.log(qrToString(url, 2))
42
+ console.log(qrToString(url))
43
43
  console.log(url)
44
44
  console.log('\nO pegá este código:\n ' + b64)
45
45
  }
package/lib/src/enroll.js CHANGED
@@ -63,12 +63,22 @@ export function scopeToCn (scope) {
63
63
  return null
64
64
  }
65
65
 
66
- /** Token aleatorio de 128 bits en hex. */
67
- export function randToken () {
68
- const b = crypto.getRandomValues(new Uint8Array(16))
66
+ /**
67
+ * Token aleatorio en hex (16 bytes = 128 bits por defecto).
68
+ *
69
+ * El emparejamiento pide 12 (96 bits): son de un solo uso, valen 5 minutos y hay
70
+ * UNA sesión viva a la vez, así que adivinarlo es 2^95 intentos contra una bóveda
71
+ * que además exige el código de 6 dígitos. A cambio, cada byte de menos son ~1,4
72
+ * caracteres menos en el QR — y el QR se mide en filas de terminal.
73
+ */
74
+ export function randToken (bytes = 16) {
75
+ const b = crypto.getRandomValues(new Uint8Array(bytes))
69
76
  return [...b].map((x) => x.toString(16).padStart(2, '0')).join('')
70
77
  }
71
78
 
79
+ /** Tamaño del token/nonce de una sesión de emparejamiento (ver `randToken`). */
80
+ const PAIR_TOKEN_BYTES = 12
81
+
72
82
  /** deviceId legible (p. ej. `C440-AC0E`) a partir de una pubkey JWK. */
73
83
  export async function deviceIdOf (pub) {
74
84
  const id = (await pubkeyId(pub)).slice(0, 8).toUpperCase()
@@ -126,8 +136,8 @@ export function createEnrollDesk ({
126
136
  */
127
137
  function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '', mode = 'join', account = '' } = {}) {
128
138
  pending.clear() // uno a la vez: una sesión nueva supersede a la anterior
129
- const token = randToken()
130
- const sn = randToken()
139
+ const token = randToken(PAIR_TOKEN_BYTES)
140
+ const sn = randToken(PAIR_TOKEN_BYTES)
131
141
  const acct = String(account || '').slice(0, 40)
132
142
  pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
133
143
  return { token, qr: { v: 2, iss, proxy, token, sn, m: mode, ...(acct ? { acct } : {}) }, expiresInMs: PAIRING_TTL_MS }
@@ -0,0 +1,334 @@
1
+ /**
2
+ * invite.js — la invitación de emparejamiento: cómo se escribe y cómo se lee.
3
+ *
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.
8
+ *
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.
15
+ *
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.
27
+ *
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í.
47
+ */
48
+
49
+ export const FMT_JSON = 'j'
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+/'
70
+
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, '_')
83
+ }
84
+
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) }
94
+ }
95
+ return Uint8Array.from(out)
96
+ }
97
+
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) {
280
+ const json = JSON.stringify(qr)
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)
289
+ }
290
+
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
+ */
298
+ const cutFragment = (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
303
+ }
304
+
305
+ /**
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
308
+ * `null` — nunca lanza, porque del otro lado hay alguien pegando texto a mano.
309
+ */
310
+ export function parseInvite (text) {
311
+ if (!text) return null
312
+ const payload = cutFragment(String(text).trim())
313
+ if (!payload) return null
314
+
315
+ const parse = (s) => { try { const o = JSON.parse(s); return (o && typeof o === 'object') ? o : null } catch { return null } }
316
+ // El navegador percent-codifica el JSON del fragmento; deshacerlo es un no-op si
317
+ // no lo tocó. Si el texto trae un `%` suelto, `decodeURIComponent` lanza: se usa
318
+ // el original.
319
+ const undoUrl = (s) => { try { return decodeURIComponent(s) } catch { return s } }
320
+
321
+ const marca = payload[0]
322
+ const resto = payload.slice(1)
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 }
326
+
327
+ // --- sin marca: formatos anteriores a la marca de formato (compatibilidad) ---
328
+ const crudo = undoUrl(payload)
329
+ if (crudo.trimStart().startsWith('{')) return parse(crudo)
330
+ const s = b64urlDecodeStr(payload)
331
+ return s ? parse(s) : null
332
+ }
333
+
334
+ export default { encodeInvite, inviteUrl, parseInvite, FMT_JSON, FMT_B64, FMT_COMPACT, PAIR_URL, DEFAULT_PROXY }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.7.9",
3
+ "version": "0.9.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": {
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
@@ -176,9 +175,10 @@ async function cmdPair (args = []) {
176
175
  for (let i = 0; i < 50; i++) { await sleep(100); const p = readJson(pairFile, null); if (p?.expiresAt > Date.now()) { pair = p; break } }
177
176
  if (!pair) { console.error('No se recibió respuesta del daemon para el emparejamiento.'); process.exit(1) }
178
177
 
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
178
+ // Una sola forma para las dos cosas: la invitación compacta (base64url de ~100
179
+ // caracteres) va igual de bien dentro del QR que pegada a mano. Ver `lib/src/invite.js`.
180
+ const b64 = encodeInvite(pair.qr)
181
+ const url = inviteUrl(pair.qr)
182
182
  const mins = Math.round((pair.expiresAt - Date.now()) / 60000)
183
183
  // QUÉ CUENTA se comparte: el vault puede tener varias bóvedas y este QR sale de
184
184
  // UNA (la activa, o la de --profile). Decirlo evita enrolar el dispositivo en la
@@ -186,7 +186,7 @@ async function cmdPair (args = []) {
186
186
  const acct = pair.profileName || pair.profile
187
187
  if (acct) console.log('\nCuenta que se comparte: %s%s', acct, pair.profileName && pair.profile ? ` (${pair.profile})` : '')
188
188
  console.log('\nEscanea este QR con el dispositivo que quieres conectar (válido %d min):\n', mins)
189
- console.log(qrToString(url, 2)) // QR con URL+JSON crudo (menos datos) y zona de silencio reducida
189
+ console.log(qrToString(url)) // el QR abre la consola de dispositivos y empareja solo
190
190
  console.log(`${R}${B}⚠ Este código deja LEER tus datos y FIRMAR con tu identidad.${Z}`)
191
191
  console.log(`${R} NO lo compartas con nadie, ni con "soporte". Solo escanéalo en TU dispositivo.${Z}`)
192
192
  console.log('\nO abre esta dirección en el dispositivo:\n ' + url)
package/src/tui/app.js CHANGED
@@ -701,7 +701,7 @@ function pairingBody (st, t, cols, height) {
701
701
  // QR: se dibuja siempre que quepa de ancho; si es más alto que la pantalla se
702
702
  // puede hacer scroll hacia arriba/abajo para verlo completo.
703
703
  let qr = ''
704
- try { qr = qrToString(info.url, 2) } catch (_) {}
704
+ try { qr = qrToString(info.url) } catch (_) {}
705
705
  const qrLines = qr ? qr.replace(/\n$/, '').split('\n') : []
706
706
  const qrWidth = qrLines.length ? Math.max(...qrLines.map((l) => l.replace(/\x1b\[[0-9;]*m/g, '').length)) : 0
707
707
  if (qrLines.length && qrWidth <= cols) {
package/src/tui/i18n.js CHANGED
@@ -137,7 +137,7 @@ const es = {
137
137
  pairAccount: (name) => `Cuenta que se comparte: ${name}`,
138
138
  pairValid: (min) => `Válido ~${min} min. Escanéalo o abre la URL en el dispositivo.`,
139
139
  pairUrl: 'URL: ',
140
- pairPaste: 'O pega este código en la pestaña #vault de profile.dotrino.com:',
140
+ pairPaste: 'O pega este código en vault.dotrino.com/dispositivos:',
141
141
  pairWarning: '⚠ Este código deja LEER tus datos y FIRMAR con tu identidad. No lo compartas.',
142
142
  pairConnected: (id) => `⧗ Se conectó: ${id} — pulsa A y escribe el código que muestra.`,
143
143
  pairWaiting: 'Esperando a que el dispositivo se conecte…',
@@ -300,7 +300,7 @@ const en = {
300
300
  pairAccount: (name) => `Account being shared: ${name}`,
301
301
  pairValid: (min) => `Valid ~${min} min. Scan it or open the URL on the device.`,
302
302
  pairUrl: 'URL: ',
303
- pairPaste: 'Or paste this code into the #vault tab of profile.dotrino.com:',
303
+ pairPaste: 'Or paste this code into vault.dotrino.com/dispositivos:',
304
304
  pairWarning: '⚠ This code lets someone READ your data and SIGN as you. Do not share it.',
305
305
  pairConnected: (id) => `⧗ Connected: ${id} — press A and type the code it shows.`,
306
306
  pairWaiting: 'Waiting for the device to connect…',
@@ -20,6 +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, inviteUrl } from '../lib/src/invite.js'
23
24
 
24
25
  const dir = dataDir()
25
26
 
@@ -255,25 +256,26 @@ export async function deleteScope (ns, profile) {
255
256
  // Emparejamiento de dispositivos (pares)
256
257
  // ---------------------------------------------------------------------------
257
258
 
258
- const PROFILE_URL = 'https://vault.dotrino.com/dispositivos#vault='
259
-
260
259
  /**
261
- * Codifica el QR crudo como URL de la consola de vault.dotrino.com.
262
- * El QR codifica la URL con el JSON crudo en el fragmento (menos datos que base64);
263
- * el código que el usuario COPIA Y PEGA sigue siendo base64url.
260
+ * Codifica el QR crudo para enseñarlo (ver `lib/src/invite.js`).
261
+ *
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
+ *
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.
264
273
  */
265
274
  export function pairUrl (qr) {
266
- const payload = JSON.stringify(qr)
267
- const b64 = Buffer.from(payload, 'utf8').toString('base64')
268
- .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
269
- return { url: PROFILE_URL + payload, payload, b64 }
275
+ const code = encodeInvite(qr)
276
+ return { url: inviteUrl(qr), code, payload: JSON.stringify(qr), b64: code }
270
277
  }
271
278
 
272
- /**
273
- * Inicia un emparejamiento. `service` (opcional) enrola un SERVICIO con acceso
274
- * SOLO a `vault:secrets:<service>` (no firma por ti ni lee tus datos).
275
- * Devuelve { qr, expiresAt, url, payload }.
276
- */
277
279
  export async function startPairing ({ profile, service } = {}) {
278
280
  requireAlive()
279
281
  rm(F.pair); rm(F.pending)
@@ -283,11 +285,11 @@ export async function startPairing ({ profile, service } = {}) {
283
285
  await sleep(100)
284
286
  const pr = read(F.pair, null)
285
287
  if (pr?.expiresAt > Date.now()) {
286
- const { url, payload, b64 } = pairUrl(pr.qr)
288
+ const { url, payload, code } = pairUrl(pr.qr)
287
289
  // `profile`/`profileName`: DE QUÉ CUENTA del vault sale este QR. El vault
288
290
  // puede tener varias y el emparejamiento mete al dispositivo en UNA; la TUI
289
291
  // y la CLI lo muestran para que no se enrole en la equivocada.
290
- return { qr: pr.qr, expiresAt: pr.expiresAt, url, payload, b64, profile: pr.profile || null, profileName: pr.profileName || '' }
292
+ return { qr: pr.qr, expiresAt: pr.expiresAt, url, payload, code, b64: code, profile: pr.profile || null, profileName: pr.profileName || '' }
291
293
  }
292
294
  }
293
295
  throw coded('el daemon no inició el emparejamiento', 'PAIR_FAILED')