@dotrino/vault 0.51.0 → 0.53.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/LICENSE +0 -0
- package/README.md +0 -0
- package/bin/dotrino-env.js +4 -1
- package/package.json +1 -1
- package/src/admin.js +0 -0
- package/src/atrest.js +47 -3
- package/src/config.js +0 -0
- package/src/enroll.js +0 -0
- package/src/env.js +0 -0
- package/src/envtext.js +0 -0
- package/src/index.js +0 -0
- package/src/invite.js +0 -0
- package/src/kek.js +0 -0
- package/src/lock.js +0 -0
- package/src/protocol.js +0 -0
- package/src/revocation.js +0 -0
- package/src/sealed.js +0 -0
- package/src/service.js +45 -8
- package/src/sshAgent.js +0 -0
- package/src/sshKeys.js +0 -0
package/LICENSE
CHANGED
|
File without changes
|
package/README.md
CHANGED
|
File without changes
|
package/bin/dotrino-env.js
CHANGED
|
@@ -206,7 +206,10 @@ async function cmdRun () {
|
|
|
206
206
|
// ejemplo— y por fuera solo se veía «waiting for approval» una y otra vez, aunque el
|
|
207
207
|
// dueño hubiera aprobado cada una. Un reintento que no dice su causa es un fallback: le
|
|
208
208
|
// pone cara de espera a un error. (Ver CLAUDE.md, «nada de repliegues».)
|
|
209
|
-
|
|
209
|
+
// El plazo se imprime en minutos cuando lo es: un «retrying in 310s» parece un cuelgue,
|
|
210
|
+
// y no lo es — es el pedido que ya timbró, esperando a vencer antes de volver a sonar.
|
|
211
|
+
const humanDelay = (ms) => (ms >= 90000 ? `${Math.round(ms / 60000)} min` : `${Math.round(ms / 1000)}s`)
|
|
212
|
+
const onRetry = (e, ms) => console.error('[dotrino-env] failed: %s — retrying in %s', e.message, humanDelay(ms))
|
|
210
213
|
// `--public`: SOLO las variables públicas, y por eso no hace sonar el teléfono de nadie.
|
|
211
214
|
// La aprobación existe para soltar claves privadas; una pública está guardada en claro y
|
|
212
215
|
// ya la ve quien administra. Es lo que quiere un arranque que solo necesita configuración
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vault",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.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/admin.js
CHANGED
|
File without changes
|
package/src/atrest.js
CHANGED
|
@@ -14,8 +14,14 @@
|
|
|
14
14
|
* · `secrets.json` secretos de servicios (tokens y llaves de producción)
|
|
15
15
|
* · `service-identity.json` en la máquina del SERVICIO (`@dotrino/vault/env`):
|
|
16
16
|
* lleva la llave privada de su dispositivo, así que va igual de cifrado.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* · `activity.log` la bitácora de auditoría, cifrada LÍNEA A LÍNEA (ver `LINE_FILES`)
|
|
18
|
+
* · `state.json`, `acta.json`, `secret-request.json`… el canal local con la CLI
|
|
19
|
+
* (`src/ipc.js`), por donde pasan la contraseña del perfil y los valores
|
|
20
|
+
* · `transport.json` el par de transporte de `@dotrino/proxy-client`, con su privada
|
|
21
|
+
*
|
|
22
|
+
* La bitácora estuvo fuera hasta 0.89, con el argumento de que «no guarda payloads». No
|
|
23
|
+
* guarda valores, pero sí el mapa de la cuenta —qué aparatos, con qué permisos, contra
|
|
24
|
+
* qué cajones y a qué hora—, que es lo que mira quien elige por dónde entrar.
|
|
19
25
|
*
|
|
20
26
|
* Qué NO resuelve, dicho sin adornos: **no protege contra alguien con acceso a esta misma
|
|
21
27
|
* máquina como tu usuario o como root** — puede leer el mismo material que leemos nosotros.
|
|
@@ -178,15 +184,31 @@ export function migrateFile (file, key) {
|
|
|
178
184
|
}
|
|
179
185
|
|
|
180
186
|
/** Los archivos de este directorio que están cifrados por nosotros. */
|
|
187
|
+
/**
|
|
188
|
+
* Archivos que van cifrados **línea a línea**, no de una pieza.
|
|
189
|
+
*
|
|
190
|
+
* La bitácora se ESCRIBE AÑADIENDO. Cifrarla entera obligaría a reescribir el archivo
|
|
191
|
+
* completo en cada anotación, así que cada entrada se cierra sola. Eso la deja fuera del
|
|
192
|
+
* barrido de abajo (una lectura de todo el archivo no descifra: son muchos textos pegados)
|
|
193
|
+
* y la mete en su propio camino al cambiar de proveedor.
|
|
194
|
+
*/
|
|
195
|
+
export const LINE_FILES = Object.freeze(['activity.log'])
|
|
196
|
+
|
|
181
197
|
export function encryptedFilesIn (dir) {
|
|
182
198
|
let names = []
|
|
183
199
|
try { names = fs.readdirSync(dir) } catch (_) { return [] }
|
|
184
200
|
return names.filter((n) => {
|
|
185
201
|
if (n === CONFIG_FILE || n === WRAPPED_FILE || n === SALT_FILE || n === MACHINE_FILE) return false
|
|
202
|
+
if (LINE_FILES.includes(n)) return false
|
|
186
203
|
try { return isEncrypted(fs.readFileSync(path.join(dir, n), 'utf8')) } catch (_) { return false }
|
|
187
204
|
})
|
|
188
205
|
}
|
|
189
206
|
|
|
207
|
+
/** Las bitácoras presentes en `dir`, con sus líneas. `[]` si no hay ninguna. */
|
|
208
|
+
export function lineFilesIn (dir) {
|
|
209
|
+
return LINE_FILES.filter((n) => { try { return fs.statSync(path.join(dir, n)).isFile() } catch (_) { return false } })
|
|
210
|
+
}
|
|
211
|
+
|
|
190
212
|
/**
|
|
191
213
|
* CAMBIAR DE PROVEEDOR: descifra todo con la clave vieja y lo vuelve a cifrar con la nueva.
|
|
192
214
|
*
|
|
@@ -219,6 +241,19 @@ export function rekeyDir (dir, newConfig, { password = '' } = {}) {
|
|
|
219
241
|
throw new KekError('kek-rekey', `cannot decrypt ${n} with the current provider (${oldCfg.provider}): ${e.message}`)
|
|
220
242
|
}
|
|
221
243
|
}
|
|
244
|
+
// Las bitácoras, por líneas. `decryptText` deja pasar el texto en claro, así que una
|
|
245
|
+
// mitad vieja sin cifrar cruza intacta y sale cifrada con la clave nueva.
|
|
246
|
+
const lineFiles = lineFilesIn(dir)
|
|
247
|
+
const plainLines = new Map()
|
|
248
|
+
for (const n of lineFiles) {
|
|
249
|
+
const p = path.join(dir, n)
|
|
250
|
+
try {
|
|
251
|
+
const lines = fs.readFileSync(p, 'utf8').split('\n')
|
|
252
|
+
plainLines.set(n, lines.map((l) => (l ? decryptText(l, oldKey) : l)))
|
|
253
|
+
} catch (e) {
|
|
254
|
+
throw new KekError('kek-rekey', `cannot decrypt ${n} with the current provider (${oldCfg.provider}): ${e.message}`)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
222
257
|
|
|
223
258
|
// 2. La clave nueva, todavía sin escribir su envoltorio.
|
|
224
259
|
let newKey; let newBlob = null
|
|
@@ -236,6 +271,15 @@ export function rekeyDir (dir, newConfig, { password = '' } = {}) {
|
|
|
236
271
|
if (decryptText(blob, newKey) !== text) throw new KekError('kek-verify', `re-encryption check failed for ${n}: nothing was written`)
|
|
237
272
|
out.set(n, blob)
|
|
238
273
|
}
|
|
274
|
+
for (const [n, lines] of plainLines) {
|
|
275
|
+
const blobs = lines.map((l) => (l ? encryptText(l, newKey) : l))
|
|
276
|
+
for (let i = 0; i < lines.length; i++) {
|
|
277
|
+
if (lines[i] && decryptText(blobs[i], newKey) !== lines[i]) {
|
|
278
|
+
throw new KekError('kek-verify', `re-encryption check failed for ${n} (line ${i + 1}): nothing was written`)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
out.set(n, blobs.join('\n'))
|
|
282
|
+
}
|
|
239
283
|
|
|
240
284
|
// 4. A disco. Copia de seguridad, temporal, renombrar.
|
|
241
285
|
const backups = []
|
|
@@ -266,6 +310,6 @@ export function rekeyDir (dir, newConfig, { password = '' } = {}) {
|
|
|
266
310
|
|
|
267
311
|
export default {
|
|
268
312
|
machineKey, kekFor, atRestFor, migrateFile, isEncrypted, encryptText, decryptText,
|
|
269
|
-
rekeyDir, encryptedFilesIn, readConfig, writeConfig, probe, KekError
|
|
313
|
+
rekeyDir, encryptedFilesIn, lineFilesIn, LINE_FILES, readConfig, writeConfig, probe, KekError
|
|
270
314
|
}
|
|
271
315
|
export { readConfig, writeConfig, probe, configFromEnv, KekError, CONFIG_FILE, WRAPPED_FILE }
|
package/src/config.js
CHANGED
|
File without changes
|
package/src/enroll.js
CHANGED
|
File without changes
|
package/src/env.js
CHANGED
|
File without changes
|
package/src/envtext.js
CHANGED
|
File without changes
|
package/src/index.js
CHANGED
|
File without changes
|
package/src/invite.js
CHANGED
|
File without changes
|
package/src/kek.js
CHANGED
|
File without changes
|
package/src/lock.js
CHANGED
|
File without changes
|
package/src/protocol.js
CHANGED
|
File without changes
|
package/src/revocation.js
CHANGED
|
File without changes
|
package/src/sealed.js
CHANGED
|
File without changes
|
package/src/service.js
CHANGED
|
@@ -668,7 +668,13 @@ async function openSealedBundle (sealed, ident, acta = null, masterPubkey = null
|
|
|
668
668
|
// sería aceptar un valor en claro dentro de un paquete que se supone todo sellado, o
|
|
669
669
|
// sea la puerta para colar uno.
|
|
670
670
|
if (typeof e.v === 'string' && !e.e) {
|
|
671
|
-
|
|
671
|
+
// TAMPOCO SE ARREGLA ESPERANDO, igual que `no-wrapping`. El valor está guardado en
|
|
672
|
+
// claro en la bóveda, de antes de que todo viajara sellado, y solo vuelve a
|
|
673
|
+
// escribirlo quien puede abrirla. Reintentar no lo sella: solo vuelve a timbrar.
|
|
674
|
+
throw Object.assign(
|
|
675
|
+
new Error(`${key} arrived in the clear: since 2026-09-02 every variable travels sealed` +
|
|
676
|
+
` — rewrite it in the vault (dotrino-vault unlock, then secret set ${ident?.ns || '<ns>'} ${key} …) or remove it`),
|
|
677
|
+
{ code: 'plaintext-var' })
|
|
672
678
|
}
|
|
673
679
|
// `owner` dice de qué cajón salió, y `gen` con qué llave de ese cajón se abre.
|
|
674
680
|
const cual = String(e.owner || '').startsWith('dev:') ? 'dev' : 'ns'
|
|
@@ -1026,21 +1032,52 @@ function fingerprintOf (secrets) {
|
|
|
1026
1032
|
* Se decide por `e.code` donde lo hay: emparejar un mensaje por su texto se rompe en
|
|
1027
1033
|
* silencio en cuanto alguien lo reescribe (ver `dotrino-error-strings`).
|
|
1028
1034
|
*/
|
|
1035
|
+
const FINAL_CODES = new Set([
|
|
1036
|
+
'no-wrapping', // falta la envoltura, y solo la fabrica el dueño abriendo la bóveda
|
|
1037
|
+
'plaintext-var' // el valor está en claro en la bóveda: hay que volver a escribirlo
|
|
1038
|
+
])
|
|
1039
|
+
|
|
1029
1040
|
export function isFinal (e) {
|
|
1030
|
-
if (e?.code
|
|
1041
|
+
if (FINAL_CODES.has(e?.code)) return true
|
|
1031
1042
|
return /not enrolled|invalid ns|unauthorized: (revoked|expired|scope|cn|untrusted-issuer|cert-device-mismatch|denied)/.test(e?.message || '')
|
|
1032
1043
|
}
|
|
1033
1044
|
|
|
1034
|
-
|
|
1035
|
-
|
|
1045
|
+
/**
|
|
1046
|
+
* CUÁNTO ESPERAR ANTES DE VOLVER A PEDIR, y por qué no siempre es el backoff.
|
|
1047
|
+
*
|
|
1048
|
+
* Un cajón con aprobación hace sonar el teléfono en CADA petición. Con el reintento a
|
|
1049
|
+
* cinco segundos, un fallo posterior a la aprobación volvía a pedir enseguida y el dueño
|
|
1050
|
+
* veía dos avisos del mismo cajón: aprobaba el primero para nada. La regla es que un
|
|
1051
|
+
* reintento no llegue nunca antes de que venza el pedido que ya está sonando.
|
|
1052
|
+
*
|
|
1053
|
+
* La otra mitad importa igual: si el intento se pasó la ventana entera esperando a que
|
|
1054
|
+
* alguien aprobara, no se le suma otra ventana — ahí ya no hay nada sonando, y hacerle
|
|
1055
|
+
* esperar cinco minutos más sería un cuelgue.
|
|
1056
|
+
*
|
|
1057
|
+
* @param {number} backoffMs lo que tocaría por el backoff normal
|
|
1058
|
+
* @param {number} rangAt cuándo timbró este intento (0 si no timbró)
|
|
1059
|
+
* @param {number} approvalTimeoutMs lo que dura un pedido de aprobación
|
|
1060
|
+
* @param {number} now
|
|
1061
|
+
*/
|
|
1062
|
+
export function retryDelay (backoffMs, rangAt, approvalTimeoutMs = APPROVAL_TIMEOUT_MS, now = Date.now()) {
|
|
1063
|
+
if (!rangAt) return backoffMs
|
|
1064
|
+
return Math.max(backoffMs, approvalTimeoutMs - (now - rangAt))
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, approvalTimeoutMs = APPROVAL_TIMEOUT_MS, onRetry, onPending, onCert, publicOnly = false } = {}) {
|
|
1068
|
+
let backoff = retryMs
|
|
1069
|
+
let rangAt = 0 // cuándo timbró ESTE intento; 0 = no hubo aprobación de por medio
|
|
1070
|
+
const notePending = (info) => { rangAt = Date.now(); onPending?.(info) }
|
|
1036
1071
|
for (;;) {
|
|
1037
1072
|
try {
|
|
1038
|
-
return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, onPending, onCert, publicOnly })
|
|
1073
|
+
return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, approvalTimeoutMs, onPending: notePending, onCert, publicOnly })
|
|
1039
1074
|
} catch (e) {
|
|
1040
1075
|
if (isFinal(e)) throw e
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1076
|
+
const wait = retryDelay(backoff, rangAt, approvalTimeoutMs)
|
|
1077
|
+
rangAt = 0
|
|
1078
|
+
try { onRetry?.(e, wait) } catch (_) {}
|
|
1079
|
+
await new Promise((r) => setTimeout(r, wait))
|
|
1080
|
+
backoff = Math.min(maxRetryMs, Math.round(backoff * 1.6))
|
|
1044
1081
|
}
|
|
1045
1082
|
}
|
|
1046
1083
|
}
|
package/src/sshAgent.js
CHANGED
|
File without changes
|
package/src/sshKeys.js
CHANGED
|
File without changes
|