@dotrino/vaultd 0.26.2 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -25
- package/bin/dotrino-vaultd.js +4 -4
- package/lib/README.md +13 -2
- package/lib/src/admin.js +88 -3
- package/lib/src/config.js +1 -1
- package/lib/src/enroll.js +26 -22
- package/lib/src/env.js +37 -17
- package/lib/src/envtext.js +94 -0
- package/lib/src/index.js +4 -4
- package/lib/src/invite.js +8 -8
- package/lib/src/protocol.js +12 -0
- package/lib/src/service.js +218 -92
- package/package.json +9 -4
- package/src/ctl.js +375 -90
- package/src/daemon.js +167 -45
- package/src/manager.js +6 -6
- package/src/profiles.js +27 -4
- package/src/secretsStore.js +191 -25
- package/src/transport.js +2 -2
- package/src/tui/app.js +512 -126
- package/src/tui/i18n.js +118 -22
- package/src/vault.js +351 -44
- package/src/vaultControl.js +253 -64
package/src/secretsStore.js
CHANGED
|
@@ -2,63 +2,229 @@
|
|
|
2
2
|
* Store de SECRETOS de servicios (`secrets.json`, 0600, mismo dir 0700 que la
|
|
3
3
|
* maestra — mismo dominio de confianza, y **cifrado en reposo** con la misma
|
|
4
4
|
* clave ligada a la máquina que la identidad, ver `atrest.js`: son tokens y
|
|
5
|
-
* llaves de producción, no pueden quedar en claro en el disco).
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* llaves de producción, no pueden quedar en claro en el disco).
|
|
6
|
+
*
|
|
7
|
+
* DOS CAJONES, y esa es la razón de ser de este archivo:
|
|
8
|
+
*
|
|
9
|
+
* · **Por scope** (`ns`) — un NAMESPACE de servicio (`proxy`, `geo`, `bots`…).
|
|
10
|
+
* Lo comparten TODOS los aparatos del perfil que sirven ese namespace: la
|
|
11
|
+
* llave de la API es la misma la corra quien la corra.
|
|
12
|
+
* · **Por aparato** (`dev`) — la llave es la del miembro (su `pub`), y solo la
|
|
13
|
+
* lee ESE aparato. Es donde va lo que cambia de máquina a máquina (el puerto,
|
|
14
|
+
* la URL pública, el nombre del nodo) sin partir el ns en uno por servidor.
|
|
15
|
+
*
|
|
16
|
+
* Al pedir el bundle se entregan MEZCLADOS y **manda el aparato**: lo suyo pisa
|
|
17
|
+
* lo del scope. Es el orden que espera cualquiera que haya usado un `.env`
|
|
18
|
+
* general y otro de la máquina — lo específico gana.
|
|
19
|
+
*
|
|
20
|
+
* Un cert `vault:secrets:<ns>` solo puede leer SU ns (y, dentro de él, solo lo
|
|
21
|
+
* suyo propio: el cajón por aparato se indexa por la llave que firma la petición,
|
|
22
|
+
* así que no hay forma de pedir el de otro).
|
|
23
|
+
*
|
|
24
|
+
* CADA VARIABLE ES PÚBLICA O PRIVADA, y eso decide UNA cosa: si su VALOR puede
|
|
25
|
+
* salir de esta máquina hacia la consola remota (`docs/consola-remota.md`). Al
|
|
26
|
+
* servicio que la lee le da igual —recibe las dos—; lo que cambia es que el valor
|
|
27
|
+
* de una privada no se le enseña a nadie más, ni siquiera a un aparato tuyo con
|
|
28
|
+
* permiso de administrar. Se nace PRIVADA: enseñar un secreto tiene que ser una
|
|
29
|
+
* decisión, no un descuido.
|
|
8
30
|
*/
|
|
9
31
|
import path from 'node:path'
|
|
10
32
|
import { readJson, writeJson } from './paths.js'
|
|
11
33
|
import { atRestFor } from './atrest.js'
|
|
12
34
|
import { isValidSecretsNs } from './protocol.js'
|
|
13
35
|
|
|
14
|
-
const SCHEMA_VERSION =
|
|
36
|
+
const SCHEMA_VERSION = 3
|
|
15
37
|
const MAX_VALUE_LEN = 8 * 1024
|
|
16
38
|
const KEY_RE = /^[A-Z0-9_]{1,64}$/
|
|
17
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Valida un par nombre/valor. Se exporta porque quien carga varias variables de golpe
|
|
42
|
+
* las comprueba TODAS antes de escribir ninguna: media configuración aplicada y un
|
|
43
|
+
* error a la mitad es justo lo que la carga en grupo viene a evitar.
|
|
44
|
+
*/
|
|
45
|
+
export function assertVar (key, value) {
|
|
46
|
+
if (!KEY_RE.test(String(key || ''))) throw new Error('invalid key (use UPPERCASE_WITH_UNDERSCORES, e.g. TURN_KEY_ID)')
|
|
47
|
+
if (typeof value !== 'string' || !value) throw new Error('value must be a non-empty string')
|
|
48
|
+
if (value.length > MAX_VALUE_LEN) throw new Error(`value too long (max ${MAX_VALUE_LEN})`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Una variable guardada: `{ v: valor, pub: ¿la puede ver la consola? }`. */
|
|
52
|
+
const entry = (value, isPublic) => ({ v: value, pub: !!isPublic })
|
|
53
|
+
|
|
54
|
+
/** Migra un cajón `{KEY: 'valor'}` (v1/v2) a `{KEY: {v, pub}}`: todo entra como PRIVADO. */
|
|
55
|
+
function migrateBag (bag) {
|
|
56
|
+
const out = {}
|
|
57
|
+
for (const [k, v] of Object.entries(bag || {})) out[k] = (v && typeof v === 'object') ? v : entry(v, false)
|
|
58
|
+
return out
|
|
59
|
+
}
|
|
60
|
+
|
|
18
61
|
export function openSecretsStore (dir) {
|
|
19
62
|
const file = path.join(dir, 'secrets.json')
|
|
20
63
|
const atRest = atRestFor(dir)
|
|
21
64
|
let data = readJson(file, null, atRest)
|
|
65
|
+
// v1 → v2: el cajón por scope se conserva y estrena el `dev`.
|
|
66
|
+
// v2 → v3: los valores dejan de ser strings sueltos y pasan a llevar su visibilidad.
|
|
67
|
+
// Lo que ya existía entra como PRIVADO: nadie marcó nunca que se pudiera ver.
|
|
68
|
+
if (data && data.schemaVersion > 0 && data.schemaVersion < SCHEMA_VERSION) {
|
|
69
|
+
const ns = {}; const dev = {}
|
|
70
|
+
for (const [k, bag] of Object.entries(data.ns || {})) ns[k] = migrateBag(bag)
|
|
71
|
+
for (const [k, bag] of Object.entries(data.dev || {})) dev[k] = migrateBag(bag)
|
|
72
|
+
data = { schemaVersion: SCHEMA_VERSION, ns, dev }
|
|
73
|
+
}
|
|
22
74
|
if (!data || data.schemaVersion !== SCHEMA_VERSION) {
|
|
23
|
-
data = { schemaVersion: SCHEMA_VERSION, ns: {} }
|
|
75
|
+
data = { schemaVersion: SCHEMA_VERSION, ns: {}, dev: {} }
|
|
24
76
|
}
|
|
77
|
+
if (!data.dev) data.dev = {}
|
|
25
78
|
writeJson(file, data, atRest) // reescribe al abrir: cifra lo que venía en claro
|
|
26
|
-
|
|
79
|
+
|
|
80
|
+
// Un grupo de escrituras se guarda UNA vez (ver `batch`). Fuera de un grupo, `held`
|
|
81
|
+
// vale 0 y cada cambio va al disco en el acto, como siempre.
|
|
82
|
+
let held = 0
|
|
83
|
+
let pending = false
|
|
84
|
+
const flush = () => writeJson(file, data, atRest)
|
|
85
|
+
const save = () => { if (held) { pending = true; return } ; flush() }
|
|
27
86
|
|
|
28
87
|
const assertNs = (ns) => {
|
|
29
88
|
if (!isValidSecretsNs(ns)) throw new Error('invalid namespace (use [a-z0-9-]{1,32}, e.g. "proxy")')
|
|
30
89
|
}
|
|
90
|
+
const assertPub = (pub) => {
|
|
91
|
+
if (typeof pub !== 'string' || !pub) throw new Error('device required (the member public key)')
|
|
92
|
+
}
|
|
93
|
+
const assertKeyValue = assertVar
|
|
94
|
+
|
|
95
|
+
/** Borra la rama si se quedó vacía: un scope (o un aparato) sin variables no existe. */
|
|
96
|
+
const prune = (bag, k) => { if (bag[k] && Object.keys(bag[k]).length === 0) delete bag[k] }
|
|
97
|
+
|
|
98
|
+
/** KEY→valor, sin la visibilidad: es lo que consume un servicio. */
|
|
99
|
+
const plain = (bag) => Object.fromEntries(Object.entries(bag || {}).map(([k, e]) => [k, e.v]))
|
|
100
|
+
/** Los NOMBRES con su visibilidad: es lo que ve cualquier lista. Nunca valores. */
|
|
101
|
+
const names = (bag) => Object.entries(bag || {}).map(([k, e]) => ({ key: k, public: !!e.pub }))
|
|
102
|
+
/** Solo las PÚBLICAS, con valor: lo único que puede salir hacia la consola remota. */
|
|
103
|
+
const publics = (bag) => Object.fromEntries(
|
|
104
|
+
Object.entries(bag || {}).filter(([, e]) => e.pub).map(([k, e]) => [k, e.v])
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Escribe en un cajón. `isPublic` es OPCIONAL a propósito: sin decir nada se
|
|
109
|
+
* conserva lo que la variable ya era (rotar un valor no debe cambiar quién puede
|
|
110
|
+
* verlo por olvido) y una variable nueva nace PRIVADA.
|
|
111
|
+
*/
|
|
112
|
+
const put = (bag, k, key, value, isPublic) => {
|
|
113
|
+
assertKeyValue(key, value)
|
|
114
|
+
if (!bag[k]) bag[k] = {}
|
|
115
|
+
const before = bag[k][key]
|
|
116
|
+
bag[k][key] = entry(value, isPublic === undefined ? !!before?.pub : isPublic)
|
|
117
|
+
save()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Cambia SOLO la visibilidad, sin tocar el valor. Existe porque, si no, hacer pública
|
|
122
|
+
* una variable obligaría a volver a teclear el secreto — y quien la marca casi nunca lo
|
|
123
|
+
* tiene a mano.
|
|
124
|
+
*/
|
|
125
|
+
const setVis = (bag, k, key, isPublic) => {
|
|
126
|
+
const e = bag[k]?.[key]
|
|
127
|
+
if (!e) return false
|
|
128
|
+
e.pub = !!isPublic
|
|
129
|
+
save()
|
|
130
|
+
return true
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const drop = (bag, k, key) => {
|
|
134
|
+
const existed = !!(bag[k] && key in bag[k])
|
|
135
|
+
if (existed) { delete bag[k][key]; prune(bag, k); save() }
|
|
136
|
+
return existed
|
|
137
|
+
}
|
|
31
138
|
|
|
32
139
|
return {
|
|
33
|
-
/**
|
|
34
|
-
|
|
140
|
+
/**
|
|
141
|
+
* MUCHAS ESCRITURAS, UN GUARDADO. Cargar la configuración de un servicio son veinte
|
|
142
|
+
* variables pero un solo cambio: con un `save()` por variable, el archivo entero se
|
|
143
|
+
* reescribía y se volvía a cifrar veinte veces.
|
|
144
|
+
*
|
|
145
|
+
* Solo síncrono: `fn` no puede esperar nada, porque el grupo se cierra al volver.
|
|
146
|
+
* Si `fn` lanza a la mitad se guarda igual lo que ya había tocado — es lo mismo que
|
|
147
|
+
* pasaba escribiendo de una en una, y quien llama valida ANTES para que no ocurra.
|
|
148
|
+
*/
|
|
149
|
+
batch (fn) {
|
|
150
|
+
held++
|
|
151
|
+
try { return fn() } finally {
|
|
152
|
+
held--
|
|
153
|
+
if (!held && pending) { pending = false; flush() }
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
/**
|
|
157
|
+
* El bundle que se le entrega a un servicio: lo del scope con lo del aparato
|
|
158
|
+
* ENCIMA. Públicas y privadas por igual — la visibilidad no es un permiso de
|
|
159
|
+
* lectura del servicio, es si el valor puede salir de esta máquina.
|
|
160
|
+
*/
|
|
161
|
+
get (ns, devicePub = null) {
|
|
35
162
|
assertNs(ns)
|
|
36
|
-
return { ...(data.ns[ns]
|
|
163
|
+
return { ...plain(data.ns[ns]), ...(devicePub ? plain(data.dev[devicePub]) : {}) }
|
|
37
164
|
},
|
|
38
|
-
set (ns, key, value) {
|
|
165
|
+
set (ns, key, value, isPublic) {
|
|
39
166
|
assertNs(ns)
|
|
40
|
-
|
|
41
|
-
if (typeof value !== 'string' || !value) throw new Error('value must be a non-empty string')
|
|
42
|
-
if (value.length > MAX_VALUE_LEN) throw new Error(`value too long (max ${MAX_VALUE_LEN})`)
|
|
43
|
-
if (!data.ns[ns]) data.ns[ns] = {}
|
|
44
|
-
data.ns[ns][key] = value
|
|
45
|
-
save()
|
|
167
|
+
put(data.ns, ns, key, value, isPublic)
|
|
46
168
|
},
|
|
47
169
|
delete (ns, key) {
|
|
48
170
|
assertNs(ns)
|
|
49
|
-
|
|
50
|
-
if (existed) {
|
|
51
|
-
delete data.ns[ns][key]
|
|
52
|
-
if (Object.keys(data.ns[ns]).length === 0) delete data.ns[ns]
|
|
53
|
-
save()
|
|
54
|
-
}
|
|
55
|
-
return existed
|
|
171
|
+
return drop(data.ns, ns, key)
|
|
56
172
|
},
|
|
57
|
-
/**
|
|
173
|
+
/** Nombres y visibilidad (ns → [{key, public}]), sin valores: para `secret list`. */
|
|
58
174
|
list () {
|
|
59
175
|
const out = {}
|
|
60
|
-
for (const ns of Object.keys(data.ns)) out[ns] =
|
|
176
|
+
for (const ns of Object.keys(data.ns)) out[ns] = names(data.ns[ns])
|
|
61
177
|
return out
|
|
178
|
+
},
|
|
179
|
+
setVisibility (ns, key, isPublic) {
|
|
180
|
+
assertNs(ns)
|
|
181
|
+
return setVis(data.ns, ns, key, isPublic)
|
|
182
|
+
},
|
|
183
|
+
/** Las PÚBLICAS de un scope, con valor. Lo que la consola remota puede ver. */
|
|
184
|
+
publicOf (ns) {
|
|
185
|
+
assertNs(ns)
|
|
186
|
+
return publics(data.ns[ns])
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
// --- cajón POR APARATO -------------------------------------------------
|
|
190
|
+
/** Las variables propias de un aparato (KEY→valor; {} si no hay). */
|
|
191
|
+
getDevice (pub) {
|
|
192
|
+
assertPub(pub)
|
|
193
|
+
return plain(data.dev[pub])
|
|
194
|
+
},
|
|
195
|
+
setDevice (pub, key, value, isPublic) {
|
|
196
|
+
assertPub(pub)
|
|
197
|
+
put(data.dev, pub, key, value, isPublic)
|
|
198
|
+
},
|
|
199
|
+
deleteDevice (pub, key) {
|
|
200
|
+
assertPub(pub)
|
|
201
|
+
return drop(data.dev, pub, key)
|
|
202
|
+
},
|
|
203
|
+
/**
|
|
204
|
+
* Se va un aparato, se van sus variables. Lo llama el vault al quitar un
|
|
205
|
+
* miembro: dejarlas sería guardar la configuración de una llave que ya no
|
|
206
|
+
* entra, y reaparecería sola si mañana se enrola otro aparato con esa llave.
|
|
207
|
+
*/
|
|
208
|
+
forgetDevice (pub) {
|
|
209
|
+
assertPub(pub)
|
|
210
|
+
const keys = Object.keys(data.dev[pub] || {})
|
|
211
|
+
if (keys.length) { delete data.dev[pub]; save() }
|
|
212
|
+
return keys.length
|
|
213
|
+
},
|
|
214
|
+
setDeviceVisibility (pub, key, isPublic) {
|
|
215
|
+
assertPub(pub)
|
|
216
|
+
return setVis(data.dev, pub, key, isPublic)
|
|
217
|
+
},
|
|
218
|
+
/** Nombres y visibilidad (pub → [{key, public}]), sin valores. */
|
|
219
|
+
listDevices () {
|
|
220
|
+
const out = {}
|
|
221
|
+
for (const pub of Object.keys(data.dev)) out[pub] = names(data.dev[pub])
|
|
222
|
+
return out
|
|
223
|
+
},
|
|
224
|
+
/** Las PÚBLICAS de un aparato, con valor. */
|
|
225
|
+
publicOfDevice (pub) {
|
|
226
|
+
assertPub(pub)
|
|
227
|
+
return publics(data.dev[pub])
|
|
62
228
|
}
|
|
63
229
|
}
|
|
64
230
|
}
|
package/src/transport.js
CHANGED
|
@@ -53,8 +53,8 @@ export async function createTransport ({ identity, dir, url = DEFAULT_PROXY }) {
|
|
|
53
53
|
const { signature } = await identity.signData(data)
|
|
54
54
|
// Con el acta, el proxy bindea también el `profileId`: escribirle a la PERSONA llega
|
|
55
55
|
// a cualquiera de sus dispositivos, no solo a esta bóveda.
|
|
56
|
-
const
|
|
57
|
-
await client.identify({ data, signature, acta })
|
|
56
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
57
|
+
await client.identify({ data, signature, acta: record })
|
|
58
58
|
}
|
|
59
59
|
await identify()
|
|
60
60
|
// Re-identificar al reconectar (el token cambia).
|