@dotrino/vaultd 0.83.0 → 0.84.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/lib/src/env.js +26 -8
- package/lib/src/service.js +6 -4
- package/package.json +1 -1
- package/src/secretsStore.js +17 -2
- package/src/vault.js +18 -7
package/lib/src/env.js
CHANGED
|
@@ -53,11 +53,26 @@ export const isValidVaultLabel = (p) => typeof p === 'string' && /^[a-z0-9-]{1,3
|
|
|
53
53
|
/** Directorio de la identidad del servicio `ns` bajo la etiqueta de bóveda `vault`. */
|
|
54
54
|
export function serviceDir (vault, ns) {
|
|
55
55
|
if (process.env.DOTRINO_ENV_DIR) return process.env.DOTRINO_ENV_DIR
|
|
56
|
-
if (!isValidVaultLabel(vault)) {
|
|
57
|
-
throw new Error('missing vault label: pass --vault <nombre> (use [a-z0-9-]{1,32}). It is a label of YOUR machine: it says which vault this drawer came from')
|
|
58
|
-
}
|
|
59
56
|
if (!isValidSecretsNs(ns)) throw new Error('invalid ns (use [a-z0-9-]{1,32}, e.g. "myapp")')
|
|
60
|
-
return path.join(serviceRoot(), vault, ns)
|
|
57
|
+
if (isValidVaultLabel(vault)) return path.join(serviceRoot(), vault, ns)
|
|
58
|
+
// SIN `--vault`: SE BUSCA, Y SOLO SE EXIGE SI HAY EMPATE (dueño, 2026-09-01).
|
|
59
|
+
//
|
|
60
|
+
// La etiqueta se hizo obligatoria para que dos bóvedas pudieran tener un cajón que se
|
|
61
|
+
// llame igual sin pisarse. Eso lo arregla la RUTA, no la bandera: con un solo enrolamiento
|
|
62
|
+
// de ese `ns` no hay nada que desambiguar y pedirla es fricción sin nada a cambio.
|
|
63
|
+
//
|
|
64
|
+
// Con dos, en cambio, elegir por el usuario sería adivinar cuál de sus cuentas usar — así
|
|
65
|
+
// que se para y se dice cuáles hay. Eso es lo contrario de un repliegue: no se resuelve
|
|
66
|
+
// una ambigüedad a la brava, se señala.
|
|
67
|
+
const donde = listEnrolled().filter((e) => !e.legacy && e.ns === ns)
|
|
68
|
+
if (donde.length === 1) return path.join(serviceRoot(), donde[0].vault, ns)
|
|
69
|
+
if (donde.length > 1) {
|
|
70
|
+
throw new Error(`"${ns}" is enrolled in more than one vault (${donde.map((e) => e.vault).join(', ')}): pick one with --vault <label>`)
|
|
71
|
+
}
|
|
72
|
+
const otros = listEnrolled().filter((e) => !e.legacy)
|
|
73
|
+
throw new Error(otros.length
|
|
74
|
+
? `"${ns}" is not enrolled on this machine (there is: ${otros.map((e) => `${e.vault}/${e.ns}`).join(', ')})`
|
|
75
|
+
: `"${ns}" is not enrolled on this machine: run \`dotrino-env enroll --vault <label> --ns ${ns}\` first`)
|
|
61
76
|
}
|
|
62
77
|
|
|
63
78
|
/** La etiqueta de bóveda: `--vault`, o `DOTRINO_ENV_VAULT`. */
|
|
@@ -139,18 +154,21 @@ let lastApplied = null
|
|
|
139
154
|
* @param {boolean} [opts.wait] `true` (default) = si el vault no está, ESPERA (reintenta) en vez de fallar.
|
|
140
155
|
* @param {string[]} [opts.required] Claves que deben venir; si falta alguna, lanza.
|
|
141
156
|
* @param {(e:Error, ms:number)=>void} [opts.onRetry]
|
|
157
|
+
* @param {boolean} [opts.publicOnly] Solo las variables PÚBLICAS. No hay claves privadas de
|
|
158
|
+
* por medio, así que la bóveda no pide aprobación: sirve para un arranque que solo
|
|
159
|
+
* necesita configuración (una URL, un puerto) y no debe despertar a nadie.
|
|
142
160
|
* @returns {Promise<{ns, secrets, injected:string[], overridden:string[], skipped:string[]}>}
|
|
143
161
|
* `overridden` = las que YA tenían otro valor en el entorno y el vault pisó. Es
|
|
144
162
|
* el dato que delata un `.env` rancio, así que se reporta en vez de callarse.
|
|
145
163
|
*/
|
|
146
|
-
export async function loadEnv ({ ns, vault, dir, override, wait = true, required = [], onRetry, onPending } = {}) {
|
|
164
|
+
export async function loadEnv ({ ns, vault, dir, override, wait = true, required = [], onRetry, onPending, publicOnly = false } = {}) {
|
|
147
165
|
if (override === undefined) override = overrideByDefault()
|
|
148
166
|
ns = resolveNs(ns)
|
|
149
|
-
// La
|
|
150
|
-
//
|
|
167
|
+
// La etiqueta de bóveda es OPCIONAL: `serviceDir` busca el `ns` entre las que hay y solo
|
|
168
|
+
// la exige si está enrolado en más de una (ahí sí hay que elegir, y elige el dueño).
|
|
151
169
|
dir = dir || serviceDir(resolveVaultLabel(vault), ns)
|
|
152
170
|
const load = wait ? waitForSecrets : fetchSecrets
|
|
153
|
-
const secrets = await load({ dir, ns, onRetry, onPending })
|
|
171
|
+
const secrets = await load({ dir, ns, onRetry, onPending, publicOnly })
|
|
154
172
|
|
|
155
173
|
const missing = required.filter((k) => !(k in secrets))
|
|
156
174
|
if (missing.length) {
|
package/lib/src/service.js
CHANGED
|
@@ -421,7 +421,7 @@ export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device,
|
|
|
421
421
|
* Renueva el cert automáticamente si está por vencer (best-effort).
|
|
422
422
|
* @returns {Promise<Record<string,string>>} secretos KEY→valor
|
|
423
423
|
*/
|
|
424
|
-
export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, timeoutMs = 30000, onPending, onCert, approvalTimeoutMs = APPROVAL_TIMEOUT_MS } = {}) {
|
|
424
|
+
export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, timeoutMs = 30000, onPending, onCert, approvalTimeoutMs = APPROVAL_TIMEOUT_MS, publicOnly = false } = {}) {
|
|
425
425
|
let saved = null
|
|
426
426
|
if (dir) saved = readServiceIdentity(dir)
|
|
427
427
|
// Sin `dir`: la identidad viene entera por parámetros — es el caso de un agente
|
|
@@ -499,7 +499,9 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
499
499
|
}
|
|
500
500
|
|
|
501
501
|
const eph = await makeEphemeralKey()
|
|
502
|
-
|
|
502
|
+
// `publicOnly` VIAJA FIRMADO (va dentro de `data`), así que nadie lo puede quitar ni
|
|
503
|
+
// poner por el camino. Quien decide qué se manda es la bóveda; esto solo es la petición.
|
|
504
|
+
const data = { op: 'secrets', ns, ek: eph.ek, publickey: device.publickey, ts: Date.now(), ...(publicOnly ? { publicOnly: true } : {}) }
|
|
503
505
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
504
506
|
const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
|
|
505
507
|
client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
|
|
@@ -1022,11 +1024,11 @@ export function isFinal (e) {
|
|
|
1022
1024
|
return /not enrolled|invalid ns|unauthorized: (revoked|expired|scope|cn|untrusted-issuer|cert-device-mismatch|denied)/.test(e?.message || '')
|
|
1023
1025
|
}
|
|
1024
1026
|
|
|
1025
|
-
export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, onRetry, onPending, onCert } = {}) {
|
|
1027
|
+
export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, onRetry, onPending, onCert, publicOnly = false } = {}) {
|
|
1026
1028
|
let delay = retryMs
|
|
1027
1029
|
for (;;) {
|
|
1028
1030
|
try {
|
|
1029
|
-
return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, onPending, onCert })
|
|
1031
|
+
return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, onPending, onCert, publicOnly })
|
|
1030
1032
|
} catch (e) {
|
|
1031
1033
|
if (isFinal(e)) throw e
|
|
1032
1034
|
try { onRetry?.(e, delay) } catch (_) {}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.84.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/secretsStore.js
CHANGED
|
@@ -329,10 +329,25 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
329
329
|
* Van TODAS sus generaciones, no solo la última, porque desde v5 cada variable puede
|
|
330
330
|
* venir de una escritura distinta. En v3 devuelve los valores en claro, como siempre.
|
|
331
331
|
*/
|
|
332
|
-
|
|
332
|
+
/**
|
|
333
|
+
* @param {string} ns
|
|
334
|
+
* @param {string|null} devicePub
|
|
335
|
+
* @param {{publicOnly?: boolean}} [opts] `publicOnly`: SOLO las públicas, y sin
|
|
336
|
+
* envolturas. Una pública está guardada en claro, así que no hay llave que repartir
|
|
337
|
+
* ni sobre que abrir — y por eso pedirlas no pasa por la aprobación (ver `handleSecrets`).
|
|
338
|
+
*/
|
|
339
|
+
bundleFor (ns, devicePub = null, { publicOnly = false } = {}) {
|
|
333
340
|
assertNs(ns)
|
|
334
|
-
const
|
|
341
|
+
const todo = { ...entriesOf(data.ns, ns), ...(devicePub ? entriesOf(data.dev, devicePub) : {}) }
|
|
342
|
+
// EL FILTRO LO HACE LA BÓVEDA, no quien pide. Si mandara todo y el cliente eligiera,
|
|
343
|
+
// pedir «solo públicas» sería la forma de saltarse la aprobación y llevarse las
|
|
344
|
+
// privadas igual — o sea justo lo contrario de lo que se quiere.
|
|
345
|
+
const entries = publicOnly
|
|
346
|
+
? Object.fromEntries(Object.entries(todo).filter(([, e]) => e.pub))
|
|
347
|
+
: todo
|
|
335
348
|
if (isLegacy()) return { legacy: true, entries }
|
|
349
|
+
// Sin privadas no hay nada que envolver: el paquete va sin llavero.
|
|
350
|
+
if (publicOnly) return { entries, ns: null, dev: null, wraps: { ns: [], dev: [] } }
|
|
336
351
|
const misWraps = (bag, k) => (bag[k]?.keyring || [])
|
|
337
352
|
.filter((g) => g.wraps?.[devicePub])
|
|
338
353
|
.map((g) => ({ gen: g.gen, wrap: g.wraps[devicePub] }))
|
package/src/vault.js
CHANGED
|
@@ -852,7 +852,18 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
852
852
|
// claves en memoria y no vuelve a pedir. El pedido se apunta, se avisa a quien aprueba y se
|
|
853
853
|
// contesta «pendiente»; la respuesta de verdad sale cuando el teléfono firme
|
|
854
854
|
// (`handleApproval`), sellada a la misma `ek`.
|
|
855
|
-
|
|
855
|
+
// SOLO PÚBLICAS: NO SE PIDE APROBACIÓN (dueño, 2026-09-01).
|
|
856
|
+
//
|
|
857
|
+
// La aprobación existe para soltar CLAVES PRIVADAS. Una variable pública está guardada
|
|
858
|
+
// en claro —eso es lo que significa marcarla— y ya la ve cualquiera que administre, así
|
|
859
|
+
// que hacer sonar el teléfono del dueño para entregarla es molestarlo por nada.
|
|
860
|
+
//
|
|
861
|
+
// Lo que NO se relaja: quien pide sigue teniendo que ser un miembro identificado, con su
|
|
862
|
+
// papel y con el `cn` de este cajón (las dos comprobaciones de arriba). Y el filtro lo
|
|
863
|
+
// hace la BÓVEDA (`bundleFor({ publicOnly })`), no el que pide: si mandáramos todo y el
|
|
864
|
+
// cliente eligiera, pedir «solo públicas» sería la manera de saltarse la aprobación.
|
|
865
|
+
const publicOnly = p.data?.publicOnly === true
|
|
866
|
+
if (!publicOnly && needsApproval(chk.device, record)) {
|
|
856
867
|
const deviceId = await deviceIdOf(chk.device).catch(() => null)
|
|
857
868
|
const label = (record?.members || []).find((m) => m.pub === chk.device)?.label || ''
|
|
858
869
|
const pend = approvals.request({ ns, device: chk.device, deviceId, label, ek: p.data.ek })
|
|
@@ -866,20 +877,20 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
866
877
|
return
|
|
867
878
|
}
|
|
868
879
|
let res
|
|
869
|
-
try { res = await resultFor(ns, chk.device, p.data.ek, record) } catch (e) {
|
|
880
|
+
try { res = await resultFor(ns, chk.device, p.data.ek, record, { publicOnly }) } catch (e) {
|
|
870
881
|
return reply(from, { type: MSG.ERROR, error: 'secrets: invalid ek' })
|
|
871
882
|
}
|
|
872
|
-
audit('secrets', { device: await deviceIdOf(chk.device), ns })
|
|
883
|
+
audit('secrets', { device: await deviceIdOf(chk.device), ns, ...(publicOnly ? { publicOnly: true } : {}) })
|
|
873
884
|
reply(from, { type: MSG.SECRETS_RESULT, ...res })
|
|
874
885
|
}
|
|
875
886
|
|
|
876
|
-
/** El bundle de `ns` para `devicePub`, sellado a su `ek` y firmado por la
|
|
877
|
-
async function resultFor (ns, devicePub, ek, record) {
|
|
887
|
+
/** El bundle de `ns` para `devicePub`, sellado a su `ek` y firmado por la llave de sellado. */
|
|
888
|
+
async function resultFor (ns, devicePub, ek, record, { publicOnly = false } = {}) {
|
|
878
889
|
// Mientras el archivo siga en v3 el cable NO cambia: se mandan los valores como
|
|
879
890
|
// siempre. Solo tras la migración viajan sobres, y entonces quien los abre es el
|
|
880
891
|
// agente con su llave. Así el despliegue del daemon se deshace con un reinicio,
|
|
881
892
|
// porque hasta el primer desbloqueo no ha cambiado nada de lo que ve nadie.
|
|
882
|
-
const b = secrets.bundleFor(ns, devicePub)
|
|
893
|
+
const b = secrets.bundleFor(ns, devicePub, { publicOnly })
|
|
883
894
|
// EL ACTA VIAJA CON EL BUNDLE (§8.8): es lo que le permite al agente comprobar que
|
|
884
895
|
// los sobres los selló esta bóveda, y con qué llave —la que el acta nombra para el
|
|
885
896
|
// `seq` con el que se firmaron—. No es un dato secreto: el acta es pública dentro
|
|
@@ -897,7 +908,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
897
908
|
// como si se hubiera servido. Desde fuera parecía que el pedido funcionaba y volvía a
|
|
898
909
|
// aparecer una y otra vez. Falta la envoltura, y eso solo se arregla ABRIENDO la
|
|
899
910
|
// bóveda; decirlo aquí es lo que convierte un bucle mudo en algo que se puede arreglar.
|
|
900
|
-
if (!b.legacy) {
|
|
911
|
+
if (!b.legacy && !publicOnly) {
|
|
901
912
|
const faltan = [...secrets.missingFor(`ns:${ns}`, devicePub), ...secrets.missingFor(`dev:${devicePub}`, devicePub)]
|
|
902
913
|
if (faltan.length) {
|
|
903
914
|
const quien = await deviceIdOf(devicePub).catch(() => '????-????')
|