@dotrino/vaultd 0.7.8 → 0.8.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/bin/dotrino-vaultd.js +5 -3
- package/lib/src/invite.js +87 -0
- package/package.json +1 -1
- package/src/ctl.js +1 -1
- package/src/tui/app.js +1 -1
- package/src/vaultControl.js +17 -11
package/bin/dotrino-vaultd.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { runDaemon } from '../src/daemon.js'
|
|
16
16
|
import { qrToString } from '../src/qr.js'
|
|
17
|
-
import { daemonAlive } from '../src/vaultControl.js'
|
|
17
|
+
import { daemonAlive, pairUrl } from '../src/vaultControl.js'
|
|
18
18
|
|
|
19
19
|
// `--tui` con una bóveda YA CORRIENDO en otra ventana: se engancha a ella y abre solo la
|
|
20
20
|
// interfaz. Antes intentaba levantar una segunda bóveda sobre los mismos datos, chocaba
|
|
@@ -37,9 +37,11 @@ const mgr = await runDaemon()
|
|
|
37
37
|
// Empareja contra el perfil ACTIVO.
|
|
38
38
|
if (process.argv.includes('--pair')) {
|
|
39
39
|
const { qr, expiresInMs } = mgr.current().startPairing({ label: 'cli' })
|
|
40
|
+
const { url, b64 } = pairUrl(qr)
|
|
40
41
|
console.log(`\nEmparejá un dispositivo (válido ${expiresInMs / 60000} min):\n`)
|
|
41
|
-
console.log(qrToString(
|
|
42
|
-
console.log(
|
|
42
|
+
console.log(qrToString(url))
|
|
43
|
+
console.log(url)
|
|
44
|
+
console.log('\nO pegá este código:\n ' + b64)
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
// --tui: la bóveda Y su interfaz en la MISMA ventana. Donde no queda como servicio
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* invite.js — la invitación de emparejamiento: cómo se escribe y cómo se lee.
|
|
3
|
+
*
|
|
4
|
+
* Un emparejamiento viaja de dos maneras, y cada una quiere una codificación
|
|
5
|
+
* distinta:
|
|
6
|
+
*
|
|
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í sí 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.
|
|
13
|
+
*
|
|
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.
|
|
18
|
+
*
|
|
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».
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export const FMT_JSON = 'j'
|
|
27
|
+
export const FMT_B64 = 'b'
|
|
28
|
+
|
|
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(/=+$/, '')
|
|
35
|
+
}
|
|
36
|
+
|
|
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)
|
|
44
|
+
}
|
|
45
|
+
return Buffer.from(pad, 'base64').toString('utf8')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** El payload marcado, listo para meter en el `#fragment` o para copiar y pegar. */
|
|
49
|
+
export function encodeInvite (qr, fmt = FMT_B64) {
|
|
50
|
+
const json = JSON.stringify(qr)
|
|
51
|
+
return fmt === FMT_JSON ? FMT_JSON + json : FMT_B64 + b64urlEncode(json)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Una URL sin percent-encodear rompe el `#`: el fragmento se corta ahí. */
|
|
55
|
+
const cutFragment = (text) => {
|
|
56
|
+
const i = text.indexOf('#vault=')
|
|
57
|
+
return i >= 0 ? text.slice(i + 7) : text
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
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
|
|
63
|
+
* `null` — nunca lanza, porque del otro lado hay alguien pegando texto a mano.
|
|
64
|
+
*/
|
|
65
|
+
export function parseInvite (text) {
|
|
66
|
+
if (!text) return null
|
|
67
|
+
const payload = cutFragment(String(text).trim())
|
|
68
|
+
if (!payload) return null
|
|
69
|
+
|
|
70
|
+
const parse = (s) => { try { const o = JSON.parse(s); return (o && typeof o === 'object') ? o : null } catch { return null } }
|
|
71
|
+
// El navegador percent-codifica el JSON del fragmento; deshacerlo es un no-op si
|
|
72
|
+
// no lo tocó. Si el texto trae un `%` suelto, `decodeURIComponent` lanza: se usa
|
|
73
|
+
// el original.
|
|
74
|
+
const undoUrl = (s) => { try { return decodeURIComponent(s) } catch { return s } }
|
|
75
|
+
|
|
76
|
+
const marca = payload[0]
|
|
77
|
+
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 } }
|
|
80
|
+
|
|
81
|
+
// --- sin marca: formatos anteriores a la marca de formato (compatibilidad) ---
|
|
82
|
+
const crudo = undoUrl(payload)
|
|
83
|
+
if (crudo.trimStart().startsWith('{')) return parse(crudo)
|
|
84
|
+
try { return parse(b64urlDecode(payload)) } catch { return null }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export default { encodeInvite, parseInvite, FMT_JSON, FMT_B64 }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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
|
@@ -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(
|
|
189
|
+
console.log(qrToString(url)) // el QR abre vault.dotrino.com/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.
|
|
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/vaultControl.js
CHANGED
|
@@ -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, FMT_JSON, FMT_B64 } from '../lib/src/invite.js'
|
|
23
24
|
|
|
24
25
|
const dir = dataDir()
|
|
25
26
|
|
|
@@ -257,19 +258,24 @@ export async function deleteScope (ns, profile) {
|
|
|
257
258
|
|
|
258
259
|
const PROFILE_URL = 'https://vault.dotrino.com/dispositivos#vault='
|
|
259
260
|
|
|
260
|
-
/**
|
|
261
|
+
/**
|
|
262
|
+
* Codifica el QR crudo en sus dos formas (ver `lib/src/invite.js`):
|
|
263
|
+
*
|
|
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.
|
|
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.
|
|
272
|
+
*/
|
|
261
273
|
export function pairUrl (qr) {
|
|
262
274
|
const payload = JSON.stringify(qr)
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
return { url: PROFILE_URL + b64, payload, b64 }
|
|
275
|
+
const code = encodeInvite(qr, FMT_B64)
|
|
276
|
+
return { url: PROFILE_URL + encodeInvite(qr, FMT_JSON), code, payload, b64: code }
|
|
266
277
|
}
|
|
267
278
|
|
|
268
|
-
/**
|
|
269
|
-
* Inicia un emparejamiento. `service` (opcional) enrola un SERVICIO con acceso
|
|
270
|
-
* SOLO a `vault:secrets:<service>` (no firma por ti ni lee tus datos).
|
|
271
|
-
* Devuelve { qr, expiresAt, url, payload }.
|
|
272
|
-
*/
|
|
273
279
|
export async function startPairing ({ profile, service } = {}) {
|
|
274
280
|
requireAlive()
|
|
275
281
|
rm(F.pair); rm(F.pending)
|
|
@@ -279,11 +285,11 @@ export async function startPairing ({ profile, service } = {}) {
|
|
|
279
285
|
await sleep(100)
|
|
280
286
|
const pr = read(F.pair, null)
|
|
281
287
|
if (pr?.expiresAt > Date.now()) {
|
|
282
|
-
const { url, payload,
|
|
288
|
+
const { url, payload, code } = pairUrl(pr.qr)
|
|
283
289
|
// `profile`/`profileName`: DE QUÉ CUENTA del vault sale este QR. El vault
|
|
284
290
|
// puede tener varias y el emparejamiento mete al dispositivo en UNA; la TUI
|
|
285
291
|
// y la CLI lo muestran para que no se enrole en la equivocada.
|
|
286
|
-
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 || '' }
|
|
287
293
|
}
|
|
288
294
|
}
|
|
289
295
|
throw coded('el daemon no inició el emparejamiento', 'PAIR_FAILED')
|