@dotrino/vault 0.8.0 → 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/bin/dotrino-env.js +6 -10
- package/package.json +1 -1
- package/src/enroll.js +20 -4
- package/src/invite.js +87 -0
package/bin/dotrino-env.js
CHANGED
|
@@ -44,18 +44,14 @@ Entorno: DOTRINO_NS · DOTRINO_ENV_DIR · DOTRINO_ENV_HOME · DOTRINO_ENV_QUIET`
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
|
-
* La invitación que imprime `dotrino-vault pair
|
|
48
|
-
*
|
|
49
|
-
*
|
|
47
|
+
* La invitación que imprime `dotrino-vault pair`, en cualquiera de sus formas
|
|
48
|
+
* (URL del QR, código pegable, formatos viejos). El parser es el compartido:
|
|
49
|
+
* `lib/src/invite.js`, que lee la marca de formato en vez de adivinar.
|
|
50
50
|
*/
|
|
51
51
|
function parseInvite (raw) {
|
|
52
|
-
const
|
|
53
|
-
if (!
|
|
54
|
-
|
|
55
|
-
const b64 = s.includes('#vault=') ? s.split('#vault=')[1] : (s.includes('#') ? s.split('#').pop() : s)
|
|
56
|
-
const json = Buffer.from(b64.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')
|
|
57
|
-
if (!json.trim().startsWith('{')) throw new Error('no parece una invitación del vault')
|
|
58
|
-
return JSON.parse(json)
|
|
52
|
+
const o = sharedParseInvite(raw)
|
|
53
|
+
if (!o) throw new Error('no parece una invitación del vault')
|
|
54
|
+
return o
|
|
59
55
|
}
|
|
60
56
|
|
|
61
57
|
async function readInvite () {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vault",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Usa ESTE dispositivo (navegador) como bóveda/CA del ecosistema Dotrino: atiende enrolamientos por el proxy y firma certificados de delegación a tus máquinas. Incluye el cliente de SERVICIO (Node): un proyecto se enrola una vez y jala sus credenciales del vault en vez del .env (`import '@dotrino/vault/config'`).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/enroll.js
CHANGED
|
@@ -108,13 +108,29 @@ export function createEnrollDesk ({
|
|
|
108
108
|
const reply = (to, obj) => { try { send(to, obj) } catch (e) { log('[vault] no se pudo responder:', e.message) } }
|
|
109
109
|
const isFresh = (d) => typeof d?.ts === 'number' && Math.abs(Date.now() - d.ts) <= FRESH_WINDOW_MS
|
|
110
110
|
|
|
111
|
-
/**
|
|
112
|
-
|
|
111
|
+
/**
|
|
112
|
+
* Inicia un emparejamiento: token + nonce de sesión. NO firma nada todavía.
|
|
113
|
+
*
|
|
114
|
+
* `mode` y `account` son LO QUE LA BÓVEDA DECLARA que va a pasar, y viajan en el QR
|
|
115
|
+
* para que el aparato pueda **decirlo antes de hacerlo** en vez de emparejar a
|
|
116
|
+
* ciegas (decisión V9 de `docs/vinculacion-de-cuentas.md`: pregunta el vault, el
|
|
117
|
+
* dispositivo muestra el proceso y sus consecuencias):
|
|
118
|
+
*
|
|
119
|
+
* · `mode: 'join'` → el dispositivo estrena una cuenta suya y entra a la de la
|
|
120
|
+
* bóveda. Es lo único que existe hoy.
|
|
121
|
+
* · `mode: 'adopt'` → la bóveda se quedaría con la cuenta que trae el aparato
|
|
122
|
+
* (camino A). Reservado: todavía no hay protocolo.
|
|
123
|
+
* · `account` → cómo se llama la cuenta de la bóveda, para nombrarla en el
|
|
124
|
+
* aviso. Es ORIENTATIVO (un nombre que puso su dueño); la
|
|
125
|
+
* identidad de verdad de la cuenta es `iss`.
|
|
126
|
+
*/
|
|
127
|
+
function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '', mode = 'join', account = '' } = {}) {
|
|
113
128
|
pending.clear() // uno a la vez: una sesión nueva supersede a la anterior
|
|
114
129
|
const token = randToken()
|
|
115
130
|
const sn = randToken()
|
|
116
|
-
|
|
117
|
-
|
|
131
|
+
const acct = String(account || '').slice(0, 40)
|
|
132
|
+
pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
|
|
133
|
+
return { token, qr: { v: 2, iss, proxy, token, sn, m: mode, ...(acct ? { acct } : {}) }, expiresInMs: PAIRING_TTL_MS }
|
|
118
134
|
}
|
|
119
135
|
|
|
120
136
|
function stopPairing (token) { pending.delete(token) }
|
package/src/invite.js
ADDED
|
@@ -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 }
|