@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.
@@ -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). Organizado por
6
- * NAMESPACE de servicio (`proxy`, `geo`, `bots`…): un cert
7
- * `vault:secrets:<ns>` solo puede leer SU ns.
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 = 1
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
- const save = () => writeJson(file, data, atRest)
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
- /** Secretos de un ns (objeto plano KEY→valor; {} si no hay). */
34
- get (ns) {
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
- if (!KEY_RE.test(String(key || ''))) throw new Error('invalid key (use UPPERCASE_WITH_UNDERSCORES, e.g. TURN_KEY_ID)')
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
- const existed = !!(data.ns[ns] && key in data.ns[ns])
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
- /** Solo nombres (ns → [claves]), sin valores: para `secret list`. */
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] = Object.keys(data.ns[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 acta = (await identity.profileActa?.().catch(() => null))?.acta || null
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).